getdoorman 1.0.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.
- package/LICENSE +21 -0
- package/README.md +181 -0
- package/bin/doorman.js +444 -0
- package/package.json +74 -0
- package/src/ai-fixer.js +559 -0
- package/src/ast-scanner.js +434 -0
- package/src/auth.js +149 -0
- package/src/baseline.js +48 -0
- package/src/compliance.js +539 -0
- package/src/config.js +466 -0
- package/src/custom-rules.js +32 -0
- package/src/dashboard.js +202 -0
- package/src/detector.js +142 -0
- package/src/fix-engine.js +48 -0
- package/src/fix-registry-extra.js +95 -0
- package/src/fix-registry-go-rust.js +77 -0
- package/src/fix-registry-java-csharp.js +77 -0
- package/src/fix-registry-js.js +99 -0
- package/src/fix-registry-mcp-ai.js +57 -0
- package/src/fix-registry-python.js +87 -0
- package/src/fixer-ruby-php.js +608 -0
- package/src/fixer.js +2113 -0
- package/src/hooks.js +115 -0
- package/src/ignore.js +176 -0
- package/src/index.js +384 -0
- package/src/metrics.js +126 -0
- package/src/monorepo.js +65 -0
- package/src/presets.js +54 -0
- package/src/reporter.js +975 -0
- package/src/rule-worker.js +36 -0
- package/src/rules/ast-rules.js +756 -0
- package/src/rules/bugs/accessibility.js +235 -0
- package/src/rules/bugs/ai-codegen-fixable.js +172 -0
- package/src/rules/bugs/ai-codegen.js +365 -0
- package/src/rules/bugs/code-smell-bugs.js +247 -0
- package/src/rules/bugs/crypto-bugs.js +195 -0
- package/src/rules/bugs/docker-bugs.js +158 -0
- package/src/rules/bugs/general.js +361 -0
- package/src/rules/bugs/go-bugs.js +279 -0
- package/src/rules/bugs/index.js +73 -0
- package/src/rules/bugs/js-api.js +257 -0
- package/src/rules/bugs/js-array-object.js +210 -0
- package/src/rules/bugs/js-async-fixable.js +223 -0
- package/src/rules/bugs/js-async.js +211 -0
- package/src/rules/bugs/js-closure-scope.js +182 -0
- package/src/rules/bugs/js-database.js +203 -0
- package/src/rules/bugs/js-error-handling.js +148 -0
- package/src/rules/bugs/js-logic.js +261 -0
- package/src/rules/bugs/js-memory.js +214 -0
- package/src/rules/bugs/js-node.js +361 -0
- package/src/rules/bugs/js-react.js +373 -0
- package/src/rules/bugs/js-regex.js +200 -0
- package/src/rules/bugs/js-state.js +272 -0
- package/src/rules/bugs/js-type-coercion.js +318 -0
- package/src/rules/bugs/nextjs-bugs.js +242 -0
- package/src/rules/bugs/nextjs-fixable.js +120 -0
- package/src/rules/bugs/node-fixable.js +178 -0
- package/src/rules/bugs/python-advanced.js +245 -0
- package/src/rules/bugs/python-fixable.js +98 -0
- package/src/rules/bugs/python.js +284 -0
- package/src/rules/bugs/react-fixable.js +207 -0
- package/src/rules/bugs/ruby-bugs.js +182 -0
- package/src/rules/bugs/shell-bugs.js +181 -0
- package/src/rules/bugs/silent-failures.js +261 -0
- package/src/rules/bugs/ts-bugs.js +235 -0
- package/src/rules/bugs/unused-vars.js +65 -0
- package/src/rules/compliance/accessibility-ext.js +468 -0
- package/src/rules/compliance/education.js +322 -0
- package/src/rules/compliance/financial.js +421 -0
- package/src/rules/compliance/frameworks.js +507 -0
- package/src/rules/compliance/healthcare.js +520 -0
- package/src/rules/compliance/index.js +2714 -0
- package/src/rules/compliance/regional-eu.js +480 -0
- package/src/rules/compliance/regional-international.js +903 -0
- package/src/rules/cost/index.js +1993 -0
- package/src/rules/data/index.js +2503 -0
- package/src/rules/dependencies/index.js +1684 -0
- package/src/rules/deployment/index.js +2050 -0
- package/src/rules/index.js +71 -0
- package/src/rules/infrastructure/index.js +3048 -0
- package/src/rules/performance/index.js +3455 -0
- package/src/rules/quality/index.js +3175 -0
- package/src/rules/reliability/index.js +3040 -0
- package/src/rules/scope-rules.js +815 -0
- package/src/rules/security/ai-api.js +1177 -0
- package/src/rules/security/auth.js +1328 -0
- package/src/rules/security/cors.js +127 -0
- package/src/rules/security/crypto.js +527 -0
- package/src/rules/security/csharp.js +862 -0
- package/src/rules/security/csrf.js +193 -0
- package/src/rules/security/dart.js +835 -0
- package/src/rules/security/deserialization.js +291 -0
- package/src/rules/security/file-upload.js +187 -0
- package/src/rules/security/go.js +850 -0
- package/src/rules/security/headers.js +235 -0
- package/src/rules/security/index.js +65 -0
- package/src/rules/security/injection.js +1639 -0
- package/src/rules/security/mcp-server.js +71 -0
- package/src/rules/security/misconfiguration.js +660 -0
- package/src/rules/security/oauth-jwt.js +329 -0
- package/src/rules/security/path-traversal.js +295 -0
- package/src/rules/security/php.js +1054 -0
- package/src/rules/security/prototype-pollution.js +283 -0
- package/src/rules/security/rate-limiting.js +208 -0
- package/src/rules/security/ruby.js +1061 -0
- package/src/rules/security/rust.js +693 -0
- package/src/rules/security/secrets.js +747 -0
- package/src/rules/security/shell.js +647 -0
- package/src/rules/security/ssrf.js +298 -0
- package/src/rules/security/supply-chain-advanced.js +393 -0
- package/src/rules/security/supply-chain.js +734 -0
- package/src/rules/security/swift.js +835 -0
- package/src/rules/security/taint.js +27 -0
- package/src/rules/security/xss.js +520 -0
- package/src/scan-cache.js +71 -0
- package/src/scanner.js +710 -0
- package/src/scope-analyzer.js +685 -0
- package/src/share.js +88 -0
- package/src/taint.js +300 -0
- package/src/telemetry.js +183 -0
- package/src/tracer.js +190 -0
- package/src/upload.js +35 -0
- package/src/worker.js +31 -0
|
@@ -0,0 +1,2503 @@
|
|
|
1
|
+
const JS_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'];
|
|
2
|
+
function isSourceFile(f) { return JS_EXTENSIONS.some(ext => f.endsWith(ext)); }
|
|
3
|
+
|
|
4
|
+
const rules = [
|
|
5
|
+
// DATA-001: Weak password hashing
|
|
6
|
+
{
|
|
7
|
+
id: 'DATA-001',
|
|
8
|
+
category: 'data',
|
|
9
|
+
severity: 'critical',
|
|
10
|
+
confidence: 'definite',
|
|
11
|
+
title: 'Weak Password Hashing',
|
|
12
|
+
check({ files }) {
|
|
13
|
+
const findings = [];
|
|
14
|
+
for (const [filepath, content] of files) {
|
|
15
|
+
if (!isSourceFile(filepath)) continue;
|
|
16
|
+
const lines = content.split('\n');
|
|
17
|
+
for (let i = 0; i < lines.length; i++) {
|
|
18
|
+
if (lines[i].match(/(?:md5|sha1|sha256)\s*\(/) &&
|
|
19
|
+
(content.includes('password') || content.includes('passwd'))) {
|
|
20
|
+
findings.push({
|
|
21
|
+
ruleId: 'DATA-001', category: 'data', severity: 'critical',
|
|
22
|
+
title: 'Using MD5/SHA1/SHA256 for password hashing — use bcrypt or argon2',
|
|
23
|
+
description: 'These hash functions are too fast for passwords and vulnerable to brute force. Use bcrypt, scrypt, or argon2.',
|
|
24
|
+
file: filepath, line: i + 1, fix: null,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return findings;
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// DATA-002: No input validation library
|
|
34
|
+
{
|
|
35
|
+
id: 'DATA-002',
|
|
36
|
+
category: 'data',
|
|
37
|
+
severity: 'high',
|
|
38
|
+
confidence: 'likely',
|
|
39
|
+
title: 'No Input Validation Library',
|
|
40
|
+
check({ files, stack }) {
|
|
41
|
+
const findings = [];
|
|
42
|
+
if (stack.runtime !== 'node') return findings;
|
|
43
|
+
|
|
44
|
+
const hasValidation = Object.keys({ ...stack.dependencies, ...stack.devDependencies }).some(dep =>
|
|
45
|
+
['zod', 'joi', 'yup', 'class-validator', 'superstruct', 'valibot', 'ajv'].includes(dep)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (!hasValidation) {
|
|
49
|
+
const hasApiRoutes = [...files.keys()].some(f => f.includes('/api/') || f.includes('routes'));
|
|
50
|
+
if (hasApiRoutes) {
|
|
51
|
+
findings.push({
|
|
52
|
+
ruleId: 'DATA-002', category: 'data', severity: 'high',
|
|
53
|
+
title: 'No input validation library detected (zod, joi, yup, etc.)',
|
|
54
|
+
description: 'Validate all user input on the server. Never trust data from the client.',
|
|
55
|
+
fix: null,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return findings;
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// DATA-003: Logging sensitive data
|
|
64
|
+
{
|
|
65
|
+
id: 'DATA-003',
|
|
66
|
+
category: 'data',
|
|
67
|
+
severity: 'high',
|
|
68
|
+
confidence: 'likely',
|
|
69
|
+
title: 'Potentially Logging Sensitive Data',
|
|
70
|
+
check({ files }) {
|
|
71
|
+
const findings = [];
|
|
72
|
+
for (const [filepath, content] of files) {
|
|
73
|
+
if (!isSourceFile(filepath)) continue;
|
|
74
|
+
const lines = content.split('\n');
|
|
75
|
+
for (let i = 0; i < lines.length; i++) {
|
|
76
|
+
if (lines[i].match(/console\.log.*(?:password|token|secret|credit.?card|ssn|api.?key)/i) ||
|
|
77
|
+
lines[i].match(/logger?\.(?:info|debug|log).*(?:password|token|secret|credit.?card|ssn)/i)) {
|
|
78
|
+
findings.push({
|
|
79
|
+
ruleId: 'DATA-003', category: 'data', severity: 'high',
|
|
80
|
+
title: 'Potentially logging sensitive data (passwords, tokens, etc.)',
|
|
81
|
+
description: 'Never log passwords, tokens, or PII. Redact sensitive fields before logging.',
|
|
82
|
+
file: filepath, line: i + 1, fix: null,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return findings;
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
// DATA-004: Verbose errors in production
|
|
92
|
+
{
|
|
93
|
+
id: 'DATA-004',
|
|
94
|
+
category: 'data',
|
|
95
|
+
severity: 'medium',
|
|
96
|
+
confidence: 'likely',
|
|
97
|
+
title: 'Verbose Error Messages Exposed',
|
|
98
|
+
check({ files }) {
|
|
99
|
+
const findings = [];
|
|
100
|
+
for (const [filepath, content] of files) {
|
|
101
|
+
if (!isSourceFile(filepath)) continue;
|
|
102
|
+
if (!filepath.includes('api/') && !filepath.includes('route')) continue;
|
|
103
|
+
|
|
104
|
+
const lines = content.split('\n');
|
|
105
|
+
for (let i = 0; i < lines.length; i++) {
|
|
106
|
+
// Sending error stack/message directly to client
|
|
107
|
+
if (lines[i].match(/(?:res\.(?:json|send|status)).*(?:err\.message|err\.stack|error\.message|error\.stack)/)) {
|
|
108
|
+
findings.push({
|
|
109
|
+
ruleId: 'DATA-004', category: 'data', severity: 'medium',
|
|
110
|
+
title: 'Error details sent to client — information disclosure risk',
|
|
111
|
+
description: 'Send generic error messages to users. Log detailed errors server-side only.',
|
|
112
|
+
file: filepath, line: i + 1, fix: null,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return findings;
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
// DATA-005: No .env.example
|
|
122
|
+
{
|
|
123
|
+
id: 'DATA-005',
|
|
124
|
+
category: 'data',
|
|
125
|
+
severity: 'low',
|
|
126
|
+
confidence: 'suggestion',
|
|
127
|
+
title: 'No .env.example File',
|
|
128
|
+
check({ files }) {
|
|
129
|
+
const findings = [];
|
|
130
|
+
const hasEnv = [...files.keys()].some(f => f === '.env' || f === '.env.local');
|
|
131
|
+
const hasExample = [...files.keys()].some(f => f === '.env.example' || f === '.env.template');
|
|
132
|
+
|
|
133
|
+
if (hasEnv && !hasExample) {
|
|
134
|
+
findings.push({
|
|
135
|
+
ruleId: 'DATA-005', category: 'data', severity: 'low',
|
|
136
|
+
title: 'No .env.example file — developers won\'t know what env vars are needed',
|
|
137
|
+
description: 'Create a .env.example with placeholder values so new developers know what to configure.',
|
|
138
|
+
fix: null,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return findings;
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
// DATA-ENC-001: HTTP URLs for API calls
|
|
146
|
+
{ id: 'DATA-ENC-001', category: 'data', severity: 'high', confidence: 'likely', title: 'HTTP URL in API Call (Not HTTPS)',
|
|
147
|
+
check({ files }) {
|
|
148
|
+
const findings = [];
|
|
149
|
+
for (const [fp, c] of files) {
|
|
150
|
+
if (!isSourceFile(fp)) continue;
|
|
151
|
+
const lines = c.split('\n');
|
|
152
|
+
for (let i = 0; i < lines.length; i++) {
|
|
153
|
+
if (lines[i].match(/fetch\s*\(\s*['"`]http:\/\/(?!localhost|127\.0\.0\.1)/) ||
|
|
154
|
+
lines[i].match(/axios\.\w+\s*\(\s*['"`]http:\/\/(?!localhost|127\.0\.0\.1)/)) {
|
|
155
|
+
findings.push({ ruleId: 'DATA-ENC-001', category: 'data', severity: 'high',
|
|
156
|
+
title: 'API call over HTTP instead of HTTPS — data transmitted in plaintext', description: 'Use HTTPS for all API calls in production. HTTP exposes data to man-in-the-middle attacks.', file: fp, line: i + 1, fix: null });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return findings;
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
// DATA-ENC-002: ECB mode for AES
|
|
165
|
+
{ id: 'DATA-ENC-002', category: 'data', severity: 'critical', confidence: 'definite', title: 'AES-ECB Mode — Insecure Encryption',
|
|
166
|
+
check({ files }) {
|
|
167
|
+
const findings = [];
|
|
168
|
+
for (const [fp, c] of files) {
|
|
169
|
+
if (!isSourceFile(fp)) continue;
|
|
170
|
+
const lines = c.split('\n');
|
|
171
|
+
for (let i = 0; i < lines.length; i++) {
|
|
172
|
+
if (lines[i].match(/aes-\d+-ecb|AES.*ECB/i)) {
|
|
173
|
+
findings.push({ ruleId: 'DATA-ENC-002', category: 'data', severity: 'critical',
|
|
174
|
+
title: 'AES-ECB mode is insecure — patterns in plaintext are visible in ciphertext', description: 'Use AES-GCM or AES-CBC with a random IV. ECB mode produces identical ciphertexts for identical plaintexts.', file: fp, line: i + 1, fix: null });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return findings;
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
// DATA-ENC-003: Hardcoded IV/nonce
|
|
183
|
+
{ id: 'DATA-ENC-003', category: 'data', severity: 'critical', confidence: 'definite', title: 'Hardcoded IV or Nonce',
|
|
184
|
+
check({ files }) {
|
|
185
|
+
const findings = [];
|
|
186
|
+
for (const [fp, c] of files) {
|
|
187
|
+
if (!isSourceFile(fp)) continue;
|
|
188
|
+
const lines = c.split('\n');
|
|
189
|
+
for (let i = 0; i < lines.length; i++) {
|
|
190
|
+
if (lines[i].match(/\biv\s*=\s*['"`][0-9a-fA-F]{16,}['"`]|Buffer\.from\s*\(\s*['"`][0-9a-fA-F]+['"`]\s*,\s*['"`]hex['"`]\s*\)/)) {
|
|
191
|
+
findings.push({ ruleId: 'DATA-ENC-003', category: 'data', severity: 'critical',
|
|
192
|
+
title: 'Hardcoded IV/nonce — breaks encryption security', description: 'Always generate a random IV with crypto.randomBytes(16). A fixed IV with the same key allows ciphertext comparison attacks.', file: fp, line: i + 1, fix: null });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return findings;
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
// DATA-ENC-004: Cookie not marked Secure
|
|
201
|
+
{ id: 'DATA-ENC-004', category: 'data', severity: 'high', confidence: 'likely', title: 'Cookie Not Marked Secure',
|
|
202
|
+
check({ files }) {
|
|
203
|
+
const findings = [];
|
|
204
|
+
for (const [fp, c] of files) {
|
|
205
|
+
if (!isSourceFile(fp)) continue;
|
|
206
|
+
const lines = c.split('\n');
|
|
207
|
+
for (let i = 0; i < lines.length; i++) {
|
|
208
|
+
if (lines[i].match(/res\.cookie\s*\(/) || lines[i].match(/setCookie\s*\(/)) {
|
|
209
|
+
const block = lines.slice(i, Math.min(i + 5, lines.length)).join('\n');
|
|
210
|
+
if (!block.match(/secure\s*:\s*true|secure:\s*true/)) {
|
|
211
|
+
findings.push({ ruleId: 'DATA-ENC-004', category: 'data', severity: 'high',
|
|
212
|
+
title: 'Cookie set without Secure flag — sent over HTTP connections', description: "Add secure: true so cookies are only sent over HTTPS. In Express: res.cookie('name', value, { secure: true })", file: fp, line: i + 1, fix: null });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return findings;
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
// DATA-ENC-005: Weak RSA key size
|
|
222
|
+
{ id: 'DATA-ENC-005', category: 'data', severity: 'high', confidence: 'likely', title: 'Weak RSA Key Size (<2048 bits)',
|
|
223
|
+
check({ files }) {
|
|
224
|
+
const findings = [];
|
|
225
|
+
for (const [fp, c] of files) {
|
|
226
|
+
if (!isSourceFile(fp)) continue;
|
|
227
|
+
const lines = c.split('\n');
|
|
228
|
+
for (let i = 0; i < lines.length; i++) {
|
|
229
|
+
if (lines[i].match(/modulusLength\s*:\s*(?:512|768|1024)\b/)) {
|
|
230
|
+
findings.push({ ruleId: 'DATA-ENC-005', category: 'data', severity: 'high',
|
|
231
|
+
title: 'RSA key size < 2048 bits is considered weak', description: 'Use modulusLength: 4096 for new keys. 512/768/1024-bit RSA keys can be factored with modern hardware.', file: fp, line: i + 1, fix: null });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return findings;
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
// DATA-PII-001: SSN pattern in code
|
|
240
|
+
{ id: 'DATA-PII-001', category: 'data', severity: 'critical', confidence: 'definite', title: 'Social Security Number Handling',
|
|
241
|
+
check({ files }) {
|
|
242
|
+
const findings = [];
|
|
243
|
+
for (const [fp, c] of files) {
|
|
244
|
+
if (!isSourceFile(fp)) continue;
|
|
245
|
+
if (c.match(/\bssn\b|\bsocial.?security/i)) {
|
|
246
|
+
findings.push({ ruleId: 'DATA-PII-001', category: 'data', severity: 'critical',
|
|
247
|
+
title: 'SSN (Social Security Number) handling detected', description: 'SSNs require strict access controls, encryption, and PCI/HIPAA compliance. Minimize collection and ensure field-level encryption.', file: fp, fix: null });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return findings;
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
// DATA-PII-002: PII in URL query params
|
|
255
|
+
{ id: 'DATA-PII-002', category: 'data', severity: 'high', confidence: 'likely', title: 'PII in URL Query Parameters',
|
|
256
|
+
check({ files }) {
|
|
257
|
+
const findings = [];
|
|
258
|
+
for (const [fp, c] of files) {
|
|
259
|
+
if (!isSourceFile(fp)) continue;
|
|
260
|
+
const lines = c.split('\n');
|
|
261
|
+
for (let i = 0; i < lines.length; i++) {
|
|
262
|
+
if (lines[i].match(/[?&](?:email|name|phone|dob|ssn|password)=/i) ||
|
|
263
|
+
lines[i].match(/\`[^`]*[?&](?:email|name|phone)=\$\{/i)) {
|
|
264
|
+
findings.push({ ruleId: 'DATA-PII-002', category: 'data', severity: 'high',
|
|
265
|
+
title: 'PII in URL query parameters — logged in server access logs', description: 'Never put PII (email, name, phone) in URLs. Use POST body or encrypted tokens instead.', file: fp, line: i + 1, fix: null });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return findings;
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
|
|
273
|
+
// DATA-PII-003: Full name/email in logs
|
|
274
|
+
{ id: 'DATA-PII-003', category: 'data', severity: 'high', confidence: 'likely', title: 'User PII Logged',
|
|
275
|
+
check({ files }) {
|
|
276
|
+
const findings = [];
|
|
277
|
+
for (const [fp, c] of files) {
|
|
278
|
+
if (!isSourceFile(fp)) continue;
|
|
279
|
+
const lines = c.split('\n');
|
|
280
|
+
for (let i = 0; i < lines.length; i++) {
|
|
281
|
+
if (lines[i].match(/console\.(?:log|info|debug|error)|logger\.(?:info|debug|error)/)) {
|
|
282
|
+
if (lines[i].match(/user\.email|user\.name|user\.phone|req\.user\.email|profile\.email/)) {
|
|
283
|
+
findings.push({ ruleId: 'DATA-PII-003', category: 'data', severity: 'high',
|
|
284
|
+
title: 'User PII (email/name) being logged', description: 'Do not log PII. Log user IDs instead of emails/names to enable debugging without exposing personal data.', file: fp, line: i + 1, fix: null });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return findings;
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
// DATA-VAL-001: No max length on string inputs
|
|
294
|
+
{ id: 'DATA-VAL-001', category: 'data', severity: 'medium', confidence: 'likely', title: 'No Max Length on String Inputs',
|
|
295
|
+
check({ files }) {
|
|
296
|
+
const findings = [];
|
|
297
|
+
for (const [fp, c] of files) {
|
|
298
|
+
if (!isSourceFile(fp)) continue;
|
|
299
|
+
if ((fp.includes('api/') || fp.includes('route')) && (c.includes('req.body') || c.includes('req.query'))) {
|
|
300
|
+
if (!c.match(/maxLength|max_length|max:.*\d+|\.max\s*\(\s*\d+/)) {
|
|
301
|
+
findings.push({ ruleId: 'DATA-VAL-001', category: 'data', severity: 'medium',
|
|
302
|
+
title: 'No max length validation on user input', description: 'Without max length, users can send megabyte strings that exhaust memory or cause ReDoS. Add .max(255) or similar limits.', file: fp, fix: null });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return findings;
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
// DATA-VAL-002: No email validation
|
|
311
|
+
{ id: 'DATA-VAL-002', category: 'data', severity: 'medium', confidence: 'likely', title: 'No Email Format Validation',
|
|
312
|
+
check({ files }) {
|
|
313
|
+
const findings = [];
|
|
314
|
+
for (const [fp, c] of files) {
|
|
315
|
+
if (!isSourceFile(fp)) continue;
|
|
316
|
+
if ((fp.includes('api/') || fp.includes('route')) && c.match(/req\.body\.email|body\.email/)) {
|
|
317
|
+
if (!c.match(/email\(\)|isEmail|EmailStr|z\.string\(\)\.email|@IsEmail|validateEmail/)) {
|
|
318
|
+
findings.push({ ruleId: 'DATA-VAL-002', category: 'data', severity: 'medium',
|
|
319
|
+
title: 'Email field used without format validation', description: "Validate email format before saving. Use zod's .email(), joi's .email(), or a library like validator.js.", file: fp, fix: null });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return findings;
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
// DATA-VAL-003: No file size limit on uploads
|
|
328
|
+
{ id: 'DATA-VAL-003', category: 'data', severity: 'high', confidence: 'likely', title: 'No File Size Limit on Uploads',
|
|
329
|
+
check({ files }) {
|
|
330
|
+
const findings = [];
|
|
331
|
+
for (const [fp, c] of files) {
|
|
332
|
+
if (!isSourceFile(fp)) continue;
|
|
333
|
+
if (c.includes('multer') || c.includes('formidable') || c.includes('busboy')) {
|
|
334
|
+
if (!c.match(/fileSize|maxFileSize|maxSize|limits\s*:\s*\{/)) {
|
|
335
|
+
findings.push({ ruleId: 'DATA-VAL-003', category: 'data', severity: 'high',
|
|
336
|
+
title: 'File upload without size limit — denial of service risk', description: 'Set limits.fileSize in multer or equivalent to prevent oversized file uploads from exhausting disk/memory.', file: fp, fix: null });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return findings;
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
// DATA-STORE-001: S3 bucket with public read
|
|
345
|
+
{ id: 'DATA-STORE-001', category: 'data', severity: 'critical', confidence: 'definite', title: 'S3 Bucket With Public Read Access',
|
|
346
|
+
check({ files }) {
|
|
347
|
+
const findings = [];
|
|
348
|
+
for (const [fp, c] of files) {
|
|
349
|
+
if (c.match(/public-read|PublicRead|ACL.*public/i)) {
|
|
350
|
+
findings.push({ ruleId: 'DATA-STORE-001', category: 'data', severity: 'critical',
|
|
351
|
+
title: 'S3 bucket configured with public-read ACL', description: 'Public-read exposes all objects to the internet. Use pre-signed URLs for controlled access instead.', file: fp, fix: null });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return findings;
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
// DATA-STORE-002: Sensitive data in localStorage
|
|
359
|
+
{ id: 'DATA-STORE-002', category: 'data', severity: 'high', confidence: 'likely', title: 'Sensitive Data in localStorage',
|
|
360
|
+
check({ files }) {
|
|
361
|
+
const findings = [];
|
|
362
|
+
for (const [fp, c] of files) {
|
|
363
|
+
if (!isSourceFile(fp)) continue;
|
|
364
|
+
const lines = c.split('\n');
|
|
365
|
+
for (let i = 0; i < lines.length; i++) {
|
|
366
|
+
if (lines[i].match(/localStorage\.setItem\s*\(/) && lines[i].match(/token|password|secret|key|auth|session/i)) {
|
|
367
|
+
findings.push({ ruleId: 'DATA-STORE-002', category: 'data', severity: 'high',
|
|
368
|
+
title: 'Storing sensitive data (token/password) in localStorage — XSS risk', description: 'localStorage is accessible to any JavaScript on the page. Use httpOnly cookies for auth tokens.', file: fp, line: i + 1, fix: null });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return findings;
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
// DATA-STORE-003: File uploads stored in webroot
|
|
377
|
+
{ id: 'DATA-STORE-003', category: 'data', severity: 'high', confidence: 'likely', title: 'File Uploads Stored in Webroot',
|
|
378
|
+
check({ files }) {
|
|
379
|
+
const findings = [];
|
|
380
|
+
for (const [fp, c] of files) {
|
|
381
|
+
if (!isSourceFile(fp)) continue;
|
|
382
|
+
if (c.match(/multer|upload|formidable/) && c.match(/dest\s*:\s*['"`]public|uploads.*public|public.*uploads/)) {
|
|
383
|
+
findings.push({ ruleId: 'DATA-STORE-003', category: 'data', severity: 'high',
|
|
384
|
+
title: 'Uploaded files stored in public webroot directory', description: 'Files stored in /public are directly accessible via URL. Store uploads outside the webroot and serve via authenticated endpoints.', file: fp, fix: null });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return findings;
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
// DATA-STORE-004: No virus scan on uploads
|
|
392
|
+
{ id: 'DATA-STORE-004', category: 'data', severity: 'high', confidence: 'likely', title: 'No Virus/Malware Scanning on Uploads',
|
|
393
|
+
check({ files, stack }) {
|
|
394
|
+
const findings = [];
|
|
395
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
396
|
+
const hasUpload = [...files.values()].some(c => c.includes('multer') || c.includes('upload') || c.includes('formidable'));
|
|
397
|
+
const hasScan = 'clamscan' in allDeps || 'nodejs-clamscan' in allDeps || [...files.values()].some(c => c.match(/clamav|virustotal|malware.*scan|scan.*file/i));
|
|
398
|
+
if (hasUpload && !hasScan) {
|
|
399
|
+
findings.push({ ruleId: 'DATA-STORE-004', category: 'data', severity: 'high',
|
|
400
|
+
title: 'File uploads without virus/malware scanning', description: 'Users can upload malware that gets distributed to other users. Scan with ClamAV or a cloud virus scanning service.', fix: null });
|
|
401
|
+
}
|
|
402
|
+
return findings;
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
// DATA-LIFE-001: No TTL on Redis keys with PII
|
|
407
|
+
{ id: 'DATA-LIFE-001', category: 'data', severity: 'medium', confidence: 'likely', title: 'No TTL on Redis Keys Containing PII',
|
|
408
|
+
check({ files }) {
|
|
409
|
+
const findings = [];
|
|
410
|
+
for (const [fp, c] of files) {
|
|
411
|
+
if (!isSourceFile(fp)) continue;
|
|
412
|
+
if (c.match(/redis|ioredis/) && c.match(/\.set\s*\(.*(?:user|session|token|email|profile)/i)) {
|
|
413
|
+
if (!c.match(/EX\s+\d+|expire\s*\(|\bEX\b|\bPX\b|ttl/i)) {
|
|
414
|
+
findings.push({ ruleId: 'DATA-LIFE-001', category: 'data', severity: 'medium',
|
|
415
|
+
title: 'Redis keys containing user data set without TTL', description: 'PII stored in Redis without expiry persists indefinitely. Set EX (seconds) or PX (milliseconds) on all user-related keys.', file: fp, fix: null });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return findings;
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
// DATA-QUAL-001: Currency stored as float
|
|
424
|
+
{ id: 'DATA-QUAL-001', category: 'data', severity: 'high', confidence: 'likely', title: 'Currency Stored as Float',
|
|
425
|
+
check({ files }) {
|
|
426
|
+
const findings = [];
|
|
427
|
+
for (const [fp, c] of files) {
|
|
428
|
+
if (!isSourceFile(fp)) continue;
|
|
429
|
+
if (c.match(/(?:price|amount|cost|total|balance)\s*:.*Float|Float.*(?:price|amount|cost)/i) ||
|
|
430
|
+
c.match(/type\s*:\s*['"`]FLOAT['"`].*(?:price|amount)|DECIMAL.*price/i)) {
|
|
431
|
+
if (!c.match(/Integer|BigInt|BIGINT|INT.*(?:price|amount)/i)) {
|
|
432
|
+
findings.push({ ruleId: 'DATA-QUAL-001', category: 'data', severity: 'high',
|
|
433
|
+
title: 'Currency stored as Float — floating point precision errors in money calculations', description: 'Store monetary values as integers (cents/smallest unit). Float arithmetic causes errors like $1.10 + $2.20 = $3.3000000000000003.', file: fp, fix: null });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return findings;
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
// DATA-QUAL-002: Timestamps without timezone
|
|
442
|
+
{ id: 'DATA-QUAL-002', category: 'data', severity: 'medium', confidence: 'likely', title: 'Timestamps Without Timezone Info',
|
|
443
|
+
check({ files }) {
|
|
444
|
+
const findings = [];
|
|
445
|
+
for (const [fp, c] of files) {
|
|
446
|
+
if (!isSourceFile(fp)) continue;
|
|
447
|
+
if (c.match(/new Date\(\)|Date\.now\(\)/) && c.match(/save|insert|create|store/i)) {
|
|
448
|
+
if (!c.match(/toISOString|UTC|timezone|Intl\.DateTimeFormat/)) {
|
|
449
|
+
findings.push({ ruleId: 'DATA-QUAL-002', category: 'data', severity: 'medium',
|
|
450
|
+
title: 'Timestamps stored without explicit timezone information', description: 'Always store timestamps in UTC (Date.toISOString() or timestamp with timezone). Local times cause bugs when servers change timezone.', file: fp, fix: null });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return findings;
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
// DATA-ENC-006: No HSTS header
|
|
459
|
+
{ id: 'DATA-ENC-006', category: 'data', severity: 'high', confidence: 'likely', title: 'No HSTS Header Configured',
|
|
460
|
+
check({ files }) {
|
|
461
|
+
const findings = [];
|
|
462
|
+
const has = [...files.values()].some(c => c.match(/Strict-Transport-Security|hsts|helmet/i));
|
|
463
|
+
if (!has) findings.push({ ruleId: 'DATA-ENC-006', category: 'data', severity: 'high', title: 'No HTTP Strict-Transport-Security header — browsers may allow HTTP downgrade', description: "Add helmet() or set 'Strict-Transport-Security: max-age=31536000; includeSubDomains'.", fix: null });
|
|
464
|
+
return findings;
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
// DATA-ENC-007: TLS 1.0/1.1 enabled
|
|
469
|
+
{ id: 'DATA-ENC-007', category: 'data', severity: 'high', confidence: 'likely', title: 'Legacy TLS Version Enabled',
|
|
470
|
+
check({ files }) {
|
|
471
|
+
const findings = [];
|
|
472
|
+
for (const [fp, c] of files) {
|
|
473
|
+
const lines = c.split('\n');
|
|
474
|
+
for (let i = 0; i < lines.length; i++) {
|
|
475
|
+
if (lines[i].match(/TLSv1[^.2]|TLSv1\.0|TLSv1\.1|ssl.*v3|SSLv3/i)) {
|
|
476
|
+
findings.push({ ruleId: 'DATA-ENC-007', category: 'data', severity: 'high', title: 'Legacy TLS 1.0/1.1 or SSLv3 enabled — vulnerable to POODLE/BEAST', description: 'Enforce TLS 1.2 minimum, prefer TLS 1.3. Disable TLSv1.0, TLSv1.1, SSLv3.', file: fp, line: i + 1, fix: null });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return findings;
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
// DATA-ENC-008: TLS certificate validation disabled
|
|
485
|
+
{ id: 'DATA-ENC-008', category: 'data', severity: 'high', confidence: 'likely', title: 'TLS Certificate Validation Disabled',
|
|
486
|
+
check({ files }) {
|
|
487
|
+
const findings = [];
|
|
488
|
+
for (const [fp, c] of files) {
|
|
489
|
+
if (!isSourceFile(fp)) continue;
|
|
490
|
+
const lines = c.split('\n');
|
|
491
|
+
for (let i = 0; i < lines.length; i++) {
|
|
492
|
+
if (lines[i].match(/rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED.*0/)) {
|
|
493
|
+
findings.push({ ruleId: 'DATA-ENC-008', category: 'data', severity: 'high', title: 'TLS certificate validation disabled — vulnerable to MITM', description: 'Never set rejectUnauthorized: false or NODE_TLS_REJECT_UNAUTHORIZED=0 in production. Use a valid CA-signed certificate.', file: fp, line: i + 1, fix: null });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return findings;
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
|
|
501
|
+
// DATA-ENC-009: Weak password hashing (MD5/SHA1)
|
|
502
|
+
{ id: 'DATA-ENC-009', category: 'data', severity: 'critical', confidence: 'definite', title: 'Weak Password Hashing Algorithm',
|
|
503
|
+
check({ files }) {
|
|
504
|
+
const findings = [];
|
|
505
|
+
for (const [fp, c] of files) {
|
|
506
|
+
if (!isSourceFile(fp)) continue;
|
|
507
|
+
const lines = c.split('\n');
|
|
508
|
+
for (let i = 0; i < lines.length; i++) {
|
|
509
|
+
if (lines[i].match(/createHash\(['"]md5['"]\)|createHash\(['"]sha1['"]\)|md5\(password|sha1\(password/i)) {
|
|
510
|
+
findings.push({ ruleId: 'DATA-ENC-009', category: 'data', severity: 'critical', title: 'Password hashed with MD5 or SHA1 — trivially cracked with rainbow tables', description: 'Use bcrypt, argon2, or scrypt for password hashing.', file: fp, line: i + 1, fix: null });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return findings;
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
// DATA-ENC-010: Secrets in docker-compose environment
|
|
519
|
+
{ id: 'DATA-ENC-010', category: 'data', severity: 'high', confidence: 'definite', title: 'Hardcoded Secret in docker-compose',
|
|
520
|
+
check({ files }) {
|
|
521
|
+
const findings = [];
|
|
522
|
+
for (const [fp, c] of files) {
|
|
523
|
+
if (!fp.match(/docker-compose|compose\.ya?ml/i)) continue;
|
|
524
|
+
const lines = c.split('\n');
|
|
525
|
+
for (let i = 0; i < lines.length; i++) {
|
|
526
|
+
if (lines[i].match(/PASSWORD\s*:|SECRET\s*:|API_KEY\s*:|TOKEN\s*:/i) && lines[i].match(/:\s*\S{6,}/)) {
|
|
527
|
+
findings.push({ ruleId: 'DATA-ENC-010', category: 'data', severity: 'high', title: 'Hardcoded secret in docker-compose environment', description: 'Use Docker secrets (secrets:) or env_file with a gitignored .env. Never embed passwords in compose files.', file: fp, line: i + 1, fix: null });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return findings;
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
|
|
535
|
+
// DATA-PII-004: User email in logs
|
|
536
|
+
{ id: 'DATA-PII-004', category: 'data', severity: 'high', confidence: 'likely', title: 'User Email Logged Directly',
|
|
537
|
+
check({ files }) {
|
|
538
|
+
const findings = [];
|
|
539
|
+
for (const [fp, c] of files) {
|
|
540
|
+
if (!isSourceFile(fp)) continue;
|
|
541
|
+
const lines = c.split('\n');
|
|
542
|
+
for (let i = 0; i < lines.length; i++) {
|
|
543
|
+
if (lines[i].match(/console\.(log|info|warn|error)|logger\.(info|debug|warn|error)/i) && lines[i].match(/user\.email|req\.user\.email|body\.email/i)) {
|
|
544
|
+
findings.push({ ruleId: 'DATA-PII-004', category: 'data', severity: 'high', title: 'User email address logged — PII exposure in log files', description: 'Log user IDs instead of emails. GDPR and CCPA restrict logging personal data in plaintext.', file: fp, line: i + 1, fix: null });
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return findings;
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
|
|
552
|
+
// DATA-PII-005: Phone number logged
|
|
553
|
+
{ id: 'DATA-PII-005', category: 'data', severity: 'high', confidence: 'likely', title: 'Phone Number in Logs',
|
|
554
|
+
check({ files }) {
|
|
555
|
+
const findings = [];
|
|
556
|
+
for (const [fp, c] of files) {
|
|
557
|
+
if (!isSourceFile(fp)) continue;
|
|
558
|
+
const lines = c.split('\n');
|
|
559
|
+
for (let i = 0; i < lines.length; i++) {
|
|
560
|
+
if (lines[i].match(/console\.|logger\./i) && lines[i].match(/phone|phoneNumber|mobile|msisdn/i)) {
|
|
561
|
+
findings.push({ ruleId: 'DATA-PII-005', category: 'data', severity: 'high', title: 'Phone number potentially logged — PII in logs', description: 'Mask phone numbers in logs (e.g., +1-XXX-XXX-1234). Phone numbers are PII under GDPR and CCPA.', file: fp, line: i + 1, fix: null });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return findings;
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
|
|
569
|
+
// DATA-PII-006: Full name in query string
|
|
570
|
+
{ id: 'DATA-PII-006', category: 'data', severity: 'medium', confidence: 'likely', title: 'Full Name in URL Query String',
|
|
571
|
+
check({ files }) {
|
|
572
|
+
const findings = [];
|
|
573
|
+
for (const [fp, c] of files) {
|
|
574
|
+
if (!isSourceFile(fp)) continue;
|
|
575
|
+
const lines = c.split('\n');
|
|
576
|
+
for (let i = 0; i < lines.length; i++) {
|
|
577
|
+
if (lines[i].match(/[?&](name|fullName|firstName|lastName|username)=/i)) {
|
|
578
|
+
findings.push({ ruleId: 'DATA-PII-006', category: 'data', severity: 'medium', title: 'PII (name) exposed in URL query string — logged by proxies and servers', description: 'Move PII to POST body or headers. Query strings are logged by web servers, proxies, and browsers.', file: fp, line: i + 1, fix: null });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return findings;
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
|
|
586
|
+
// DATA-PII-007: User IP stored without anonymization
|
|
587
|
+
{ id: 'DATA-PII-007', category: 'data', severity: 'medium', confidence: 'likely', title: 'IP Address Stored Without Anonymization',
|
|
588
|
+
check({ files }) {
|
|
589
|
+
const findings = [];
|
|
590
|
+
for (const [fp, c] of files) {
|
|
591
|
+
if (!isSourceFile(fp)) continue;
|
|
592
|
+
const lines = c.split('\n');
|
|
593
|
+
for (let i = 0; i < lines.length; i++) {
|
|
594
|
+
if (lines[i].match(/req\.ip|req\.socket\.remoteAddress|x-forwarded-for/i) && lines[i].match(/save|insert|create|store|log/i)) {
|
|
595
|
+
findings.push({ ruleId: 'DATA-PII-007', category: 'data', severity: 'medium', title: 'IP address stored — truncate last octet for GDPR compliance', description: 'IP addresses are personal data under GDPR. Truncate last octet (192.168.1.0) or pseudonymize before storage.', file: fp, line: i + 1, fix: null });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return findings;
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
|
|
603
|
+
// DATA-PII-008: Credit card data in logs
|
|
604
|
+
{ id: 'DATA-PII-008', category: 'data', severity: 'critical', confidence: 'definite', title: 'Payment Card Data in Logs',
|
|
605
|
+
check({ files }) {
|
|
606
|
+
const findings = [];
|
|
607
|
+
for (const [fp, c] of files) {
|
|
608
|
+
if (!isSourceFile(fp)) continue;
|
|
609
|
+
const lines = c.split('\n');
|
|
610
|
+
for (let i = 0; i < lines.length; i++) {
|
|
611
|
+
if (lines[i].match(/console\.|logger\./i) && lines[i].match(/card|cardNumber|pan|cvv|expiry|expirationDate/i)) {
|
|
612
|
+
findings.push({ ruleId: 'DATA-PII-008', category: 'data', severity: 'critical', title: 'Payment card data potentially logged — PCI DSS violation', description: 'Never log card numbers, CVV, or track data. This is a PCI DSS requirement and triggers mandatory breach notification.', file: fp, line: i + 1, fix: null });
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return findings;
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
|
|
620
|
+
// DATA-VAL-004: User content rendered without sanitization
|
|
621
|
+
{ id: 'DATA-VAL-004', category: 'data', severity: 'high', confidence: 'likely', title: 'User Content Rendered Without Sanitization',
|
|
622
|
+
check({ files }) {
|
|
623
|
+
const findings = [];
|
|
624
|
+
for (const [fp, c] of files) {
|
|
625
|
+
if (!isSourceFile(fp)) continue;
|
|
626
|
+
const lines = c.split('\n');
|
|
627
|
+
for (let i = 0; i < lines.length; i++) {
|
|
628
|
+
if (lines[i].match(/dangerouslySetInnerHTML|innerHTML\s*=|document\.write/) && !lines[i].match(/DOMPurify|sanitize|xss/i)) {
|
|
629
|
+
findings.push({ ruleId: 'DATA-VAL-004', category: 'data', severity: 'high', title: 'HTML rendered without sanitization — XSS risk', description: 'Use DOMPurify.sanitize() before inserting HTML. Never pass raw user content to dangerouslySetInnerHTML or innerHTML.', file: fp, line: i + 1, fix: null });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return findings;
|
|
634
|
+
},
|
|
635
|
+
},
|
|
636
|
+
|
|
637
|
+
// DATA-VAL-005: No request body schema validation
|
|
638
|
+
{ id: 'DATA-VAL-005', category: 'data', severity: 'medium', confidence: 'likely', title: 'No Request Body Schema Validation Library',
|
|
639
|
+
check({ files, stack }) {
|
|
640
|
+
const findings = [];
|
|
641
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
642
|
+
const hasValidation = ['joi', 'zod', 'yup', 'ajv', 'class-validator', 'express-validator', '@hapi/joi'].some(d => d in allDeps);
|
|
643
|
+
if (stack.framework && !hasValidation) {
|
|
644
|
+
findings.push({ ruleId: 'DATA-VAL-005', category: 'data', severity: 'medium', title: 'No input validation library — API accepts unvalidated request bodies', description: 'Add Joi, Zod, or express-validator. Without schema validation, malformed data reaches your database.', fix: null });
|
|
645
|
+
}
|
|
646
|
+
return findings;
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
// DATA-VAL-006: User input in RegExp (ReDoS)
|
|
651
|
+
{ id: 'DATA-VAL-006', category: 'data', severity: 'high', confidence: 'likely', title: 'ReDoS Risk: User Input in RegExp Constructor',
|
|
652
|
+
check({ files }) {
|
|
653
|
+
const findings = [];
|
|
654
|
+
for (const [fp, c] of files) {
|
|
655
|
+
if (!isSourceFile(fp)) continue;
|
|
656
|
+
const lines = c.split('\n');
|
|
657
|
+
for (let i = 0; i < lines.length; i++) {
|
|
658
|
+
if (lines[i].match(/new RegExp\(.*(?:req\.|body\.|params\.|query\.)/)) {
|
|
659
|
+
findings.push({ ruleId: 'DATA-VAL-006', category: 'data', severity: 'high', title: 'User input used in new RegExp() — Regular Expression Denial of Service (ReDoS)', description: 'Validate and escape user input before using in regex. Use safe-regex or re2 to prevent catastrophic backtracking.', file: fp, line: i + 1, fix: null });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return findings;
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
|
|
667
|
+
// DATA-VAL-007: Numeric input without bounds check
|
|
668
|
+
{ id: 'DATA-VAL-007', category: 'data', severity: 'medium', confidence: 'likely', title: 'Numeric Input Without Bounds Check',
|
|
669
|
+
check({ files }) {
|
|
670
|
+
const findings = [];
|
|
671
|
+
for (const [fp, c] of files) {
|
|
672
|
+
if (!isSourceFile(fp)) continue;
|
|
673
|
+
const lines = c.split('\n');
|
|
674
|
+
for (let i = 0; i < lines.length; i++) {
|
|
675
|
+
if (lines[i].match(/parseInt\(.*(?:req\.|body\.|params\.|query\.)|Number\(.*(?:req\.|body\.|params\.|query\.)/)) {
|
|
676
|
+
const nearby = lines.slice(Math.max(0, i - 2), i + 4).join('\n');
|
|
677
|
+
if (!nearby.match(/isNaN|isFinite|min|max|<\s*\d|>\s*\d/)) {
|
|
678
|
+
findings.push({ ruleId: 'DATA-VAL-007', category: 'data', severity: 'medium', title: 'Numeric user input parsed without bounds or NaN check', description: 'After parseInt/parseFloat, check isNaN() and enforce min/max bounds to prevent logic errors.', file: fp, line: i + 1, fix: null });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return findings;
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
|
|
687
|
+
// DATA-STORE-005: Database connection string hardcoded
|
|
688
|
+
{ id: 'DATA-STORE-005', category: 'data', severity: 'critical', confidence: 'definite', title: 'Database Connection String Hardcoded',
|
|
689
|
+
check({ files }) {
|
|
690
|
+
const findings = [];
|
|
691
|
+
for (const [fp, c] of files) {
|
|
692
|
+
if (!isSourceFile(fp)) continue;
|
|
693
|
+
const lines = c.split('\n');
|
|
694
|
+
for (let i = 0; i < lines.length; i++) {
|
|
695
|
+
if (lines[i].match(/mongodb:\/\/[^${\s'"]{3,}:[^${\s'"]{3,}@|postgresql:\/\/[^${\s'"]{3,}:[^${\s'"]{3,}@|mysql:\/\/[^${\s'"]{3,}:[^${\s'"]{3,}@/)) {
|
|
696
|
+
findings.push({ ruleId: 'DATA-STORE-005', category: 'data', severity: 'critical', title: 'Database connection string with credentials hardcoded', description: 'Move database URLs to environment variables. Rotate credentials immediately if committed to git.', file: fp, line: i + 1, fix: null });
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return findings;
|
|
701
|
+
},
|
|
702
|
+
},
|
|
703
|
+
|
|
704
|
+
// DATA-STORE-006: No database connection pooling
|
|
705
|
+
{ id: 'DATA-STORE-006', category: 'data', severity: 'medium', confidence: 'likely', title: 'No Database Connection Pooling',
|
|
706
|
+
check({ files, stack }) {
|
|
707
|
+
const findings = [];
|
|
708
|
+
if (!stack.database) return findings;
|
|
709
|
+
const allCode = [...files.values()].join('\n');
|
|
710
|
+
if (!allCode.match(/pool:|poolSize|connectionLimit|max.*connections|pg\.Pool|createPool/i)) {
|
|
711
|
+
findings.push({ ruleId: 'DATA-STORE-006', category: 'data', severity: 'medium', title: 'No database connection pooling configured', description: 'Configure pooling (pool: { min: 2, max: 10 }). Without pooling, each request opens a new DB connection.', fix: null });
|
|
712
|
+
}
|
|
713
|
+
return findings;
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
|
|
717
|
+
// DATA-STORE-007: S3 bucket without versioning
|
|
718
|
+
{ id: 'DATA-STORE-007', category: 'data', severity: 'medium', confidence: 'likely', title: 'S3 Bucket Without Versioning',
|
|
719
|
+
check({ files }) {
|
|
720
|
+
const findings = [];
|
|
721
|
+
for (const [fp, c] of files) {
|
|
722
|
+
if (!fp.match(/\.(tf|json|ya?ml)$/)) continue;
|
|
723
|
+
if (c.match(/aws_s3_bucket|S3Bucket|Type.*AWS::S3/i) && !c.match(/versioning|VersioningConfiguration/i)) {
|
|
724
|
+
findings.push({ ruleId: 'DATA-STORE-007', category: 'data', severity: 'medium', title: 'S3 bucket without versioning — accidental deletions are unrecoverable', description: 'Enable S3 versioning and MFA delete for production buckets.', file: fp, fix: null });
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return findings;
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
|
|
731
|
+
// DATA-STORE-008: MongoDB without authentication
|
|
732
|
+
{ id: 'DATA-STORE-008', category: 'data', severity: 'critical', confidence: 'definite', title: 'MongoDB Connection Without Authentication',
|
|
733
|
+
check({ files }) {
|
|
734
|
+
const findings = [];
|
|
735
|
+
for (const [fp, c] of files) {
|
|
736
|
+
if (!isSourceFile(fp)) continue;
|
|
737
|
+
const lines = c.split('\n');
|
|
738
|
+
for (let i = 0; i < lines.length; i++) {
|
|
739
|
+
if (lines[i].match(/mongoose\.connect\(['"]mongodb:\/\/localhost|mongodb\.connect\(['"]mongodb:\/\/localhost/) && !lines[i].match(/authSource|username|password/i)) {
|
|
740
|
+
findings.push({ ruleId: 'DATA-STORE-008', category: 'data', severity: 'critical', title: 'MongoDB connection without authentication credentials', description: 'Enable MongoDB auth (--auth) and create least-privilege user accounts.', file: fp, line: i + 1, fix: null });
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return findings;
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
|
|
748
|
+
// DATA-STORE-009: Redis without password
|
|
749
|
+
{ id: 'DATA-STORE-009', category: 'data', severity: 'high', confidence: 'likely', title: 'Redis Connection Without Password',
|
|
750
|
+
check({ files }) {
|
|
751
|
+
const findings = [];
|
|
752
|
+
for (const [fp, c] of files) {
|
|
753
|
+
if (!isSourceFile(fp)) continue;
|
|
754
|
+
const lines = c.split('\n');
|
|
755
|
+
for (let i = 0; i < lines.length; i++) {
|
|
756
|
+
if (lines[i].match(/new Redis\(\)|createClient\(\)|redis\.createClient\(\)/) && !lines[i].match(/password|auth/i)) {
|
|
757
|
+
findings.push({ ruleId: 'DATA-STORE-009', category: 'data', severity: 'high', title: 'Redis connected without password — exposed Redis has led to many breaches', description: 'Set requirepass in redis.conf and pass password in the client config.', file: fp, line: i + 1, fix: null });
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return findings;
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
|
|
765
|
+
// DATA-STORE-010: Path traversal risk in file operations
|
|
766
|
+
{ id: 'DATA-STORE-010', category: 'data', severity: 'critical', confidence: 'definite', title: 'Path Traversal Risk in File Operations',
|
|
767
|
+
check({ files }) {
|
|
768
|
+
const findings = [];
|
|
769
|
+
for (const [fp, c] of files) {
|
|
770
|
+
if (!isSourceFile(fp)) continue;
|
|
771
|
+
const lines = c.split('\n');
|
|
772
|
+
for (let i = 0; i < lines.length; i++) {
|
|
773
|
+
if (lines[i].match(/readFile|writeFile|createReadStream|createWriteStream|unlink/i) && lines[i].match(/req\.(params|query|body)\.|params\.|query\.|body\./) && !lines[i].match(/path\.basename|path\.resolve|sanitize/)) {
|
|
774
|
+
findings.push({ ruleId: 'DATA-STORE-010', category: 'data', severity: 'critical', title: 'User-controlled path in file operation — path traversal risk', description: 'Use path.basename() to strip directory components. Validate the resolved path is within the allowed directory.', file: fp, line: i + 1, fix: null });
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return findings;
|
|
779
|
+
},
|
|
780
|
+
},
|
|
781
|
+
|
|
782
|
+
// DATA-LIFE-002: No backup strategy
|
|
783
|
+
{ id: 'DATA-LIFE-002', category: 'data', severity: 'medium', confidence: 'likely', title: 'No Database Backup Strategy',
|
|
784
|
+
check({ files }) {
|
|
785
|
+
const findings = [];
|
|
786
|
+
const allCode = [...files.values()].join('\n');
|
|
787
|
+
const allPaths = [...files.keys()].join(' ');
|
|
788
|
+
if (!allCode.match(/backup|pg_dump|mongodump|snapshots|point-in-time|rds.*backup/i) && !allPaths.match(/backup/i)) {
|
|
789
|
+
findings.push({ ruleId: 'DATA-LIFE-002', category: 'data', severity: 'medium', title: 'No database backup strategy detected', description: 'Configure automated backups (RDS automated backups, MongoDB Atlas, or custom pg_dump scripts). Test restores regularly.', fix: null });
|
|
790
|
+
}
|
|
791
|
+
return findings;
|
|
792
|
+
},
|
|
793
|
+
},
|
|
794
|
+
|
|
795
|
+
// DATA-LIFE-003: No soft delete for user records
|
|
796
|
+
{ id: 'DATA-LIFE-003', category: 'data', severity: 'low', confidence: 'suggestion', title: 'No Soft Delete for User Records',
|
|
797
|
+
check({ files }) {
|
|
798
|
+
const findings = [];
|
|
799
|
+
for (const [fp, c] of files) {
|
|
800
|
+
if (!isSourceFile(fp)) continue;
|
|
801
|
+
if (c.match(/destroy\(\)|deleteOne\(\)|deleteMany\(\)/i) && c.match(/user|account|profile/i) && !c.match(/deletedAt|soft.*delete|paranoid|is_deleted/i)) {
|
|
802
|
+
findings.push({ ruleId: 'DATA-LIFE-003', category: 'data', severity: 'low', title: 'Hard delete on user records — no audit trail', description: 'Add deletedAt column (paranoid mode in Sequelize). Hard deletes make GDPR right-to-erasure audits impossible.', file: fp, fix: null });
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return findings;
|
|
806
|
+
},
|
|
807
|
+
},
|
|
808
|
+
|
|
809
|
+
// DATA-LIFE-004: No data classification on sensitive fields
|
|
810
|
+
{ id: 'DATA-LIFE-004', category: 'data', severity: 'low', confidence: 'suggestion', title: 'No Data Classification on Sensitive Fields',
|
|
811
|
+
check({ files }) {
|
|
812
|
+
const findings = [];
|
|
813
|
+
for (const [fp, c] of files) {
|
|
814
|
+
if (!fp.match(/model|schema|entity|migration/i) || !isSourceFile(fp)) continue;
|
|
815
|
+
if (c.match(/password|ssn|credit_card|card_number|secret|api_key/i) && !c.match(/@sensitive|@pii|@encrypted|data_classification|PII/)) {
|
|
816
|
+
findings.push({ ruleId: 'DATA-LIFE-004', category: 'data', severity: 'low', title: 'Sensitive database fields without data classification annotations', description: 'Add @sensitive or @pii comments to fields containing PII or secrets. Enables automated scanning and access controls.', file: fp, fix: null });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return findings;
|
|
820
|
+
},
|
|
821
|
+
},
|
|
822
|
+
|
|
823
|
+
// DATA-QUAL-003: Date parsed without ISO format
|
|
824
|
+
{ id: 'DATA-QUAL-003', category: 'data', severity: 'low', confidence: 'suggestion', title: 'Date Parsed Without ISO Format',
|
|
825
|
+
check({ files }) {
|
|
826
|
+
const findings = [];
|
|
827
|
+
for (const [fp, c] of files) {
|
|
828
|
+
if (!isSourceFile(fp)) continue;
|
|
829
|
+
const lines = c.split('\n');
|
|
830
|
+
for (let i = 0; i < lines.length; i++) {
|
|
831
|
+
if (lines[i].match(/new Date\(['"][0-9]{1,2}\/[0-9]{1,2}\/['"]/)) {
|
|
832
|
+
findings.push({ ruleId: 'DATA-QUAL-003', category: 'data', severity: 'low', title: 'Date string parsed without ISO format — locale-dependent behavior', description: 'Use ISO 8601 format (YYYY-MM-DD) or a date library (date-fns, dayjs). MM/DD/YYYY vs DD/MM/YYYY differs by locale.', file: fp, line: i + 1, fix: null });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return findings;
|
|
837
|
+
},
|
|
838
|
+
},
|
|
839
|
+
|
|
840
|
+
// DATA-QUAL-004: Sensitive data in error messages
|
|
841
|
+
{ id: 'DATA-QUAL-004', category: 'data', severity: 'high', confidence: 'likely', title: 'Sensitive Data in Error Messages',
|
|
842
|
+
check({ files }) {
|
|
843
|
+
const findings = [];
|
|
844
|
+
for (const [fp, c] of files) {
|
|
845
|
+
if (!isSourceFile(fp)) continue;
|
|
846
|
+
const lines = c.split('\n');
|
|
847
|
+
for (let i = 0; i < lines.length; i++) {
|
|
848
|
+
if (lines[i].match(/throw new Error|new Error\(/) && lines[i].match(/password|secret|token|key|credential/i)) {
|
|
849
|
+
findings.push({ ruleId: 'DATA-QUAL-004', category: 'data', severity: 'high', title: 'Sensitive value included in error message — may leak to logs or client', description: 'Use generic error messages for the user. Log details server-side without credentials.', file: fp, line: i + 1, fix: null });
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return findings;
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
|
|
857
|
+
// DATA-QUAL-005: Floating point for monetary values
|
|
858
|
+
{ id: 'DATA-QUAL-005', category: 'data', severity: 'medium', confidence: 'likely', title: 'Floating Point for Monetary Values',
|
|
859
|
+
check({ files }) {
|
|
860
|
+
const findings = [];
|
|
861
|
+
for (const [fp, c] of files) {
|
|
862
|
+
if (!isSourceFile(fp)) continue;
|
|
863
|
+
const lines = c.split('\n');
|
|
864
|
+
for (let i = 0; i < lines.length; i++) {
|
|
865
|
+
if (lines[i].match(/price|amount|balance|total|cost/i) && lines[i].match(/:\s*Float|:\s*FLOAT|:\s*DOUBLE|type.*float/i)) {
|
|
866
|
+
findings.push({ ruleId: 'DATA-QUAL-005', category: 'data', severity: 'medium', title: 'Monetary value stored as float — rounding errors accumulate', description: 'Store money as integers (cents) or use DECIMAL/NUMERIC SQL type. Use dinero.js for arithmetic. Float is imprecise for currency.', file: fp, line: i + 1, fix: null });
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return findings;
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
|
|
874
|
+
// DATA-QUAL-006: DB connection pool exhaustion
|
|
875
|
+
{ id: 'DATA-QUAL-006', category: 'data', severity: 'high', confidence: 'likely', title: 'Database Connection Pool Exhaustion Risk',
|
|
876
|
+
check({ files }) {
|
|
877
|
+
const findings = [];
|
|
878
|
+
for (const [fp, c] of files) {
|
|
879
|
+
if (!isSourceFile(fp)) continue;
|
|
880
|
+
const lines = c.split('\n');
|
|
881
|
+
for (let i = 0; i < lines.length; i++) {
|
|
882
|
+
if (lines[i].match(/const\s+client\s*=\s*await\s+pool\.connect\(\)/)) {
|
|
883
|
+
const block = lines.slice(i, i + 30).join('\n');
|
|
884
|
+
if (!block.match(/finally[\s\S]*client\.release/)) {
|
|
885
|
+
findings.push({ ruleId: 'DATA-QUAL-006', category: 'data', severity: 'high', title: 'pool.connect() without client.release() in finally — connection pool exhaustion', description: 'Always call client.release() in a finally block. Leaked connections exhaust the pool causing timeouts.', file: fp, line: i + 1, fix: null });
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return findings;
|
|
891
|
+
},
|
|
892
|
+
},
|
|
893
|
+
|
|
894
|
+
// DATA-QUAL-007: JSON.stringify on circular objects
|
|
895
|
+
{ id: 'DATA-QUAL-007', category: 'data', severity: 'medium', confidence: 'likely', title: 'JSON.stringify on Potentially Circular Object',
|
|
896
|
+
check({ files }) {
|
|
897
|
+
const findings = [];
|
|
898
|
+
for (const [fp, c] of files) {
|
|
899
|
+
if (!isSourceFile(fp)) continue;
|
|
900
|
+
const lines = c.split('\n');
|
|
901
|
+
for (let i = 0; i < lines.length; i++) {
|
|
902
|
+
if (lines[i].match(/JSON\.stringify\(/) && lines[i].match(/req\.|res\.|error|Error/i) && !lines[i].match(/replacer|circular|safe-json/i)) {
|
|
903
|
+
findings.push({ ruleId: 'DATA-QUAL-007', category: 'data', severity: 'medium', title: 'JSON.stringify on potentially circular object — may throw at runtime', description: 'Use safe-json-stringify or custom replacer when serializing request/response/error objects. Express req/res are circular.', file: fp, line: i + 1, fix: null });
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return findings;
|
|
908
|
+
},
|
|
909
|
+
},
|
|
910
|
+
|
|
911
|
+
// DATA-QUAL-008: Unbounded array growth in module scope
|
|
912
|
+
{ id: 'DATA-QUAL-008', category: 'data', severity: 'medium', confidence: 'likely', title: 'Unbounded Array in Module Scope',
|
|
913
|
+
check({ files }) {
|
|
914
|
+
const findings = [];
|
|
915
|
+
for (const [fp, c] of files) {
|
|
916
|
+
if (!isSourceFile(fp)) continue;
|
|
917
|
+
const lines = c.split('\n');
|
|
918
|
+
for (let i = 0; i < lines.length; i++) {
|
|
919
|
+
if (lines[i].match(/^(?:const|let|var)\s+\w+\s*=\s*\[\]/) && !lines[i].match(/\/\/ bounded|MAX_SIZE/)) {
|
|
920
|
+
const rest = lines.slice(i + 1, i + 50).join('\n');
|
|
921
|
+
if (rest.match(/\.push\(/) && !rest.match(/\.shift\(\)|\.splice\(|\.slice\(|maxLength|MAX_/)) {
|
|
922
|
+
findings.push({ ruleId: 'DATA-QUAL-008', category: 'data', severity: 'medium', title: 'Module-scope array grows unboundedly — potential memory leak', description: 'Cap the array size or use a circular buffer. Unbounded module-level arrays leak memory over time.', file: fp, line: i + 1, fix: null });
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return findings;
|
|
928
|
+
},
|
|
929
|
+
},
|
|
930
|
+
|
|
931
|
+
// DATA-QUAL-009: Object spread loses prototype methods
|
|
932
|
+
{ id: 'DATA-QUAL-009', category: 'data', severity: 'low', confidence: 'suggestion', title: 'Object Spread on Class Instance Loses Methods',
|
|
933
|
+
check({ files }) {
|
|
934
|
+
const findings = [];
|
|
935
|
+
for (const [fp, c] of files) {
|
|
936
|
+
if (!isSourceFile(fp)) continue;
|
|
937
|
+
const lines = c.split('\n');
|
|
938
|
+
for (let i = 0; i < lines.length; i++) {
|
|
939
|
+
if (lines[i].match(/\{\s*\.\.\.(new\s+\w+|await\s+\w+\.\w+\()/)) {
|
|
940
|
+
findings.push({ ruleId: 'DATA-QUAL-009', category: 'data', severity: 'low', title: 'Spreading class instance — prototype methods and non-enumerable props lost', description: 'Use Object.assign() or explicitly list fields. Spreading a class instance only copies own enumerable properties.', file: fp, line: i + 1, fix: null });
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return findings;
|
|
945
|
+
},
|
|
946
|
+
},
|
|
947
|
+
|
|
948
|
+
// DATA-QUAL-010: Large payload serialized to string for comparison
|
|
949
|
+
{ id: 'DATA-QUAL-010', category: 'data', severity: 'medium', confidence: 'likely', title: 'JSON Serialization for Object Comparison',
|
|
950
|
+
check({ files }) {
|
|
951
|
+
const findings = [];
|
|
952
|
+
for (const [fp, c] of files) {
|
|
953
|
+
if (!isSourceFile(fp)) continue;
|
|
954
|
+
const lines = c.split('\n');
|
|
955
|
+
for (let i = 0; i < lines.length; i++) {
|
|
956
|
+
if (lines[i].match(/JSON\.stringify\(.*\)\s*===?\s*JSON\.stringify\(|JSON\.stringify\(.*\)\s*!==?\s*JSON\.stringify\(/)) {
|
|
957
|
+
findings.push({ ruleId: 'DATA-QUAL-010', category: 'data', severity: 'medium', title: 'JSON.stringify used for object equality comparison — fragile and slow', description: 'Use deep-equal library or compare individual fields. JSON.stringify comparison fails for different key ordering and ignores undefined values.', file: fp, line: i + 1, fix: null });
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return findings;
|
|
962
|
+
},
|
|
963
|
+
},
|
|
964
|
+
|
|
965
|
+
// DATA-ENC-011: No end-to-end encryption for sensitive messages
|
|
966
|
+
{ id: 'DATA-ENC-011', category: 'data', severity: 'high', confidence: 'likely', title: 'Sensitive Messages Without E2E Encryption',
|
|
967
|
+
check({ files }) {
|
|
968
|
+
const findings = [];
|
|
969
|
+
for (const [fp, c] of files) {
|
|
970
|
+
if (!isSourceFile(fp)) continue;
|
|
971
|
+
if (c.match(/message|chat|inbox|dm\b|directMessage/i) && c.match(/save|send|store|insert/i)) {
|
|
972
|
+
if (!c.match(/e2e|end.*to.*end|encrypt.*message|sealed|libsodium|signal.*protocol/i)) {
|
|
973
|
+
findings.push({ ruleId: 'DATA-ENC-011', category: 'data', severity: 'high', title: 'Messages stored/sent without end-to-end encryption', description: 'If privacy is a feature, implement E2E encryption using libsodium or the Signal Protocol. Server should not be able to read message contents.', file: fp, fix: null });
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return findings;
|
|
978
|
+
},
|
|
979
|
+
},
|
|
980
|
+
|
|
981
|
+
// DATA-PII-009: Health data stored without consent
|
|
982
|
+
{ id: 'DATA-PII-009', category: 'data', severity: 'critical', confidence: 'definite', title: 'Health Data Collected Without Explicit Consent',
|
|
983
|
+
check({ files }) {
|
|
984
|
+
const findings = [];
|
|
985
|
+
for (const [fp, c] of files) {
|
|
986
|
+
if (!isSourceFile(fp)) continue;
|
|
987
|
+
if (c.match(/health|medical|fitness|sleep|heart.*rate|bloodPressure|diagnosis/i) && c.match(/save|create|insert|store/i)) {
|
|
988
|
+
if (!c.match(/consent|hipaa|authorization|permission/i)) {
|
|
989
|
+
findings.push({ ruleId: 'DATA-PII-009', category: 'data', severity: 'critical', title: 'Health data stored without explicit consent or HIPAA authorization', description: 'Obtain explicit consent before collecting health data. Document the purpose, obtain written authorization, and implement HIPAA safeguards.', file: fp, fix: null });
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return findings;
|
|
994
|
+
},
|
|
995
|
+
},
|
|
996
|
+
|
|
997
|
+
// DATA-STORE-011: No database index on foreign keys
|
|
998
|
+
{ id: 'DATA-STORE-011', category: 'data', severity: 'medium', confidence: 'likely', title: 'Foreign Keys Without Index',
|
|
999
|
+
check({ files }) {
|
|
1000
|
+
const findings = [];
|
|
1001
|
+
for (const [fp, c] of files) {
|
|
1002
|
+
if (!fp.match(/migration|schema|model/i) || !isSourceFile(fp)) continue;
|
|
1003
|
+
const lines = c.split('\n');
|
|
1004
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1005
|
+
if (lines[i].match(/\.integer\(.*Id\)|\.bigInteger\(.*Id\)|references\(\)|foreign.*key/i)) {
|
|
1006
|
+
const nearby = lines.slice(Math.max(0, i - 3), i + 5).join('\n');
|
|
1007
|
+
if (!nearby.match(/\.index\(\)|index.*true|createIndex/i)) {
|
|
1008
|
+
findings.push({ ruleId: 'DATA-STORE-011', category: 'data', severity: 'medium', title: 'Foreign key column without explicit index', description: 'Add index to foreign key columns: table.index("userId"). JOINs and WHERE clauses on FK columns are slow without indexes.', file: fp, line: i + 1, fix: null });
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return findings;
|
|
1014
|
+
},
|
|
1015
|
+
},
|
|
1016
|
+
|
|
1017
|
+
// DATA-STORE-012: Storing secrets in database as plain text
|
|
1018
|
+
{ id: 'DATA-STORE-012', category: 'data', severity: 'critical', confidence: 'definite', title: 'Secrets Stored as Plain Text in Database',
|
|
1019
|
+
check({ files }) {
|
|
1020
|
+
const findings = [];
|
|
1021
|
+
for (const [fp, c] of files) {
|
|
1022
|
+
if (!isSourceFile(fp)) continue;
|
|
1023
|
+
if (c.match(/api_key|apiKey|secret|token|access_token/i) && c.match(/save|insert|create|store/i)) {
|
|
1024
|
+
if (!c.match(/encrypt|hash|bcrypt|argon|vault|kms/i)) {
|
|
1025
|
+
findings.push({ ruleId: 'DATA-STORE-012', category: 'data', severity: 'critical', title: 'API keys/secrets potentially stored as plain text in database', description: 'Encrypt sensitive values before storage using KMS or AES-256-GCM. Plain text secrets in DB become breached if DB is compromised.', file: fp, fix: null });
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return findings;
|
|
1030
|
+
},
|
|
1031
|
+
},
|
|
1032
|
+
|
|
1033
|
+
// DATA-QUAL-011: Using parseInt with no radix
|
|
1034
|
+
{ id: 'DATA-QUAL-011', category: 'data', severity: 'low', confidence: 'suggestion', title: 'parseInt Without Radix Parameter',
|
|
1035
|
+
check({ files }) {
|
|
1036
|
+
const findings = [];
|
|
1037
|
+
for (const [fp, c] of files) {
|
|
1038
|
+
if (!isSourceFile(fp)) continue;
|
|
1039
|
+
const lines = c.split('\n');
|
|
1040
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1041
|
+
if (lines[i].match(/parseInt\s*\([^,)]+\)/) && !lines[i].match(/parseInt\s*\([^,)]+,\s*10\)/)) {
|
|
1042
|
+
findings.push({ ruleId: 'DATA-QUAL-011', category: 'data', severity: 'low', title: 'parseInt() called without radix — octal parsing of strings starting with 0', description: 'Always pass radix 10: parseInt(str, 10). Without radix, "08" returns 0 in some engines (octal). Enable ESLint radix rule.', file: fp, line: i + 1, fix: null });
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
return findings;
|
|
1047
|
+
},
|
|
1048
|
+
},
|
|
1049
|
+
|
|
1050
|
+
// DATA-QUAL-012: Inconsistent null handling
|
|
1051
|
+
{ id: 'DATA-QUAL-012', category: 'data', severity: 'medium', confidence: 'likely', title: 'Optional Chaining Not Used Consistently',
|
|
1052
|
+
check({ files }) {
|
|
1053
|
+
const findings = [];
|
|
1054
|
+
for (const [fp, c] of files) {
|
|
1055
|
+
if (!isSourceFile(fp)) continue;
|
|
1056
|
+
const looseChecks = (c.match(/if\s*\(\s*\w+\s*&&\s*\w+\.\w+\s*&&\s*\w+\.\w+\.\w+/g) || []).length;
|
|
1057
|
+
const optionalChaining = (c.match(/\?\./g) || []).length;
|
|
1058
|
+
if (looseChecks > 5 && optionalChaining === 0) {
|
|
1059
|
+
findings.push({ ruleId: 'DATA-QUAL-012', category: 'data', severity: 'medium', title: `${looseChecks} manual null-guard chains — use optional chaining (?.)`, description: 'Replace a && a.b && a.b.c with a?.b?.c. Optional chaining is safer and more concise, reducing null dereference errors.', file: fp, fix: null });
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
return findings;
|
|
1063
|
+
},
|
|
1064
|
+
},
|
|
1065
|
+
|
|
1066
|
+
// DATA-LIFE-005: No PII anonymization in test data
|
|
1067
|
+
{ id: 'DATA-LIFE-005', category: 'data', severity: 'high', confidence: 'likely', title: 'Production PII Used in Test Data',
|
|
1068
|
+
check({ files }) {
|
|
1069
|
+
const findings = [];
|
|
1070
|
+
for (const [fp, c] of files) {
|
|
1071
|
+
if (!fp.match(/seed|fixture|factory|test.*data|mock.*data/i)) continue;
|
|
1072
|
+
if (c.match(/john@|jane@|@gmail\.com|@yahoo\.com/i) || c.match(/"firstName".*"John"|"lastName".*"Smith"/i)) {
|
|
1073
|
+
findings.push({ ruleId: 'DATA-LIFE-005', category: 'data', severity: 'high', title: 'Real-looking PII in test data/fixtures — may be copied from production', description: 'Use obviously fake data (test@example.com, user@test.invalid). Never copy production PII to test environments. Use faker.js to generate synthetic data.', file: fp, fix: null });
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return findings;
|
|
1077
|
+
},
|
|
1078
|
+
},
|
|
1079
|
+
|
|
1080
|
+
// DATA-ENC-012: JWT with none algorithm
|
|
1081
|
+
{ id: 'DATA-ENC-012', category: 'data', severity: 'critical', confidence: 'definite', title: 'JWT Algorithm Set to None',
|
|
1082
|
+
check({ files }) {
|
|
1083
|
+
const findings = [];
|
|
1084
|
+
for (const [fp, c] of files) {
|
|
1085
|
+
if (!isSourceFile(fp)) continue;
|
|
1086
|
+
const lines = c.split('\n');
|
|
1087
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1088
|
+
if (lines[i].match(/algorithm.*['"]none['"]|alg.*['"]none['"]/i)) {
|
|
1089
|
+
findings.push({ ruleId: 'DATA-ENC-012', category: 'data', severity: 'critical', title: 'JWT configured with algorithm "none" — tokens are unsigned and forgeable', description: 'Remove "none" algorithm. jwt.verify() must specify allowed algorithms: verify(token, secret, { algorithms: ["HS256"] }).', file: fp, line: i + 1, fix: null });
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
return findings;
|
|
1094
|
+
},
|
|
1095
|
+
},
|
|
1096
|
+
|
|
1097
|
+
// DATA-STORE-013: Using eval() to parse untrusted JSON
|
|
1098
|
+
{ id: 'DATA-STORE-013', category: 'data', severity: 'critical', confidence: 'definite', title: 'eval() Used to Parse JSON',
|
|
1099
|
+
check({ files }) {
|
|
1100
|
+
const findings = [];
|
|
1101
|
+
for (const [fp, c] of files) {
|
|
1102
|
+
if (!isSourceFile(fp)) continue;
|
|
1103
|
+
const lines = c.split('\n');
|
|
1104
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1105
|
+
if (lines[i].match(/eval\s*\(.*json|eval\s*\(.*response|eval\s*\(.*body/i)) {
|
|
1106
|
+
findings.push({ ruleId: 'DATA-STORE-013', category: 'data', severity: 'critical', title: 'eval() used to parse JSON — arbitrary code execution', description: 'Use JSON.parse() instead of eval(). eval() executes any JavaScript in the string, enabling remote code execution from malicious JSON.', file: fp, line: i + 1, fix: null });
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return findings;
|
|
1111
|
+
},
|
|
1112
|
+
},
|
|
1113
|
+
|
|
1114
|
+
// DATA-ENC-013: No certificate pinning for mobile
|
|
1115
|
+
{ id: 'DATA-ENC-013', category: 'data', severity: 'medium', confidence: 'likely', title: 'No Certificate Pinning for Mobile App',
|
|
1116
|
+
check({ files, stack }) {
|
|
1117
|
+
const findings = [];
|
|
1118
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
1119
|
+
const isMobile = ['react-native', 'expo', '@capacitor/core'].some(d => d in allDeps);
|
|
1120
|
+
const hasPinning = [...files.values()].some(c => c.match(/certificate.*pin|ssl.*pin|publicKeyHash|RNCNetworkingReactNative/i));
|
|
1121
|
+
if (isMobile && !hasPinning) {
|
|
1122
|
+
findings.push({ ruleId: 'DATA-ENC-013', category: 'data', severity: 'medium', title: 'Mobile app without certificate pinning — MITM interception possible', description: 'Implement certificate pinning for API calls. Without pinning, attackers can install root CA on devices and intercept all encrypted traffic.', fix: null });
|
|
1123
|
+
}
|
|
1124
|
+
return findings;
|
|
1125
|
+
},
|
|
1126
|
+
},
|
|
1127
|
+
// DATA-VAL-008: URL validation without protocol check
|
|
1128
|
+
{ id: 'DATA-VAL-008', category: 'data', severity: 'high', confidence: 'likely', title: 'URL Validation Without Protocol Allowlist',
|
|
1129
|
+
check({ files }) {
|
|
1130
|
+
const findings = [];
|
|
1131
|
+
for (const [fp, c] of files) {
|
|
1132
|
+
if (!isSourceFile(fp)) continue;
|
|
1133
|
+
const lines = c.split('\n');
|
|
1134
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1135
|
+
if (lines[i].match(/new URL\(.*req\.|URL\(.*body\.|URL\(.*params\./)) {
|
|
1136
|
+
if (!lines.slice(i, i + 5).join('\n').match(/protocol.*https|startsWith.*https|allowedProtocols/i)) {
|
|
1137
|
+
findings.push({ ruleId: 'DATA-VAL-008', category: 'data', severity: 'high', title: 'URL parsed from user input without protocol validation — SSRF risk', description: 'Validate protocol: if (!url.protocol.startsWith("https")) throw. Accepting javascript:// or file:// enables XSS or SSRF attacks.', file: fp, line: i + 1, fix: null });
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
return findings;
|
|
1143
|
+
},
|
|
1144
|
+
},
|
|
1145
|
+
// DATA-STORE-014: Large objects cached in memory indefinitely
|
|
1146
|
+
{ id: 'DATA-STORE-014', category: 'data', severity: 'medium', confidence: 'likely', title: 'In-Memory Cache Without Size Limit or TTL',
|
|
1147
|
+
check({ files }) {
|
|
1148
|
+
const findings = [];
|
|
1149
|
+
for (const [fp, c] of files) {
|
|
1150
|
+
if (!isSourceFile(fp)) continue;
|
|
1151
|
+
const lines = c.split('\n');
|
|
1152
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1153
|
+
if (lines[i].match(/^(?:const|let|var)\s+(?:cache|Cache|CACHE)\s*=\s*new\s+Map\(\)|^(?:const|let|var)\s+\w*cache\w*\s*=\s*\{\}/) ) {
|
|
1154
|
+
const rest = lines.slice(i, i + 50).join('\n');
|
|
1155
|
+
if (!rest.match(/\.delete\(|expire|ttl|maxSize|LRU|lru/i)) {
|
|
1156
|
+
findings.push({ ruleId: 'DATA-STORE-014', category: 'data', severity: 'medium', title: 'In-memory cache without eviction policy or TTL — memory leak', description: 'Use a proper LRU cache (lru-cache) with maxSize and TTL. Unlimited Maps leak memory as entries accumulate indefinitely.', file: fp, line: i + 1, fix: null });
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
return findings;
|
|
1162
|
+
},
|
|
1163
|
+
},
|
|
1164
|
+
// DATA-QUAL-013: Missing input trimming
|
|
1165
|
+
{ id: 'DATA-QUAL-013', category: 'data', severity: 'low', confidence: 'suggestion', title: 'User Text Input Not Trimmed',
|
|
1166
|
+
check({ files }) {
|
|
1167
|
+
const findings = [];
|
|
1168
|
+
for (const [fp, c] of files) {
|
|
1169
|
+
if (!isSourceFile(fp)) continue;
|
|
1170
|
+
const lines = c.split('\n');
|
|
1171
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1172
|
+
if (lines[i].match(/req\.body\.\w+|req\.query\.\w+/) && lines[i].match(/==/)) {
|
|
1173
|
+
const nearby = lines.slice(Math.max(0, i - 5), i + 5).join('\n');
|
|
1174
|
+
if (!nearby.match(/\.trim\(\)|sanitize|normalize/)) {
|
|
1175
|
+
findings.push({ ruleId: 'DATA-QUAL-013', category: 'data', severity: 'low', title: 'User input compared without trimming — leading/trailing spaces cause logic bugs', description: 'Always trim user input before comparison or storage: req.body.email.trim(). Leading/trailing whitespace causes "john@ex.com " to not match "john@ex.com".', file: fp, line: i + 1, fix: null });
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
return findings;
|
|
1181
|
+
},
|
|
1182
|
+
},
|
|
1183
|
+
// DATA-PII-010: Social media access tokens stored insecurely
|
|
1184
|
+
{ id: 'DATA-PII-010', category: 'data', severity: 'high', confidence: 'likely', title: 'OAuth Access Tokens Stored Insecurely',
|
|
1185
|
+
check({ files }) {
|
|
1186
|
+
const findings = [];
|
|
1187
|
+
for (const [fp, c] of files) {
|
|
1188
|
+
if (!isSourceFile(fp)) continue;
|
|
1189
|
+
if (c.match(/accessToken|access_token|oauthToken/i) && c.match(/localStorage|cookie|req\.session/i)) {
|
|
1190
|
+
if (!c.match(/httpOnly.*true|Secure.*true|encrypt/i)) {
|
|
1191
|
+
findings.push({ ruleId: 'DATA-PII-010', category: 'data', severity: 'high', title: 'OAuth access token stored without security flags', description: 'Store OAuth tokens in httpOnly, Secure cookies or encrypt before storing. Tokens in non-httpOnly cookies or localStorage are accessible to XSS.', file: fp, fix: null });
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return findings;
|
|
1196
|
+
},
|
|
1197
|
+
},
|
|
1198
|
+
// DATA-ENC-014: Using MD5 for data integrity
|
|
1199
|
+
{ id: 'DATA-ENC-014', category: 'data', severity: 'high', confidence: 'likely', title: 'MD5 Used for Data Integrity Check',
|
|
1200
|
+
check({ files }) {
|
|
1201
|
+
const findings = [];
|
|
1202
|
+
for (const [fp, c] of files) {
|
|
1203
|
+
if (!isSourceFile(fp)) continue;
|
|
1204
|
+
const lines = c.split('\n');
|
|
1205
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1206
|
+
if (lines[i].match(/createHash\(['"]md5['"]\)/i) && !lines[i].match(/\/\//)) {
|
|
1207
|
+
findings.push({ ruleId: 'DATA-ENC-014', category: 'data', severity: 'high', title: 'MD5 used for integrity check — trivially collide-able', description: 'Use SHA-256 or SHA-3 for integrity checks. MD5 has known collision attacks that allow forging files with the same hash.', file: fp, line: i + 1, fix: null });
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
return findings;
|
|
1212
|
+
},
|
|
1213
|
+
},
|
|
1214
|
+
// DATA-QUAL-014: Using JSON.parse without try/catch
|
|
1215
|
+
{ id: 'DATA-QUAL-014', category: 'data', severity: 'medium', confidence: 'likely', title: 'JSON.parse Without Error Handling',
|
|
1216
|
+
check({ files }) {
|
|
1217
|
+
const findings = [];
|
|
1218
|
+
for (const [fp, c] of files) {
|
|
1219
|
+
if (!isSourceFile(fp)) continue;
|
|
1220
|
+
const lines = c.split('\n');
|
|
1221
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1222
|
+
if (lines[i].match(/JSON\.parse\s*\(/) && !lines[i].match(/\/\//)) {
|
|
1223
|
+
const context = lines.slice(Math.max(0, i - 5), i + 5).join('\n');
|
|
1224
|
+
if (!context.match(/try\s*\{/) && !context.match(/catch\s*\(/)) {
|
|
1225
|
+
if (lines[i].match(/req\.|res\.|body\.|query\.|param\.|process\.env\.|config\./)) {
|
|
1226
|
+
findings.push({ ruleId: 'DATA-QUAL-014', category: 'data', severity: 'medium', title: 'JSON.parse on external data without try/catch — crashes on malformed input', description: 'Wrap JSON.parse in try/catch when parsing external data. Malformed JSON throws SyntaxError which crashes the request if not caught.', file: fp, line: i + 1, fix: null });
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
return findings;
|
|
1233
|
+
},
|
|
1234
|
+
},
|
|
1235
|
+
// DATA-STORE-015: Storing passwords as password_hash with MD5/SHA1
|
|
1236
|
+
{ id: 'DATA-STORE-015', category: 'data', severity: 'critical', confidence: 'definite', title: 'Weak Password Hashing Algorithm',
|
|
1237
|
+
check({ files }) {
|
|
1238
|
+
const findings = [];
|
|
1239
|
+
for (const [fp, c] of files) {
|
|
1240
|
+
if (!isSourceFile(fp)) continue;
|
|
1241
|
+
const lines = c.split('\n');
|
|
1242
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1243
|
+
if (lines[i].match(/createHash\(['"]sha1['"]|createHash\(['"]md5['"]\)/) && lines.slice(Math.max(0, i - 3), i + 3).some(l => l.match(/password|passwd|pwd/i))) {
|
|
1244
|
+
findings.push({ ruleId: 'DATA-STORE-015', category: 'data', severity: 'critical', title: 'Password hashed with MD5/SHA1 — crackable via rainbow tables', description: 'Use bcrypt, Argon2id, or scrypt for password hashing. MD5/SHA1 are fast hash functions — unsuitable for passwords. Rainbow table attacks crack them instantly.', file: fp, line: i + 1, fix: null });
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return findings;
|
|
1249
|
+
},
|
|
1250
|
+
},
|
|
1251
|
+
// DATA-ENC-015: Encryption key derived from user input directly
|
|
1252
|
+
{ id: 'DATA-ENC-015', category: 'data', severity: 'high', confidence: 'likely', title: 'Encryption Key Derived from Password Without KDF',
|
|
1253
|
+
check({ files }) {
|
|
1254
|
+
const findings = [];
|
|
1255
|
+
for (const [fp, c] of files) {
|
|
1256
|
+
if (!isSourceFile(fp)) continue;
|
|
1257
|
+
const lines = c.split('\n');
|
|
1258
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1259
|
+
if (lines[i].match(/createCipheriv|AES|crypto\.createCipher\b/) && !lines[i].match(/\/\//)) {
|
|
1260
|
+
const ctx = lines.slice(Math.max(0, i - 10), i + 3).join('\n');
|
|
1261
|
+
if (ctx.match(/password|passphrase|userKey/i) && !ctx.match(/pbkdf2|scrypt|argon2|bcrypt/i)) {
|
|
1262
|
+
findings.push({ ruleId: 'DATA-ENC-015', category: 'data', severity: 'high', title: 'Encryption key derived from password without KDF', description: 'Use PBKDF2, scrypt, or Argon2 to derive encryption keys from passwords. Raw passwords as keys are weak and predictable due to low entropy.', file: fp, line: i + 1, fix: null });
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
return findings;
|
|
1268
|
+
},
|
|
1269
|
+
},
|
|
1270
|
+
// DATA-PII-011: PII returned in API response body unnecessarily
|
|
1271
|
+
{ id: 'DATA-PII-011', category: 'data', severity: 'medium', confidence: 'likely', title: 'PII Fields Returned in API Response Without Filtering',
|
|
1272
|
+
check({ files }) {
|
|
1273
|
+
const findings = [];
|
|
1274
|
+
for (const [fp, c] of files) {
|
|
1275
|
+
if (!isSourceFile(fp)) continue;
|
|
1276
|
+
const lines = c.split('\n');
|
|
1277
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1278
|
+
if (lines[i].match(/res\.json\s*\(|res\.send\s*\(/) && !lines[i].match(/\/\//)) {
|
|
1279
|
+
const ctx = lines.slice(Math.max(0, i - 5), i + 3).join('\n');
|
|
1280
|
+
if (ctx.match(/password|ssn|credit_card|cardNumber|social_security/i)) {
|
|
1281
|
+
findings.push({ ruleId: 'DATA-PII-011', category: 'data', severity: 'medium', title: 'Sensitive PII fields in API response — over-exposure risk', description: 'Explicitly select fields to return in API responses. Use a DTO/serializer pattern to ensure password, SSN, and card data are never accidentally serialized.', file: fp, line: i + 1, fix: null });
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
return findings;
|
|
1287
|
+
},
|
|
1288
|
+
},
|
|
1289
|
+
// DATA-VAL-009: Missing input length validation
|
|
1290
|
+
{ id: 'DATA-VAL-009', category: 'data', severity: 'medium', confidence: 'likely', title: 'No Maximum Length Validation on Text Inputs',
|
|
1291
|
+
check({ files }) {
|
|
1292
|
+
const findings = [];
|
|
1293
|
+
for (const [fp, c] of files) {
|
|
1294
|
+
if (!isSourceFile(fp)) continue;
|
|
1295
|
+
const lines = c.split('\n');
|
|
1296
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1297
|
+
if (lines[i].match(/req\.body\.\w+|req\.query\.\w+/) && !lines[i].match(/\.length|maxlength|max\s*:|\.max\s*\(/i)) {
|
|
1298
|
+
const ctx = lines.slice(Math.max(0, i - 2), i + 5).join('\n');
|
|
1299
|
+
if (!ctx.match(/\.max\s*\(|maxLength|MAX_LENGTH|\.slice\s*\(|\.substring\s*\(|\.length\s*[<>]/)) {
|
|
1300
|
+
findings.push({ ruleId: 'DATA-VAL-009', category: 'data', severity: 'medium', title: 'User input used without maximum length check', description: 'Add maximum length validation for all user inputs. Unconstrained input length enables denial of service (memory), database column truncation bugs, and ReDoS.', file: fp, line: i + 1, fix: null });
|
|
1301
|
+
break;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
return findings;
|
|
1307
|
+
},
|
|
1308
|
+
},
|
|
1309
|
+
// DATA-LIFE-006: No data archiving strategy
|
|
1310
|
+
{ id: 'DATA-LIFE-006', category: 'data', severity: 'low', confidence: 'suggestion', title: 'No Data Archiving Strategy for Old Records',
|
|
1311
|
+
check({ files, stack }) {
|
|
1312
|
+
const findings = [];
|
|
1313
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
1314
|
+
const hasDB = ['pg', 'mysql', 'mysql2', 'mongoose'].some(d => d in allDeps);
|
|
1315
|
+
const hasArchive = [...files.values()].some(c => c.match(/archive|partition|cold.?storage|glacier|s3.*old|TTL|expires_at/i));
|
|
1316
|
+
const hasLargeModels = [...files.values()].some(c => c.match(/createdAt|created_at|timestamp/i));
|
|
1317
|
+
if (hasDB && hasLargeModels && !hasArchive) {
|
|
1318
|
+
findings.push({ ruleId: 'DATA-LIFE-006', category: 'data', severity: 'low', title: 'No data archiving strategy — database grows unbounded over time', description: 'Implement archiving for historical data (partitioning, archive tables, S3 cold storage). Unbounded database growth increases query times and storage costs.', fix: null });
|
|
1319
|
+
}
|
|
1320
|
+
return findings;
|
|
1321
|
+
},
|
|
1322
|
+
},
|
|
1323
|
+
// DATA-STORE-016: Using localStorage for sensitive data
|
|
1324
|
+
{ id: 'DATA-STORE-016', category: 'data', severity: 'high', confidence: 'likely', title: 'Sensitive Data Stored in localStorage',
|
|
1325
|
+
check({ files }) {
|
|
1326
|
+
const findings = [];
|
|
1327
|
+
for (const [fp, c] of files) {
|
|
1328
|
+
if (!fp.match(/\.(js|jsx|ts|tsx)$/)) continue;
|
|
1329
|
+
const lines = c.split('\n');
|
|
1330
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1331
|
+
if (lines[i].match(/localStorage\.setItem\s*\(.*?(token|password|secret|key|auth|credential)/i) && !lines[i].match(/\/\//)) {
|
|
1332
|
+
findings.push({ ruleId: 'DATA-STORE-016', category: 'data', severity: 'high', title: 'Sensitive data stored in localStorage — accessible to XSS attacks', description: 'Store authentication tokens in httpOnly cookies. localStorage is accessible to JavaScript and any XSS vulnerability can steal all stored tokens.', file: fp, line: i + 1, fix: null });
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
return findings;
|
|
1337
|
+
},
|
|
1338
|
+
},
|
|
1339
|
+
// DATA-ENC-016: Weak random number for cryptographic purpose
|
|
1340
|
+
{ id: 'DATA-ENC-016', category: 'data', severity: 'high', confidence: 'likely', title: 'Math.random() Used for Security-Sensitive Randomness',
|
|
1341
|
+
check({ files }) {
|
|
1342
|
+
const findings = [];
|
|
1343
|
+
for (const [fp, c] of files) {
|
|
1344
|
+
if (!isSourceFile(fp)) continue;
|
|
1345
|
+
const lines = c.split('\n');
|
|
1346
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1347
|
+
if (lines[i].match(/Math\.random\s*\(\s*\)/) && !lines[i].match(/\/\//)) {
|
|
1348
|
+
const ctx = lines.slice(Math.max(0, i - 5), i + 5).join('\n');
|
|
1349
|
+
if (ctx.match(/token|session|key|salt|nonce|otp|code|password|secret|csrf/i)) {
|
|
1350
|
+
findings.push({ ruleId: 'DATA-ENC-016', category: 'data', severity: 'high', title: 'Math.random() used for security token — not cryptographically secure', description: 'Use crypto.randomBytes() or crypto.randomUUID(). Math.random() is a PRNG seeded from system time, predictable, and unsuitable for security tokens.', file: fp, line: i + 1, fix: null });
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
return findings;
|
|
1356
|
+
},
|
|
1357
|
+
},
|
|
1358
|
+
// DATA-STORE-017: Database queries with no transaction for multi-step writes
|
|
1359
|
+
{ id: 'DATA-STORE-017', category: 'data', severity: 'high', confidence: 'likely', title: 'Multi-Step DB Write Without Transaction',
|
|
1360
|
+
check({ files }) {
|
|
1361
|
+
const findings = [];
|
|
1362
|
+
for (const [fp, c] of files) {
|
|
1363
|
+
if (!isSourceFile(fp)) continue;
|
|
1364
|
+
const lines = c.split('\n');
|
|
1365
|
+
let insertCount = 0;
|
|
1366
|
+
let hasTransaction = false;
|
|
1367
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1368
|
+
if (lines[i].match(/\.save\s*\(\s*\)|\.insert\s*\(|\.create\s*\(/)) insertCount++;
|
|
1369
|
+
if (lines[i].match(/transaction|beginTransaction|BEGIN\s+TRANSACTION/i)) hasTransaction = true;
|
|
1370
|
+
}
|
|
1371
|
+
if (insertCount >= 3 && !hasTransaction) {
|
|
1372
|
+
findings.push({ ruleId: 'DATA-STORE-017', category: 'data', severity: 'high', title: 'Multiple DB writes without transaction — partial write on failure leaves data corrupt', description: 'Wrap related writes in a database transaction. Without transactions, a failure midway through a multi-step write leaves the database in an inconsistent state.', file: fp, fix: null });
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return findings;
|
|
1376
|
+
},
|
|
1377
|
+
},
|
|
1378
|
+
// DATA-QUAL-015: Comparing dates as strings
|
|
1379
|
+
{ id: 'DATA-QUAL-015', category: 'data', severity: 'medium', confidence: 'likely', title: 'Date Comparison Using String Comparison',
|
|
1380
|
+
check({ files }) {
|
|
1381
|
+
const findings = [];
|
|
1382
|
+
for (const [fp, c] of files) {
|
|
1383
|
+
if (!isSourceFile(fp)) continue;
|
|
1384
|
+
const lines = c.split('\n');
|
|
1385
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1386
|
+
if (lines[i].match(/date.*[><=!]{1,2}.*date|createdAt.*[><=!]+.*date|updatedAt.*[><=!]+/i) && lines[i].match(/['"`]\d{4}-\d{2}-\d{2}['"`]/)) {
|
|
1387
|
+
findings.push({ ruleId: 'DATA-QUAL-015', category: 'data', severity: 'medium', title: 'Date compared as string — breaks with non-ISO formats', description: 'Convert to Date objects before comparing. String date comparison only works for ISO 8601 format and breaks with locale-specific formats.', file: fp, line: i + 1, fix: null });
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
return findings;
|
|
1392
|
+
},
|
|
1393
|
+
},
|
|
1394
|
+
// DATA-STORE-018: Caching sensitive user data without expiry
|
|
1395
|
+
{ id: 'DATA-STORE-018', category: 'data', severity: 'high', confidence: 'likely', title: 'Sensitive Data Cached Without Expiry',
|
|
1396
|
+
check({ files }) {
|
|
1397
|
+
const findings = [];
|
|
1398
|
+
for (const [fp, c] of files) {
|
|
1399
|
+
if (!isSourceFile(fp)) continue;
|
|
1400
|
+
const lines = c.split('\n');
|
|
1401
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1402
|
+
if (lines[i].match(/cache\.set\s*\(|redis\.set\s*\(/) && !lines[i].match(/\/\//)) {
|
|
1403
|
+
const ctx = lines.slice(i, Math.min(lines.length, i + 3)).join(' ');
|
|
1404
|
+
if (ctx.match(/password|token|session|credit|ssn|secret/i) && !ctx.match(/EX|TTL|expire|ttl/i)) {
|
|
1405
|
+
findings.push({ ruleId: 'DATA-STORE-018', category: 'data', severity: 'high', title: 'Sensitive data cached without TTL/expiry', description: 'Always set expiry (EX/TTL) when caching sensitive data. Without expiry, stale tokens and credentials persist in cache indefinitely after invalidation.', file: fp, line: i + 1, fix: null });
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
return findings;
|
|
1411
|
+
},
|
|
1412
|
+
},
|
|
1413
|
+
// DATA-PII-012: No data anonymization for analytics
|
|
1414
|
+
{ id: 'DATA-PII-012', category: 'data', severity: 'medium', confidence: 'likely', title: 'Raw PII Sent to Analytics Without Anonymization',
|
|
1415
|
+
check({ files }) {
|
|
1416
|
+
const findings = [];
|
|
1417
|
+
for (const [fp, c] of files) {
|
|
1418
|
+
if (!isSourceFile(fp)) continue;
|
|
1419
|
+
const lines = c.split('\n');
|
|
1420
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1421
|
+
if (lines[i].match(/analytics|mixpanel|amplitude|segment|heap/i) && lines[i].match(/\.track\s*\(|\.identify\s*\(/)) {
|
|
1422
|
+
const ctx = lines.slice(i, Math.min(lines.length, i + 10)).join('\n');
|
|
1423
|
+
if (ctx.match(/email|phone|name|address|ip_address|userId.*@/i)) {
|
|
1424
|
+
findings.push({ ruleId: 'DATA-PII-012', category: 'data', severity: 'medium', title: 'Raw PII sent to analytics platform', description: 'Hash or pseudonymize PII before sending to analytics. Many jurisdictions require that analytics data cannot be used to identify individuals.', file: fp, line: i + 1, fix: null });
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return findings;
|
|
1430
|
+
},
|
|
1431
|
+
},
|
|
1432
|
+
// DATA-VAL-010: Trusting user-supplied Content-Type
|
|
1433
|
+
{ id: 'DATA-VAL-010', category: 'data', severity: 'high', confidence: 'likely', title: 'File Type Validated Only by Content-Type Header',
|
|
1434
|
+
check({ files }) {
|
|
1435
|
+
const findings = [];
|
|
1436
|
+
for (const [fp, c] of files) {
|
|
1437
|
+
if (!isSourceFile(fp)) continue;
|
|
1438
|
+
const lines = c.split('\n');
|
|
1439
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1440
|
+
if (lines[i].match(/req\.(file|files).*mimetype|mimetype.*image|mimetype.*pdf/i) && !lines[i].match(/\/\//)) {
|
|
1441
|
+
const ctx = lines.slice(Math.max(0, i - 2), i + 10).join('\n');
|
|
1442
|
+
if (!ctx.match(/magic|file-type|mmmagic|file\.signature|Buffer\.from.*header/i)) {
|
|
1443
|
+
findings.push({ ruleId: 'DATA-VAL-010', category: 'data', severity: 'high', title: 'File upload validated only by Content-Type — easily spoofed', description: 'Validate file type using magic bytes (file-type package) not just Content-Type header. The mimetype header is user-controlled and can be set to any value to bypass content-type checks.', file: fp, line: i + 1, fix: null });
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
return findings;
|
|
1449
|
+
},
|
|
1450
|
+
},
|
|
1451
|
+
// DATA-STORE-019: Using eval to deserialize data from storage
|
|
1452
|
+
{ id: 'DATA-STORE-019', category: 'data', severity: 'critical', confidence: 'definite', title: 'eval() Used to Deserialize Stored Data',
|
|
1453
|
+
check({ files }) {
|
|
1454
|
+
const findings = [];
|
|
1455
|
+
for (const [fp, c] of files) {
|
|
1456
|
+
if (!isSourceFile(fp)) continue;
|
|
1457
|
+
const lines = c.split('\n');
|
|
1458
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1459
|
+
if (lines[i].match(/eval\s*\(/) && !lines[i].match(/\/\//)) {
|
|
1460
|
+
const ctx = lines.slice(Math.max(0, i - 5), i + 3).join('\n');
|
|
1461
|
+
if (ctx.match(/redis|cache|storage|db\.|database|localStorage|session/i)) {
|
|
1462
|
+
findings.push({ ruleId: 'DATA-STORE-019', category: 'data', severity: 'critical', title: 'eval() used to deserialize data from storage — RCE if storage is compromised', description: 'Never use eval() to deserialize data. Use JSON.parse with schema validation. If an attacker can write to your cache or database, eval() enables full remote code execution.', file: fp, line: i + 1, fix: null });
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
return findings;
|
|
1468
|
+
},
|
|
1469
|
+
},
|
|
1470
|
+
// DATA-ENC-017: Hardcoded initialization vector (IV)
|
|
1471
|
+
{ id: 'DATA-ENC-017', category: 'data', severity: 'critical', confidence: 'definite', title: 'Hardcoded IV/Nonce for Encryption',
|
|
1472
|
+
check({ files }) {
|
|
1473
|
+
const findings = [];
|
|
1474
|
+
for (const [fp, c] of files) {
|
|
1475
|
+
if (!isSourceFile(fp)) continue;
|
|
1476
|
+
const lines = c.split('\n');
|
|
1477
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1478
|
+
if (lines[i].match(/iv\s*=\s*Buffer\.from\s*\(['"`][0-9a-f]{16,}['"`]\)|iv\s*=\s*['"`][0-9a-f]{16,}['"`]/i)) {
|
|
1479
|
+
findings.push({ ruleId: 'DATA-ENC-017', category: 'data', severity: 'critical', title: 'Hardcoded encryption IV — deterministic ciphertext enables plaintext recovery', description: 'Generate a random IV for every encryption with crypto.randomBytes(16). Reusing the same IV with AES-CBC or AES-CTR breaks confidentiality and allows plaintext recovery.', file: fp, line: i + 1, fix: null });
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
return findings;
|
|
1484
|
+
},
|
|
1485
|
+
},
|
|
1486
|
+
// DATA-PII-013: Full user object passed to logging
|
|
1487
|
+
{ id: 'DATA-PII-013', category: 'data', severity: 'high', confidence: 'likely', title: 'Full User Object Passed to Logger',
|
|
1488
|
+
check({ files }) {
|
|
1489
|
+
const findings = [];
|
|
1490
|
+
for (const [fp, c] of files) {
|
|
1491
|
+
if (!isSourceFile(fp)) continue;
|
|
1492
|
+
const lines = c.split('\n');
|
|
1493
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1494
|
+
if (lines[i].match(/log(?:ger)?\.(info|debug|warn|error)\s*\(/) && lines[i].match(/req\.user\b|user:\s*req\.user|user,/i) && !lines[i].match(/user\.id\b|userId/)) {
|
|
1495
|
+
findings.push({ ruleId: 'DATA-PII-013', category: 'data', severity: 'high', title: 'Full user object logged — may contain password hash, PII, or tokens', description: 'Log only user.id not the entire user object. User objects typically contain password hashes, email addresses, and profile data that should not appear in logs.', file: fp, line: i + 1, fix: null });
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
return findings;
|
|
1500
|
+
},
|
|
1501
|
+
},
|
|
1502
|
+
// DATA-QUAL-016: Object spread for deep merge (shallow copies only)
|
|
1503
|
+
{ id: 'DATA-QUAL-016', category: 'data', severity: 'medium', confidence: 'likely', title: 'Object Spread Used for Deep Merge (Shallow Copy Only)',
|
|
1504
|
+
check({ files }) {
|
|
1505
|
+
const findings = [];
|
|
1506
|
+
for (const [fp, c] of files) {
|
|
1507
|
+
if (!isSourceFile(fp)) continue;
|
|
1508
|
+
const lines = c.split('\n');
|
|
1509
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1510
|
+
if (lines[i].match(/=\s*\{\s*\.\.\.\w+,\s*\.\.\.\w+\s*\}/) && !lines[i].match(/\/\//)) {
|
|
1511
|
+
const ctx = lines.slice(Math.max(0, i - 3), i + 5).join('\n');
|
|
1512
|
+
if (ctx.match(/config|settings|options|defaults|override/i)) {
|
|
1513
|
+
findings.push({ ruleId: 'DATA-QUAL-016', category: 'data', severity: 'medium', title: 'Object spread for config merge — only shallow merge, nested objects replaced not merged', description: 'Use lodash.merge or a deep merge utility for nested config objects. {...a, ...b} only copies top-level properties; nested objects in b completely replace those in a.', file: fp, line: i + 1, fix: null });
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
return findings;
|
|
1519
|
+
},
|
|
1520
|
+
},
|
|
1521
|
+
// DATA-STORE-020: In-memory session with no store
|
|
1522
|
+
{ id: 'DATA-STORE-020', category: 'data', severity: 'high', confidence: 'likely', title: 'Express Session Using In-Memory Store',
|
|
1523
|
+
check({ files, stack }) {
|
|
1524
|
+
const findings = [];
|
|
1525
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
1526
|
+
const hasSession = 'express-session' in allDeps;
|
|
1527
|
+
const hasSessionStore = ['connect-redis', 'connect-mongo', 'connect-pg-simple', 'express-mysql-session', 'memcached'].some(d => d in allDeps);
|
|
1528
|
+
if (hasSession && !hasSessionStore) {
|
|
1529
|
+
findings.push({ ruleId: 'DATA-STORE-020', category: 'data', severity: 'high', title: 'express-session without persistent store — sessions lost on server restart', description: 'Use connect-redis or connect-mongo for session storage. The default MemoryStore leaks memory, cannot be shared across processes, and loses all sessions on restart.', fix: null });
|
|
1530
|
+
}
|
|
1531
|
+
return findings;
|
|
1532
|
+
},
|
|
1533
|
+
},
|
|
1534
|
+
// DATA-ENC-018: Using DES or 3DES encryption
|
|
1535
|
+
{ id: 'DATA-ENC-018', category: 'data', severity: 'critical', confidence: 'definite', title: 'DES or 3DES Encryption Algorithm Used',
|
|
1536
|
+
check({ files }) {
|
|
1537
|
+
const findings = [];
|
|
1538
|
+
for (const [fp, c] of files) {
|
|
1539
|
+
if (!isSourceFile(fp)) continue;
|
|
1540
|
+
const lines = c.split('\n');
|
|
1541
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1542
|
+
if (lines[i].match(/createCipher.*des|des-cbc|des3|triple.?des|3des/i) && !lines[i].match(/\/\//)) {
|
|
1543
|
+
findings.push({ ruleId: 'DATA-ENC-018', category: 'data', severity: 'critical', title: 'DES/3DES algorithm used — deprecated, vulnerable to Sweet32 attack', description: 'Replace DES/3DES with AES-256-GCM. DES was deprecated in 2005 and 3DES is vulnerable to the Sweet32 birthday attack. NIST deprecated 3DES in 2023.', file: fp, line: i + 1, fix: null });
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
return findings;
|
|
1548
|
+
},
|
|
1549
|
+
},
|
|
1550
|
+
// DATA-LIFE-007: No data export capability for users
|
|
1551
|
+
{ id: 'DATA-LIFE-007', category: 'data', severity: 'medium', confidence: 'likely', title: 'No User Data Export Functionality',
|
|
1552
|
+
check({ files, stack }) {
|
|
1553
|
+
const findings = [];
|
|
1554
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
1555
|
+
const hasAuth = ['passport', 'jsonwebtoken', 'express-session', 'auth0'].some(d => d in allDeps);
|
|
1556
|
+
const hasExport = [...files.values()].some(c => c.match(/export.*user|user.*export|download.*data|data.*portability|gdpr.*export/i));
|
|
1557
|
+
if (hasAuth && !hasExport) {
|
|
1558
|
+
findings.push({ ruleId: 'DATA-LIFE-007', category: 'data', severity: 'medium', title: 'Authenticated application without user data export feature', description: 'Implement a user data export endpoint. GDPR Article 20 grants data portability rights — users must be able to download their data in a machine-readable format (JSON/CSV).', fix: null });
|
|
1559
|
+
}
|
|
1560
|
+
return findings;
|
|
1561
|
+
},
|
|
1562
|
+
},
|
|
1563
|
+
// DATA-VAL-011: Missing sanitization of HTML input
|
|
1564
|
+
{ id: 'DATA-VAL-011', category: 'data', severity: 'high', confidence: 'likely', title: 'HTML Content Stored Without Sanitization',
|
|
1565
|
+
check({ files, stack }) {
|
|
1566
|
+
const findings = [];
|
|
1567
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
1568
|
+
const hasSanitizer = ['dompurify', 'sanitize-html', 'xss', 'isomorphic-dompurify'].some(d => d in allDeps);
|
|
1569
|
+
const storesHTML = [...files.values()].some(c => c.match(/richText|htmlContent|bodyHtml|content.*html|html.*content/i) && c.match(/save\s*\(|insert|create\s*\(|update\s*\(/));
|
|
1570
|
+
if (storesHTML && !hasSanitizer) {
|
|
1571
|
+
findings.push({ ruleId: 'DATA-VAL-011', category: 'data', severity: 'high', title: 'HTML content stored without sanitization library', description: 'Use DOMPurify or sanitize-html before storing rich text. Unsanitized HTML creates stored XSS vulnerabilities that execute malicious scripts for all users viewing the content.', fix: null });
|
|
1572
|
+
}
|
|
1573
|
+
return findings;
|
|
1574
|
+
},
|
|
1575
|
+
},
|
|
1576
|
+
// DATA-STORE-021: Soft-deleted records returned in queries
|
|
1577
|
+
{ id: 'DATA-STORE-021', category: 'data', severity: 'medium', confidence: 'likely', title: 'Soft Delete Without Default Scope Filter',
|
|
1578
|
+
check({ files }) {
|
|
1579
|
+
const findings = [];
|
|
1580
|
+
for (const [fp, c] of files) {
|
|
1581
|
+
if (!isSourceFile(fp)) continue;
|
|
1582
|
+
const hasSoftDelete = c.match(/deletedAt|deleted_at|is_deleted|isDeleted/i);
|
|
1583
|
+
const hasDefaultScope = c.match(/defaultScope|paranoid:\s*true|where.*deletedAt.*null|soft.?delete/i);
|
|
1584
|
+
if (hasSoftDelete && !hasDefaultScope) {
|
|
1585
|
+
findings.push({ ruleId: 'DATA-STORE-021', category: 'data', severity: 'medium', title: 'Soft delete field exists but no default scope — deleted records may appear in queries', description: 'Add a default scope that filters out soft-deleted records (where deletedAt IS NULL). Without it, every query must manually exclude deleted records, and omissions cause data leaks.', file: fp, fix: null });
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
return findings;
|
|
1589
|
+
},
|
|
1590
|
+
},
|
|
1591
|
+
// DATA-QUAL-017: Mutable global config object
|
|
1592
|
+
{ id: 'DATA-QUAL-017', category: 'data', severity: 'medium', confidence: 'likely', title: 'Global Configuration Object Mutable at Runtime',
|
|
1593
|
+
check({ files }) {
|
|
1594
|
+
const findings = [];
|
|
1595
|
+
for (const [fp, c] of files) {
|
|
1596
|
+
if (!isSourceFile(fp)) continue;
|
|
1597
|
+
const lines = c.split('\n');
|
|
1598
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1599
|
+
if (lines[i].match(/^(?:const|let|var)\s+config\s*=\s*\{/) && !lines[i].match(/Object\.freeze/)) {
|
|
1600
|
+
const ctx = lines.slice(i, Math.min(lines.length, i + 30)).join('\n');
|
|
1601
|
+
if (!ctx.match(/Object\.freeze\s*\(/) && ctx.match(/module\.exports|export default|export\s+\{/)) {
|
|
1602
|
+
findings.push({ ruleId: 'DATA-QUAL-017', category: 'data', severity: 'medium', title: 'Exported config object not frozen — accidental mutation possible', description: 'Use Object.freeze(config) before exporting. Mutable config objects can be accidentally modified by code that imports them, causing hard-to-debug configuration drift.', file: fp, line: i + 1, fix: null });
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
return findings;
|
|
1608
|
+
},
|
|
1609
|
+
},
|
|
1610
|
+
// DATA-PII-014: SSN or date-of-birth stored in plain text
|
|
1611
|
+
{ id: 'DATA-PII-014', category: 'data', severity: 'critical', confidence: 'definite', title: 'SSN or Date of Birth Stored in Plain Text',
|
|
1612
|
+
check({ files }) {
|
|
1613
|
+
const findings = [];
|
|
1614
|
+
for (const [fp, c] of files) {
|
|
1615
|
+
if (!isSourceFile(fp)) continue;
|
|
1616
|
+
const lines = c.split('\n');
|
|
1617
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1618
|
+
if (lines[i].match(/\bssn\b|social_security_number|social_security\b|date_of_birth\s*[=:]/i) && lines[i].match(/String|TEXT|VARCHAR|string:/i) && !lines[i].match(/encrypt|hash|mask|tokenize/i)) {
|
|
1619
|
+
findings.push({ ruleId: 'DATA-PII-014', category: 'data', severity: 'critical', title: 'SSN/Date of birth field stored without encryption annotation', description: 'Encrypt SSN and full date-of-birth at the application layer or use database-level column encryption. These are high-value PII targets that require encryption at rest.', file: fp, line: i + 1, fix: null });
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
return findings;
|
|
1624
|
+
},
|
|
1625
|
+
},
|
|
1626
|
+
];
|
|
1627
|
+
|
|
1628
|
+
export default rules;
|
|
1629
|
+
|
|
1630
|
+
// DATA-015: Sensitive data in query string
|
|
1631
|
+
rules.push({
|
|
1632
|
+
id: 'DATA-015', category: 'data', severity: 'high', confidence: 'likely', title: 'Sensitive data passed in URL query string',
|
|
1633
|
+
check({ files }) {
|
|
1634
|
+
const findings = [];
|
|
1635
|
+
const p = /[?&](?:password|passwd|token|api_key|apikey|secret|auth|ssn|credit_card)[=]/i;
|
|
1636
|
+
for (const [fp, c] of files) {
|
|
1637
|
+
if (!isSourceFile(fp)) continue;
|
|
1638
|
+
const lines = c.split('\n');
|
|
1639
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1640
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1641
|
+
if (p.test(lines[i])) findings.push({ ruleId: 'DATA-015', category: 'data', severity: 'high', title: 'Credential in URL query string — visible in server logs and browser history', description: 'Never pass passwords, tokens, or API keys as query parameters. They appear in server access logs, browser history, and Referer headers. Use POST body or Authorization header.', file: fp, line: i + 1, fix: null });
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
return findings;
|
|
1645
|
+
},
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
// DATA-016: Unencrypted sensitive data at rest
|
|
1649
|
+
rules.push({
|
|
1650
|
+
id: 'DATA-016', category: 'data', severity: 'high', confidence: 'likely', title: 'Sensitive field stored without encryption indication',
|
|
1651
|
+
check({ files }) {
|
|
1652
|
+
const findings = [];
|
|
1653
|
+
const sensitiveField = /(?:credit_card|ssn|social_security|bank_account|passport_number|drivers_license)\s*[:=]/i;
|
|
1654
|
+
const encryptMarker = /encrypt|cipher|@Encrypted|hashed|tokenize/i;
|
|
1655
|
+
for (const [fp, c] of files) {
|
|
1656
|
+
if (!isSourceFile(fp)) continue;
|
|
1657
|
+
const lines = c.split('\n');
|
|
1658
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1659
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1660
|
+
if (sensitiveField.test(lines[i]) && /String|TEXT|VARCHAR|string:/i.test(lines[i]) && !encryptMarker.test(lines[i])) {
|
|
1661
|
+
findings.push({ ruleId: 'DATA-016', category: 'data', severity: 'high', title: 'Sensitive PII field stored without encryption annotation', description: 'Financial and government ID fields must be encrypted at rest. Use application-level encryption or database column encryption for SSNs, card numbers, and similar data.', file: fp, line: i + 1, fix: null });
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
return findings;
|
|
1666
|
+
},
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
// DATA-017: SSN returned in API response
|
|
1670
|
+
rules.push({
|
|
1671
|
+
id: 'DATA-017', category: 'data', severity: 'critical', confidence: 'definite', title: 'Full SSN returned in API response — data exposure',
|
|
1672
|
+
check({ files }) {
|
|
1673
|
+
const findings = [];
|
|
1674
|
+
const p = /(?:res\.json|res\.send|return.*json)\s*\([^)]*(?:ssn|social_security|socialSecurity)/i;
|
|
1675
|
+
for (const [fp, c] of files) {
|
|
1676
|
+
if (!isSourceFile(fp)) continue;
|
|
1677
|
+
const lines = c.split('\n');
|
|
1678
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1679
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1680
|
+
if (p.test(lines[i]) && !/mask|redact|xxxx|last.*4/i.test(lines[i])) {
|
|
1681
|
+
findings.push({ ruleId: 'DATA-017', category: 'data', severity: 'critical', title: 'SSN included in API response without masking', description: 'Never return full SSNs in API responses. Return only masked versions (***-**-1234) and only when necessary.', file: fp, line: i + 1, fix: null });
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
return findings;
|
|
1686
|
+
},
|
|
1687
|
+
});
|
|
1688
|
+
|
|
1689
|
+
// DATA-018: Full card number in API response
|
|
1690
|
+
rules.push({
|
|
1691
|
+
id: 'DATA-018', category: 'data', severity: 'critical', confidence: 'definite', title: 'Full card number returned in API response',
|
|
1692
|
+
check({ files }) {
|
|
1693
|
+
const findings = [];
|
|
1694
|
+
const p = /(?:res\.json|res\.send|return.*json)\s*\([^)]*(?:cardNumber|card_number|pan|creditCard)/i;
|
|
1695
|
+
for (const [fp, c] of files) {
|
|
1696
|
+
if (!isSourceFile(fp)) continue;
|
|
1697
|
+
const lines = c.split('\n');
|
|
1698
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1699
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1700
|
+
if (p.test(lines[i]) && !/mask|last.*4|xxxx|\*{4}/i.test(lines[i])) {
|
|
1701
|
+
findings.push({ ruleId: 'DATA-018', category: 'data', severity: 'critical', title: 'Full card number in API response — PCI-DSS violation', description: 'Never return full card numbers in API responses. Return only the last 4 digits. Full PANs must never traverse an API that is not PCI-certified.', file: fp, line: i + 1, fix: null });
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
return findings;
|
|
1706
|
+
},
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
// DATA-019: JSON.stringify of sensitive objects in logs
|
|
1710
|
+
rules.push({
|
|
1711
|
+
id: 'DATA-019', category: 'data', severity: 'high', confidence: 'likely', title: 'JSON.stringify of user/request object in logs — credential exposure',
|
|
1712
|
+
check({ files }) {
|
|
1713
|
+
const findings = [];
|
|
1714
|
+
const p = /(?:console\.|logger\.)\w*\s*\([^)]*JSON\.stringify\s*\(\s*(?:req|user|body|session|ctx)/i;
|
|
1715
|
+
for (const [fp, c] of files) {
|
|
1716
|
+
if (!isSourceFile(fp)) continue;
|
|
1717
|
+
const lines = c.split('\n');
|
|
1718
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1719
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1720
|
+
if (p.test(lines[i])) {
|
|
1721
|
+
findings.push({ ruleId: 'DATA-019', category: 'data', severity: 'high', title: 'JSON.stringify(req/user/body) logged — may expose passwords and tokens', description: 'Logging entire request objects or user records can expose passwords, tokens, and PII. Log only specific safe fields.', file: fp, line: i + 1, fix: null });
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
return findings;
|
|
1726
|
+
},
|
|
1727
|
+
});
|
|
1728
|
+
|
|
1729
|
+
// DATA-020: Insecure deserialization
|
|
1730
|
+
rules.push({
|
|
1731
|
+
id: 'DATA-020', category: 'data', severity: 'high', confidence: 'likely', title: 'Insecure deserialization of user-controlled data',
|
|
1732
|
+
check({ files }) {
|
|
1733
|
+
const findings = [];
|
|
1734
|
+
const p = /(?:unserialize|deserialize|eval\s*\(|Function\s*\()\s*\(\s*(?:req\.|body\.|params\.|query\.|input)/;
|
|
1735
|
+
for (const [fp, c] of files) {
|
|
1736
|
+
if (!isSourceFile(fp)) continue;
|
|
1737
|
+
const lines = c.split('\n');
|
|
1738
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1739
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1740
|
+
if (p.test(lines[i])) {
|
|
1741
|
+
findings.push({ ruleId: 'DATA-020', category: 'data', severity: 'high', title: 'Unsafe deserialization of user input — code execution risk', description: 'Deserializing untrusted data can lead to remote code execution. Validate and sanitize input before deserialization. Never use eval() on user data.', file: fp, line: i + 1, fix: null });
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
return findings;
|
|
1746
|
+
},
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
// DATA-021: Missing input sanitization before DB storage
|
|
1750
|
+
rules.push({
|
|
1751
|
+
id: 'DATA-021', category: 'data', severity: 'high', confidence: 'likely', title: 'User input stored in DB without sanitization',
|
|
1752
|
+
check({ files }) {
|
|
1753
|
+
const findings = [];
|
|
1754
|
+
const p = /\.(?:create|insert|save|upsert)\s*\(\s*\{[^}]*req\.body/;
|
|
1755
|
+
for (const [fp, c] of files) {
|
|
1756
|
+
if (!isSourceFile(fp)) continue;
|
|
1757
|
+
const lines = c.split('\n');
|
|
1758
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1759
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1760
|
+
if (p.test(lines[i]) && !/sanitize|validate|joi|zod|yup|schema/i.test(c.substring(0, c.indexOf(lines[i])))) {
|
|
1761
|
+
findings.push({ ruleId: 'DATA-021', category: 'data', severity: 'high', title: 'req.body passed directly to DB create/insert without validation', description: 'Validate and sanitize all user input before storing to prevent mass assignment, XSS payload storage, and injection attacks.', file: fp, line: i + 1, fix: null });
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
return findings;
|
|
1766
|
+
},
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
// DATA-022: Field-level encryption missing for PII
|
|
1770
|
+
rules.push({
|
|
1771
|
+
id: 'DATA-022', category: 'data', severity: 'high', confidence: 'likely', title: 'PII fields stored without field-level encryption',
|
|
1772
|
+
check({ files }) {
|
|
1773
|
+
const findings = [];
|
|
1774
|
+
const piiFields = /(?:email|phone_number|address|ip_address|fingerprint)\s*[:=]\s*(?:req\.|String|TEXT)/i;
|
|
1775
|
+
const encryptMarker = /encrypt|cipher|@Encrypted|tokenize/i;
|
|
1776
|
+
for (const [fp, c] of files) {
|
|
1777
|
+
if (!isSourceFile(fp)) continue;
|
|
1778
|
+
const lines = c.split('\n');
|
|
1779
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1780
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1781
|
+
if (piiFields.test(lines[i]) && !encryptMarker.test(lines[i])) {
|
|
1782
|
+
const ctx = lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 3)).join('\n');
|
|
1783
|
+
if (!encryptMarker.test(ctx)) {
|
|
1784
|
+
findings.push({ ruleId: 'DATA-022', category: 'data', severity: 'high', title: 'PII field without field-level encryption', description: 'Email addresses, phone numbers, and IP addresses are PII under GDPR. Consider field-level encryption to minimize breach impact.', file: fp, line: i + 1, fix: null });
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
return findings;
|
|
1790
|
+
},
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
// DATA-023: Debug data exposure in error responses
|
|
1794
|
+
rules.push({
|
|
1795
|
+
id: 'DATA-023', category: 'data', severity: 'high', confidence: 'likely', title: 'Error response includes internal stack trace or sensitive details',
|
|
1796
|
+
check({ files }) {
|
|
1797
|
+
const findings = [];
|
|
1798
|
+
const p = /res\.(?:json|send|status\s*\(\s*\d+\s*\)\.json)\s*\([^)]*(?:err\.stack|error\.stack|stack:|err\.message|stackTrace)/i;
|
|
1799
|
+
for (const [fp, c] of files) {
|
|
1800
|
+
if (!isSourceFile(fp)) continue;
|
|
1801
|
+
const lines = c.split('\n');
|
|
1802
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1803
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1804
|
+
if (p.test(lines[i])) {
|
|
1805
|
+
findings.push({ ruleId: 'DATA-023', category: 'data', severity: 'high', title: 'Stack trace returned in HTTP response — information disclosure', description: 'Returning error stack traces in API responses reveals internal paths, library versions, and architecture. Return generic error messages in production.', file: fp, line: i + 1, fix: null });
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
return findings;
|
|
1810
|
+
},
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1813
|
+
// DATA-024: Sensitive data in browser storage
|
|
1814
|
+
rules.push({
|
|
1815
|
+
id: 'DATA-024', category: 'data', severity: 'high', confidence: 'likely', title: 'PII or sensitive data stored in localStorage/sessionStorage',
|
|
1816
|
+
check({ files }) {
|
|
1817
|
+
const findings = [];
|
|
1818
|
+
const p = /(?:localStorage|sessionStorage)\.setItem\s*\(\s*['"][^'"]*['"],\s*[^)]*(?:password|ssn|credit|social_security|passport|dob)/i;
|
|
1819
|
+
for (const [fp, c] of files) {
|
|
1820
|
+
if (!isSourceFile(fp)) continue;
|
|
1821
|
+
const lines = c.split('\n');
|
|
1822
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1823
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1824
|
+
if (p.test(lines[i])) {
|
|
1825
|
+
findings.push({ ruleId: 'DATA-024', category: 'data', severity: 'high', title: 'Sensitive data stored in browser storage — accessible to XSS', description: 'localStorage and sessionStorage are accessible to all JavaScript. Never store passwords, SSNs, or financial data in browser storage.', file: fp, line: i + 1, fix: null });
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
return findings;
|
|
1830
|
+
},
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
// DATA-025: User data logged without masking
|
|
1834
|
+
rules.push({
|
|
1835
|
+
id: 'DATA-025', category: 'data', severity: 'high', confidence: 'likely', title: 'User email or personal data logged without masking',
|
|
1836
|
+
check({ files }) {
|
|
1837
|
+
const findings = [];
|
|
1838
|
+
const p = /(?:console\.|logger\.)\w*\s*\([^)]*(?:user\.email|req\.user\.|profile\.email|email\s*:)/i;
|
|
1839
|
+
for (const [fp, c] of files) {
|
|
1840
|
+
if (!isSourceFile(fp)) continue;
|
|
1841
|
+
const lines = c.split('\n');
|
|
1842
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1843
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1844
|
+
if (p.test(lines[i]) && !/mask|redact|hash/i.test(lines[i])) {
|
|
1845
|
+
findings.push({ ruleId: 'DATA-025', category: 'data', severity: 'high', title: 'User email logged without masking — PII in log files', description: 'Logging email addresses creates PII in log files, which may be stored, shipped to third-party services, and subject to breach notification. Mask emails in logs: u***@example.com.', file: fp, line: i + 1, fix: null });
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
return findings;
|
|
1850
|
+
},
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
// DATA-026: Prototype pollution via merge of user data
|
|
1854
|
+
rules.push({
|
|
1855
|
+
id: 'DATA-026', category: 'data', severity: 'high', confidence: 'likely', title: 'Deep merge with user data — prototype pollution risk',
|
|
1856
|
+
check({ files }) {
|
|
1857
|
+
const findings = [];
|
|
1858
|
+
const p = /(?:_.merge|deepmerge|merge\s*\(|assign\s*\()\s*\([^)]*(?:req\.body|req\.query|user|input)/i;
|
|
1859
|
+
for (const [fp, c] of files) {
|
|
1860
|
+
if (!isSourceFile(fp)) continue;
|
|
1861
|
+
const lines = c.split('\n');
|
|
1862
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1863
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1864
|
+
if (p.test(lines[i])) {
|
|
1865
|
+
findings.push({ ruleId: 'DATA-026', category: 'data', severity: 'high', title: 'Deep merge with user-supplied data — prototype pollution vulnerability', description: 'Deep merging user input can pollute Object.prototype via __proto__ or constructor keys. Use JSON.parse(JSON.stringify(obj)) or a safe-merge library.', file: fp, line: i + 1, fix: null });
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
return findings;
|
|
1870
|
+
},
|
|
1871
|
+
});
|
|
1872
|
+
|
|
1873
|
+
// DATA-027: Sensitive response fields not excluded
|
|
1874
|
+
rules.push({
|
|
1875
|
+
id: 'DATA-027', category: 'data', severity: 'high', confidence: 'likely', title: 'User object returned in response without excluding sensitive fields',
|
|
1876
|
+
check({ files }) {
|
|
1877
|
+
const findings = [];
|
|
1878
|
+
const p = /res\.json\s*\(\s*(?:user|account|profile)\s*\)|return.*(?:user|account|profile)\s*;/;
|
|
1879
|
+
for (const [fp, c] of files) {
|
|
1880
|
+
if (!isSourceFile(fp)) continue;
|
|
1881
|
+
const lines = c.split('\n');
|
|
1882
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1883
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1884
|
+
if (p.test(lines[i])) {
|
|
1885
|
+
const ctx = lines.slice(Math.max(0, i - 10), i).join('\n');
|
|
1886
|
+
if (!/delete.*password|omit|exclude|select.*{/.test(ctx)) {
|
|
1887
|
+
findings.push({ ruleId: 'DATA-027', category: 'data', severity: 'high', title: 'User object returned in API response may include password hash', description: 'Remove sensitive fields (password, passwordHash, salt, tokens) before returning user objects. Use a DTO pattern or delete the fields explicitly.', file: fp, line: i + 1, fix: null });
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
return findings;
|
|
1893
|
+
},
|
|
1894
|
+
});
|
|
1895
|
+
|
|
1896
|
+
// DATA-028 through DATA-045: Additional data rules
|
|
1897
|
+
|
|
1898
|
+
// DATA-028: Password stored with reversible encryption
|
|
1899
|
+
rules.push({
|
|
1900
|
+
id: 'DATA-028', category: 'data', severity: 'critical', confidence: 'definite', title: 'Password stored with reversible encryption instead of one-way hash',
|
|
1901
|
+
check({ files }) {
|
|
1902
|
+
const findings = [];
|
|
1903
|
+
const encryptPassword = /(?:encrypt|cipher|aes|des)\s*\([^)]*password|password\s*[:=]\s*(?:encrypt|cipher)/i;
|
|
1904
|
+
const safeHash = /bcrypt|argon2|scrypt|pbkdf2/i;
|
|
1905
|
+
for (const [fp, c] of files) {
|
|
1906
|
+
if (!isSourceFile(fp)) continue;
|
|
1907
|
+
if (safeHash.test(c)) continue;
|
|
1908
|
+
const lines = c.split('\n');
|
|
1909
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1910
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1911
|
+
if (encryptPassword.test(lines[i])) {
|
|
1912
|
+
findings.push({ ruleId: 'DATA-028', category: 'data', severity: 'critical', title: 'Password encrypted (reversible) instead of hashed — use bcrypt/argon2', description: 'Passwords should be hashed with a one-way function (bcrypt, argon2). Reversible encryption allows credential recovery if the key is compromised.', file: fp, line: i + 1, fix: null });
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
return findings;
|
|
1917
|
+
},
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
// DATA-029: PII transmitted over HTTP
|
|
1921
|
+
rules.push({
|
|
1922
|
+
id: 'DATA-029', category: 'data', severity: 'critical', confidence: 'definite', title: 'PII data transmitted over insecure HTTP',
|
|
1923
|
+
check({ files }) {
|
|
1924
|
+
const findings = [];
|
|
1925
|
+
for (const [fp, c] of files) {
|
|
1926
|
+
if (!isSourceFile(fp)) continue;
|
|
1927
|
+
const lines = c.split('\n');
|
|
1928
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1929
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1930
|
+
if (/fetch\s*\(\s*['"]http:\/\//.test(lines[i]) && !/localhost|127\.0\.0\.1|0\.0\.0\.0/.test(lines[i])) {
|
|
1931
|
+
const ctx = lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 5)).join('\n');
|
|
1932
|
+
if (/email|password|phone|address|ssn|name/i.test(ctx)) {
|
|
1933
|
+
findings.push({ ruleId: 'DATA-029', category: 'data', severity: 'critical', title: 'PII transmitted over HTTP (not HTTPS)', description: 'Personal data must only be transmitted over encrypted HTTPS connections. Plain HTTP exposes data to network eavesdropping.', file: fp, line: i + 1, fix: null });
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
return findings;
|
|
1939
|
+
},
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
// DATA-030: Raw error messages with internal details exposed
|
|
1943
|
+
rules.push({
|
|
1944
|
+
id: 'DATA-030', category: 'data', severity: 'medium', confidence: 'likely', title: 'Database error messages exposed to client',
|
|
1945
|
+
check({ files }) {
|
|
1946
|
+
const findings = [];
|
|
1947
|
+
const p = /res\.(?:json|send)\s*\([^)]*(?:err|error)\.message/i;
|
|
1948
|
+
for (const [fp, c] of files) {
|
|
1949
|
+
if (!isSourceFile(fp)) continue;
|
|
1950
|
+
const lines = c.split('\n');
|
|
1951
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1952
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1953
|
+
if (p.test(lines[i])) {
|
|
1954
|
+
findings.push({ ruleId: 'DATA-030', category: 'data', severity: 'medium', title: 'Raw error message returned to client — may expose SQL/schema details', description: 'Database error messages may contain table names, column names, or SQL syntax. Return generic error messages to clients in production.', file: fp, line: i + 1, fix: null });
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
return findings;
|
|
1959
|
+
},
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1962
|
+
// DATA-031: Audit log without immutability
|
|
1963
|
+
rules.push({
|
|
1964
|
+
id: 'DATA-031', category: 'data', severity: 'medium', confidence: 'likely', title: 'Audit logs without write-once/immutability protection',
|
|
1965
|
+
check({ files }) {
|
|
1966
|
+
const findings = [];
|
|
1967
|
+
const hasAuditLog = [...files.values()].some(c => /auditLog|AuditLog|audit_log/i.test(c));
|
|
1968
|
+
const hasImmutability = [...files.values()].some(c => /s3.*object.*lock|worm|immutable.*log|append.*only|CloudTrail/i.test(c));
|
|
1969
|
+
if (hasAuditLog && !hasImmutability) {
|
|
1970
|
+
findings.push({ ruleId: 'DATA-031', category: 'data', severity: 'medium', title: 'Audit logs without immutability protection — logs could be tampered with', description: 'Audit logs must be tamper-evident. Store audit logs in append-only storage, S3 with Object Lock, or a dedicated SIEM that prevents modification.', fix: null });
|
|
1971
|
+
}
|
|
1972
|
+
return findings;
|
|
1973
|
+
},
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
// DATA-032: User-generated content not sanitized before storage
|
|
1977
|
+
rules.push({
|
|
1978
|
+
id: 'DATA-032', category: 'data', severity: 'high', confidence: 'likely', title: 'User-generated content stored without HTML sanitization',
|
|
1979
|
+
check({ files }) {
|
|
1980
|
+
const findings = [];
|
|
1981
|
+
const ugcSave = /(?:create|update|save|insert)\s*\([^)]*(?:content|body|description|bio|comment|post|message)/i;
|
|
1982
|
+
const hasSanitize = /DOMPurify|sanitize-html|xss|sanitize|escape/i;
|
|
1983
|
+
for (const [fp, c] of files) {
|
|
1984
|
+
if (!isSourceFile(fp)) continue;
|
|
1985
|
+
if (!ugcSave.test(c)) continue;
|
|
1986
|
+
if (!hasSanitize.test(c)) {
|
|
1987
|
+
findings.push({ ruleId: 'DATA-032', category: 'data', severity: 'high', title: 'User content saved without HTML sanitization — stored XSS risk', description: 'User-generated HTML content must be sanitized before storage and on output. Use DOMPurify or sanitize-html to strip malicious tags.', file: fp, fix: null });
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
return findings;
|
|
1991
|
+
},
|
|
1992
|
+
});
|
|
1993
|
+
|
|
1994
|
+
// DATA-033: Direct SQL in migration without parameterization
|
|
1995
|
+
rules.push({
|
|
1996
|
+
id: 'DATA-033', category: 'data', severity: 'medium', confidence: 'likely', title: 'Migration file with dynamic SQL construction',
|
|
1997
|
+
check({ files }) {
|
|
1998
|
+
const findings = [];
|
|
1999
|
+
for (const [fp, c] of files) {
|
|
2000
|
+
if (!fp.match(/migration/i) || !isSourceFile(fp)) continue;
|
|
2001
|
+
const lines = c.split('\n');
|
|
2002
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2003
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
2004
|
+
if (/queryInterface\.(?:bulkInsert|sequelize\.query)\s*\([^)]*\$\{/i.test(lines[i])) {
|
|
2005
|
+
findings.push({ ruleId: 'DATA-033', category: 'data', severity: 'medium', title: 'Migration SQL with template literals — injection risk if data is external', description: 'Avoid dynamic SQL in migrations. Use bind parameters for any variable data in migration scripts.', file: fp, line: i + 1, fix: null });
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
return findings;
|
|
2010
|
+
},
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
// DATA-034: GraphQL introspection enabled in production
|
|
2014
|
+
rules.push({
|
|
2015
|
+
id: 'DATA-034', category: 'data', severity: 'medium', confidence: 'likely', title: 'GraphQL introspection enabled in production — schema exposure',
|
|
2016
|
+
check({ files }) {
|
|
2017
|
+
const findings = [];
|
|
2018
|
+
for (const [fp, c] of files) {
|
|
2019
|
+
if (!isSourceFile(fp)) continue;
|
|
2020
|
+
if (/graphql|ApolloServer/i.test(c) && !/introspection.*false|disableIntrospection/i.test(c)) {
|
|
2021
|
+
const hasProduction = /NODE_ENV.*production|process\.env\.NODE_ENV/i.test(c);
|
|
2022
|
+
if (!hasProduction || !/introspection.*NODE_ENV.*development|NODE_ENV.*development.*introspection/i.test(c)) {
|
|
2023
|
+
findings.push({ ruleId: 'DATA-034', category: 'data', severity: 'medium', title: 'GraphQL introspection not disabled for production — full schema exposed', description: 'Disable GraphQL introspection in production: { introspection: process.env.NODE_ENV !== "production" }. Introspection reveals your entire API schema to attackers.', file: fp, fix: null });
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
return findings;
|
|
2028
|
+
},
|
|
2029
|
+
});
|
|
2030
|
+
|
|
2031
|
+
// DATA-035 through DATA-050: More data rules
|
|
2032
|
+
|
|
2033
|
+
// DATA-035: API response includes internal IDs
|
|
2034
|
+
rules.push({
|
|
2035
|
+
id: 'DATA-035', category: 'data', severity: 'low', confidence: 'suggestion', title: 'Internal database IDs exposed in API response',
|
|
2036
|
+
check({ files }) {
|
|
2037
|
+
const findings = [];
|
|
2038
|
+
for (const [fp, c] of files) {
|
|
2039
|
+
if (!isSourceFile(fp)) continue;
|
|
2040
|
+
const lines = c.split('\n');
|
|
2041
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2042
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
2043
|
+
if (/res\.json\s*\([^)]*\bid\b[^)]*\)/.test(lines[i]) && !/uuid|externalId|publicId/i.test(lines[i])) {
|
|
2044
|
+
findings.push({ ruleId: 'DATA-035', category: 'data', severity: 'low', title: 'Sequential integer ID exposed in API — enables enumeration attacks', description: 'Exposing sequential database IDs allows attackers to enumerate resources. Use UUIDs or a separate public ID field for external references.', file: fp, line: i + 1, fix: null });
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
return findings;
|
|
2049
|
+
},
|
|
2050
|
+
});
|
|
2051
|
+
|
|
2052
|
+
// DATA-036: No rate limiting on data export
|
|
2053
|
+
rules.push({
|
|
2054
|
+
id: 'DATA-036', category: 'data', severity: 'medium', confidence: 'likely', title: 'Data export endpoint without rate limiting',
|
|
2055
|
+
check({ files }) {
|
|
2056
|
+
const findings = [];
|
|
2057
|
+
for (const [fp, c] of files) {
|
|
2058
|
+
if (!isSourceFile(fp)) continue;
|
|
2059
|
+
const lines = c.split('\n');
|
|
2060
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2061
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
2062
|
+
if (/(?:router|app)\.\w+\s*\(\s*['"`][^'"`]*(?:export|download|csv|report)[^'"`]*['"`]/i.test(lines[i])) {
|
|
2063
|
+
const ctx = lines.slice(Math.max(0, i - 5), Math.min(lines.length, i + 10)).join('\n');
|
|
2064
|
+
if (!/rateLimit|throttle|rate-limit/i.test(ctx)) {
|
|
2065
|
+
findings.push({ ruleId: 'DATA-036', category: 'data', severity: 'medium', title: 'Data export endpoint without rate limiting — bulk data exfiltration risk', description: 'Data export endpoints should be rate limited and require authentication. Unlimited exports can be used for data exfiltration.', file: fp, line: i + 1, fix: null });
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
return findings;
|
|
2071
|
+
},
|
|
2072
|
+
});
|
|
2073
|
+
|
|
2074
|
+
// DATA-037: Logging request/response body in middleware
|
|
2075
|
+
rules.push({
|
|
2076
|
+
id: 'DATA-037', category: 'data', severity: 'high', confidence: 'likely', title: 'Request body logged in middleware — may expose credentials',
|
|
2077
|
+
check({ files }) {
|
|
2078
|
+
const findings = [];
|
|
2079
|
+
for (const [fp, c] of files) {
|
|
2080
|
+
if (!isSourceFile(fp)) continue;
|
|
2081
|
+
const lines = c.split('\n');
|
|
2082
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2083
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
2084
|
+
if (/console\.|logger\./.test(lines[i]) && /req\.body|request\.body/.test(lines[i])) {
|
|
2085
|
+
findings.push({ ruleId: 'DATA-037', category: 'data', severity: 'high', title: 'req.body logged in middleware — passwords and tokens exposed in logs', description: 'Never log full request bodies. They may contain passwords, tokens, and payment information. Log only safe fields or redact sensitive keys.', file: fp, line: i + 1, fix: null });
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
return findings;
|
|
2090
|
+
},
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
// DATA-038: Sensitive headers forwarded to third parties
|
|
2094
|
+
rules.push({
|
|
2095
|
+
id: 'DATA-038', category: 'data', severity: 'high', confidence: 'likely', title: 'Authorization header forwarded to third-party API',
|
|
2096
|
+
check({ files }) {
|
|
2097
|
+
const findings = [];
|
|
2098
|
+
for (const [fp, c] of files) {
|
|
2099
|
+
if (!isSourceFile(fp)) continue;
|
|
2100
|
+
const lines = c.split('\n');
|
|
2101
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2102
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
2103
|
+
if (/headers.*Authorization.*req\.headers|req\.headers.*Authorization.*forward/i.test(lines[i])) {
|
|
2104
|
+
findings.push({ ruleId: 'DATA-038', category: 'data', severity: 'high', title: 'User\'s Authorization header forwarded to external service', description: 'Forwarding user authorization headers to third-party APIs exposes user credentials. Generate service-to-service credentials for downstream calls.', file: fp, line: i + 1, fix: null });
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
return findings;
|
|
2109
|
+
},
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
// DATA-039: No data classification system
|
|
2113
|
+
rules.push({
|
|
2114
|
+
id: 'DATA-039', category: 'data', severity: 'low', confidence: 'suggestion', title: 'No data classification labels in code — sensitivity unknown',
|
|
2115
|
+
check({ files }) {
|
|
2116
|
+
const findings = [];
|
|
2117
|
+
const hasPII = [...files.values()].some(c => /email|ssn|phone|address|credit_card/i.test(c));
|
|
2118
|
+
const hasClassification = [...files.values()].some(c => /PUBLIC|CONFIDENTIAL|RESTRICTED|PII|PHI|@sensitive|@classified|DataClassification/i.test(c));
|
|
2119
|
+
if (hasPII && !hasClassification) {
|
|
2120
|
+
findings.push({ ruleId: 'DATA-039', category: 'data', severity: 'low', title: 'PII fields without data classification annotations', description: 'Annotate PII and sensitive fields with classification labels (@PII, @Sensitive) to enable automated data governance and access control policies.', fix: null });
|
|
2121
|
+
}
|
|
2122
|
+
return findings;
|
|
2123
|
+
},
|
|
2124
|
+
});
|
|
2125
|
+
|
|
2126
|
+
// DATA-040: Missing data validation on file uploads
|
|
2127
|
+
rules.push({
|
|
2128
|
+
id: 'DATA-040', category: 'data', severity: 'high', confidence: 'likely', title: 'File upload without content-type or size validation',
|
|
2129
|
+
check({ files }) {
|
|
2130
|
+
const findings = [];
|
|
2131
|
+
for (const [fp, c] of files) {
|
|
2132
|
+
if (!isSourceFile(fp)) continue;
|
|
2133
|
+
if (/multer|formidable|busboy|multipart|file.*upload/i.test(c)) {
|
|
2134
|
+
if (!/fileSize|maxSize|mimetype|allowedTypes|fileFilter|limits/i.test(c)) {
|
|
2135
|
+
findings.push({ ruleId: 'DATA-040', category: 'data', severity: 'high', title: 'File upload handler without size or MIME type validation', description: 'File uploads without validation allow uploading malicious files (web shells) or very large files (DoS). Validate MIME type and set file size limits.', file: fp, fix: null });
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
return findings;
|
|
2140
|
+
},
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
// DATA-041: Sending PII to analytics
|
|
2144
|
+
rules.push({
|
|
2145
|
+
id: 'DATA-041', category: 'data', severity: 'high', confidence: 'likely', title: 'PII potentially sent to analytics service',
|
|
2146
|
+
check({ files }) {
|
|
2147
|
+
const findings = [];
|
|
2148
|
+
for (const [fp, c] of files) {
|
|
2149
|
+
if (!isSourceFile(fp)) continue;
|
|
2150
|
+
const lines = c.split('\n');
|
|
2151
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2152
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
2153
|
+
if (/(?:analytics|gtag|fbq|mixpanel|heap|segment)\s*\.[^(]*\([^)]*(?:email|phone|name|ssn)/i.test(lines[i])) {
|
|
2154
|
+
findings.push({ ruleId: 'DATA-041', category: 'data', severity: 'high', title: 'PII sent to analytics provider — GDPR/CCPA violation', description: 'Sending email addresses, phone numbers, or names to third-party analytics violates GDPR and CCPA data minimization requirements. Hash identifiers before sending.', file: fp, line: i + 1, fix: null });
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
return findings;
|
|
2159
|
+
},
|
|
2160
|
+
});
|
|
2161
|
+
|
|
2162
|
+
// DATA-042: Sensitive fields returned in paginated list responses
|
|
2163
|
+
rules.push({
|
|
2164
|
+
id: 'DATA-042', category: 'data', severity: 'high', confidence: 'likely', title: 'User list endpoint returning sensitive fields',
|
|
2165
|
+
check({ files }) {
|
|
2166
|
+
const findings = [];
|
|
2167
|
+
for (const [fp, c] of files) {
|
|
2168
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2169
|
+
const lines = c.split('\n');
|
|
2170
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2171
|
+
if (/find\s*\(\s*\)\s*\.select|findAll\s*\(\s*\)/.test(lines[i])) {
|
|
2172
|
+
const block = lines.slice(i, Math.min(lines.length, i+5)).join('\n');
|
|
2173
|
+
if (/password|token|secret|ssn|creditCard/i.test(block)) findings.push({ ruleId: 'DATA-042', category: 'data', severity: 'high', title: 'User list query may return sensitive fields', description: 'Explicitly exclude sensitive fields from list queries using .select("-password -token").', file: fp, line: i + 1, fix: null });
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
return findings;
|
|
2178
|
+
},
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2181
|
+
// DATA-043: Unmasked PAN in API response
|
|
2182
|
+
rules.push({
|
|
2183
|
+
id: 'DATA-043', category: 'data', severity: 'critical', confidence: 'definite', title: 'Credit card PAN returned in API response without masking',
|
|
2184
|
+
check({ files }) {
|
|
2185
|
+
const findings = [];
|
|
2186
|
+
for (const [fp, c] of files) {
|
|
2187
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2188
|
+
const lines = c.split('\n');
|
|
2189
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2190
|
+
if (/res\.(?:json|send)\s*\([^)]*(?:cardNumber|card_number|pan|creditCard)/i.test(lines[i])) {
|
|
2191
|
+
findings.push({ ruleId: 'DATA-043', category: 'data', severity: 'critical', title: 'Credit card number in API response — must be masked (show only last 4 digits)', description: 'Never return full card numbers in API responses. Mask to show only last 4 digits.', file: fp, line: i + 1, fix: null });
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
return findings;
|
|
2196
|
+
},
|
|
2197
|
+
});
|
|
2198
|
+
|
|
2199
|
+
// DATA-044: Database IDs exposed directly in API
|
|
2200
|
+
rules.push({
|
|
2201
|
+
id: 'DATA-044', category: 'data', severity: 'medium', confidence: 'likely', title: 'Sequential integer IDs exposed in API — enumeration attack risk',
|
|
2202
|
+
check({ files }) {
|
|
2203
|
+
const findings = [];
|
|
2204
|
+
for (const [fp, c] of files) {
|
|
2205
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2206
|
+
const lines = c.split('\n');
|
|
2207
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2208
|
+
if (/req\.params\.id\s*\*1|parseInt\s*\(\s*req\.params\.id|Number\s*\(\s*req\.params\.id/.test(lines[i])) {
|
|
2209
|
+
findings.push({ ruleId: 'DATA-044', category: 'data', severity: 'medium', title: 'Sequential integer ID in route — enables enumeration attacks', description: 'Use UUIDs or Hashids instead of sequential integers to prevent data enumeration.', file: fp, line: i + 1, fix: null });
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
return findings;
|
|
2214
|
+
},
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
// DATA-045: Passwords stored with reversible hashing
|
|
2218
|
+
rules.push({
|
|
2219
|
+
id: 'DATA-045', category: 'data', severity: 'critical', confidence: 'definite', title: 'Password hashed with reversible algorithm (MD5/SHA1)',
|
|
2220
|
+
check({ files }) {
|
|
2221
|
+
const findings = [];
|
|
2222
|
+
for (const [fp, c] of files) {
|
|
2223
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2224
|
+
const lines = c.split('\n');
|
|
2225
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2226
|
+
if (/(?:md5|sha1|sha256)\s*\([^)]*password|password[^=]*=\s*(?:md5|sha1|sha256)\s*\(/i.test(lines[i])) {
|
|
2227
|
+
findings.push({ ruleId: 'DATA-045', category: 'data', severity: 'critical', title: 'Password hashed with non-password-hashing algorithm — use bcrypt/argon2', description: 'Hash passwords with bcrypt, argon2, or scrypt. MD5/SHA1/SHA256 are not suitable for passwords.', file: fp, line: i + 1, fix: null });
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
return findings;
|
|
2232
|
+
},
|
|
2233
|
+
});
|
|
2234
|
+
|
|
2235
|
+
// DATA-046: User data written to temp files without cleanup
|
|
2236
|
+
rules.push({
|
|
2237
|
+
id: 'DATA-046', category: 'data', severity: 'high', confidence: 'likely', title: 'Sensitive data written to temp files without cleanup',
|
|
2238
|
+
check({ files }) {
|
|
2239
|
+
const findings = [];
|
|
2240
|
+
for (const [fp, c] of files) {
|
|
2241
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2242
|
+
const lines = c.split('\n');
|
|
2243
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2244
|
+
if (/(?:writeFile|writeFileSync)\s*\([^)]*(?:\/tmp|os\.tmpdir|\btmp\b)/i.test(lines[i])) {
|
|
2245
|
+
const block = lines.slice(i, Math.min(lines.length, i + 20)).join('\n');
|
|
2246
|
+
if (!/unlink|rm\s*\(|delete|cleanup|finally/.test(block)) findings.push({ ruleId: 'DATA-046', category: 'data', severity: 'high', title: 'Data written to temp file without cleanup — sensitive data persists on disk', description: 'Delete temporary files in a finally block or use a temp file library that cleans up automatically.', file: fp, line: i + 1, fix: null });
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
return findings;
|
|
2251
|
+
},
|
|
2252
|
+
});
|
|
2253
|
+
|
|
2254
|
+
// DATA-047: Unencrypted backup files
|
|
2255
|
+
rules.push({
|
|
2256
|
+
id: 'DATA-047', category: 'data', severity: 'high', confidence: 'likely', title: 'Database backup script without encryption',
|
|
2257
|
+
check({ files }) {
|
|
2258
|
+
const findings = [];
|
|
2259
|
+
for (const [fp, c] of files) {
|
|
2260
|
+
if (!fp.endsWith('.sh') && !fp.endsWith('.bash') && !fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2261
|
+
if (/pg_dump|mysqldump|mongodump/i.test(c) && !/encrypt|gpg|openssl|aes|cipher/i.test(c)) {
|
|
2262
|
+
findings.push({ ruleId: 'DATA-047', category: 'data', severity: 'high', title: 'Database backup without encryption — backup files contain plaintext data', description: 'Encrypt backup files using gpg or openssl before storing or transmitting.', file: fp, fix: null });
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
return findings;
|
|
2266
|
+
},
|
|
2267
|
+
});
|
|
2268
|
+
|
|
2269
|
+
// DATA-048: GraphQL mutation accepting entire user object
|
|
2270
|
+
rules.push({
|
|
2271
|
+
id: 'DATA-048', category: 'data', severity: 'high', confidence: 'likely', title: 'GraphQL mutation accepting full user input object — mass assignment risk',
|
|
2272
|
+
check({ files }) {
|
|
2273
|
+
const findings = [];
|
|
2274
|
+
for (const [fp, c] of files) {
|
|
2275
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts') && !fp.endsWith('.graphql') && !fp.endsWith('.gql')) continue;
|
|
2276
|
+
if (/mutation\s+\w+\s*\([^)]*input:\s*\w*Input\b/i.test(c) && !/role|permission|isAdmin/i.test(c)) {
|
|
2277
|
+
findings.push({ ruleId: 'DATA-048', category: 'data', severity: 'high', title: 'GraphQL mutation with generic input type — may allow mass assignment of privileged fields', description: 'Use specific input types and explicitly whitelist allowed fields in GraphQL mutations.', file: fp, fix: null });
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
return findings;
|
|
2281
|
+
},
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
// DATA-049: Personal data not anonymized in test databases
|
|
2285
|
+
rules.push({
|
|
2286
|
+
id: 'DATA-049', category: 'data', severity: 'high', confidence: 'likely', title: 'Production data patterns in test fixtures — potential PII in tests',
|
|
2287
|
+
check({ files }) {
|
|
2288
|
+
const findings = [];
|
|
2289
|
+
const emailPattern = /[a-zA-Z0-9._%+-]+@(?:gmail|yahoo|hotmail|outlook)\.[a-zA-Z]{2,}/;
|
|
2290
|
+
for (const [fp, c] of files) {
|
|
2291
|
+
if (!fp.includes('fixture') && !fp.includes('seed') && !fp.includes('test')) continue;
|
|
2292
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts') && !fp.endsWith('.json')) continue;
|
|
2293
|
+
if (emailPattern.test(c) && /password|ssn|creditCard|phone/i.test(c)) {
|
|
2294
|
+
findings.push({ ruleId: 'DATA-049', category: 'data', severity: 'high', title: 'Real-looking PII in test fixtures — use anonymous test data', description: 'Use fake/anonymous data in test fixtures. Real email addresses and personal data should never appear in test files.', file: fp, fix: null });
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
return findings;
|
|
2298
|
+
},
|
|
2299
|
+
});
|
|
2300
|
+
|
|
2301
|
+
// DATA-050: Sensitive data in URL query parameters logged by web server
|
|
2302
|
+
rules.push({
|
|
2303
|
+
id: 'DATA-050', category: 'data', severity: 'high', confidence: 'likely', title: 'Sensitive data passed as URL query parameter — logged in access logs',
|
|
2304
|
+
check({ files }) {
|
|
2305
|
+
const findings = [];
|
|
2306
|
+
for (const [fp, c] of files) {
|
|
2307
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts') && !fp.match(/\.[jt]sx?$/)) continue;
|
|
2308
|
+
const lines = c.split('\n');
|
|
2309
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2310
|
+
if (/(?:fetch|axios)\s*\([^)]*\?.*(?:token|password|secret|key)=/i.test(lines[i]) || /window\.location.*\?.*(?:token|password|secret)=/i.test(lines[i])) {
|
|
2311
|
+
findings.push({ ruleId: 'DATA-050', category: 'data', severity: 'high', title: 'Sensitive data in URL query string — visible in server logs and browser history', description: 'Pass sensitive values in request headers or body, never as URL query parameters.', file: fp, line: i + 1, fix: null });
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
return findings;
|
|
2316
|
+
},
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
// DATA-051: User data exported without authorization check
|
|
2320
|
+
rules.push({
|
|
2321
|
+
id: 'DATA-051', category: 'data', severity: 'high', confidence: 'likely', title: 'Data export endpoint without ownership/authorization check',
|
|
2322
|
+
check({ files }) {
|
|
2323
|
+
const findings = [];
|
|
2324
|
+
for (const [fp, c] of files) {
|
|
2325
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2326
|
+
const lines = c.split('\n');
|
|
2327
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2328
|
+
if (/(?:router|app)\.get\s*\([^)]*(?:export|download)/i.test(lines[i])) {
|
|
2329
|
+
const body = lines.slice(i, Math.min(lines.length, i + 20)).join('\n');
|
|
2330
|
+
if (!/req\.user|userId|ownerId|authorize|permission/i.test(body)) findings.push({ ruleId: 'DATA-051', category: 'data', severity: 'high', title: 'Export endpoint without authorization check — IDOR vulnerability', description: 'Verify that the authenticated user owns the data being exported before allowing the download.', file: fp, line: i + 1, fix: null });
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
return findings;
|
|
2335
|
+
},
|
|
2336
|
+
});
|
|
2337
|
+
|
|
2338
|
+
// DATA-052: Sensitive data returned in error stack traces
|
|
2339
|
+
rules.push({
|
|
2340
|
+
id: 'DATA-052', category: 'data', severity: 'high', confidence: 'likely', title: 'Error responses include stack traces — may leak sensitive data',
|
|
2341
|
+
check({ files }) {
|
|
2342
|
+
const findings = [];
|
|
2343
|
+
for (const [fp, c] of files) {
|
|
2344
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2345
|
+
const lines = c.split('\n');
|
|
2346
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2347
|
+
if (/res\.(?:json|send)\s*\([^)]*error\.stack|message:\s*err\.stack|stack:\s*err\.stack/.test(lines[i])) {
|
|
2348
|
+
findings.push({ ruleId: 'DATA-052', category: 'data', severity: 'high', title: 'Error stack trace in API response — exposes internal code structure', description: 'Never send error.stack to clients. Log it server-side and return a generic error message.', file: fp, line: i + 1, fix: null });
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
return findings;
|
|
2353
|
+
},
|
|
2354
|
+
});
|
|
2355
|
+
|
|
2356
|
+
// DATA-053: Missing data masking for logs
|
|
2357
|
+
rules.push({
|
|
2358
|
+
id: 'DATA-053', category: 'data', severity: 'high', confidence: 'likely', title: 'User object logged without redacting sensitive fields',
|
|
2359
|
+
check({ files }) {
|
|
2360
|
+
const findings = [];
|
|
2361
|
+
for (const [fp, c] of files) {
|
|
2362
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2363
|
+
const lines = c.split('\n');
|
|
2364
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2365
|
+
if (/(?:console\.log|logger\.\w+)\s*\([^)]*\buser\b/.test(lines[i]) && !/redact|mask|omit|sanitize|exclude|select/i.test(lines[i])) {
|
|
2366
|
+
findings.push({ ruleId: 'DATA-053', category: 'data', severity: 'high', title: 'User object logged without redacting sensitive fields', description: 'Before logging user objects, redact sensitive fields: const { password, token, ...safeUser } = user.', file: fp, line: i + 1, fix: null });
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
return findings;
|
|
2371
|
+
},
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
// DATA-054: User-controlled sort/order by parameter in database query
|
|
2375
|
+
rules.push({
|
|
2376
|
+
id: 'DATA-054', category: 'data', severity: 'high', confidence: 'likely', title: 'Database sort/orderBy field controlled directly by user input',
|
|
2377
|
+
check({ files }) {
|
|
2378
|
+
const findings = [];
|
|
2379
|
+
for (const [fp, c] of files) {
|
|
2380
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2381
|
+
const lines = c.split('\n');
|
|
2382
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2383
|
+
if (/(?:orderBy|sort)\s*:\s*(?:req\.|body\.|params\.|query\.)/i.test(lines[i])) {
|
|
2384
|
+
findings.push({ ruleId: 'DATA-054', category: 'data', severity: 'high', title: 'User-controlled orderBy/sort — SQL injection or data leakage risk', description: 'Validate sort fields against an allowlist before using in database queries.', file: fp, line: i + 1, fix: null });
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
return findings;
|
|
2389
|
+
},
|
|
2390
|
+
});
|
|
2391
|
+
|
|
2392
|
+
// DATA-055: Sensitive data in localStorage
|
|
2393
|
+
rules.push({
|
|
2394
|
+
id: 'DATA-055', category: 'data', severity: 'high', confidence: 'likely', title: 'Sensitive user data stored in localStorage',
|
|
2395
|
+
check({ files }) {
|
|
2396
|
+
const findings = [];
|
|
2397
|
+
for (const [fp, c] of files) {
|
|
2398
|
+
if (!fp.match(/\.[jt]sx?$/)) continue;
|
|
2399
|
+
const lines = c.split('\n');
|
|
2400
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2401
|
+
if (/localStorage\.setItem\s*\([^)]*(?:ssn|creditCard|card_number|password|secret|privateKey)/i.test(lines[i])) {
|
|
2402
|
+
findings.push({ ruleId: 'DATA-055', category: 'data', severity: 'high', title: 'Sensitive data stored in localStorage — accessible to XSS attacks', description: 'Never store sensitive data in localStorage. Use sessionStorage or encrypt before storing.', file: fp, line: i + 1, fix: null });
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
return findings;
|
|
2407
|
+
},
|
|
2408
|
+
});
|
|
2409
|
+
|
|
2410
|
+
// DATA-056: Event sourcing without encryption for PII events
|
|
2411
|
+
rules.push({
|
|
2412
|
+
id: 'DATA-056', category: 'data', severity: 'high', confidence: 'likely', title: 'Event store records containing PII without encryption',
|
|
2413
|
+
check({ files }) {
|
|
2414
|
+
const findings = [];
|
|
2415
|
+
for (const [fp, c] of files) {
|
|
2416
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2417
|
+
if (/eventStore|EventStore|event_store|appendToStream|saveEvent/i.test(c) && /email|phone|ssn|address/i.test(c) && !/encrypt|cipher|crypto/i.test(c)) {
|
|
2418
|
+
findings.push({ ruleId: 'DATA-056', category: 'data', severity: 'high', title: 'Event store contains PII fields without encryption', description: 'Encrypt PII fields in event store records. Events are immutable so encryption must be applied at write time.', file: fp, fix: null });
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
return findings;
|
|
2422
|
+
},
|
|
2423
|
+
});
|
|
2424
|
+
|
|
2425
|
+
// DATA-057: Missing data retention policy implementation
|
|
2426
|
+
rules.push({
|
|
2427
|
+
id: 'DATA-057', category: 'data', severity: 'medium', confidence: 'likely', title: 'No data retention/deletion job found — stale data accumulation',
|
|
2428
|
+
check({ files }) {
|
|
2429
|
+
const findings = [];
|
|
2430
|
+
const hasDeletion = [...files.values()].some(c => /deleteMany\s*\(\s*\{[^}]*(?:createdAt|date|expir)/i.test(c) || /cron|schedule|retention/i.test(c));
|
|
2431
|
+
if (!hasDeletion) {
|
|
2432
|
+
const dbFiles = [...files.keys()].filter(f => (f.endsWith('.js') || f.endsWith('.ts')) && /model|schema|entity/.test(f));
|
|
2433
|
+
if (dbFiles.length > 0) findings.push({ ruleId: 'DATA-057', category: 'data', severity: 'medium', title: 'No data retention job found — stale records accumulate over time', description: 'Implement scheduled jobs to delete or archive records older than your retention policy.', file: dbFiles[0], fix: null });
|
|
2434
|
+
}
|
|
2435
|
+
return findings;
|
|
2436
|
+
},
|
|
2437
|
+
});
|
|
2438
|
+
|
|
2439
|
+
// DATA-058: Geolocation data stored without consent
|
|
2440
|
+
rules.push({
|
|
2441
|
+
id: 'DATA-058', category: 'data', severity: 'high', confidence: 'likely', title: 'Geolocation data stored without explicit user consent',
|
|
2442
|
+
check({ files }) {
|
|
2443
|
+
const findings = [];
|
|
2444
|
+
for (const [fp, c] of files) {
|
|
2445
|
+
if (!fp.match(/\.[jt]sx?$/)) continue;
|
|
2446
|
+
if (/navigator\.geolocation|getCurrentPosition|watchPosition/i.test(c) && !/consent|permission|hasPermission/i.test(c)) {
|
|
2447
|
+
findings.push({ ruleId: 'DATA-058', category: 'data', severity: 'high', title: 'Geolocation accessed without checking stored consent', description: 'Check for and store user consent before accessing or storing geolocation data.', file: fp, fix: null });
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
return findings;
|
|
2451
|
+
},
|
|
2452
|
+
});
|
|
2453
|
+
|
|
2454
|
+
// DATA-059: Biometric data without special category treatment
|
|
2455
|
+
rules.push({
|
|
2456
|
+
id: 'DATA-059', category: 'data', severity: 'critical', confidence: 'definite', title: 'Biometric data processed without GDPR special category controls',
|
|
2457
|
+
check({ files }) {
|
|
2458
|
+
const findings = [];
|
|
2459
|
+
for (const [fp, c] of files) {
|
|
2460
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2461
|
+
if (/fingerprint|faceId|biometric|voicePrint|retina/i.test(c) && !/explicit.consent|special.category|gdpr|article.9/i.test(c)) {
|
|
2462
|
+
findings.push({ ruleId: 'DATA-059', category: 'data', severity: 'critical', title: 'Biometric data requires GDPR Article 9 explicit consent and special protections', description: 'Biometric data is a special category under GDPR. Implement explicit consent, strict access controls, and document processing basis.', file: fp, fix: null });
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
return findings;
|
|
2466
|
+
},
|
|
2467
|
+
});
|
|
2468
|
+
|
|
2469
|
+
// DATA-060: Unvalidated JSON merge patch
|
|
2470
|
+
rules.push({
|
|
2471
|
+
id: 'DATA-060', category: 'data', severity: 'high', confidence: 'likely', title: 'JSON merge patch applied without field validation — mass assignment',
|
|
2472
|
+
check({ files }) {
|
|
2473
|
+
const findings = [];
|
|
2474
|
+
for (const [fp, c] of files) {
|
|
2475
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2476
|
+
const lines = c.split('\n');
|
|
2477
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2478
|
+
if (/Object\.assign\s*\(\s*\w+,\s*(?:req\.body|body\b)/i.test(lines[i]) || /\.\.\.\s*req\.body/.test(lines[i])) {
|
|
2479
|
+
findings.push({ ruleId: 'DATA-060', category: 'data', severity: 'high', title: 'Object.assign/spread with req.body — mass assignment vulnerability', description: 'Explicitly whitelist fields from req.body before assigning to database objects.', file: fp, line: i + 1, fix: null });
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
return findings;
|
|
2484
|
+
},
|
|
2485
|
+
});
|
|
2486
|
+
|
|
2487
|
+
// DATA-061: Missing data minimization in API responses
|
|
2488
|
+
rules.push({
|
|
2489
|
+
id: 'DATA-061', category: 'data', severity: 'medium', confidence: 'likely', title: 'API response may include more fields than required (over-sharing)',
|
|
2490
|
+
check({ files }) {
|
|
2491
|
+
const findings = [];
|
|
2492
|
+
for (const [fp, c] of files) {
|
|
2493
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2494
|
+
const lines = c.split('\n');
|
|
2495
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2496
|
+
if (/res\.json\s*\(\s*(?:await\s+)?\w+\.findById\s*\(|res\.json\s*\(\s*(?:await\s+)?\w+\.findOne\s*\(/.test(lines[i]) && !/\.select\s*\(|\.toJSON\s*\(|\.toObject\s*\(|pick\s*\(|omit\s*\(/.test(lines[i])) {
|
|
2497
|
+
findings.push({ ruleId: 'DATA-061', category: 'data', severity: 'medium', title: 'Database document returned directly in response without field selection', description: 'Use .select() or toSafeObject() to explicitly include only needed fields in API responses.', file: fp, line: i + 1, fix: null });
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
return findings;
|
|
2502
|
+
},
|
|
2503
|
+
});
|