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,2714 @@
|
|
|
1
|
+
import healthcareRules from './healthcare.js';
|
|
2
|
+
import regionalEuRules from './regional-eu.js';
|
|
3
|
+
import regionalIntlRules from './regional-international.js';
|
|
4
|
+
import educationRules from './education.js';
|
|
5
|
+
import financialRules from './financial.js';
|
|
6
|
+
import frameworksRules from './frameworks.js';
|
|
7
|
+
import accessibilityExtRules from './accessibility-ext.js';
|
|
8
|
+
|
|
9
|
+
const rules = [
|
|
10
|
+
// COMP-001: No privacy policy
|
|
11
|
+
{
|
|
12
|
+
id: 'COMP-001',
|
|
13
|
+
category: 'compliance',
|
|
14
|
+
severity: 'high',
|
|
15
|
+
confidence: 'likely',
|
|
16
|
+
title: 'No Privacy Policy Page',
|
|
17
|
+
check({ files }) {
|
|
18
|
+
const findings = [];
|
|
19
|
+
const hasPrivacyPage = [...files.keys()].some(f =>
|
|
20
|
+
f.match(/privacy/i) || f.match(/privacypolicy/i) || f.match(/privacy-policy/i)
|
|
21
|
+
);
|
|
22
|
+
const hasPrivacyLink = [...files.values()].some(c =>
|
|
23
|
+
c.includes('/privacy') || c.includes('privacy-policy') || c.includes('privacy policy')
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
if (!hasPrivacyPage && !hasPrivacyLink) {
|
|
27
|
+
// Only flag if it looks like a web app with users
|
|
28
|
+
const hasUserFacing = [...files.values()].some(c =>
|
|
29
|
+
c.includes('signup') || c.includes('login') || c.includes('register') ||
|
|
30
|
+
c.includes('<form') || c.includes('email')
|
|
31
|
+
);
|
|
32
|
+
if (hasUserFacing) {
|
|
33
|
+
findings.push({
|
|
34
|
+
ruleId: 'COMP-001', category: 'compliance', severity: 'high',
|
|
35
|
+
title: 'No privacy policy page detected',
|
|
36
|
+
description: 'Required by GDPR, CCPA, and most app stores. Every app collecting user data needs one.',
|
|
37
|
+
fix: null,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return findings;
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
// COMP-002: No cookie consent
|
|
46
|
+
{
|
|
47
|
+
id: 'COMP-002',
|
|
48
|
+
category: 'compliance',
|
|
49
|
+
severity: 'high',
|
|
50
|
+
confidence: 'likely',
|
|
51
|
+
title: 'No Cookie Consent Banner',
|
|
52
|
+
check({ files }) {
|
|
53
|
+
const findings = [];
|
|
54
|
+
const hasCookieConsent = [...files.values()].some(c =>
|
|
55
|
+
c.includes('cookie-consent') || c.includes('cookieConsent') ||
|
|
56
|
+
c.includes('cookie banner') || c.includes('CookieBanner') ||
|
|
57
|
+
c.includes('cookie_consent')
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Check if cookies or analytics are used
|
|
61
|
+
const usesCookies = [...files.values()].some(c =>
|
|
62
|
+
c.includes('document.cookie') || c.includes('setCookie') ||
|
|
63
|
+
c.includes('analytics') || c.includes('gtag') || c.includes('GA_TRACKING') ||
|
|
64
|
+
c.includes('google-analytics') || c.includes('mixpanel') || c.includes('segment')
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
if (usesCookies && !hasCookieConsent) {
|
|
68
|
+
findings.push({
|
|
69
|
+
ruleId: 'COMP-002', category: 'compliance', severity: 'high',
|
|
70
|
+
title: 'Using cookies/analytics without consent banner',
|
|
71
|
+
description: 'GDPR and ePrivacy Directive require user consent before setting non-essential cookies.',
|
|
72
|
+
fix: null,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return findings;
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// COMP-003: No data deletion endpoint
|
|
80
|
+
{
|
|
81
|
+
id: 'COMP-003',
|
|
82
|
+
category: 'compliance',
|
|
83
|
+
severity: 'medium',
|
|
84
|
+
confidence: 'likely',
|
|
85
|
+
title: 'No Data Deletion / Right to Erasure',
|
|
86
|
+
check({ files }) {
|
|
87
|
+
const findings = [];
|
|
88
|
+
const hasDeleteEndpoint = [...files.entries()].some(([filepath, content]) =>
|
|
89
|
+
(filepath.includes('api/') || filepath.includes('route')) &&
|
|
90
|
+
(content.includes('delete') || content.includes('DELETE')) &&
|
|
91
|
+
(content.includes('user') || content.includes('account'))
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const hasUsers = [...files.values()].some(c =>
|
|
95
|
+
c.includes('signup') || c.includes('register') || c.includes('createUser')
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (hasUsers && !hasDeleteEndpoint) {
|
|
99
|
+
findings.push({
|
|
100
|
+
ruleId: 'COMP-003', category: 'compliance', severity: 'medium',
|
|
101
|
+
title: 'No user data deletion endpoint (GDPR right to erasure)',
|
|
102
|
+
description: 'GDPR requires you to delete user data on request. Add an account deletion feature.',
|
|
103
|
+
fix: null,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return findings;
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// COMP-004: No accessibility basics
|
|
111
|
+
{
|
|
112
|
+
id: 'COMP-004',
|
|
113
|
+
category: 'compliance',
|
|
114
|
+
severity: 'medium',
|
|
115
|
+
confidence: 'likely',
|
|
116
|
+
title: 'Missing Accessibility Attributes',
|
|
117
|
+
check({ files }) {
|
|
118
|
+
const findings = [];
|
|
119
|
+
let imgWithoutAlt = 0;
|
|
120
|
+
let buttonWithoutLabel = 0;
|
|
121
|
+
|
|
122
|
+
for (const [filepath, content] of files) {
|
|
123
|
+
if (!filepath.match(/\.(jsx|tsx|html)$/)) continue;
|
|
124
|
+
|
|
125
|
+
// Check for images without alt
|
|
126
|
+
const imgMatches = content.match(/<img[^>]*>/g) || [];
|
|
127
|
+
for (const img of imgMatches) {
|
|
128
|
+
if (!img.includes('alt=') && !img.includes('alt =')) {
|
|
129
|
+
imgWithoutAlt++;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check for buttons without accessible labels
|
|
134
|
+
const btnMatches = content.match(/<button[^>]*>[^<]*<\/button>/g) || [];
|
|
135
|
+
for (const btn of btnMatches) {
|
|
136
|
+
if (btn.match(/<button[^>]*>\s*<\/button>/)) {
|
|
137
|
+
buttonWithoutLabel++;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (imgWithoutAlt > 0) {
|
|
143
|
+
findings.push({
|
|
144
|
+
ruleId: 'COMP-004', category: 'compliance', severity: 'medium',
|
|
145
|
+
title: `${imgWithoutAlt} image(s) missing alt text (accessibility)`,
|
|
146
|
+
description: 'Screen readers need alt text to describe images. Required for WCAG compliance.',
|
|
147
|
+
fix: null,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (buttonWithoutLabel > 0) {
|
|
152
|
+
findings.push({
|
|
153
|
+
ruleId: 'COMP-004b', category: 'compliance', severity: 'medium',
|
|
154
|
+
title: `${buttonWithoutLabel} button(s) without accessible labels`,
|
|
155
|
+
description: 'Buttons need text content or aria-label for screen reader accessibility.',
|
|
156
|
+
fix: null,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return findings;
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
// COMP-005: Terms of Service
|
|
164
|
+
{
|
|
165
|
+
id: 'COMP-005',
|
|
166
|
+
category: 'compliance',
|
|
167
|
+
severity: 'low',
|
|
168
|
+
confidence: 'suggestion',
|
|
169
|
+
title: 'No Terms of Service Page',
|
|
170
|
+
check({ files }) {
|
|
171
|
+
const findings = [];
|
|
172
|
+
const hasToS = [...files.keys()].some(f =>
|
|
173
|
+
f.match(/terms/i) || f.match(/tos/i)
|
|
174
|
+
);
|
|
175
|
+
const hasToSLink = [...files.values()].some(c =>
|
|
176
|
+
c.includes('/terms') || c.includes('terms-of-service') || c.includes('terms of service')
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const hasUsers = [...files.values()].some(c =>
|
|
180
|
+
c.includes('signup') || c.includes('register')
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
if (hasUsers && !hasToS && !hasToSLink) {
|
|
184
|
+
findings.push({
|
|
185
|
+
ruleId: 'COMP-005', category: 'compliance', severity: 'low',
|
|
186
|
+
title: 'No terms of service page detected',
|
|
187
|
+
description: 'A ToS protects you legally. Important before accepting payments or user data.',
|
|
188
|
+
fix: null,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return findings;
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
// COMP-GDPR-001: Missing consent checkbox on signup
|
|
196
|
+
{ id: 'COMP-GDPR-001', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Missing Consent Checkbox on Signup',
|
|
197
|
+
check({ files }) {
|
|
198
|
+
const findings = [];
|
|
199
|
+
for (const [fp, c] of files) {
|
|
200
|
+
if (!fp.match(/\.(jsx|tsx|html)$/)) continue;
|
|
201
|
+
if ((c.includes('signup') || c.includes('register') || c.includes('createAccount')) && c.includes('<form')) {
|
|
202
|
+
if (!c.match(/consent|agree|terms.*checkbox|checkbox.*terms|gdpr/i)) {
|
|
203
|
+
findings.push({ ruleId: 'COMP-GDPR-001', category: 'compliance', severity: 'high',
|
|
204
|
+
title: 'Signup form missing explicit consent checkbox (GDPR)', description: 'GDPR requires freely given, specific, informed consent. Add a checkbox for terms/privacy that users must check.', file: fp, fix: null });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return findings;
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
// COMP-GDPR-002: No data export endpoint (right to portability)
|
|
213
|
+
{ id: 'COMP-GDPR-002', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Data Export Endpoint (GDPR Right to Portability)',
|
|
214
|
+
check({ files }) {
|
|
215
|
+
const findings = [];
|
|
216
|
+
const hasExport = [...files.values()].some(c => c.match(/export.*data|data.*export|download.*data|portability/i));
|
|
217
|
+
const hasUsers = [...files.values()].some(c => c.includes('signup') || c.includes('createUser'));
|
|
218
|
+
if (hasUsers && !hasExport) {
|
|
219
|
+
findings.push({ ruleId: 'COMP-GDPR-002', category: 'compliance', severity: 'medium',
|
|
220
|
+
title: 'No data export endpoint — GDPR right to data portability', description: 'GDPR Article 20 requires you to provide users their data in a machine-readable format on request.', fix: null });
|
|
221
|
+
}
|
|
222
|
+
return findings;
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
// COMP-GDPR-003: Analytics without consent
|
|
227
|
+
{ id: 'COMP-GDPR-003', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Analytics Loaded Without Consent Check',
|
|
228
|
+
check({ files }) {
|
|
229
|
+
const findings = [];
|
|
230
|
+
for (const [fp, c] of files) {
|
|
231
|
+
if (!fp.match(/\.(jsx|tsx|html|js)$/)) continue;
|
|
232
|
+
if (c.match(/gtag|google-analytics|GA_MEASUREMENT|mixpanel|segment|amplitude|heap\.io/)) {
|
|
233
|
+
if (!c.match(/consent|cookie.*accept|cookieBanner|hasConsent|gdpr/i)) {
|
|
234
|
+
findings.push({ ruleId: 'COMP-GDPR-003', category: 'compliance', severity: 'high',
|
|
235
|
+
title: 'Analytics loaded without checking user consent', description: 'GDPR requires consent before loading tracking scripts. Check consent before initializing analytics.', file: fp, fix: null });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return findings;
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
// COMP-GDPR-004: IP addresses logged without anonymization
|
|
244
|
+
{ id: 'COMP-GDPR-004', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'IP Addresses Logged Without Anonymization',
|
|
245
|
+
check({ files }) {
|
|
246
|
+
const findings = [];
|
|
247
|
+
for (const [fp, c] of files) {
|
|
248
|
+
if (!fp.match(/\.(js|ts)$/)) continue;
|
|
249
|
+
if (c.match(/req\.ip|req\.socket\.remoteAddress|x-forwarded-for/i)) {
|
|
250
|
+
if (c.match(/log|store|save|insert|record/i) && !c.match(/anonymi[sz]|mask|redact|truncate/i)) {
|
|
251
|
+
findings.push({ ruleId: 'COMP-GDPR-004', category: 'compliance', severity: 'medium',
|
|
252
|
+
title: 'IP addresses logged/stored without anonymization', description: 'IP addresses are personal data under GDPR. Anonymize (truncate last octet) before logging or storing.', file: fp, fix: null });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return findings;
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
// COMP-GDPR-005: No data retention policy
|
|
261
|
+
{ id: 'COMP-GDPR-005', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Data Retention Policy',
|
|
262
|
+
check({ files }) {
|
|
263
|
+
const has = [...files.values()].some(c => c.match(/retention|purge|expire|ttl|deleteOld/i));
|
|
264
|
+
if (!has) {
|
|
265
|
+
return [{ ruleId: 'COMP-GDPR-005', category: 'compliance', severity: 'medium',
|
|
266
|
+
title: 'No data retention policy detected', description: 'GDPR requires data to be deleted when no longer needed. Implement scheduled deletion jobs and document retention periods.', fix: null }];
|
|
267
|
+
}
|
|
268
|
+
return [];
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
// COMP-A11Y-001: Form inputs without labels
|
|
273
|
+
{ id: 'COMP-A11Y-001', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Form Inputs Without Labels',
|
|
274
|
+
check({ files }) {
|
|
275
|
+
const findings = [];
|
|
276
|
+
for (const [fp, c] of files) {
|
|
277
|
+
if (!fp.match(/\.(jsx|tsx|html)$/)) continue;
|
|
278
|
+
const inputs = (c.match(/<input[^>]*>/gi) || []);
|
|
279
|
+
for (const inp of inputs) {
|
|
280
|
+
if (!inp.match(/aria-label|aria-labelledby/i) && !inp.match(/type=["']hidden["']/i)) {
|
|
281
|
+
const surroundingCtx = c.substring(Math.max(0, c.indexOf(inp) - 200), c.indexOf(inp));
|
|
282
|
+
if (!surroundingCtx.match(/<label/i)) {
|
|
283
|
+
findings.push({ ruleId: 'COMP-A11Y-001', category: 'compliance', severity: 'high',
|
|
284
|
+
title: 'Input element without associated label (WCAG 1.3.1)', description: 'Every input needs a <label for="id"> or aria-label for screen reader accessibility.', file: fp, fix: null });
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return findings;
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
// COMP-A11Y-002: Images without alt text
|
|
295
|
+
{ id: 'COMP-A11Y-002', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Images Missing Alt Text',
|
|
296
|
+
check({ files }) {
|
|
297
|
+
const findings = [];
|
|
298
|
+
for (const [fp, c] of files) {
|
|
299
|
+
if (!fp.match(/\.(jsx|tsx|html)$/)) continue;
|
|
300
|
+
const imgs = (c.match(/<img[^>]*>/gi) || []);
|
|
301
|
+
let missing = 0;
|
|
302
|
+
for (const img of imgs) {
|
|
303
|
+
if (!img.match(/alt\s*=/i)) missing++;
|
|
304
|
+
}
|
|
305
|
+
if (missing > 0) {
|
|
306
|
+
findings.push({ ruleId: 'COMP-A11Y-002', category: 'compliance', severity: 'high',
|
|
307
|
+
title: `${missing} image(s) missing alt attribute (WCAG 1.1.1)`, description: 'Screen readers require alt text. Use alt="" for decorative images, descriptive text for informational images.', file: fp, fix: null });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return findings;
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
// COMP-A11Y-003: Focus outline removed
|
|
315
|
+
{ id: 'COMP-A11Y-003', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Focus Outline Removed (Keyboard Navigation Broken)',
|
|
316
|
+
check({ files }) {
|
|
317
|
+
const findings = [];
|
|
318
|
+
for (const [fp, c] of files) {
|
|
319
|
+
if (!fp.match(/\.(css|scss|sass|less|jsx|tsx)$/)) continue;
|
|
320
|
+
if (c.match(/outline\s*:\s*0|outline\s*:\s*none/)) {
|
|
321
|
+
if (!c.match(/outline-offset|:focus-visible|focus-ring/)) {
|
|
322
|
+
findings.push({ ruleId: 'COMP-A11Y-003', category: 'compliance', severity: 'high',
|
|
323
|
+
title: 'outline: none removes keyboard focus indicator (WCAG 2.4.7)', description: 'Never remove focus outlines without providing an alternative. Use :focus-visible to show focus only for keyboard users.', file: fp, fix: null });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return findings;
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
// COMP-A11Y-004: Interactive div without role
|
|
332
|
+
{ id: 'COMP-A11Y-004', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Clickable div Without ARIA Role',
|
|
333
|
+
check({ files }) {
|
|
334
|
+
const findings = [];
|
|
335
|
+
for (const [fp, c] of files) {
|
|
336
|
+
if (!fp.match(/\.(jsx|tsx|html)$/)) continue;
|
|
337
|
+
const lines = c.split('\n');
|
|
338
|
+
for (let i = 0; i < lines.length; i++) {
|
|
339
|
+
if (lines[i].match(/<div[^>]*onClick/i) && !lines[i].match(/role=["'](?:button|link|menuitem)/i)) {
|
|
340
|
+
findings.push({ ruleId: 'COMP-A11Y-004', category: 'compliance', severity: 'medium',
|
|
341
|
+
title: 'Clickable <div> without role="button" (WCAG 4.1.2)', description: 'Use <button> instead of <div onClick>. If div is required, add role="button" tabIndex={0} and keyboard event handlers.', file: fp, line: i + 1, fix: null });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return findings;
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
// COMP-A11Y-005: Video without captions
|
|
350
|
+
{ id: 'COMP-A11Y-005', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Video Without Captions',
|
|
351
|
+
check({ files }) {
|
|
352
|
+
const findings = [];
|
|
353
|
+
for (const [fp, c] of files) {
|
|
354
|
+
if (!fp.match(/\.(jsx|tsx|html)$/)) continue;
|
|
355
|
+
if (c.includes('<video') && !c.includes('<track') && !c.match(/captions|subtitles/i)) {
|
|
356
|
+
findings.push({ ruleId: 'COMP-A11Y-005', category: 'compliance', severity: 'medium',
|
|
357
|
+
title: 'Video element without captions/subtitles (WCAG 1.2.2)', description: 'Add <track kind="captions"> for prerecorded video. Required for WCAG AA compliance.', file: fp, fix: null });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return findings;
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
// COMP-A11Y-006: Missing lang attribute on html
|
|
365
|
+
{ id: 'COMP-A11Y-006', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Missing lang Attribute on HTML Element',
|
|
366
|
+
check({ files }) {
|
|
367
|
+
const findings = [];
|
|
368
|
+
for (const [fp, c] of files) {
|
|
369
|
+
if (!fp.match(/\.(html)$/) && !fp.match(/_document\.(jsx|tsx)$/)) continue;
|
|
370
|
+
if (c.includes('<html') && !c.match(/<html[^>]*lang=/i)) {
|
|
371
|
+
findings.push({ ruleId: 'COMP-A11Y-006', category: 'compliance', severity: 'medium',
|
|
372
|
+
title: 'HTML element missing lang attribute (WCAG 3.1.1)', description: 'Add lang="en" (or appropriate locale) to the <html> element for screen readers and search engines.', file: fp, fix: null });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return findings;
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
// COMP-A11Y-007: Non-descriptive link text
|
|
380
|
+
{ id: 'COMP-A11Y-007', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Non-Descriptive Link Text',
|
|
381
|
+
check({ files }) {
|
|
382
|
+
const findings = [];
|
|
383
|
+
for (const [fp, c] of files) {
|
|
384
|
+
if (!fp.match(/\.(jsx|tsx|html)$/)) continue;
|
|
385
|
+
const matches = c.match(/<a[^>]*>\s*(?:click here|read more|here|more|link|this)\s*<\/a>/gi) || [];
|
|
386
|
+
if (matches.length > 0) {
|
|
387
|
+
findings.push({ ruleId: 'COMP-A11Y-007', category: 'compliance', severity: 'medium',
|
|
388
|
+
title: `${matches.length} link(s) with non-descriptive text ("click here", "read more")`, description: 'Screen reader users navigate by links. Use descriptive text like "Read the privacy policy" instead of "click here".', file: fp, fix: null });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return findings;
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
|
|
395
|
+
// COMP-EMAIL-001: No unsubscribe link in email templates
|
|
396
|
+
{ id: 'COMP-EMAIL-001', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Email Template Missing Unsubscribe Link',
|
|
397
|
+
check({ files }) {
|
|
398
|
+
const findings = [];
|
|
399
|
+
for (const [fp, c] of files) {
|
|
400
|
+
if (!fp.match(/email|mail|template/i)) continue;
|
|
401
|
+
if (c.match(/<html|text\/html|sendMail|transporter\.send/i)) {
|
|
402
|
+
if (!c.match(/unsubscribe|opt.?out|manage.*preferences/i)) {
|
|
403
|
+
findings.push({ ruleId: 'COMP-EMAIL-001', category: 'compliance', severity: 'high',
|
|
404
|
+
title: 'Email template missing unsubscribe link (CAN-SPAM / GDPR)', description: 'All marketing emails must include an unsubscribe link. CAN-SPAM violations can result in $50k+ fines per email.', file: fp, fix: null });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return findings;
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
// COMP-EMAIL-002: No DMARC/SPF/DKIM reference
|
|
413
|
+
{ id: 'COMP-EMAIL-002', category: 'compliance', severity: 'high', confidence: 'likely', title: 'No Email Authentication (SPF/DKIM/DMARC)',
|
|
414
|
+
check({ files }) {
|
|
415
|
+
const has = [...files.values()].some(c => c.match(/dmarc|spf|dkim|email.*auth/i));
|
|
416
|
+
if (!has && [...files.values()].some(c => c.match(/sendMail|sendgrid|nodemailer|smtp/i))) {
|
|
417
|
+
return [{ ruleId: 'COMP-EMAIL-002', category: 'compliance', severity: 'high',
|
|
418
|
+
title: 'No SPF/DKIM/DMARC email authentication configured', description: 'Without email authentication, your emails will be marked as spam and you are vulnerable to spoofing. Configure SPF, DKIM, and DMARC DNS records.', fix: null }];
|
|
419
|
+
}
|
|
420
|
+
return [];
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
|
|
424
|
+
// COMP-FIN-001: Payment card data stored
|
|
425
|
+
{ id: 'COMP-FIN-001', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'Payment Card Data Stored (PCI DSS Violation)',
|
|
426
|
+
check({ files }) {
|
|
427
|
+
const findings = [];
|
|
428
|
+
for (const [fp, c] of files) {
|
|
429
|
+
if (!isSourceFile(fp)) continue;
|
|
430
|
+
if (c.match(/cardNumber|card_number|creditCard|credit_card|cvv|cvc|expiry|expirationDate/i) &&
|
|
431
|
+
(c.match(/save|store|insert|create|log/i))) {
|
|
432
|
+
if (!c.match(/token|stripe|braintree|adyen/i)) {
|
|
433
|
+
findings.push({ ruleId: 'COMP-FIN-001', category: 'compliance', severity: 'critical',
|
|
434
|
+
title: 'Possible payment card data being stored (PCI DSS violation)', description: 'Never store raw card numbers, CVV, or expiry dates. Use Stripe/Braintree tokenization — they store card data, you store tokens.', file: fp, fix: null });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return findings;
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
|
|
442
|
+
// COMP-FIN-002: CVV stored after authorization
|
|
443
|
+
{ id: 'COMP-FIN-002', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'CVV/CVC Stored After Authorization',
|
|
444
|
+
check({ files }) {
|
|
445
|
+
const findings = [];
|
|
446
|
+
for (const [fp, c] of files) {
|
|
447
|
+
if (!isSourceFile(fp)) continue;
|
|
448
|
+
if (c.match(/\bcvv\b|\bcvc\b|\bcvc2\b|\bcvv2\b/i) && c.match(/save|store|insert|db\.|redis\./i)) {
|
|
449
|
+
findings.push({ ruleId: 'COMP-FIN-002', category: 'compliance', severity: 'critical',
|
|
450
|
+
title: 'CVV/CVC value being stored — prohibited by PCI DSS', description: 'Storing CVV after authorization is a PCI DSS violation regardless of encryption. Remove all CVV storage.', file: fp, fix: null });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return findings;
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
// COMP-COPPA-001: No age verification — only fires on apps explicitly targeting children
|
|
458
|
+
{ id: 'COMP-COPPA-001', category: 'compliance', severity: 'high', confidence: 'likely', title: 'No Age Verification on Registration',
|
|
459
|
+
check({ files }) {
|
|
460
|
+
const findings = [];
|
|
461
|
+
// Only fire if the app explicitly targets children (COPPA-specific keywords in code, not just any mention)
|
|
462
|
+
const hasChildrenTargeting = [...files.entries()].some(([fp, c]) =>
|
|
463
|
+
(fp.endsWith('package.json') || fp.endsWith('.json')) && /coppa|kids.*app|children.*platform|parental.*consent/i.test(c)
|
|
464
|
+
);
|
|
465
|
+
if (!hasChildrenTargeting) return findings;
|
|
466
|
+
const hasUsers = [...files.values()].some(c => c.includes('signup') || c.includes('register'));
|
|
467
|
+
const hasAgeGate = [...files.values()].some(c => c.match(/age.*gate|age.*verif|under.*13.*check|13.*under.*check/i));
|
|
468
|
+
if (hasUsers && !hasAgeGate) {
|
|
469
|
+
findings.push({ ruleId: 'COMP-COPPA-001', category: 'compliance', severity: 'high',
|
|
470
|
+
title: 'No age verification on user registration (COPPA risk)', description: "COPPA requires parental consent for users under 13. Add age verification or a 'I am over 13' confirmation to your signup flow.", fix: null });
|
|
471
|
+
}
|
|
472
|
+
return findings;
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
// COMP-INTL-001: No i18n framework
|
|
477
|
+
{ id: 'COMP-INTL-001', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'No Internationalization Framework',
|
|
478
|
+
check({ files, stack }) {
|
|
479
|
+
const findings = [];
|
|
480
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
481
|
+
const hasI18n = ['next-i18next', 'react-i18next', 'i18next', 'vue-i18n', 'lingui', 'formatjs', '@lingui/core'].some(d => d in allDeps);
|
|
482
|
+
if (!hasI18n && [...files.keys()].some(f => f.match(/\.(jsx|tsx)$/))) {
|
|
483
|
+
findings.push({ ruleId: 'COMP-INTL-001', category: 'compliance', severity: 'low',
|
|
484
|
+
title: 'No i18n framework detected — app cannot be easily localized', description: 'Add react-i18next or similar early. Retrofitting i18n into hardcoded strings is extremely costly.', fix: null });
|
|
485
|
+
}
|
|
486
|
+
return findings;
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
// COMP-INTL-002: Hardcoded currency symbol
|
|
491
|
+
{ id: 'COMP-INTL-002', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'Hardcoded Currency Symbol',
|
|
492
|
+
check({ files }) {
|
|
493
|
+
const findings = [];
|
|
494
|
+
for (const [fp, c] of files) {
|
|
495
|
+
if (!fp.match(/\.(jsx|tsx|html|js|ts)$/)) continue;
|
|
496
|
+
const lines = c.split('\n');
|
|
497
|
+
for (let i = 0; i < lines.length; i++) {
|
|
498
|
+
if (lines[i].match(/['"`]\$|['"`]€|['"`]£/) && lines[i].match(/price|amount|cost|total/i)) {
|
|
499
|
+
findings.push({ ruleId: 'COMP-INTL-002', category: 'compliance', severity: 'low',
|
|
500
|
+
title: 'Hardcoded currency symbol — not localization-friendly', description: 'Use Intl.NumberFormat with currency option for locale-aware currency formatting.', file: fp, line: i + 1, fix: null });
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return findings;
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
|
|
509
|
+
// COMP-SOC2-001: No security policy
|
|
510
|
+
{ id: 'COMP-SOC2-001', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'No Security Policy Documentation',
|
|
511
|
+
check({ files }) {
|
|
512
|
+
const has = [...files.keys()].some(f => f.match(/security\.md|SECURITY\.md|security-policy/i));
|
|
513
|
+
if (!has) {
|
|
514
|
+
return [{ ruleId: 'COMP-SOC2-001', category: 'compliance', severity: 'low',
|
|
515
|
+
title: 'No SECURITY.md — missing vulnerability disclosure policy', description: 'Add a SECURITY.md with a responsible disclosure policy and contact information for reporting vulnerabilities.', fix: null }];
|
|
516
|
+
}
|
|
517
|
+
return [];
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
|
|
521
|
+
// COMP-SOC2-002: No penetration testing
|
|
522
|
+
{ id: 'COMP-SOC2-002', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'No Penetration Testing Reference',
|
|
523
|
+
check({ files }) {
|
|
524
|
+
const has = [...files.values()].some(c => c.match(/pentest|penetration.test|security.audit|bug.bounty/i));
|
|
525
|
+
if (!has) {
|
|
526
|
+
return [{ ruleId: 'COMP-SOC2-002', category: 'compliance', severity: 'low',
|
|
527
|
+
title: 'No penetration testing or security audit documentation found', description: 'Schedule annual penetration tests. Document findings and remediations for SOC2/ISO27001 compliance.', fix: null }];
|
|
528
|
+
}
|
|
529
|
+
return [];
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
|
|
533
|
+
// COMP-A11Y-008: No axe-core testing
|
|
534
|
+
{ id: 'COMP-A11Y-008', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Automated Accessibility Testing',
|
|
535
|
+
check({ files, stack }) {
|
|
536
|
+
const findings = [];
|
|
537
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
538
|
+
const hasA11yTest = ['axe-core', '@axe-core/react', 'jest-axe', '@testing-library/jest-axe', 'cypress-axe', 'playwright-axe'].some(d => d in allDeps);
|
|
539
|
+
if (!hasA11yTest && [...files.keys()].some(f => f.match(/\.(jsx|tsx)$/))) {
|
|
540
|
+
findings.push({ ruleId: 'COMP-A11Y-008', category: 'compliance', severity: 'medium',
|
|
541
|
+
title: 'No automated accessibility testing (axe-core, jest-axe)',
|
|
542
|
+
description: 'Add jest-axe to catch accessibility violations in tests. Automated tools catch 30-40% of WCAG issues.', fix: null });
|
|
543
|
+
}
|
|
544
|
+
return findings;
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
|
|
548
|
+
// COMP-A11Y-009: Input with only placeholder (no label)
|
|
549
|
+
{ id: 'COMP-A11Y-009', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Input Uses Only Placeholder as Label',
|
|
550
|
+
check({ files }) {
|
|
551
|
+
const findings = [];
|
|
552
|
+
for (const [fp, c] of files) {
|
|
553
|
+
if (!fp.match(/\.(jsx|tsx|html)$/)) continue;
|
|
554
|
+
const inputs = c.match(/<input[^>]*placeholder=[^>]*>/gi) || [];
|
|
555
|
+
for (const inp of inputs) {
|
|
556
|
+
if (!inp.match(/aria-label|aria-labelledby|id=/i)) {
|
|
557
|
+
const idx = c.indexOf(inp);
|
|
558
|
+
if (!c.substring(Math.max(0, idx - 200), idx).match(/<label/i)) {
|
|
559
|
+
findings.push({ ruleId: 'COMP-A11Y-009', category: 'compliance', severity: 'high',
|
|
560
|
+
title: 'Input relies on placeholder as the only label (WCAG 1.3.1)',
|
|
561
|
+
description: 'Placeholders disappear when typing and have low contrast. Use a visible <label> element instead.', file: fp, fix: null });
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return findings;
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
|
|
571
|
+
// COMP-A11Y-010: Modal without focus trap
|
|
572
|
+
{ id: 'COMP-A11Y-010', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Modal Dialog Without Focus Trap',
|
|
573
|
+
check({ files, stack }) {
|
|
574
|
+
const findings = [];
|
|
575
|
+
if (!['react', 'nextjs', 'vue'].includes(stack.framework)) return findings;
|
|
576
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
577
|
+
const hasFocusTrap = 'focus-trap-react' in allDeps || 'focus-trap' in allDeps || '@headlessui/react' in allDeps || 'radix-ui' in allDeps;
|
|
578
|
+
for (const [fp, c] of files) {
|
|
579
|
+
if (!fp.match(/\.(jsx|tsx)$/) || !c.match(/modal|dialog|overlay|drawer/i)) continue;
|
|
580
|
+
if (!hasFocusTrap && !c.match(/focusTrap|useFocusTrap|focus-trap|aria-modal/)) {
|
|
581
|
+
findings.push({ ruleId: 'COMP-A11Y-010', category: 'compliance', severity: 'high',
|
|
582
|
+
title: 'Modal component without focus trap — keyboard users can navigate behind modal',
|
|
583
|
+
description: 'Use focus-trap-react or @headlessui/react Dialog to trap focus within open modals (WCAG 2.4.3).', file: fp, fix: null });
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return findings;
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
|
|
591
|
+
// COMP-A11Y-011: No skip navigation link
|
|
592
|
+
{ id: 'COMP-A11Y-011', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Skip Navigation Link',
|
|
593
|
+
check({ files, stack }) {
|
|
594
|
+
const findings = [];
|
|
595
|
+
if (!['react', 'nextjs', 'vue'].includes(stack.framework)) return findings;
|
|
596
|
+
const hasSkipLink = [...files.values()].some(c => c.match(/skip.*nav|skip.*content|skip.*main/i));
|
|
597
|
+
if (!hasSkipLink && [...files.keys()].some(f => f.match(/\.(jsx|tsx|html)$/))) {
|
|
598
|
+
findings.push({ ruleId: 'COMP-A11Y-011', category: 'compliance', severity: 'medium',
|
|
599
|
+
title: 'No "Skip to main content" link for keyboard navigation (WCAG 2.4.1)',
|
|
600
|
+
description: 'Add a visually-hidden skip link at the top of the page so keyboard users can bypass repeated navigation.', fix: null });
|
|
601
|
+
}
|
|
602
|
+
return findings;
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
|
|
606
|
+
// COMP-GDPR-006: No GDPR DPA reference
|
|
607
|
+
{ id: 'COMP-GDPR-006', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'No Data Processing Agreement Reference',
|
|
608
|
+
check({ files }) {
|
|
609
|
+
const has = [...files.values()].some(c => c.match(/data.*processing.*agreement|DPA|processor.*agreement/i));
|
|
610
|
+
if (!has && [...files.values()].some(c => c.includes('signup') || c.includes('register'))) {
|
|
611
|
+
return [{ ruleId: 'COMP-GDPR-006', category: 'compliance', severity: 'low',
|
|
612
|
+
title: 'No Data Processing Agreement (DPA) documentation found',
|
|
613
|
+
description: 'If you process EU user data using third-party processors (AWS, Stripe, etc.), you need a DPA with each processor. Required under GDPR Article 28.', fix: null }];
|
|
614
|
+
}
|
|
615
|
+
return [];
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
|
|
619
|
+
// COMP-GDPR-007: Personal data in URL
|
|
620
|
+
{ id: 'COMP-GDPR-007', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Personal Data Exposed in URL',
|
|
621
|
+
check({ files }) {
|
|
622
|
+
const findings = [];
|
|
623
|
+
for (const [fp, c] of files) {
|
|
624
|
+
if (!isSourceFile(fp)) continue;
|
|
625
|
+
if (c.match(/router\.\w+\s*\(\s*['"`][^'"]*:email|router\.\w+.*:name\b|router\.\w+.*:phone/)) {
|
|
626
|
+
findings.push({ ruleId: 'COMP-GDPR-007', category: 'compliance', severity: 'high',
|
|
627
|
+
title: 'Personal data (email/name/phone) as URL path parameter',
|
|
628
|
+
description: 'Personal data in URLs appears in browser history, server logs, and referrer headers. Use opaque IDs (UUIDs) in URLs instead.', file: fp, fix: null });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return findings;
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
|
|
635
|
+
// COMP-EMAIL-003: No double opt-in
|
|
636
|
+
{ id: 'COMP-EMAIL-003', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Double Opt-In for Email Marketing',
|
|
637
|
+
check({ files }) {
|
|
638
|
+
const findings = [];
|
|
639
|
+
const hasEmailMarketing = [...files.values()].some(c =>
|
|
640
|
+
c.match(/subscribe|newsletter|mailing.?list|marketing.?email/i)
|
|
641
|
+
);
|
|
642
|
+
const hasDoubleOptIn = [...files.values()].some(c =>
|
|
643
|
+
c.match(/confirm.*email|verify.*email|double.*opt|confirmation.*link|email.*verify/i)
|
|
644
|
+
);
|
|
645
|
+
if (hasEmailMarketing && !hasDoubleOptIn) {
|
|
646
|
+
findings.push({ ruleId: 'COMP-EMAIL-003', category: 'compliance', severity: 'medium',
|
|
647
|
+
title: 'Email subscription without double opt-in confirmation',
|
|
648
|
+
description: 'GDPR best practice requires double opt-in for marketing emails. Send a confirmation email before adding users to marketing lists.', fix: null });
|
|
649
|
+
}
|
|
650
|
+
return findings;
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
|
|
654
|
+
// COMP-FIN-003: Payment page not on HTTPS
|
|
655
|
+
{ id: 'COMP-FIN-003', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'Payment Form Served Over HTTP',
|
|
656
|
+
check({ files }) {
|
|
657
|
+
const findings = [];
|
|
658
|
+
for (const [fp, c] of files) {
|
|
659
|
+
if (!fp.match(/\.(jsx|tsx|html)$/)) continue;
|
|
660
|
+
if (c.match(/credit.?card|card.*number|payment.*form|checkout/i)) {
|
|
661
|
+
const hasHTTPS = [...files.values()].some(content =>
|
|
662
|
+
content.match(/https:\/\/|HTTPS_ONLY|force.*https|http.*redirect.*https/i)
|
|
663
|
+
);
|
|
664
|
+
if (!hasHTTPS) {
|
|
665
|
+
findings.push({ ruleId: 'COMP-FIN-003', category: 'compliance', severity: 'critical',
|
|
666
|
+
title: 'Payment/checkout form without explicit HTTPS enforcement',
|
|
667
|
+
description: 'All payment pages must use HTTPS. Configure HSTS and redirect HTTP to HTTPS at the server/CDN level.', file: fp, fix: null });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return findings;
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
|
|
675
|
+
// COMP-SOC2-003: No incident response plan
|
|
676
|
+
{ id: 'COMP-SOC2-003', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'No Incident Response Plan',
|
|
677
|
+
check({ files }) {
|
|
678
|
+
const has = [...files.keys()].some(f => f.match(/incident|runbook|playbook/i)) ||
|
|
679
|
+
[...files.values()].some(c => c.match(/incident.*response|on.?call|pagerduty|opsgenie/i));
|
|
680
|
+
if (!has) {
|
|
681
|
+
return [{ ruleId: 'COMP-SOC2-003', category: 'compliance', severity: 'low',
|
|
682
|
+
title: 'No incident response plan or runbook detected',
|
|
683
|
+
description: 'Document your incident response process: how to detect, escalate, communicate, and remediate incidents. Required for SOC2 Type II.', fix: null }];
|
|
684
|
+
}
|
|
685
|
+
return [];
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
|
|
689
|
+
// COMP-INTL-003: Hardcoded English error messages
|
|
690
|
+
{ id: 'COMP-INTL-003', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'Hardcoded English Error Messages',
|
|
691
|
+
check({ files }) {
|
|
692
|
+
const findings = [];
|
|
693
|
+
for (const [fp, c] of files) {
|
|
694
|
+
if (!fp.match(/\.(jsx|tsx)$/)) continue;
|
|
695
|
+
const hardcoded = (c.match(/>(?:[A-Z][^<>{}\n]{5,30})<\//g) || [])
|
|
696
|
+
.filter(m => !m.match(/^\s*{/) && m.match(/\s/) && !m.match(/^\d/)).length;
|
|
697
|
+
if (hardcoded > 10) {
|
|
698
|
+
findings.push({ ruleId: 'COMP-INTL-003', category: 'compliance', severity: 'low',
|
|
699
|
+
title: `${hardcoded} potentially hardcoded English strings in UI component`,
|
|
700
|
+
description: 'Extract user-visible strings to i18n translation keys. Hardcoded strings cannot be localized.', file: fp, fix: null });
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return findings;
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
|
|
707
|
+
// COMP-INTL-004: No locale-aware number formatting
|
|
708
|
+
{ id: 'COMP-INTL-004', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'No Locale-Aware Number Formatting',
|
|
709
|
+
check({ files }) {
|
|
710
|
+
const findings = [];
|
|
711
|
+
for (const [fp, c] of files) {
|
|
712
|
+
if (!isSourceFile(fp)) continue;
|
|
713
|
+
if (c.match(/\.toFixed\s*\(\s*2\s*\)/) && c.match(/price|amount|cost|total/i)) {
|
|
714
|
+
if (!c.match(/Intl\.NumberFormat|toLocaleString/)) {
|
|
715
|
+
findings.push({ ruleId: 'COMP-INTL-004', category: 'compliance', severity: 'low',
|
|
716
|
+
title: 'Using .toFixed(2) for currency display instead of Intl.NumberFormat',
|
|
717
|
+
description: 'Use new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(amount) for locale-aware currency display.', file: fp, fix: null });
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return findings;
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
|
|
725
|
+
// COMP-CCPA-001: No "Do Not Sell" link
|
|
726
|
+
{ id: 'COMP-CCPA-001', category: 'compliance', severity: 'high', confidence: 'likely', title: 'No "Do Not Sell My Personal Information" Link',
|
|
727
|
+
check({ files }) {
|
|
728
|
+
const findings = [];
|
|
729
|
+
const servesCalifornia = true; // assume any web app may serve California users
|
|
730
|
+
const hasDoNotSell = [...files.values()].some(c => c.match(/do not sell|doNotSell|opt.out.*sale|data.*sale/i));
|
|
731
|
+
const hasUsers = [...files.values()].some(c => c.includes('signup') || c.includes('register'));
|
|
732
|
+
if (hasUsers && !hasDoNotSell) {
|
|
733
|
+
findings.push({ ruleId: 'COMP-CCPA-001', category: 'compliance', severity: 'high',
|
|
734
|
+
title: 'No "Do Not Sell My Personal Information" link (CCPA requirement)',
|
|
735
|
+
description: 'California businesses that sell personal data must provide a "Do Not Sell" link. Add it to your privacy policy and footer.', fix: null });
|
|
736
|
+
}
|
|
737
|
+
return findings;
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
|
|
741
|
+
// COMP-COPPA-002: Age verification — only for children-targeting apps
|
|
742
|
+
{ id: 'COMP-COPPA-002', category: 'compliance', severity: 'high', confidence: 'likely', title: 'No Minimum Age Validation',
|
|
743
|
+
check({ files }) {
|
|
744
|
+
const findings = [];
|
|
745
|
+
const hasChildrenTargeting = [...files.entries()].some(([fp, c]) =>
|
|
746
|
+
(fp.endsWith('package.json') || fp.endsWith('.json')) && /coppa|kids.*app|children.*platform/i.test(c)
|
|
747
|
+
);
|
|
748
|
+
if (!hasChildrenTargeting) return findings;
|
|
749
|
+
for (const [fp, c] of files) {
|
|
750
|
+
if (!isSourceFile(fp)) continue;
|
|
751
|
+
if (c.match(/birthdate|dateOfBirth|dob|age/i) && c.match(/register|signup|create.*user/i)) {
|
|
752
|
+
if (!c.match(/age.*>=?\s*13|13.*years|minimumAge|minAge/)) {
|
|
753
|
+
findings.push({ ruleId: 'COMP-COPPA-002', category: 'compliance', severity: 'high',
|
|
754
|
+
title: 'Age field collected but no minimum age validation (COPPA requires 13+)',
|
|
755
|
+
description: 'Validate that users are at least 13. For users under 13, COPPA requires verifiable parental consent before collecting any personal information.', file: fp, fix: null });
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return findings;
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
|
|
763
|
+
// COMP-GDPR-008: No cookie consent management
|
|
764
|
+
{ id: 'COMP-GDPR-008', category: 'compliance', severity: 'high', confidence: 'likely', title: 'No Cookie Consent Management',
|
|
765
|
+
check({ files }) {
|
|
766
|
+
const findings = [];
|
|
767
|
+
const allDeps = { ...Object.fromEntries([...files.keys()].filter(f => f.endsWith('package.json')).flatMap(f => { try { return Object.entries(JSON.parse([...files.values()][0])); } catch { return []; } })) };
|
|
768
|
+
const hasCookieConsent = [...files.values()].some(c => c.match(/cookieconsent|cookie.*consent|CookieBanner|gdpr.*cookie|OneTrust|Cookiebot|Consent.*Manager/i));
|
|
769
|
+
const hasCookies = [...files.values()].some(c => c.match(/res\.cookie\(|document\.cookie|cookie-parser|cookieSession/i));
|
|
770
|
+
if (hasCookies && !hasCookieConsent) {
|
|
771
|
+
findings.push({ ruleId: 'COMP-GDPR-008', category: 'compliance', severity: 'high', title: 'Cookies set without cookie consent management — GDPR ePrivacy Directive violation', description: 'Add a cookie consent banner. Non-essential cookies (analytics, advertising) require explicit consent under GDPR/ePrivacy. Use OneTrust, Cookiebot, or react-cookieconsent.', fix: null });
|
|
772
|
+
}
|
|
773
|
+
return findings;
|
|
774
|
+
},
|
|
775
|
+
},
|
|
776
|
+
|
|
777
|
+
// COMP-GDPR-009: No privacy policy link
|
|
778
|
+
{ id: 'COMP-GDPR-009', category: 'compliance', severity: 'high', confidence: 'likely', title: 'No Privacy Policy Link',
|
|
779
|
+
check({ files }) {
|
|
780
|
+
const findings = [];
|
|
781
|
+
const hasPrivacyLink = [...files.values()].some(c => c.match(/privacy.*policy|privacy-policy|\/privacy/i));
|
|
782
|
+
const hasHTML = [...files.keys()].some(f => f.match(/\.(html|jsx|tsx|vue)$/));
|
|
783
|
+
if (hasHTML && !hasPrivacyLink) {
|
|
784
|
+
findings.push({ ruleId: 'COMP-GDPR-009', category: 'compliance', severity: 'high', title: 'No privacy policy link found — required under GDPR, CCPA, and most privacy laws', description: "Add a link to your privacy policy. GDPR Article 13 requires informing users about data collection. Include in footer, registration, and checkout.", fix: null });
|
|
785
|
+
}
|
|
786
|
+
return findings;
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
|
|
790
|
+
// COMP-GDPR-010: No terms of service
|
|
791
|
+
{ id: 'COMP-GDPR-010', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Terms of Service',
|
|
792
|
+
check({ files }) {
|
|
793
|
+
const findings = [];
|
|
794
|
+
const hasTOS = [...files.values()].some(c => c.match(/terms.*of.*service|terms-of-service|\/tos|terms.*conditions|\/terms/i));
|
|
795
|
+
const hasHTML = [...files.keys()].some(f => f.match(/\.(html|jsx|tsx|vue)$/));
|
|
796
|
+
if (hasHTML && !hasTOS) {
|
|
797
|
+
findings.push({ ruleId: 'COMP-GDPR-010', category: 'compliance', severity: 'medium', title: 'No Terms of Service page linked', description: 'Add Terms of Service. Required for enforceability of your service terms and protection against abuse. Link from registration and footer.', fix: null });
|
|
798
|
+
}
|
|
799
|
+
return findings;
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
|
|
803
|
+
// COMP-A11Y-012: Color contrast — inline styles with low contrast colors
|
|
804
|
+
{ id: 'COMP-A11Y-012', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Potential Low Color Contrast (WCAG 2.1)',
|
|
805
|
+
check({ files }) {
|
|
806
|
+
const findings = [];
|
|
807
|
+
for (const [fp, c] of files) {
|
|
808
|
+
if (!fp.match(/\.(css|scss|sass|less|styl)$/)) continue;
|
|
809
|
+
const lines = c.split('\n');
|
|
810
|
+
for (let i = 0; i < lines.length; i++) {
|
|
811
|
+
if (lines[i].match(/color:\s*#[a-fA-F0-9]{3,6}/) && lines[i].match(/#(?:ccc|ddd|eee|f0f0f0|e0e0e0|bbb|aaa|999)/i)) {
|
|
812
|
+
findings.push({ ruleId: 'COMP-A11Y-012', category: 'compliance', severity: 'medium', title: 'Potentially low contrast color detected — WCAG 2.1 AA requires 4.5:1 ratio', description: 'Use a contrast checker (WebAIM, axe). Text must have 4.5:1 contrast ratio (AA) or 7:1 (AAA). Failing contrast affects low-vision users.', file: fp, line: i + 1, fix: null });
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return findings;
|
|
817
|
+
},
|
|
818
|
+
},
|
|
819
|
+
|
|
820
|
+
// COMP-A11Y-013: Interactive elements too small (touch target)
|
|
821
|
+
{ id: 'COMP-A11Y-013', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Touch Target Too Small (WCAG 2.5.5)',
|
|
822
|
+
check({ files }) {
|
|
823
|
+
const findings = [];
|
|
824
|
+
for (const [fp, c] of files) {
|
|
825
|
+
if (!fp.match(/\.(css|scss|sass|less)$/)) continue;
|
|
826
|
+
const lines = c.split('\n');
|
|
827
|
+
for (let i = 0; i < lines.length; i++) {
|
|
828
|
+
if (lines[i].match(/(?:button|\.btn|a\s*\{)[^}]*/i)) {
|
|
829
|
+
const block = lines.slice(i, i + 10).join('\n');
|
|
830
|
+
if (block.match(/height:\s*(\d+)px/) && parseInt((block.match(/height:\s*(\d+)px/) || ['', '0'])[1]) < 44) {
|
|
831
|
+
findings.push({ ruleId: 'COMP-A11Y-013', category: 'compliance', severity: 'medium', title: 'Button/link height below 44px — WCAG 2.5.5 minimum touch target size', description: 'Make interactive elements at least 44x44px. Small touch targets cause mis-taps on mobile devices, especially for users with motor disabilities.', file: fp, line: i + 1, fix: null });
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return findings;
|
|
837
|
+
},
|
|
838
|
+
},
|
|
839
|
+
|
|
840
|
+
// COMP-A11Y-014: No ARIA live regions for dynamic content
|
|
841
|
+
{ id: 'COMP-A11Y-014', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Dynamic Content Without ARIA Live Region',
|
|
842
|
+
check({ files }) {
|
|
843
|
+
const findings = [];
|
|
844
|
+
for (const [fp, c] of files) {
|
|
845
|
+
if (!isSourceFile(fp)) continue;
|
|
846
|
+
if (c.match(/toast|notification|alert|snackbar|spinner|loading/i) && !c.match(/aria-live|role="alert"|role="status"|aria-atomic/i)) {
|
|
847
|
+
findings.push({ ruleId: 'COMP-A11Y-014', category: 'compliance', severity: 'medium', title: 'Dynamic content (notifications/alerts) without aria-live region', description: 'Add aria-live="polite" or role="alert" to notification containers. Screen readers cannot detect content changes without ARIA live regions.', file: fp, fix: null });
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return findings;
|
|
851
|
+
},
|
|
852
|
+
},
|
|
853
|
+
|
|
854
|
+
// COMP-A11Y-015: Form without autocomplete attributes
|
|
855
|
+
{ id: 'COMP-A11Y-015', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'Form Inputs Without autocomplete Attribute',
|
|
856
|
+
check({ files }) {
|
|
857
|
+
const findings = [];
|
|
858
|
+
for (const [fp, c] of files) {
|
|
859
|
+
if (!fp.match(/\.(html|jsx|tsx|vue)$/)) continue;
|
|
860
|
+
const lines = c.split('\n');
|
|
861
|
+
for (let i = 0; i < lines.length; i++) {
|
|
862
|
+
if (lines[i].match(/<input[^>]+type=["'](email|tel|name|address)['"]/i) && !lines[i].match(/autocomplete=/i)) {
|
|
863
|
+
findings.push({ ruleId: 'COMP-A11Y-015', category: 'compliance', severity: 'low', title: 'Input missing autocomplete attribute — harder to use for assistive technology', description: 'Add autocomplete="email", autocomplete="name", etc. Autocomplete helps users with memory and motor disabilities fill forms faster.', file: fp, line: i + 1, fix: null });
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return findings;
|
|
868
|
+
},
|
|
869
|
+
},
|
|
870
|
+
|
|
871
|
+
// COMP-PCI-001: Credit card number in logs
|
|
872
|
+
{ id: 'COMP-PCI-001', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'Credit Card Number Potentially Logged',
|
|
873
|
+
check({ files }) {
|
|
874
|
+
const findings = [];
|
|
875
|
+
for (const [fp, c] of files) {
|
|
876
|
+
if (!isSourceFile(fp)) continue;
|
|
877
|
+
const lines = c.split('\n');
|
|
878
|
+
for (let i = 0; i < lines.length; i++) {
|
|
879
|
+
if (lines[i].match(/console\.|logger\./i) && lines[i].match(/card|cardNumber|card_number|creditCard|pan\b/i)) {
|
|
880
|
+
findings.push({ ruleId: 'COMP-PCI-001', category: 'compliance', severity: 'critical', title: 'Payment card data potentially logged — PCI DSS Requirement 3 violation', description: 'PCI DSS forbids logging PANs. Remove card data from logs immediately. Violations trigger mandatory breach notification and fines.', file: fp, line: i + 1, fix: null });
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return findings;
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
|
|
888
|
+
// COMP-PCI-002: Direct Stripe/Braintree keys in client-side code
|
|
889
|
+
{ id: 'COMP-PCI-002', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'Payment Provider Secret Key in Client-Side Code',
|
|
890
|
+
check({ files }) {
|
|
891
|
+
const findings = [];
|
|
892
|
+
for (const [fp, c] of files) {
|
|
893
|
+
if (!fp.match(/\.(js|jsx|ts|tsx)$/) || fp.includes('test') || fp.includes('spec')) continue;
|
|
894
|
+
if (fp.match(/public|client|frontend|browser/i) || c.match(/window\.|document\.|React/)) {
|
|
895
|
+
if (c.match(/sk_live_|rk_live_|STRIPE_SECRET|braintree.*privateKey/i)) {
|
|
896
|
+
findings.push({ ruleId: 'COMP-PCI-002', category: 'compliance', severity: 'critical', title: 'Payment secret key exposed in client-side code — PCI DSS violation', description: 'Never include sk_live_ or other secret keys in client-side code. Secret keys must only be used server-side. Rotate immediately if exposed.', file: fp, fix: null });
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return findings;
|
|
901
|
+
},
|
|
902
|
+
},
|
|
903
|
+
|
|
904
|
+
// COMP-PCI-003: No TLS on payment endpoints
|
|
905
|
+
{ id: 'COMP-PCI-003', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'Payment Endpoint Without TLS Enforcement',
|
|
906
|
+
check({ files }) {
|
|
907
|
+
const findings = [];
|
|
908
|
+
for (const [fp, c] of files) {
|
|
909
|
+
if (!isSourceFile(fp)) continue;
|
|
910
|
+
if (c.match(/\/payment|\/checkout|\/charge|\/purchase|stripe\.|braintree\./i)) {
|
|
911
|
+
if (!c.match(/https|HTTPS|ssl|SSL|tls|TLS|secure.*true/i)) {
|
|
912
|
+
findings.push({ ruleId: 'COMP-PCI-003', category: 'compliance', severity: 'critical', title: 'Payment processing code without explicit TLS enforcement', description: 'PCI DSS requires TLS 1.2+ for all payment data transmission. Verify all payment endpoints use HTTPS and enforce it in code.', file: fp, fix: null });
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return findings;
|
|
917
|
+
},
|
|
918
|
+
},
|
|
919
|
+
|
|
920
|
+
// COMP-SOC2-004: No access review process
|
|
921
|
+
{ id: 'COMP-SOC2-004', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Access Review or Offboarding Process',
|
|
922
|
+
check({ files }) {
|
|
923
|
+
const findings = [];
|
|
924
|
+
const allPaths = [...files.keys()].join(' ');
|
|
925
|
+
const allCode = [...files.values()].join('\n');
|
|
926
|
+
const hasAccessReview = allCode.match(/offboard|access.*review|user.*deactivat|disable.*account|revoke.*access/i) || allPaths.match(/offboard|access-review/i);
|
|
927
|
+
if (!hasAccessReview) {
|
|
928
|
+
findings.push({ ruleId: 'COMP-SOC2-004', category: 'compliance', severity: 'medium', title: 'No access review or offboarding process detected — SOC 2 CC6.3 requirement', description: 'Implement user deactivation on employee offboarding. SOC 2 requires periodic access reviews and prompt revocation when employees leave.', fix: null });
|
|
929
|
+
}
|
|
930
|
+
return findings;
|
|
931
|
+
},
|
|
932
|
+
},
|
|
933
|
+
|
|
934
|
+
// COMP-SOC2-005: No multi-factor authentication for admin
|
|
935
|
+
{ id: 'COMP-SOC2-005', category: 'compliance', severity: 'high', confidence: 'likely', title: 'No MFA for Admin/Privileged Access',
|
|
936
|
+
check({ files }) {
|
|
937
|
+
const findings = [];
|
|
938
|
+
const allCode = [...files.values()].join('\n');
|
|
939
|
+
const hasAdminRoute = allCode.match(/\/admin|role.*admin|isAdmin|superuser/i);
|
|
940
|
+
const hasMFA = allCode.match(/totp|speakeasy|mfa|two.*factor|2fa|authenticator/i);
|
|
941
|
+
if (hasAdminRoute && !hasMFA) {
|
|
942
|
+
findings.push({ ruleId: 'COMP-SOC2-005', category: 'compliance', severity: 'high', title: 'Admin routes without MFA — SOC 2 CC6.1 and PCI DSS requirement', description: 'Require MFA (TOTP/hardware key) for admin accounts. SOC 2, PCI DSS, and most cyber insurance policies mandate MFA for privileged access.', fix: null });
|
|
943
|
+
}
|
|
944
|
+
return findings;
|
|
945
|
+
},
|
|
946
|
+
},
|
|
947
|
+
|
|
948
|
+
// COMP-HIPAA-001: Health data without encryption
|
|
949
|
+
{ id: 'COMP-HIPAA-001', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'Health Information Without Encryption (HIPAA)',
|
|
950
|
+
check({ files }) {
|
|
951
|
+
const findings = [];
|
|
952
|
+
for (const [fp, c] of files) {
|
|
953
|
+
if (!isSourceFile(fp)) continue;
|
|
954
|
+
const hasPHI = c.match(/diagnosis|medication|prescription|healthRecord|medicalHistory|patientData|ehr|phi\b/i);
|
|
955
|
+
const hasEncryption = c.match(/encrypt|AES|cipher|kms|vault/i);
|
|
956
|
+
if (hasPHI && !hasEncryption) {
|
|
957
|
+
findings.push({ ruleId: 'COMP-HIPAA-001', category: 'compliance', severity: 'critical', title: 'Protected Health Information (PHI) without encryption at rest — HIPAA violation', description: 'Encrypt PHI at rest and in transit. HIPAA requires technical safeguards for PHI. Violations carry fines up to $1.9M per violation category.', file: fp, fix: null });
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return findings;
|
|
961
|
+
},
|
|
962
|
+
},
|
|
963
|
+
|
|
964
|
+
// COMP-HIPAA-002: PHI logged without redaction
|
|
965
|
+
{ id: 'COMP-HIPAA-002', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'Protected Health Information in Logs',
|
|
966
|
+
check({ files }) {
|
|
967
|
+
const findings = [];
|
|
968
|
+
for (const [fp, c] of files) {
|
|
969
|
+
if (!isSourceFile(fp)) continue;
|
|
970
|
+
const lines = c.split('\n');
|
|
971
|
+
for (let i = 0; i < lines.length; i++) {
|
|
972
|
+
if (lines[i].match(/console\.|logger\./i) && lines[i].match(/diagnosis|medication|prescription|patientId|healthRecord/i)) {
|
|
973
|
+
findings.push({ ruleId: 'COMP-HIPAA-002', category: 'compliance', severity: 'critical', title: 'PHI potentially in application logs — HIPAA violation', description: 'Redact health information from logs. HIPAA Security Rule prohibits storing PHI in log files without proper controls. Use patient IDs instead.', file: fp, line: i + 1, fix: null });
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return findings;
|
|
978
|
+
},
|
|
979
|
+
},
|
|
980
|
+
|
|
981
|
+
// COMP-INTL-005: Hardcoded locale-specific phone format
|
|
982
|
+
{ id: 'COMP-INTL-005', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'Hardcoded US Phone Format',
|
|
983
|
+
check({ files }) {
|
|
984
|
+
const findings = [];
|
|
985
|
+
for (const [fp, c] of files) {
|
|
986
|
+
if (!isSourceFile(fp)) continue;
|
|
987
|
+
const lines = c.split('\n');
|
|
988
|
+
for (let i = 0; i < lines.length; i++) {
|
|
989
|
+
if (lines[i].match(/phone.*regex|\\d\{3\}.*\\d\{4\}|[0-9]{3}.*[0-9]{4}/)) {
|
|
990
|
+
findings.push({ ruleId: 'COMP-INTL-005', category: 'compliance', severity: 'low', title: 'US-format phone number regex — rejects international numbers', description: 'Use libphonenumber-js for phone validation. Hardcoded US format (###-###-####) rejects valid international numbers.', file: fp, line: i + 1, fix: null });
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return findings;
|
|
995
|
+
},
|
|
996
|
+
},
|
|
997
|
+
|
|
998
|
+
// COMP-INTL-006: No RTL support
|
|
999
|
+
{ id: 'COMP-INTL-006', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'No RTL (Right-to-Left) Language Support',
|
|
1000
|
+
check({ files }) {
|
|
1001
|
+
const findings = [];
|
|
1002
|
+
const hasI18n = [...files.values()].some(c => c.match(/i18n|react-intl|i18next|vue-i18n|linguiJS/i));
|
|
1003
|
+
const hasRTL = [...files.values()].some(c => c.match(/dir=["']rtl|direction.*rtl|rtl-css|stylelint.*rtl/i));
|
|
1004
|
+
if (hasI18n && !hasRTL) {
|
|
1005
|
+
findings.push({ ruleId: 'COMP-INTL-006', category: 'compliance', severity: 'low', title: 'i18n configured but no RTL support — Arabic/Hebrew/Farsi users get broken layout', description: 'Add dir={dir} to root element and test with RTL languages. CSS logical properties (margin-inline-start) handle RTL automatically.', fix: null });
|
|
1006
|
+
}
|
|
1007
|
+
return findings;
|
|
1008
|
+
},
|
|
1009
|
+
},
|
|
1010
|
+
|
|
1011
|
+
// COMP-INTL-007: Date format without locale
|
|
1012
|
+
{ id: 'COMP-INTL-007', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'Date Formatted Without Locale Awareness',
|
|
1013
|
+
check({ files }) {
|
|
1014
|
+
const findings = [];
|
|
1015
|
+
for (const [fp, c] of files) {
|
|
1016
|
+
if (!isSourceFile(fp)) continue;
|
|
1017
|
+
const lines = c.split('\n');
|
|
1018
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1019
|
+
if (lines[i].match(/\.toLocaleDateString\(\)|\.toLocaleString\(\)/) && !lines[i].match(/locale|language|Intl\./)) {
|
|
1020
|
+
findings.push({ ruleId: 'COMP-INTL-007', category: 'compliance', severity: 'low', title: 'toLocaleDateString() called without locale parameter — uses browser locale, inconsistent', description: "Pass explicit locale: date.toLocaleDateString('en-US', {...}). Without locale, output varies by user's browser settings.", file: fp, line: i + 1, fix: null });
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return findings;
|
|
1025
|
+
},
|
|
1026
|
+
},
|
|
1027
|
+
|
|
1028
|
+
// COMP-INTL-008: No pluralization support
|
|
1029
|
+
{ id: 'COMP-INTL-008', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'Hardcoded Plural Forms Without i18n',
|
|
1030
|
+
check({ files }) {
|
|
1031
|
+
const findings = [];
|
|
1032
|
+
for (const [fp, c] of files) {
|
|
1033
|
+
if (!isSourceFile(fp)) continue;
|
|
1034
|
+
const lines = c.split('\n');
|
|
1035
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1036
|
+
if (lines[i].match(/count === 1 \? ['"].*['"]\s*:\s*['"]/i) || lines[i].match(/n === 1 \? ['"].*['"]\s*:\s*['"]/i)) {
|
|
1037
|
+
findings.push({ ruleId: 'COMP-INTL-008', category: 'compliance', severity: 'low', title: 'Hardcoded English singular/plural — Arabic/Russian have 6 plural forms', description: "Use Intl.PluralRules or i18n library for pluralization. English has 2 plural forms; many languages have 3-6. Hardcoded English plurals break internationally.", file: fp, line: i + 1, fix: null });
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
return findings;
|
|
1042
|
+
},
|
|
1043
|
+
},
|
|
1044
|
+
|
|
1045
|
+
// COMP-GDPR-011: No data breach notification procedure
|
|
1046
|
+
{ id: 'COMP-GDPR-011', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Data Breach Notification Procedure',
|
|
1047
|
+
check({ files }) {
|
|
1048
|
+
const findings = [];
|
|
1049
|
+
const allPaths = [...files.keys()].join(' ');
|
|
1050
|
+
const allCode = [...files.values()].join('\n');
|
|
1051
|
+
const hasBreachPlan = allCode.match(/breach.*notification|incident.*response|data.*breach|notify.*72.*hours|72h/i) || allPaths.match(/breach|incident/i);
|
|
1052
|
+
if (!hasBreachPlan) {
|
|
1053
|
+
findings.push({ ruleId: 'COMP-GDPR-011', category: 'compliance', severity: 'medium', title: 'No data breach notification procedure detected — GDPR requires 72-hour notification', description: 'Document your breach notification process. GDPR Article 33 requires notifying the supervisory authority within 72 hours of discovering a breach.', fix: null });
|
|
1054
|
+
}
|
|
1055
|
+
return findings;
|
|
1056
|
+
},
|
|
1057
|
+
},
|
|
1058
|
+
|
|
1059
|
+
// COMP-CCPA-002: No opt-out mechanism for data sale
|
|
1060
|
+
{ id: 'COMP-CCPA-002', category: 'compliance', severity: 'high', confidence: 'likely', title: 'No Data Sale Opt-Out Mechanism',
|
|
1061
|
+
check({ files }) {
|
|
1062
|
+
const findings = [];
|
|
1063
|
+
const allCode = [...files.values()].join('\n');
|
|
1064
|
+
const hasAnalytics = allCode.match(/google.*analytics|mixpanel|segment|amplitude|facebook.*pixel|gtag/i);
|
|
1065
|
+
const hasOptOut = allCode.match(/opt.*out|doNotSell|do-not-sell|optOut.*sale/i);
|
|
1066
|
+
if (hasAnalytics && !hasOptOut) {
|
|
1067
|
+
findings.push({ ruleId: 'COMP-CCPA-002', category: 'compliance', severity: 'high', title: 'Analytics/tracking without CCPA opt-out mechanism', description: 'California residents have the right to opt-out of data selling. Add opt-out mechanism and honor Global Privacy Control (GPC) header.', fix: null });
|
|
1068
|
+
}
|
|
1069
|
+
return findings;
|
|
1070
|
+
},
|
|
1071
|
+
},
|
|
1072
|
+
|
|
1073
|
+
// COMP-GDPR-012: localStorage contains personal data
|
|
1074
|
+
{ id: 'COMP-GDPR-012', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Personal Data Stored in localStorage',
|
|
1075
|
+
check({ files }) {
|
|
1076
|
+
const findings = [];
|
|
1077
|
+
for (const [fp, c] of files) {
|
|
1078
|
+
if (!isSourceFile(fp)) continue;
|
|
1079
|
+
const lines = c.split('\n');
|
|
1080
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1081
|
+
if (lines[i].match(/localStorage\.setItem\(/) && lines[i].match(/email|name|phone|address|ssn|dob|passport/i)) {
|
|
1082
|
+
findings.push({ ruleId: 'COMP-GDPR-012', category: 'compliance', severity: 'high', title: 'Personal data stored in localStorage — accessible by any JS on the page', description: 'Avoid storing PII in localStorage. It persists indefinitely and is accessible to XSS. Store user IDs only; fetch profile data from API.', file: fp, line: i + 1, fix: null });
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
return findings;
|
|
1087
|
+
},
|
|
1088
|
+
},
|
|
1089
|
+
|
|
1090
|
+
// COMP-GDPR-013: Third-party tracking before consent
|
|
1091
|
+
{ id: 'COMP-GDPR-013', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Third-Party Tracking Loaded Unconditionally',
|
|
1092
|
+
check({ files }) {
|
|
1093
|
+
const findings = [];
|
|
1094
|
+
for (const [fp, c] of files) {
|
|
1095
|
+
if (!fp.match(/\.(html|jsx|tsx|js)$/)) continue;
|
|
1096
|
+
const hasTracking = c.match(/facebook.*pixel|fbq\(|\_fbq|google.*analytics|gtag\(|ga\(|mixpanel\.init|amplitude\.init/i);
|
|
1097
|
+
const hasConsentCheck = c.match(/consent|hasConsented|cookieConsent|gdprConsent/i);
|
|
1098
|
+
if (hasTracking && !hasConsentCheck) {
|
|
1099
|
+
findings.push({ ruleId: 'COMP-GDPR-013', category: 'compliance', severity: 'high', title: 'Third-party tracking pixel/analytics loaded without consent check', description: 'Only load tracking after consent: if (hasConsented()) { loadAnalytics(); }. Loading before consent violates GDPR and ePrivacy.', file: fp, fix: null });
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return findings;
|
|
1103
|
+
},
|
|
1104
|
+
},
|
|
1105
|
+
|
|
1106
|
+
// COMP-A11Y-016: No error summary for form validation
|
|
1107
|
+
{ id: 'COMP-A11Y-016', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Error Summary for Form Validation (WCAG 3.3.1)',
|
|
1108
|
+
check({ files }) {
|
|
1109
|
+
const findings = [];
|
|
1110
|
+
for (const [fp, c] of files) {
|
|
1111
|
+
if (!isSourceFile(fp)) continue;
|
|
1112
|
+
if (c.match(/onSubmit|handleSubmit/i) && c.match(/error|invalid|validation/i)) {
|
|
1113
|
+
if (!c.match(/aria-describedby|aria-invalid|role=["']alert|error.*summary/i)) {
|
|
1114
|
+
findings.push({ ruleId: 'COMP-A11Y-016', category: 'compliance', severity: 'medium', title: 'Form without accessible error announcements — WCAG 3.3.1', description: 'Use aria-invalid="true" and aria-describedby to link fields to error messages. Screen readers need programmatic error associations.', file: fp, fix: null });
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return findings;
|
|
1119
|
+
},
|
|
1120
|
+
},
|
|
1121
|
+
|
|
1122
|
+
// COMP-A11Y-017: PDF/documents without accessible text
|
|
1123
|
+
{ id: 'COMP-A11Y-017', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'PDF Generation Without Accessibility Tags',
|
|
1124
|
+
check({ files, stack }) {
|
|
1125
|
+
const findings = [];
|
|
1126
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
1127
|
+
const hasPDF = ['puppeteer', 'playwright', 'pdfkit', 'jspdf', 'html2pdf.js', '@react-pdf/renderer'].some(d => d in allDeps);
|
|
1128
|
+
const hasA11y = [...files.values()].some(c => c.match(/tagged.*pdf|PDF\/UA|accessibility.*pdf|pdf.*alt/i));
|
|
1129
|
+
if (hasPDF && !hasA11y) {
|
|
1130
|
+
findings.push({ ruleId: 'COMP-A11Y-017', category: 'compliance', severity: 'medium', title: 'PDF generated without accessibility tags (PDF/UA)', description: 'Generate PDFs with tagged structure (headings, alt text, reading order). Required for ADA/Section 508 compliance for public-facing documents.', fix: null });
|
|
1131
|
+
}
|
|
1132
|
+
return findings;
|
|
1133
|
+
},
|
|
1134
|
+
},
|
|
1135
|
+
|
|
1136
|
+
// COMP-A11Y-018: No keyboard navigation for dropdown menus
|
|
1137
|
+
{ id: 'COMP-A11Y-018', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Dropdown Menu Without Keyboard Navigation',
|
|
1138
|
+
check({ files }) {
|
|
1139
|
+
const findings = [];
|
|
1140
|
+
for (const [fp, c] of files) {
|
|
1141
|
+
if (!isSourceFile(fp)) continue;
|
|
1142
|
+
if (c.match(/dropdown|menu.*open|toggle.*menu/i) && !c.match(/onKeyDown|onKeyPress|keyCode|ArrowDown|ArrowUp|Escape/i)) {
|
|
1143
|
+
findings.push({ ruleId: 'COMP-A11Y-018', category: 'compliance', severity: 'medium', title: 'Dropdown menu without keyboard navigation — WCAG 2.1.1', description: 'Handle arrow keys, Enter, Escape for menus. WCAG 2.1.1 requires all functionality operable via keyboard. Use WAI-ARIA menu role.', file: fp, fix: null });
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
return findings;
|
|
1147
|
+
},
|
|
1148
|
+
},
|
|
1149
|
+
|
|
1150
|
+
// COMP-SOC2-006: No change management process
|
|
1151
|
+
{ id: 'COMP-SOC2-006', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Change Management Documentation',
|
|
1152
|
+
check({ files }) {
|
|
1153
|
+
const findings = [];
|
|
1154
|
+
const allPaths = [...files.keys()].join(' ');
|
|
1155
|
+
const hasChangeManagement = allPaths.match(/CHANGELOG|change.*log|CHANGES|CONTRIBUTING|change.*control/i) || [...files.values()].some(c => c.match(/change.*management|change.*control|change.*review/i));
|
|
1156
|
+
if (!hasChangeManagement) {
|
|
1157
|
+
findings.push({ ruleId: 'COMP-SOC2-006', category: 'compliance', severity: 'medium', title: 'No CHANGELOG or change management documentation — SOC 2 CC8.1 requirement', description: 'Maintain a CHANGELOG.md and document change management process. SOC 2 requires tracking changes to production systems.', fix: null });
|
|
1158
|
+
}
|
|
1159
|
+
return findings;
|
|
1160
|
+
},
|
|
1161
|
+
},
|
|
1162
|
+
|
|
1163
|
+
// COMP-SOC2-007: No vendor security assessment
|
|
1164
|
+
{ id: 'COMP-SOC2-007', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'No Vendor Security Assessment',
|
|
1165
|
+
check({ files, stack }) {
|
|
1166
|
+
const findings = [];
|
|
1167
|
+
const allDeps = { ...stack.dependencies, ...stack.devDependencies };
|
|
1168
|
+
const criticalVendors = ['stripe', 'twilio', 'sendgrid', 'aws-sdk', '@aws-sdk/client-s3', 'firebase'];
|
|
1169
|
+
const hasVendor = criticalVendors.some(v => v in allDeps);
|
|
1170
|
+
const hasAssessment = [...files.values()].some(c => c.match(/vendor.*assessment|third.*party.*review|supplier.*security/i));
|
|
1171
|
+
if (hasVendor && !hasAssessment) {
|
|
1172
|
+
findings.push({ ruleId: 'COMP-SOC2-007', category: 'compliance', severity: 'low', title: 'Critical vendor integrations without documented security assessment', description: 'Document security assessments for vendors handling your data (Stripe, Twilio, AWS). SOC 2 Availability CC9.1 requires vendor risk management.', fix: null });
|
|
1173
|
+
}
|
|
1174
|
+
return findings;
|
|
1175
|
+
},
|
|
1176
|
+
},
|
|
1177
|
+
|
|
1178
|
+
// COMP-PCI-004: Weak TLS cipher suites for payment
|
|
1179
|
+
{ id: 'COMP-PCI-004', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Weak TLS Ciphers for Payment Processing',
|
|
1180
|
+
check({ files }) {
|
|
1181
|
+
const findings = [];
|
|
1182
|
+
for (const [fp, c] of files) {
|
|
1183
|
+
if (!isSourceFile(fp)) continue;
|
|
1184
|
+
if (c.match(/stripe|braintree|payment|checkout/i) && c.match(/ciphers:/i)) {
|
|
1185
|
+
if (c.match(/RC4|DES|3DES|MD5|NULL|EXPORT|anon/i)) {
|
|
1186
|
+
findings.push({ ruleId: 'COMP-PCI-004', category: 'compliance', severity: 'high', title: 'Weak TLS cipher suite configured for payment endpoint — PCI DSS violation', description: 'PCI DSS 4.0 requires TLS 1.2+ with strong ciphers. Disable RC4, DES, 3DES, EXPORT ciphers. Use modern ECDHE ciphersuites.', file: fp, fix: null });
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return findings;
|
|
1191
|
+
},
|
|
1192
|
+
},
|
|
1193
|
+
|
|
1194
|
+
// COMP-HIPAA-003: Audit log missing for PHI access
|
|
1195
|
+
{ id: 'COMP-HIPAA-003', category: 'compliance', severity: 'high', confidence: 'likely', title: 'No Audit Log for PHI Access',
|
|
1196
|
+
check({ files }) {
|
|
1197
|
+
const findings = [];
|
|
1198
|
+
const allCode = [...files.values()].join('\n');
|
|
1199
|
+
const hasPHI = allCode.match(/diagnosis|medication|prescription|healthRecord|patientData/i);
|
|
1200
|
+
const hasAuditLog = allCode.match(/auditLog|audit_log|audit\.log|accessLog|hipaa.*log/i);
|
|
1201
|
+
if (hasPHI && !hasAuditLog) {
|
|
1202
|
+
findings.push({ ruleId: 'COMP-HIPAA-003', category: 'compliance', severity: 'high', title: 'PHI accessed without audit logging — HIPAA Security Rule 164.312(b)', description: 'Log all PHI access: who accessed, when, what data, from where. HIPAA requires audit controls for all ePHI access. Retain logs for 6 years.', fix: null });
|
|
1203
|
+
}
|
|
1204
|
+
return findings;
|
|
1205
|
+
},
|
|
1206
|
+
},
|
|
1207
|
+
|
|
1208
|
+
// COMP-HIPAA-004: PHI not de-identified before analytics
|
|
1209
|
+
{ id: 'COMP-HIPAA-004', category: 'compliance', severity: 'high', confidence: 'likely', title: 'PHI Sent to Analytics Without De-identification',
|
|
1210
|
+
check({ files }) {
|
|
1211
|
+
const findings = [];
|
|
1212
|
+
for (const [fp, c] of files) {
|
|
1213
|
+
if (!isSourceFile(fp)) continue;
|
|
1214
|
+
if (c.match(/diagnosis|medication|prescription|healthRecord/i) && c.match(/analytics|mixpanel|segment|amplitude|gtag/i)) {
|
|
1215
|
+
if (!c.match(/deidentify|anonymize|hash\(|redact/i)) {
|
|
1216
|
+
findings.push({ ruleId: 'COMP-HIPAA-004', category: 'compliance', severity: 'high', title: 'PHI data potentially sent to third-party analytics without de-identification', description: 'De-identify or anonymize health data before sending to analytics platforms. Business associates must have a BAA with your organization.', file: fp, fix: null });
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
return findings;
|
|
1221
|
+
},
|
|
1222
|
+
},
|
|
1223
|
+
|
|
1224
|
+
// COMP-INTL-009: Currency displayed without formatting
|
|
1225
|
+
{ id: 'COMP-INTL-009', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'Currency Displayed Without Locale Formatting',
|
|
1226
|
+
check({ files }) {
|
|
1227
|
+
const findings = [];
|
|
1228
|
+
for (const [fp, c] of files) {
|
|
1229
|
+
if (!isSourceFile(fp)) continue;
|
|
1230
|
+
const lines = c.split('\n');
|
|
1231
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1232
|
+
if (lines[i].match(/\$\{.*price|amount|total.*\}|\${.*cost.*}/i) && !lines[i].match(/Intl\.|toLocaleString|currency.*format/i)) {
|
|
1233
|
+
findings.push({ ruleId: 'COMP-INTL-009', category: 'compliance', severity: 'low', title: 'Currency interpolated directly — no locale-aware formatting', description: 'Use Intl.NumberFormat: new Intl.NumberFormat(locale, {style:"currency",currency:"USD"}).format(amount). Different locales use different decimal/thousand separators.', file: fp, line: i + 1, fix: null });
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
return findings;
|
|
1238
|
+
},
|
|
1239
|
+
},
|
|
1240
|
+
|
|
1241
|
+
// COMP-INTL-010: Missing timezone handling for global users
|
|
1242
|
+
{ id: 'COMP-INTL-010', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'No Timezone Handling for User Dates',
|
|
1243
|
+
check({ files }) {
|
|
1244
|
+
const findings = [];
|
|
1245
|
+
for (const [fp, c] of files) {
|
|
1246
|
+
if (!isSourceFile(fp)) continue;
|
|
1247
|
+
if (c.match(/schedule|appointment|booking|event.*date|meeting/i) && !c.match(/timezone|timeZone|moment.*tz|date-fns-tz|luxon|dayjs.*timezone/i)) {
|
|
1248
|
+
findings.push({ ruleId: 'COMP-INTL-010', category: 'compliance', severity: 'low', title: 'Date/time scheduling without timezone handling — confusing for global users', description: 'Store dates in UTC, display in user timezone. Use date-fns-tz or Luxon. Ignoring timezones causes scheduling errors for international users.', file: fp, fix: null });
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
return findings;
|
|
1252
|
+
},
|
|
1253
|
+
},
|
|
1254
|
+
|
|
1255
|
+
// COMP-CCPA-003: No data subject request process
|
|
1256
|
+
{ id: 'COMP-CCPA-003', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Data Subject Request (DSR) Process',
|
|
1257
|
+
check({ files }) {
|
|
1258
|
+
const findings = [];
|
|
1259
|
+
const allCode = [...files.values()].join('\n');
|
|
1260
|
+
const hasDSR = allCode.match(/data.*request|subject.*request|dsr\b|data.*access.*request|right.*access|export.*data/i);
|
|
1261
|
+
const hasUserData = allCode.match(/user\.email|user\.name|personal.*data|userData/i);
|
|
1262
|
+
if (hasUserData && !hasDSR) {
|
|
1263
|
+
findings.push({ ruleId: 'COMP-CCPA-003', category: 'compliance', severity: 'medium', title: 'No data subject request process detected — CCPA and GDPR requirement', description: 'Implement data access/deletion request handling. CCPA and GDPR give users the right to know what data you hold and request deletion.', fix: null });
|
|
1264
|
+
}
|
|
1265
|
+
return findings;
|
|
1266
|
+
},
|
|
1267
|
+
},
|
|
1268
|
+
|
|
1269
|
+
// COMP-CHILDREN-001: Behavioral advertising targeting children
|
|
1270
|
+
{ id: 'COMP-CHILDREN-001', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'Behavioral Advertising on Platform Serving Minors',
|
|
1271
|
+
check({ files }) {
|
|
1272
|
+
const findings = [];
|
|
1273
|
+
const allCode = [...files.values()].join('\n');
|
|
1274
|
+
const hasKids = allCode.match(/children|kids|minors|under.*13|age.*13|family.*friendly/i);
|
|
1275
|
+
const hasBehavioral = allCode.match(/behavioral.*ad|targeted.*ad|retargeting|adtech|facebook.*pixel|google.*ads.*remarketing/i);
|
|
1276
|
+
if (hasKids && hasBehavioral) {
|
|
1277
|
+
findings.push({ ruleId: 'COMP-CHILDREN-001', category: 'compliance', severity: 'critical', title: 'Behavioral advertising on platform serving children — COPPA/GDPR-K violation', description: 'COPPA prohibits behavioral advertising to children under 13. The FTC actively enforces this. Remove behavioral advertising or implement age verification.', fix: null });
|
|
1278
|
+
}
|
|
1279
|
+
return findings;
|
|
1280
|
+
},
|
|
1281
|
+
},
|
|
1282
|
+
|
|
1283
|
+
// COMP-GDPR-014: No right to erasure implementation
|
|
1284
|
+
{ id: 'COMP-GDPR-014', category: 'compliance', severity: 'high', confidence: 'likely', title: 'No Right to Erasure (GDPR Article 17)',
|
|
1285
|
+
check({ files }) {
|
|
1286
|
+
const findings = [];
|
|
1287
|
+
const allCode = [...files.values()].join('\n');
|
|
1288
|
+
const hasDelete = allCode.match(/deleteUser|delete.*account|removeUser|eraseUser|right.*erasure/i);
|
|
1289
|
+
const hasUserData = allCode.match(/user\.email|user\.name|userData|personal.*data/i);
|
|
1290
|
+
if (hasUserData && !hasDelete) {
|
|
1291
|
+
findings.push({ ruleId: 'COMP-GDPR-014', category: 'compliance', severity: 'high', title: 'No user account deletion/erasure endpoint — GDPR Article 17 violation', description: 'Implement DELETE /users/:id that removes all personal data. GDPR gives users the right to erasure. Include anonymization of audit logs.', fix: null });
|
|
1292
|
+
}
|
|
1293
|
+
return findings;
|
|
1294
|
+
},
|
|
1295
|
+
},
|
|
1296
|
+
|
|
1297
|
+
// COMP-GDPR-015: No data portability export
|
|
1298
|
+
{ id: 'COMP-GDPR-015', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Data Portability Export (GDPR Article 20)',
|
|
1299
|
+
check({ files }) {
|
|
1300
|
+
const findings = [];
|
|
1301
|
+
const allCode = [...files.values()].join('\n');
|
|
1302
|
+
const hasExport = allCode.match(/exportData|dataExport|download.*data|portability|gdpr.*export/i);
|
|
1303
|
+
const hasUserData = allCode.match(/user\.email|user\.name|userData/i);
|
|
1304
|
+
if (hasUserData && !hasExport) {
|
|
1305
|
+
findings.push({ ruleId: 'COMP-GDPR-015', category: 'compliance', severity: 'medium', title: 'No data export/portability feature — GDPR Article 20 requirement', description: 'Add data export endpoint returning user data in machine-readable format (JSON/CSV). GDPR users have the right to receive their data for transfer.', fix: null });
|
|
1306
|
+
}
|
|
1307
|
+
return findings;
|
|
1308
|
+
},
|
|
1309
|
+
},
|
|
1310
|
+
|
|
1311
|
+
// COMP-A11Y-019: No skip to main content link
|
|
1312
|
+
{ id: 'COMP-A11Y-019', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Skip Navigation Link (WCAG 2.4.1)',
|
|
1313
|
+
check({ files }) {
|
|
1314
|
+
const findings = [];
|
|
1315
|
+
for (const [fp, c] of files) {
|
|
1316
|
+
if (!fp.match(/\.(html|jsx|tsx|vue)$/)) continue;
|
|
1317
|
+
if (!fp.match(/component|partial|layout/i)) continue;
|
|
1318
|
+
const hasNav = c.match(/<nav|<header|navigation/i);
|
|
1319
|
+
const hasSkip = c.match(/skip.*(?:to|nav|main|content)|jump.*main|#main-content|#content/i);
|
|
1320
|
+
if (hasNav && !hasSkip) {
|
|
1321
|
+
findings.push({ ruleId: 'COMP-A11Y-019', category: 'compliance', severity: 'medium', title: 'Navigation without skip-to-main link — keyboard users must tab through all nav items', description: 'Add <a href="#main-content" class="sr-only focus:not-sr-only">Skip to main content</a>. WCAG 2.4.1 requires bypass blocks for keyboard users.', file: fp, fix: null });
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
return findings;
|
|
1325
|
+
},
|
|
1326
|
+
},
|
|
1327
|
+
|
|
1328
|
+
// COMP-A11Y-020: Table without headers
|
|
1329
|
+
{ id: 'COMP-A11Y-020', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'HTML Table Without Header Elements (WCAG 1.3.1)',
|
|
1330
|
+
check({ files }) {
|
|
1331
|
+
const findings = [];
|
|
1332
|
+
for (const [fp, c] of files) {
|
|
1333
|
+
if (!fp.match(/\.(html|jsx|tsx|vue)$/)) continue;
|
|
1334
|
+
const lines = c.split('\n');
|
|
1335
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1336
|
+
if (lines[i].match(/<table/i)) {
|
|
1337
|
+
const tableContent = lines.slice(i, i + 20).join('\n');
|
|
1338
|
+
if (!tableContent.match(/<th|scope=|role=["']columnheader/i)) {
|
|
1339
|
+
findings.push({ ruleId: 'COMP-A11Y-020', category: 'compliance', severity: 'medium', title: 'HTML table without <th> header elements — screen readers cannot understand data structure', description: 'Add <th scope="col"> or <th scope="row"> headers. Without headers, screen readers read data as a meaningless list.', file: fp, line: i + 1, fix: null });
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return findings;
|
|
1345
|
+
},
|
|
1346
|
+
},
|
|
1347
|
+
|
|
1348
|
+
// COMP-A11Y-021: Icon button without accessible name
|
|
1349
|
+
{ id: 'COMP-A11Y-021', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Icon-Only Button Without Accessible Name',
|
|
1350
|
+
check({ files }) {
|
|
1351
|
+
const findings = [];
|
|
1352
|
+
for (const [fp, c] of files) {
|
|
1353
|
+
if (!fp.match(/\.(html|jsx|tsx|vue)$/)) continue;
|
|
1354
|
+
const lines = c.split('\n');
|
|
1355
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1356
|
+
if (lines[i].match(/<button[^>]*>[\s]*<(?:svg|i\s|span\s)/i) && !lines[i].match(/aria-label|aria-labelledby|title=/i)) {
|
|
1357
|
+
findings.push({ ruleId: 'COMP-A11Y-021', category: 'compliance', severity: 'high', title: 'Icon-only button without aria-label — screen readers announce "button" with no context', description: 'Add aria-label="Close dialog" to icon buttons. Without a text label, screen reader users cannot determine the button\'s purpose.', file: fp, line: i + 1, fix: null });
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return findings;
|
|
1362
|
+
},
|
|
1363
|
+
},
|
|
1364
|
+
|
|
1365
|
+
// COMP-GDPR-016: Consent mechanism does not reject non-essential
|
|
1366
|
+
{ id: 'COMP-GDPR-016', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Cookie Consent Without Reject Option',
|
|
1367
|
+
check({ files }) {
|
|
1368
|
+
const findings = [];
|
|
1369
|
+
for (const [fp, c] of files) {
|
|
1370
|
+
if (!isSourceFile(fp)) continue;
|
|
1371
|
+
if (c.match(/cookieconsent|cookie.*banner|CookieBanner/i)) {
|
|
1372
|
+
if (!c.match(/reject|decline|deny|refuse/i)) {
|
|
1373
|
+
findings.push({ ruleId: 'COMP-GDPR-016', category: 'compliance', severity: 'high', title: 'Cookie consent banner without reject/decline option', description: 'Add "Reject All" button. GDPR requires that consent be as easy to withdraw as to give. Banners with only "Accept" violate this requirement.', file: fp, fix: null });
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
return findings;
|
|
1378
|
+
},
|
|
1379
|
+
},
|
|
1380
|
+
|
|
1381
|
+
// COMP-PCI-005: Card number pattern in source code
|
|
1382
|
+
{ id: 'COMP-PCI-005', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'Payment Card Number Regex in Source',
|
|
1383
|
+
check({ files }) {
|
|
1384
|
+
const findings = [];
|
|
1385
|
+
for (const [fp, c] of files) {
|
|
1386
|
+
if (!isSourceFile(fp)) continue;
|
|
1387
|
+
const lines = c.split('\n');
|
|
1388
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1389
|
+
if (lines[i].match(/(?:\d{4}[\s-]?){4}|4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}/)) {
|
|
1390
|
+
if (!lines[i].match(/\/\/|test|spec|mock|example|xxxx|XXXX/i)) {
|
|
1391
|
+
findings.push({ ruleId: 'COMP-PCI-005', category: 'compliance', severity: 'critical', title: 'Payment card number pattern found in source code', description: 'Never store or process raw card numbers. Use Stripe.js/Braintree.js to tokenize cards client-side so your server never sees the raw PAN.', file: fp, line: i + 1, fix: null });
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
return findings;
|
|
1397
|
+
},
|
|
1398
|
+
},
|
|
1399
|
+
|
|
1400
|
+
// COMP-SOC2-008: No system description document
|
|
1401
|
+
{ id: 'COMP-SOC2-008', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'No System Description for SOC 2',
|
|
1402
|
+
check({ files }) {
|
|
1403
|
+
const findings = [];
|
|
1404
|
+
const allPaths = [...files.keys()].join(' ');
|
|
1405
|
+
const hasSystemDesc = allPaths.match(/system.*description|trust.*criteria|soc2|soc-2|compliance.*doc/i) || [...files.values()].some(c => c.match(/trust.*service.*criteria|SOC.*Type/i));
|
|
1406
|
+
if (!hasSystemDesc) {
|
|
1407
|
+
findings.push({ ruleId: 'COMP-SOC2-008', category: 'compliance', severity: 'low', title: 'No SOC 2 system description documentation', description: 'Create system description covering: services, infrastructure, software, personnel, and risk management. Required for SOC 2 Type II audit.', fix: null });
|
|
1408
|
+
}
|
|
1409
|
+
return findings;
|
|
1410
|
+
},
|
|
1411
|
+
},
|
|
1412
|
+
|
|
1413
|
+
// COMP-INTL-011: No locale in Accept-Language header handling
|
|
1414
|
+
{ id: 'COMP-INTL-011', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'API Does Not Respect Accept-Language Header',
|
|
1415
|
+
check({ files }) {
|
|
1416
|
+
const findings = [];
|
|
1417
|
+
const hasI18n = [...files.values()].some(c => c.match(/i18n|localization|translation/i));
|
|
1418
|
+
const hasLangHeader = [...files.values()].some(c => c.match(/Accept-Language|accept-language|req\.headers.*language/i));
|
|
1419
|
+
if (hasI18n && !hasLangHeader) {
|
|
1420
|
+
findings.push({ ruleId: 'COMP-INTL-011', category: 'compliance', severity: 'low', title: 'i18n configured but API does not read Accept-Language header', description: 'Read Accept-Language header: const lang = req.headers["accept-language"]?.split(",")[0]. Use it to return localized error messages and content.', fix: null });
|
|
1421
|
+
}
|
|
1422
|
+
return findings;
|
|
1423
|
+
},
|
|
1424
|
+
},
|
|
1425
|
+
|
|
1426
|
+
// COMP-INTL-012: Number formatted without locale
|
|
1427
|
+
{ id: 'COMP-INTL-012', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'Numbers Formatted Without Locale Awareness',
|
|
1428
|
+
check({ files }) {
|
|
1429
|
+
const findings = [];
|
|
1430
|
+
for (const [fp, c] of files) {
|
|
1431
|
+
if (!isSourceFile(fp)) continue;
|
|
1432
|
+
const lines = c.split('\n');
|
|
1433
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1434
|
+
if (lines[i].match(/\.toFixed\(\d\)/) && lines[i].match(/price|amount|cost|total|balance/i)) {
|
|
1435
|
+
findings.push({ ruleId: 'COMP-INTL-012', category: 'compliance', severity: 'low', title: 'Number formatted with toFixed() — use Intl.NumberFormat for locale-aware formatting', description: 'Replace toFixed() with Intl.NumberFormat. In German, 1,234.56 is written 1.234,56 — toFixed always uses English decimal format.', file: fp, line: i + 1, fix: null });
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
return findings;
|
|
1440
|
+
},
|
|
1441
|
+
},
|
|
1442
|
+
|
|
1443
|
+
// COMP-GDPR-017: User password change without notification
|
|
1444
|
+
{ id: 'COMP-GDPR-017', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Password Change Without Security Notification',
|
|
1445
|
+
check({ files }) {
|
|
1446
|
+
const findings = [];
|
|
1447
|
+
for (const [fp, c] of files) {
|
|
1448
|
+
if (!isSourceFile(fp)) continue;
|
|
1449
|
+
if (c.match(/password.*update|updatePassword|changePassword/i)) {
|
|
1450
|
+
if (!c.match(/sendEmail|notify|notification|email.*password.*changed/i)) {
|
|
1451
|
+
findings.push({ ruleId: 'COMP-GDPR-017', category: 'compliance', severity: 'medium', title: 'Password change without notifying user via email', description: 'Send an email when password changes: "Your password was changed. If this wasn\'t you, contact us." This is a security best practice and satisfies GDPR Article 34 breach notification requirement.', file: fp, fix: null });
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
return findings;
|
|
1456
|
+
},
|
|
1457
|
+
},
|
|
1458
|
+
// COMP-A11Y-022: No aria-expanded on toggle buttons
|
|
1459
|
+
{ id: 'COMP-A11Y-022', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Toggle Button Without aria-expanded',
|
|
1460
|
+
check({ files }) {
|
|
1461
|
+
const findings = [];
|
|
1462
|
+
for (const [fp, c] of files) {
|
|
1463
|
+
if (!isSourceFile(fp)) continue;
|
|
1464
|
+
if (c.match(/toggle|accordion|collapse|expand/i) && c.match(/<button|onClick/i)) {
|
|
1465
|
+
if (!c.match(/aria-expanded|aria-controls/i)) {
|
|
1466
|
+
findings.push({ ruleId: 'COMP-A11Y-022', category: 'compliance', severity: 'medium', title: 'Toggle/accordion button without aria-expanded state', description: 'Add aria-expanded={isOpen}. Screen readers announce "button collapsed/expanded" to indicate state. Without it, users cannot determine if the panel is open.', file: fp, fix: null });
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
return findings;
|
|
1471
|
+
},
|
|
1472
|
+
},
|
|
1473
|
+
// COMP-A11Y-023: Use of tabindex > 0
|
|
1474
|
+
{ id: 'COMP-A11Y-023', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Positive tabindex Disrupts Focus Order',
|
|
1475
|
+
check({ files }) {
|
|
1476
|
+
const findings = [];
|
|
1477
|
+
for (const [fp, c] of files) {
|
|
1478
|
+
if (!fp.match(/\.(html|jsx|tsx|vue)$/)) continue;
|
|
1479
|
+
const lines = c.split('\n');
|
|
1480
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1481
|
+
if (lines[i].match(/tabIndex=["'][1-9]\d*["']|tabindex=["'][1-9]\d*["']/)) {
|
|
1482
|
+
findings.push({ ruleId: 'COMP-A11Y-023', category: 'compliance', severity: 'medium', title: 'Positive tabindex value — disrupts natural focus order (WCAG 2.4.3)', description: 'Use tabIndex={0} or tabIndex={-1}. Positive tabindex values override the natural DOM order, creating confusing keyboard navigation.', file: fp, line: i + 1, fix: null });
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
return findings;
|
|
1487
|
+
},
|
|
1488
|
+
},
|
|
1489
|
+
// COMP-SOC2-009: No background check process
|
|
1490
|
+
{ id: 'COMP-SOC2-009', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'No Personnel Security Process Documentation',
|
|
1491
|
+
check({ files }) {
|
|
1492
|
+
const findings = [];
|
|
1493
|
+
const allPaths = [...files.keys()].join(' ');
|
|
1494
|
+
const hasPersonnel = allPaths.match(/background.*check|personnel.*security|hr.*policy|employee.*security/i);
|
|
1495
|
+
if (!hasPersonnel) {
|
|
1496
|
+
findings.push({ ruleId: 'COMP-SOC2-009', category: 'compliance', severity: 'low', title: 'No personnel security policy documentation — SOC 2 CC1.4 requirement', description: 'Document background check and security onboarding procedures. SOC 2 requires evidence of personnel security controls for employees with system access.', fix: null });
|
|
1497
|
+
}
|
|
1498
|
+
return findings;
|
|
1499
|
+
},
|
|
1500
|
+
},
|
|
1501
|
+
// COMP-HIPAA-005: PHI transmitted over HTTP
|
|
1502
|
+
{ id: 'COMP-HIPAA-005', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'Health Data Transmitted Over HTTP',
|
|
1503
|
+
check({ files }) {
|
|
1504
|
+
const findings = [];
|
|
1505
|
+
for (const [fp, c] of files) {
|
|
1506
|
+
if (!isSourceFile(fp)) continue;
|
|
1507
|
+
if (c.match(/health|medical|patient|phi\b/i) && c.match(/http:\/\/(?!localhost)/i)) {
|
|
1508
|
+
findings.push({ ruleId: 'COMP-HIPAA-005', category: 'compliance', severity: 'critical', title: 'Health data endpoint using HTTP — HIPAA requires TLS for PHI transmission', description: 'Use HTTPS for all endpoints handling health data. HIPAA Security Rule §164.312(e)(1) mandates encryption for ePHI in transit.', file: fp, fix: null });
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
return findings;
|
|
1512
|
+
},
|
|
1513
|
+
},
|
|
1514
|
+
// COMP-PCI-006: No intrusion detection system
|
|
1515
|
+
{ id: 'COMP-PCI-006', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Intrusion Detection System (PCI DSS 10.7)',
|
|
1516
|
+
check({ files }) {
|
|
1517
|
+
const findings = [];
|
|
1518
|
+
const allCode = [...files.values()].join('\n');
|
|
1519
|
+
const hasPayment = allCode.match(/stripe|braintree|payment|checkout|PCI/i);
|
|
1520
|
+
const hasIDS = allCode.match(/guardduty|ids\b|intrusion.*detection|snort|suricata|ossec|wazuh/i);
|
|
1521
|
+
if (hasPayment && !hasIDS) {
|
|
1522
|
+
findings.push({ ruleId: 'COMP-PCI-006', category: 'compliance', severity: 'medium', title: 'Payment processing without intrusion detection — PCI DSS Requirement 10.7', description: 'Implement file integrity monitoring and intrusion detection. PCI DSS requires detecting and responding to security system failures within 24 hours.', fix: null });
|
|
1523
|
+
}
|
|
1524
|
+
return findings;
|
|
1525
|
+
},
|
|
1526
|
+
},
|
|
1527
|
+
// COMP-CCPA-004: No privacy notice at data collection
|
|
1528
|
+
{ id: 'COMP-CCPA-004', category: 'compliance', severity: 'high', confidence: 'likely', title: 'No Privacy Notice at Point of Collection (CCPA)',
|
|
1529
|
+
check({ files }) {
|
|
1530
|
+
const findings = [];
|
|
1531
|
+
for (const [fp, c] of files) {
|
|
1532
|
+
if (!fp.match(/\.(html|jsx|tsx|vue)$/)) continue;
|
|
1533
|
+
if (c.match(/\bform\b|signup|register|create.*account/i)) {
|
|
1534
|
+
if (!c.match(/privacy.*notice|privacy.*policy|we.*collect|personal.*information/i)) {
|
|
1535
|
+
findings.push({ ruleId: 'COMP-CCPA-004', category: 'compliance', severity: 'high', title: 'Registration/form without privacy notice at point of data collection', description: 'CCPA requires informing users of data collection at the time of collection. Add a link to privacy policy near data collection forms.', file: fp, fix: null });
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
return findings;
|
|
1540
|
+
},
|
|
1541
|
+
},
|
|
1542
|
+
// COMP-INTL-013: Hardcoded address format
|
|
1543
|
+
{ id: 'COMP-INTL-013', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'Hardcoded Address Format (US-Centric)',
|
|
1544
|
+
check({ files }) {
|
|
1545
|
+
const findings = [];
|
|
1546
|
+
for (const [fp, c] of files) {
|
|
1547
|
+
if (!fp.match(/\.(html|jsx|tsx|vue)$/)) continue;
|
|
1548
|
+
if (c.match(/zipCode|zip_code|postalCode/i) && c.match(/\d{5}(-\d{4})?/)) {
|
|
1549
|
+
if (!c.match(/country|international|locale/i)) {
|
|
1550
|
+
findings.push({ ruleId: 'COMP-INTL-013', category: 'compliance', severity: 'low', title: 'US ZIP code format without international postal code support', description: 'Support international postal codes (UK: SW1A 1AA, Canada: K1A 0A9). Add country selector and validate by country using a postal code library.', file: fp, fix: null });
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
return findings;
|
|
1555
|
+
},
|
|
1556
|
+
},
|
|
1557
|
+
// COMP-GDPR-018: Consent pre-checked
|
|
1558
|
+
{ id: 'COMP-GDPR-018', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Pre-Checked Marketing Consent Checkbox',
|
|
1559
|
+
check({ files }) {
|
|
1560
|
+
const findings = [];
|
|
1561
|
+
for (const [fp, c] of files) {
|
|
1562
|
+
if (!fp.match(/\.(html|jsx|tsx|vue|js|ts)$/)) continue;
|
|
1563
|
+
const lines = c.split('\n');
|
|
1564
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1565
|
+
if (lines[i].match(/checkbox|type=["']checkbox["']/i) && lines[i].match(/marketing|newsletter|consent|subscribe/i)) {
|
|
1566
|
+
if (lines[i].match(/checked|defaultChecked|value=["']true["']/i)) {
|
|
1567
|
+
findings.push({ ruleId: 'COMP-GDPR-018', category: 'compliance', severity: 'high', title: 'Marketing consent checkbox pre-checked — GDPR requires opt-in not opt-out', description: 'Consent must be freely given — do not pre-check consent boxes. GDPR Article 7 requires unambiguous, active consent for marketing communications.', file: fp, line: i + 1, fix: null });
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
return findings;
|
|
1573
|
+
},
|
|
1574
|
+
},
|
|
1575
|
+
// COMP-GDPR-019: No age verification for age-restricted content
|
|
1576
|
+
{ id: 'COMP-GDPR-019', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Age-Restricted Content Without Age Verification',
|
|
1577
|
+
check({ files }) {
|
|
1578
|
+
const findings = [];
|
|
1579
|
+
for (const [fp, c] of files) {
|
|
1580
|
+
if (!isSourceFile(fp)) continue;
|
|
1581
|
+
if (c.match(/adult|alcohol|gambling|tobacco|cannabis/i) && !c.match(/ageVerif|age_verif|dateOfBirth|dob.*verif|isAdult|verifyAge/i)) {
|
|
1582
|
+
findings.push({ ruleId: 'COMP-GDPR-019', category: 'compliance', severity: 'high', title: 'Age-restricted content served without age verification', description: 'Implement age verification before serving age-restricted content. GDPR and national laws require age gates for alcohol, gambling, and adult content to protect minors.', file: fp, fix: null });
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
return findings;
|
|
1586
|
+
},
|
|
1587
|
+
},
|
|
1588
|
+
// COMP-A11Y-024: No keyboard navigation support
|
|
1589
|
+
{ id: 'COMP-A11Y-024', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Interactive Elements Not Keyboard Accessible',
|
|
1590
|
+
check({ files }) {
|
|
1591
|
+
const findings = [];
|
|
1592
|
+
for (const [fp, c] of files) {
|
|
1593
|
+
if (!fp.match(/\.(jsx|tsx|html|vue)$/)) continue;
|
|
1594
|
+
const lines = c.split('\n');
|
|
1595
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1596
|
+
if (lines[i].match(/onClick\s*=|@click\s*=/i) && lines[i].match(/<div|<span|<li/) && !lines[i].match(/role=|tabIndex|tabindex|onKeyDown|onKeyPress|onKeyUp/i)) {
|
|
1597
|
+
findings.push({ ruleId: 'COMP-A11Y-024', category: 'compliance', severity: 'medium', title: 'div/span with onClick but no keyboard handler — not keyboard accessible', description: 'Add tabIndex={0} and onKeyDown handler, or use a <button> element. WCAG 2.1 SC 2.1.1: All functionality must be available via keyboard.', file: fp, line: i + 1, fix: null });
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
return findings;
|
|
1602
|
+
},
|
|
1603
|
+
},
|
|
1604
|
+
// COMP-A11Y-025: Missing role attribute on custom interactive elements
|
|
1605
|
+
{ id: 'COMP-A11Y-025', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Custom Interactive Widget Missing ARIA Role',
|
|
1606
|
+
check({ files }) {
|
|
1607
|
+
const findings = [];
|
|
1608
|
+
for (const [fp, c] of files) {
|
|
1609
|
+
if (!fp.match(/\.(jsx|tsx|html|vue)$/)) continue;
|
|
1610
|
+
const lines = c.split('\n');
|
|
1611
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1612
|
+
if (lines[i].match(/<div[^>]*tabIndex|<div[^>]*tabindex/i) && !lines[i].match(/role=/)) {
|
|
1613
|
+
findings.push({ ruleId: 'COMP-A11Y-025', category: 'compliance', severity: 'medium', title: 'Focusable div without role attribute — assistive technology cannot identify its purpose', description: 'Add an appropriate role (button, tab, listitem, etc.) to focusable divs. WCAG 2.1 SC 4.1.2 requires interactive elements to have programmatically determinable roles.', file: fp, line: i + 1, fix: null });
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
return findings;
|
|
1618
|
+
},
|
|
1619
|
+
},
|
|
1620
|
+
// COMP-SOC2-010: No data encryption at rest documentation
|
|
1621
|
+
{ id: 'COMP-SOC2-010', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Encryption at Rest Configuration Documented',
|
|
1622
|
+
check({ files }) {
|
|
1623
|
+
const findings = [];
|
|
1624
|
+
const hasSensitiveStorage = [...files.values()].some(c => c.match(/aws_rds|aws_s3|aws_dynamodb|aws_elasticache/i));
|
|
1625
|
+
const hasEncryption = [...files.values()].some(c => c.match(/storage_encrypted\s*=\s*true|encrypted\s*=\s*true|kms_key|sse_algorithm/i));
|
|
1626
|
+
if (hasSensitiveStorage && !hasEncryption) {
|
|
1627
|
+
findings.push({ ruleId: 'COMP-SOC2-010', category: 'compliance', severity: 'medium', title: 'Cloud storage resources without encryption at rest', description: 'Enable encryption at rest for all data storage (RDS, S3, DynamoDB). SOC 2 CC6.1 requires logical and physical access controls including encryption for sensitive data.', fix: null });
|
|
1628
|
+
}
|
|
1629
|
+
return findings;
|
|
1630
|
+
},
|
|
1631
|
+
},
|
|
1632
|
+
// COMP-CCPA-005: No opt-out mechanism for sale of personal data
|
|
1633
|
+
{ id: 'COMP-CCPA-005', category: 'compliance', severity: 'high', confidence: 'likely', title: 'No "Do Not Sell My Personal Information" Mechanism',
|
|
1634
|
+
check({ files }) {
|
|
1635
|
+
const findings = [];
|
|
1636
|
+
const hasSellData = [...files.values()].some(c => c.match(/segment|mixpanel|amplitude|facebook.*pixel|google.*analytics|third.*party.*data/i));
|
|
1637
|
+
const hasOptOut = [...files.values()].some(c => c.match(/doNotSell|do_not_sell|opt.?out.*sell|CCPA|data.*sale/i));
|
|
1638
|
+
if (hasSellData && !hasOptOut) {
|
|
1639
|
+
findings.push({ ruleId: 'COMP-CCPA-005', category: 'compliance', severity: 'high', title: 'Data shared with third parties without CCPA opt-out option', description: 'Add "Do Not Sell or Share My Personal Information" link as required by CCPA. California residents have the right to opt out of sale/sharing of personal data.', fix: null });
|
|
1640
|
+
}
|
|
1641
|
+
return findings;
|
|
1642
|
+
},
|
|
1643
|
+
},
|
|
1644
|
+
// COMP-HIPAA-006: PHI in URL parameters
|
|
1645
|
+
{ id: 'COMP-HIPAA-006', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'PHI in URL Parameters',
|
|
1646
|
+
check({ files }) {
|
|
1647
|
+
const findings = [];
|
|
1648
|
+
for (const [fp, c] of files) {
|
|
1649
|
+
if (!isSourceFile(fp)) continue;
|
|
1650
|
+
const lines = c.split('\n');
|
|
1651
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1652
|
+
if (lines[i].match(/[?&](patient|patient_id|mrn|ssn|diagnosis|medication|dob)=/i) && !lines[i].match(/\/\//)) {
|
|
1653
|
+
findings.push({ ruleId: 'COMP-HIPAA-006', category: 'compliance', severity: 'critical', title: 'PHI passed in URL parameters — logged in server logs and browser history', description: 'Use POST body or session for PHI. HIPAA requires safeguards to prevent unauthorized disclosure of PHI. URL parameters are logged in web server logs, proxy logs, and browser history.', file: fp, line: i + 1, fix: null });
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
return findings;
|
|
1658
|
+
},
|
|
1659
|
+
},
|
|
1660
|
+
// COMP-PCI-007: Storing CVV after authorization
|
|
1661
|
+
{ id: 'COMP-PCI-007', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'CVV/CVV2 Value Stored in Database',
|
|
1662
|
+
check({ files }) {
|
|
1663
|
+
const findings = [];
|
|
1664
|
+
for (const [fp, c] of files) {
|
|
1665
|
+
if (!isSourceFile(fp)) continue;
|
|
1666
|
+
const lines = c.split('\n');
|
|
1667
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1668
|
+
if (lines[i].match(/cvv|cvv2|cvc|security_code/i) && lines[i].match(/save\s*\(|insert|create|update|set\s*\(/i) && !lines[i].match(/\/\//)) {
|
|
1669
|
+
findings.push({ ruleId: 'COMP-PCI-007', category: 'compliance', severity: 'critical', title: 'CVV/CVV2 value being stored — PCI DSS Requirement 3 violation', description: 'Never store CVV after authorization. PCI DSS Requirement 3.3 strictly prohibits storing card validation codes. Use tokenization via your payment processor instead.', file: fp, line: i + 1, fix: null });
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
return findings;
|
|
1674
|
+
},
|
|
1675
|
+
},
|
|
1676
|
+
// COMP-INTL-014: No timezone handling for user dates
|
|
1677
|
+
{ id: 'COMP-INTL-014', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'User-Facing Dates Without Timezone Consideration',
|
|
1678
|
+
check({ files }) {
|
|
1679
|
+
const findings = [];
|
|
1680
|
+
for (const [fp, c] of files) {
|
|
1681
|
+
if (!isSourceFile(fp)) continue;
|
|
1682
|
+
const lines = c.split('\n');
|
|
1683
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1684
|
+
if (lines[i].match(/new Date\s*\(|Date\.now\s*\(\s*\)/) && !lines[i].match(/\/\//)) {
|
|
1685
|
+
const ctx = lines.slice(i, Math.min(lines.length, i + 5)).join('\n');
|
|
1686
|
+
if (ctx.match(/res\.json|res\.send|return.*date/i) && !ctx.match(/timezone|toISOString|UTC|Intl\.DateTimeFormat/i)) {
|
|
1687
|
+
findings.push({ ruleId: 'COMP-INTL-014', category: 'compliance', severity: 'medium', title: 'Date returned to client without timezone context', description: 'Always use ISO 8601 format with timezone (toISOString()) or include timezone info. Server-side dates without timezone context are interpreted in the user\'s local timezone, causing date display errors.', file: fp, line: i + 1, fix: null });
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
return findings;
|
|
1693
|
+
},
|
|
1694
|
+
},
|
|
1695
|
+
// COMP-A11Y-026: Images in CSS (background-image) for content
|
|
1696
|
+
{ id: 'COMP-A11Y-026', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'Informational Content as CSS Background Image',
|
|
1697
|
+
check({ files }) {
|
|
1698
|
+
const findings = [];
|
|
1699
|
+
for (const [fp, c] of files) {
|
|
1700
|
+
if (!fp.match(/\.(css|scss|sass|less)$/)) continue;
|
|
1701
|
+
const lines = c.split('\n');
|
|
1702
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1703
|
+
if (lines[i].match(/background-image:\s*url\s*\(/) && !lines[i].match(/decoration|ornament|pattern|gradient/i)) {
|
|
1704
|
+
findings.push({ ruleId: 'COMP-A11Y-026', category: 'compliance', severity: 'low', title: 'Informational image in CSS background — invisible to screen readers', description: 'Use <img> with alt text for informational images. CSS background images cannot have alt text and are invisible to screen readers (WCAG 2.1 SC 1.1.1).', file: fp, line: i + 1, fix: null });
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
return findings;
|
|
1709
|
+
},
|
|
1710
|
+
},
|
|
1711
|
+
// COMP-GDPR-020: Data exported without access control check
|
|
1712
|
+
{ id: 'COMP-GDPR-020', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Data Export Endpoint Without Authorization Check',
|
|
1713
|
+
check({ files }) {
|
|
1714
|
+
const findings = [];
|
|
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 (lines[i].match(/export.*csv|export.*excel|download.*data|export.*report/i) && lines[i].match(/router\.(get|post)\s*\(/)) {
|
|
1720
|
+
const ctx = lines.slice(i, Math.min(lines.length, i + 15)).join('\n');
|
|
1721
|
+
if (!ctx.match(/auth|permission|role|admin|isAuthenticated|requireAuth/i)) {
|
|
1722
|
+
findings.push({ ruleId: 'COMP-GDPR-020', category: 'compliance', severity: 'high', title: 'Data export endpoint without authentication/authorization check', description: 'Require authentication and authorization for data export endpoints. GDPR Article 5 requires access controls on personal data. Unprotected export endpoints enable bulk PII theft.', file: fp, line: i + 1, fix: null });
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
return findings;
|
|
1728
|
+
},
|
|
1729
|
+
},
|
|
1730
|
+
// COMP-SOC2-011: No penetration testing evidence
|
|
1731
|
+
{ id: 'COMP-SOC2-011', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'No Penetration Testing Process Documented',
|
|
1732
|
+
check({ files }) {
|
|
1733
|
+
const findings = [];
|
|
1734
|
+
const hasPentest = [...files.keys()].some(f => f.match(/pentest|penetration|security.?test|security.?audit/i)) || [...files.values()].some(c => c.match(/pentest|penetration.?test|security.?assessment/i));
|
|
1735
|
+
const hasCI = [...files.keys()].some(f => f.match(/\.github\/workflows|\.gitlab-ci/));
|
|
1736
|
+
if (hasCI && !hasPentest) {
|
|
1737
|
+
findings.push({ ruleId: 'COMP-SOC2-011', category: 'compliance', severity: 'medium', title: 'No penetration testing evidence in repository', description: 'Document penetration testing schedule and remediation process. SOC 2 CC7.1 requires that security testing be performed and findings remediated. Annual pen tests are standard.', fix: null });
|
|
1738
|
+
}
|
|
1739
|
+
return findings;
|
|
1740
|
+
},
|
|
1741
|
+
},
|
|
1742
|
+
// COMP-CHILDREN-002: Collecting children's location data — only for apps explicitly targeting children
|
|
1743
|
+
{ id: 'COMP-CHILDREN-002', category: 'compliance', severity: 'critical', confidence: 'definite', title: "Location Data Collected That May Apply to Children's Platforms",
|
|
1744
|
+
check({ files }) {
|
|
1745
|
+
const findings = [];
|
|
1746
|
+
// Only fire if package.json or config explicitly mentions COPPA/children targeting
|
|
1747
|
+
const hasChildrenTargeting = [...files.entries()].some(([fp, c]) =>
|
|
1748
|
+
(fp.endsWith('package.json') || fp.endsWith('.json')) && /coppa|kids.*app|children.*platform/i.test(c)
|
|
1749
|
+
);
|
|
1750
|
+
if (!hasChildrenTargeting) return findings;
|
|
1751
|
+
const hasLocation = [...files.values()].some(c => c.match(/geolocation|navigator\.geolocation|latitude|longitude|location.*tracking/i));
|
|
1752
|
+
if (hasLocation) {
|
|
1753
|
+
findings.push({ ruleId: 'COMP-CHILDREN-002', category: 'compliance', severity: 'critical', title: 'Platform targeting children may be collecting location data', description: 'COPPA and GDPR-K prohibit collecting precise geolocation from children under 13 without verifiable parental consent. Remove location tracking or implement strict age verification with parental consent flow.', fix: null });
|
|
1754
|
+
}
|
|
1755
|
+
return findings;
|
|
1756
|
+
},
|
|
1757
|
+
},
|
|
1758
|
+
// COMP-INTL-015: Currency displayed without ISO code
|
|
1759
|
+
{ id: 'COMP-INTL-015', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'Currency Amount Displayed Without ISO Currency Code',
|
|
1760
|
+
check({ files }) {
|
|
1761
|
+
const findings = [];
|
|
1762
|
+
for (const [fp, c] of files) {
|
|
1763
|
+
if (!fp.match(/\.(jsx|tsx|html|vue|js|ts)$/)) continue;
|
|
1764
|
+
const lines = c.split('\n');
|
|
1765
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1766
|
+
// Match literal $ currency display: $12.99 or "$" + amount, but NOT ${...} template literals
|
|
1767
|
+
if (lines[i].match(/\$\s*[0-9]|\"\$\"|'\$'/) && !lines[i].match(/\$\{/) && !lines[i].match(/currency.*USD|ISO|Intl\.NumberFormat.*currency/i)) {
|
|
1768
|
+
findings.push({ ruleId: 'COMP-INTL-015', category: 'compliance', severity: 'low', title: 'Currency displayed with $ symbol but no ISO code', description: 'Use Intl.NumberFormat with currency option. Display currency as "USD" or "EUR" in addition to symbol for international users and compliance with financial display regulations.', file: fp, line: i + 1, fix: null });
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
return findings;
|
|
1773
|
+
},
|
|
1774
|
+
},
|
|
1775
|
+
];
|
|
1776
|
+
|
|
1777
|
+
// allComplianceRules is constructed at end of file after all rules.push() calls
|
|
1778
|
+
|
|
1779
|
+
function isSourceFile(f) { return ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].some(e => f.endsWith(e)); }
|
|
1780
|
+
|
|
1781
|
+
// COMP-016: HIPAA - PHI in log statements
|
|
1782
|
+
rules.push({
|
|
1783
|
+
id: 'COMP-016', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'HIPAA: Protected Health Information (PHI) logged',
|
|
1784
|
+
check({ files }) {
|
|
1785
|
+
const findings = [];
|
|
1786
|
+
const phiFields = /(?:diagnosis|condition|medication|treatment|prescription|patient.*name|dob|date_of_birth|ssn|medical_record|insurance|health_plan)/i;
|
|
1787
|
+
const logPattern = /(?:console\.|logger\.|log\.)\w*\s*\([^)]*(?:patient|health|medical|diagnosis|medication)/i;
|
|
1788
|
+
for (const [fp, c] of files) {
|
|
1789
|
+
if (!isSourceFile(fp)) continue;
|
|
1790
|
+
const lines = c.split('\n');
|
|
1791
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1792
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1793
|
+
if (logPattern.test(lines[i]) || (phiFields.test(lines[i]) && /console\.|logger\./.test(lines[i]))) {
|
|
1794
|
+
findings.push({ ruleId: 'COMP-016', category: 'compliance', severity: 'critical', title: 'Potential PHI logged — HIPAA violation', description: 'Logging Protected Health Information violates HIPAA. Mask or exclude PHI from all log output. Use structured logging with field-level redaction.', file: fp, line: i + 1, fix: null });
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
return findings;
|
|
1799
|
+
},
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1802
|
+
// COMP-017: HIPAA - Unencrypted health data storage
|
|
1803
|
+
rules.push({
|
|
1804
|
+
id: 'COMP-017', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'HIPAA: Health data field stored without encryption annotation',
|
|
1805
|
+
check({ files }) {
|
|
1806
|
+
const findings = [];
|
|
1807
|
+
const phiField = /(?:diagnosis|condition|medication|prescription|health_data|medical_history|lab_result)\s*[:=]/i;
|
|
1808
|
+
const encryptMarker = /encrypt|@Encrypted|@Column.*encrypted|cipher/i;
|
|
1809
|
+
for (const [fp, c] of files) {
|
|
1810
|
+
if (!isSourceFile(fp)) continue;
|
|
1811
|
+
const lines = c.split('\n');
|
|
1812
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1813
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1814
|
+
if (phiField.test(lines[i]) && /String|TEXT|VARCHAR|string:/i.test(lines[i]) && !encryptMarker.test(lines[i])) {
|
|
1815
|
+
findings.push({ ruleId: 'COMP-017', category: 'compliance', severity: 'critical', title: 'Health data field stored without encryption — HIPAA requires encryption at rest', description: 'PHI must be encrypted at rest under HIPAA. Use application-level encryption or database column encryption for health data fields.', file: fp, line: i + 1, fix: null });
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
return findings;
|
|
1820
|
+
},
|
|
1821
|
+
});
|
|
1822
|
+
|
|
1823
|
+
// COMP-018: PCI-DSS - Full card number stored
|
|
1824
|
+
rules.push({
|
|
1825
|
+
id: 'COMP-018', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'PCI-DSS: Full credit card number stored or logged',
|
|
1826
|
+
check({ files }) {
|
|
1827
|
+
const findings = [];
|
|
1828
|
+
const cardField = /(?:card_number|cardNumber|pan|credit_card|cc_number)\s*[:=]/i;
|
|
1829
|
+
const fullPAN = /(?:card_number|cardNumber|pan|full.*card)\s*[:=]\s*(?:req\.|body\.|input\.|String|TEXT|VARCHAR)/i;
|
|
1830
|
+
for (const [fp, c] of files) {
|
|
1831
|
+
if (!isSourceFile(fp)) continue;
|
|
1832
|
+
const lines = c.split('\n');
|
|
1833
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1834
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1835
|
+
if (fullPAN.test(lines[i]) && !/mask|truncat|last.*4|token|vault/i.test(lines[i])) {
|
|
1836
|
+
findings.push({ ruleId: 'COMP-018', category: 'compliance', severity: 'critical', title: 'Full PAN stored — PCI-DSS prohibits storing full card numbers', description: 'PCI-DSS prohibits storing full Primary Account Numbers. Store only the last 4 digits or use a payment tokenization service (Stripe, Braintree).', file: fp, line: i + 1, fix: null });
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
return findings;
|
|
1841
|
+
},
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
// COMP-019: PCI-DSS - CVV stored
|
|
1845
|
+
rules.push({
|
|
1846
|
+
id: 'COMP-019', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'PCI-DSS: CVV/CVV2 security code stored',
|
|
1847
|
+
check({ files }) {
|
|
1848
|
+
const findings = [];
|
|
1849
|
+
const cvvField = /(?:cvv|cvv2|cvc|security_code|card_verification)\s*[:=]/i;
|
|
1850
|
+
for (const [fp, c] of files) {
|
|
1851
|
+
if (!isSourceFile(fp)) continue;
|
|
1852
|
+
const lines = c.split('\n');
|
|
1853
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1854
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1855
|
+
if (cvvField.test(lines[i]) && /save|store|insert|create|String|TEXT|VARCHAR/i.test(lines[i])) {
|
|
1856
|
+
findings.push({ ruleId: 'COMP-019', category: 'compliance', severity: 'critical', title: 'CVV stored — PCI-DSS strictly prohibits storing CVV after authorization', description: 'PCI-DSS Requirement 3.2 prohibits storing CVV/CVC after transaction authorization. Remove CVV storage immediately.', file: fp, line: i + 1, fix: null });
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
return findings;
|
|
1861
|
+
},
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
// COMP-020: PCI-DSS - Unmasked PAN in logs
|
|
1865
|
+
rules.push({
|
|
1866
|
+
id: 'COMP-020', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'PCI-DSS: Card number or PAN in log output',
|
|
1867
|
+
check({ files }) {
|
|
1868
|
+
const findings = [];
|
|
1869
|
+
const pattern = /(?:console\.|logger\.|log\.)\w*\s*\([^)]*(?:cardNumber|card_number|pan|creditCard|credit_card)/i;
|
|
1870
|
+
for (const [fp, c] of files) {
|
|
1871
|
+
if (!isSourceFile(fp)) continue;
|
|
1872
|
+
const lines = c.split('\n');
|
|
1873
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1874
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1875
|
+
if (pattern.test(lines[i])) {
|
|
1876
|
+
findings.push({ ruleId: 'COMP-020', category: 'compliance', severity: 'critical', title: 'Card number logged — PCI-DSS violation', description: 'PCI-DSS requires masking card numbers in logs (show only last 4 digits). Logging full PANs is a reportable violation.', file: fp, line: i + 1, fix: null });
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
return findings;
|
|
1881
|
+
},
|
|
1882
|
+
});
|
|
1883
|
+
|
|
1884
|
+
// COMP-021: GDPR - No cookie consent mechanism
|
|
1885
|
+
rules.push({
|
|
1886
|
+
id: 'COMP-021', category: 'compliance', severity: 'high', confidence: 'likely', title: 'GDPR: No cookie consent mechanism found',
|
|
1887
|
+
check({ files }) {
|
|
1888
|
+
const findings = [];
|
|
1889
|
+
const hasCookies = [...files.values()].some(c => /document\.cookie|setCookie|cookie-parser|Set-Cookie/i.test(c));
|
|
1890
|
+
const hasConsent = [...files.values()].some(c => /cookie.*consent|cookieConsent|gdpr.*cookie|acceptCookies|cookieBanner|cookie.*policy|CookieYes|Cookiebot|OneTrust/i.test(c));
|
|
1891
|
+
if (hasCookies && !hasConsent) {
|
|
1892
|
+
findings.push({ ruleId: 'COMP-021', category: 'compliance', severity: 'high', title: 'Cookies set without consent mechanism — GDPR Article 7 violation', description: 'GDPR requires explicit consent before setting non-essential cookies. Implement a cookie consent banner with opt-in/opt-out controls.', fix: null });
|
|
1893
|
+
}
|
|
1894
|
+
return findings;
|
|
1895
|
+
},
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
// COMP-022: GDPR - Third-party scripts without consent
|
|
1899
|
+
rules.push({
|
|
1900
|
+
id: 'COMP-022', category: 'compliance', severity: 'high', confidence: 'likely', title: 'GDPR: Third-party tracking scripts loaded without consent check',
|
|
1901
|
+
check({ files }) {
|
|
1902
|
+
const findings = [];
|
|
1903
|
+
const trackingScripts = /(?:google-analytics|gtag|ga\s*\(|fbq\s*\(|analytics\.js|segment\.com|mixpanel|hotjar|heap\.io)/i;
|
|
1904
|
+
const consentCheck = /cookie.*consent|hasConsent|gdprConsent|analyticsEnabled|trackingAllowed/i;
|
|
1905
|
+
for (const [fp, c] of files) {
|
|
1906
|
+
if (!fp.match(/\.(js|ts|jsx|tsx|html)$/)) continue;
|
|
1907
|
+
if (!trackingScripts.test(c)) continue;
|
|
1908
|
+
if (!consentCheck.test(c)) {
|
|
1909
|
+
findings.push({ ruleId: 'COMP-022', category: 'compliance', severity: 'high', title: 'Third-party analytics loaded without consent gate — GDPR violation', description: 'Load analytics and tracking scripts only after user consent. Wrap script loading in consent check: if (hasAnalyticsConsent()) { loadAnalytics(); }', file: fp, fix: null });
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
return findings;
|
|
1913
|
+
},
|
|
1914
|
+
});
|
|
1915
|
+
|
|
1916
|
+
// COMP-023: GDPR - No data retention policy
|
|
1917
|
+
rules.push({
|
|
1918
|
+
id: 'COMP-023', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'GDPR: No data retention or deletion mechanism',
|
|
1919
|
+
check({ files }) {
|
|
1920
|
+
const findings = [];
|
|
1921
|
+
const storesPII = [...files.values()].some(c => /email.*save|user.*create|profile.*insert|personal.*data/i.test(c));
|
|
1922
|
+
const hasRetention = [...files.values()].some(c => /deleteAfter|retention|purge|ttl|expiresAt|deleteOldRecords|data.*cleanup/i.test(c));
|
|
1923
|
+
if (storesPII && !hasRetention) {
|
|
1924
|
+
findings.push({ ruleId: 'COMP-023', category: 'compliance', severity: 'medium', title: 'PII stored without data retention policy — GDPR requires data minimization', description: 'GDPR Article 5(1)(e) requires data not be kept longer than necessary. Implement automated deletion for expired user data.', fix: null });
|
|
1925
|
+
}
|
|
1926
|
+
return findings;
|
|
1927
|
+
},
|
|
1928
|
+
});
|
|
1929
|
+
|
|
1930
|
+
// COMP-024: Accessibility - Missing aria-label on interactive elements
|
|
1931
|
+
rules.push({
|
|
1932
|
+
id: 'COMP-024', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Accessibility: Interactive element without accessible label',
|
|
1933
|
+
check({ files }) {
|
|
1934
|
+
const findings = [];
|
|
1935
|
+
for (const [fp, c] of files) {
|
|
1936
|
+
if (!fp.match(/\.(jsx|tsx|html|vue)$/)) continue;
|
|
1937
|
+
const lines = c.split('\n');
|
|
1938
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1939
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1940
|
+
if (/<(?:button|a|input|select)\b/.test(lines[i]) && !/aria-label|aria-labelledby|title=|alt=/.test(lines[i])) {
|
|
1941
|
+
const nextLines = lines.slice(i, Math.min(lines.length, i + 3)).join(' ');
|
|
1942
|
+
if (!/aria-label|aria-labelledby/.test(nextLines)) {
|
|
1943
|
+
findings.push({ ruleId: 'COMP-024', category: 'compliance', severity: 'medium', title: 'Interactive element without accessible label — WCAG 2.1 violation', description: 'Buttons, links, and form inputs need accessible labels for screen readers. Add aria-label, aria-labelledby, or visible text content.', file: fp, line: i + 1, fix: null });
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
return findings;
|
|
1949
|
+
},
|
|
1950
|
+
});
|
|
1951
|
+
|
|
1952
|
+
// COMP-025: Accessibility - Image without alt text
|
|
1953
|
+
rules.push({
|
|
1954
|
+
id: 'COMP-025', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Accessibility: <img> without alt attribute',
|
|
1955
|
+
check({ files }) {
|
|
1956
|
+
const findings = [];
|
|
1957
|
+
for (const [fp, c] of files) {
|
|
1958
|
+
if (!fp.match(/\.(jsx|tsx|html|vue)$/)) continue;
|
|
1959
|
+
const lines = c.split('\n');
|
|
1960
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1961
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
1962
|
+
if (/<img\b/.test(lines[i]) && !/alt=/.test(lines[i])) {
|
|
1963
|
+
const ctx = lines.slice(i, Math.min(lines.length, i + 3)).join(' ');
|
|
1964
|
+
if (!/alt=/.test(ctx)) {
|
|
1965
|
+
findings.push({ ruleId: 'COMP-025', category: 'compliance', severity: 'medium', title: '<img> without alt attribute — WCAG 2.1 Level A failure', description: 'Images must have alt text for screen readers. Use alt="" for decorative images and a descriptive alt for informational images.', file: fp, line: i + 1, fix: null });
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
return findings;
|
|
1971
|
+
},
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1974
|
+
// COMP-026: COPPA - No age verification
|
|
1975
|
+
rules.push({
|
|
1976
|
+
id: 'COMP-026', category: 'compliance', severity: 'high', confidence: 'likely', title: 'COPPA: No age verification before collecting user data',
|
|
1977
|
+
check({ files }) {
|
|
1978
|
+
const findings = [];
|
|
1979
|
+
const collectsData = [...files.values()].some(c => /register|signup|createAccount|createUser/i.test(c));
|
|
1980
|
+
const hasAgeCheck = [...files.values()].some(c => /age.*verif|dateOfBirth|dob|ageGate|coppa|under.*13|age.*13|birthdate/i.test(c));
|
|
1981
|
+
if (collectsData && !hasAgeCheck) {
|
|
1982
|
+
findings.push({ ruleId: 'COMP-026', category: 'compliance', severity: 'high', title: 'No age verification — COPPA requires consent for users under 13', description: 'COPPA requires verifiable parental consent before collecting personal information from children under 13. Add age gate or date-of-birth check to registration.', fix: null });
|
|
1983
|
+
}
|
|
1984
|
+
return findings;
|
|
1985
|
+
},
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
// COMP-027: Missing privacy policy link
|
|
1989
|
+
rules.push({
|
|
1990
|
+
id: 'COMP-027', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Registration/signup form without privacy policy link',
|
|
1991
|
+
check({ files }) {
|
|
1992
|
+
const findings = [];
|
|
1993
|
+
for (const [fp, c] of files) {
|
|
1994
|
+
if (!fp.match(/\.(jsx|tsx|html|vue)$/)) continue;
|
|
1995
|
+
if (!/register|signup|createAccount/i.test(c)) continue;
|
|
1996
|
+
if (!/privacy.*policy|privacyPolicy|privacy-policy/i.test(c)) {
|
|
1997
|
+
findings.push({ ruleId: 'COMP-027', category: 'compliance', severity: 'medium', title: 'Signup form without privacy policy link — required by GDPR, CCPA, COPPA', description: 'Registration forms must link to a privacy policy explaining what data is collected. GDPR, CCPA, and COPPA all require this disclosure at point of data collection.', file: fp, fix: null });
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
return findings;
|
|
2001
|
+
},
|
|
2002
|
+
});
|
|
2003
|
+
|
|
2004
|
+
// COMP-028: SOC2 - Audit logging missing
|
|
2005
|
+
rules.push({
|
|
2006
|
+
id: 'COMP-028', category: 'compliance', severity: 'medium', confidence: 'definite', title: 'SOC2: No audit logging for sensitive operations',
|
|
2007
|
+
check({ files }) {
|
|
2008
|
+
const findings = [];
|
|
2009
|
+
const hasSensitiveOps = [...files.values()].some(c => /delete.*user|update.*role|admin.*action|permission.*change/i.test(c));
|
|
2010
|
+
const hasAuditLog = [...files.values()].some(c => /auditLog|audit_log|AuditTrail|audit\.log|writeAudit/i.test(c));
|
|
2011
|
+
if (hasSensitiveOps && !hasAuditLog) {
|
|
2012
|
+
findings.push({ ruleId: 'COMP-028', category: 'compliance', severity: 'medium', title: 'Sensitive operations without audit logging — SOC2 CC7.2 requires audit trail', description: 'SOC2 requires audit trails for access changes, privilege escalations, and data deletions. Implement an audit log that records who did what and when.', fix: null });
|
|
2013
|
+
}
|
|
2014
|
+
return findings;
|
|
2015
|
+
},
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
// COMP-029: WCAG - Color-only information
|
|
2019
|
+
rules.push({
|
|
2020
|
+
id: 'COMP-029', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Accessibility: Information conveyed by color only',
|
|
2021
|
+
check({ files }) {
|
|
2022
|
+
const findings = [];
|
|
2023
|
+
for (const [fp, c] of files) {
|
|
2024
|
+
if (!fp.match(/\.(jsx|tsx|html|vue|css|scss)$/)) continue;
|
|
2025
|
+
const lines = c.split('\n');
|
|
2026
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2027
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
2028
|
+
// Look for status/error indicators that only use color
|
|
2029
|
+
if (/color.*red|color.*green|color.*error|color.*success/i.test(lines[i]) && !/icon|text|label|aria/i.test(lines[i])) {
|
|
2030
|
+
const ctx = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 2)).join('\n');
|
|
2031
|
+
if (!/text|icon|label|aria-label/.test(ctx)) {
|
|
2032
|
+
findings.push({ ruleId: 'COMP-029', category: 'compliance', severity: 'medium', title: 'Status indicated by color only — inaccessible to color-blind users (WCAG 1.4.1)', description: 'Use icons, text, or patterns in addition to color to convey information. Color alone fails WCAG 1.4.1 (Use of Color).', file: fp, line: i + 1, fix: null });
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
return findings;
|
|
2038
|
+
},
|
|
2039
|
+
});
|
|
2040
|
+
|
|
2041
|
+
// COMP-030: Missing HTTPS in production config
|
|
2042
|
+
rules.push({
|
|
2043
|
+
id: 'COMP-030', category: 'compliance', severity: 'high', confidence: 'likely', title: 'Production config using HTTP instead of HTTPS URLs',
|
|
2044
|
+
check({ files }) {
|
|
2045
|
+
const findings = [];
|
|
2046
|
+
const prodConfig = /(?:production|prod).*(?:url|endpoint|api|base)/i;
|
|
2047
|
+
for (const [fp, c] of files) {
|
|
2048
|
+
if (!fp.match(/\.(json|env|ya?ml|ts|js)$/)) continue;
|
|
2049
|
+
if (!fp.match(/prod|config|env/i)) continue;
|
|
2050
|
+
const lines = c.split('\n');
|
|
2051
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2052
|
+
if (/http:\/\/(?!localhost|127\.|0\.0\.0)/.test(lines[i]) && !/^\s*(#|\/\/)/.test(lines[i])) {
|
|
2053
|
+
findings.push({ ruleId: 'COMP-030', category: 'compliance', severity: 'high', title: 'HTTP (not HTTPS) URL in production configuration', description: 'Production services must use HTTPS. Plain HTTP exposes data in transit. Change all production URLs to use https://.', file: fp, line: i + 1, fix: null });
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
return findings;
|
|
2058
|
+
},
|
|
2059
|
+
});
|
|
2060
|
+
|
|
2061
|
+
// COMP-031 through COMP-055: Additional compliance rules
|
|
2062
|
+
|
|
2063
|
+
// COMP-031: GDPR - Right to erasure not implemented
|
|
2064
|
+
rules.push({
|
|
2065
|
+
id: 'COMP-031', category: 'compliance', severity: 'high', confidence: 'likely', title: 'GDPR: No data deletion/erasure functionality',
|
|
2066
|
+
check({ files }) {
|
|
2067
|
+
const findings = [];
|
|
2068
|
+
const storesUserData = [...files.values()].some(c => /user\.create|createUser|registerUser/i.test(c));
|
|
2069
|
+
const hasDeleteUser = [...files.values()].some(c => /deleteUser|removeUser|user\.delete|account.*delete|gdpr.*delete|erasure|right.*forget/i.test(c));
|
|
2070
|
+
if (storesUserData && !hasDeleteUser) {
|
|
2071
|
+
findings.push({ ruleId: 'COMP-031', category: 'compliance', severity: 'high', title: 'User data stored without deletion capability — GDPR Right to Erasure (Article 17)', description: 'GDPR requires you to delete personal data upon user request. Implement a user data deletion flow that removes all PII from your systems.', fix: null });
|
|
2072
|
+
}
|
|
2073
|
+
return findings;
|
|
2074
|
+
},
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
// COMP-032: PCI-DSS - Unencrypted transmission
|
|
2078
|
+
rules.push({
|
|
2079
|
+
id: 'COMP-032', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'PCI-DSS: Payment data transmitted over unencrypted connection',
|
|
2080
|
+
check({ files }) {
|
|
2081
|
+
const findings = [];
|
|
2082
|
+
for (const [fp, c] of files) {
|
|
2083
|
+
if (!isSourceFile(fp)) continue;
|
|
2084
|
+
const lines = c.split('\n');
|
|
2085
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2086
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
2087
|
+
if (/http:\/\/(?!localhost)/.test(lines[i]) && /card|payment|pan|credit/i.test(lines[i])) {
|
|
2088
|
+
findings.push({ ruleId: 'COMP-032', category: 'compliance', severity: 'critical', title: 'Payment data sent over HTTP — PCI-DSS requires TLS for cardholder data', description: 'PCI-DSS Requirement 4.1 mandates strong cryptography for cardholder data in transit. Always use HTTPS/TLS for payment data transmission.', file: fp, line: i + 1, fix: null });
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
return findings;
|
|
2093
|
+
},
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2096
|
+
// COMP-033: HIPAA - Audit log for PHI access
|
|
2097
|
+
rules.push({
|
|
2098
|
+
id: 'COMP-033', category: 'compliance', severity: 'high', confidence: 'likely', title: 'HIPAA: PHI accessed without audit log entry',
|
|
2099
|
+
check({ files }) {
|
|
2100
|
+
const findings = [];
|
|
2101
|
+
const hasPHIAccess = [...files.values()].some(c => /patient|diagnosis|medication|health_record/i.test(c) && /findOne|findById|SELECT/i.test(c));
|
|
2102
|
+
const hasAuditLog = [...files.values()].some(c => /auditLog|hipaa.*log|phi.*access|accessLog/i.test(c));
|
|
2103
|
+
if (hasPHIAccess && !hasAuditLog) {
|
|
2104
|
+
findings.push({ ruleId: 'COMP-033', category: 'compliance', severity: 'high', title: 'PHI accessed without HIPAA audit trail — HIPAA 164.312(b) violation', description: 'HIPAA requires audit controls that record and examine activity in systems containing PHI. Log who accessed what PHI data and when.', fix: null });
|
|
2105
|
+
}
|
|
2106
|
+
return findings;
|
|
2107
|
+
},
|
|
2108
|
+
});
|
|
2109
|
+
|
|
2110
|
+
// COMP-034: CCPA - No opt-out mechanism
|
|
2111
|
+
rules.push({
|
|
2112
|
+
id: 'COMP-034', category: 'compliance', severity: 'high', confidence: 'likely', title: 'CCPA: No opt-out of data sale mechanism',
|
|
2113
|
+
check({ files }) {
|
|
2114
|
+
const findings = [];
|
|
2115
|
+
const hasUserData = [...files.values()].some(c => /user.*data|personal.*info|analytics|tracking/i.test(c));
|
|
2116
|
+
const hasOptOut = [...files.values()].some(c => /opt-out|optOut|do.*not.*sell|ccpa|doNotSell/i.test(c));
|
|
2117
|
+
if (hasUserData && !hasOptOut) {
|
|
2118
|
+
findings.push({ ruleId: 'COMP-034', category: 'compliance', severity: 'high', title: 'No "Do Not Sell My Personal Information" opt-out — CCPA requirement for CA residents', description: 'CCPA requires businesses that sell personal information to provide a clear opt-out mechanism. Add a "Do Not Sell My Personal Information" link to your privacy page.', fix: null });
|
|
2119
|
+
}
|
|
2120
|
+
return findings;
|
|
2121
|
+
},
|
|
2122
|
+
});
|
|
2123
|
+
|
|
2124
|
+
// COMP-035: Accessibility - Form without label
|
|
2125
|
+
rules.push({
|
|
2126
|
+
id: 'COMP-035', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Accessibility: Form input without associated label',
|
|
2127
|
+
check({ files }) {
|
|
2128
|
+
const findings = [];
|
|
2129
|
+
for (const [fp, c] of files) {
|
|
2130
|
+
if (!fp.match(/\.(jsx|tsx|html|vue)$/)) continue;
|
|
2131
|
+
const lines = c.split('\n');
|
|
2132
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2133
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
2134
|
+
if (/<input\b/.test(lines[i]) && !/type\s*=\s*["']hidden["']/.test(lines[i]) && !/aria-label|aria-labelledby/.test(lines[i])) {
|
|
2135
|
+
const ctx = lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 3)).join('\n');
|
|
2136
|
+
if (!/<label|aria-label|aria-labelledby/.test(ctx)) {
|
|
2137
|
+
findings.push({ ruleId: 'COMP-035', category: 'compliance', severity: 'medium', title: '<input> without label — screen readers cannot identify the field', description: 'Associate form inputs with labels using <label for="id">, aria-label, or aria-labelledby. WCAG 2.1 Success Criterion 1.3.1 (Info and Relationships).', file: fp, line: i + 1, fix: null });
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
return findings;
|
|
2143
|
+
},
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
// COMP-036: SOC2 - Plaintext passwords in configuration
|
|
2147
|
+
rules.push({
|
|
2148
|
+
id: 'COMP-036', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'SOC2: Plaintext credentials in configuration files',
|
|
2149
|
+
check({ files }) {
|
|
2150
|
+
const findings = [];
|
|
2151
|
+
for (const [fp, c] of files) {
|
|
2152
|
+
if (!fp.match(/\.(?:json|yaml|yml|toml|ini|env)$/)) continue;
|
|
2153
|
+
const lines = c.split('\n');
|
|
2154
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2155
|
+
if (/^\s*[#;]/.test(lines[i])) continue;
|
|
2156
|
+
if (/(?:password|passwd|secret|api_key)\s*[:=]\s*["']?[A-Za-z0-9!@#$%^&*]{8,}["']?/i.test(lines[i]) && !/\$\{|\$\(|process\.env/i.test(lines[i])) {
|
|
2157
|
+
findings.push({ ruleId: 'COMP-036', category: 'compliance', severity: 'critical', title: 'Plaintext credential in config file — SOC2 CC6 control failure', description: 'SOC2 CC6 requires protecting logical access. Plaintext credentials in config files fail this control. Use environment variables or a secrets manager.', file: fp, line: i + 1, fix: null });
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
return findings;
|
|
2162
|
+
},
|
|
2163
|
+
});
|
|
2164
|
+
|
|
2165
|
+
// COMP-037: WCAG - Missing keyboard navigation support
|
|
2166
|
+
rules.push({
|
|
2167
|
+
id: 'COMP-037', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'Accessibility: onClick handler on non-interactive element without keyboard support',
|
|
2168
|
+
check({ files }) {
|
|
2169
|
+
const findings = [];
|
|
2170
|
+
for (const [fp, c] of files) {
|
|
2171
|
+
if (!fp.match(/\.(jsx|tsx)$/)) continue;
|
|
2172
|
+
const lines = c.split('\n');
|
|
2173
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2174
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
2175
|
+
if (/<(?:div|span|p)\s[^>]*onClick\s*=/.test(lines[i]) && !/onKeyDown|onKeyPress|tabIndex|role\s*=/.test(lines[i])) {
|
|
2176
|
+
findings.push({ ruleId: 'COMP-037', category: 'compliance', severity: 'medium', title: 'Clickable div/span without keyboard handler — inaccessible to keyboard users', description: 'Non-interactive elements with onClick need onKeyDown/onKeyPress and tabIndex="0" for keyboard accessibility. Or use <button> which has built-in keyboard support.', file: fp, line: i + 1, fix: null });
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
return findings;
|
|
2181
|
+
},
|
|
2182
|
+
});
|
|
2183
|
+
|
|
2184
|
+
// COMP-038: GDPR - Data processed without legal basis
|
|
2185
|
+
rules.push({
|
|
2186
|
+
id: 'COMP-038', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'GDPR: No terms of service or consent check before data collection',
|
|
2187
|
+
check({ files }) {
|
|
2188
|
+
const findings = [];
|
|
2189
|
+
for (const [fp, c] of files) {
|
|
2190
|
+
if (!fp.match(/\.(jsx|tsx|html|vue)$/)) continue;
|
|
2191
|
+
if (!c.match(/register|signup|sign.?up/i)) continue;
|
|
2192
|
+
if (!c.match(/terms|consent|agree|privacy/i)) {
|
|
2193
|
+
findings.push({ ruleId: 'COMP-038', category: 'compliance', severity: 'medium', title: 'Registration form without terms/consent checkbox — GDPR Article 6 legal basis', description: 'GDPR requires a lawful basis for processing personal data. Include a consent checkbox linking to terms of service and privacy policy on registration forms.', file: fp, fix: null });
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
return findings;
|
|
2197
|
+
},
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
// COMP-039: PCI-DSS - Logging card data
|
|
2201
|
+
rules.push({
|
|
2202
|
+
id: 'COMP-039', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'PCI-DSS: Full card data logged — compliance violation',
|
|
2203
|
+
check({ files }) {
|
|
2204
|
+
const findings = [];
|
|
2205
|
+
const p = /(?:console\.|logger\.)\w*\s*\([^)]*(?:card|pan|cvv|expiry|expirationDate)/i;
|
|
2206
|
+
for (const [fp, c] of files) {
|
|
2207
|
+
if (!isSourceFile(fp)) continue;
|
|
2208
|
+
const lines = c.split('\n');
|
|
2209
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2210
|
+
if (/^\s*(\/\/|\/\*|\*)/.test(lines[i])) continue;
|
|
2211
|
+
if (p.test(lines[i])) {
|
|
2212
|
+
findings.push({ ruleId: 'COMP-039', category: 'compliance', severity: 'critical', title: 'Card data logged — PCI-DSS prohibits logging of card data', description: 'PCI-DSS requires that card data never be written to logs. Remove all logging of card numbers, CVVs, and expiration dates immediately.', file: fp, line: i + 1, fix: null });
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
return findings;
|
|
2217
|
+
},
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
// COMP-040: ISO 27001 - No change management documentation
|
|
2221
|
+
rules.push({
|
|
2222
|
+
id: 'COMP-040', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'No CHANGELOG or change management documentation',
|
|
2223
|
+
check({ files }) {
|
|
2224
|
+
const findings = [];
|
|
2225
|
+
const hasRelease = [...files.values()].some(c => /npm.*publish|release|deploy/i.test(c));
|
|
2226
|
+
const hasChangelog = [...files.keys()].some(f => f.match(/CHANGELOG|CHANGES|HISTORY/i));
|
|
2227
|
+
if (hasRelease && !hasChangelog) {
|
|
2228
|
+
findings.push({ ruleId: 'COMP-040', category: 'compliance', severity: 'low', title: 'No CHANGELOG — ISO 27001 A.12.1.2 recommends change management documentation', description: 'Maintain a CHANGELOG documenting changes between versions for compliance audit trails and user communication.', fix: null });
|
|
2229
|
+
}
|
|
2230
|
+
return findings;
|
|
2231
|
+
},
|
|
2232
|
+
});
|
|
2233
|
+
|
|
2234
|
+
// COMP-041 through COMP-060: More compliance rules
|
|
2235
|
+
|
|
2236
|
+
// COMP-041: WCAG - Insufficient color contrast indication
|
|
2237
|
+
rules.push({
|
|
2238
|
+
id: 'COMP-041', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'Accessibility: Potential low color contrast ratio',
|
|
2239
|
+
check({ files }) {
|
|
2240
|
+
const findings = [];
|
|
2241
|
+
for (const [fp, c] of files) {
|
|
2242
|
+
if (!fp.match(/\.(css|scss|sass|less)$/)) continue;
|
|
2243
|
+
const lines = c.split('\n');
|
|
2244
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2245
|
+
if (/^\s*\/\/|\/\*/.test(lines[i])) continue;
|
|
2246
|
+
if (/color\s*:\s*#(?:ccc|ddd|eee|aaa|bbb|999|888|777)\b/i.test(lines[i])) {
|
|
2247
|
+
const ctx = lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 3)).join('\n');
|
|
2248
|
+
if (!/background.*#(?:000|111|222|333)|aria|placeholder/i.test(ctx)) {
|
|
2249
|
+
findings.push({ ruleId: 'COMP-041', category: 'compliance', severity: 'low', title: 'Light gray text color — may fail WCAG 1.4.3 contrast ratio (4.5:1)', description: 'Light gray text (#ccc, #ddd) on white backgrounds typically fails WCAG AA contrast requirements. Use a contrast checker to verify 4.5:1 ratio for normal text.', file: fp, line: i + 1, fix: null });
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
return findings;
|
|
2255
|
+
},
|
|
2256
|
+
});
|
|
2257
|
+
|
|
2258
|
+
// COMP-042: NIST - No multi-factor authentication
|
|
2259
|
+
rules.push({
|
|
2260
|
+
id: 'COMP-042', category: 'compliance', severity: 'high', confidence: 'likely', title: 'No multi-factor authentication implementation',
|
|
2261
|
+
check({ files }) {
|
|
2262
|
+
const findings = [];
|
|
2263
|
+
const hasAuth = [...files.values()].some(c => /login|signin|authenticate/i.test(c));
|
|
2264
|
+
const hasMFA = [...files.values()].some(c => /mfa|2fa|totp|otp|authenticator|two.?factor|second.?factor|speakeasy|otpauth/i.test(c));
|
|
2265
|
+
if (hasAuth && !hasMFA) {
|
|
2266
|
+
findings.push({ ruleId: 'COMP-042', category: 'compliance', severity: 'high', title: 'No MFA implementation — NIST 800-63B recommends MFA for all accounts', description: 'NIST 800-63B recommends multi-factor authentication. Implement TOTP, SMS, or hardware key second factors for user authentication.', fix: null });
|
|
2267
|
+
}
|
|
2268
|
+
return findings;
|
|
2269
|
+
},
|
|
2270
|
+
});
|
|
2271
|
+
|
|
2272
|
+
// COMP-043: GDPR - Data export not implemented
|
|
2273
|
+
rules.push({
|
|
2274
|
+
id: 'COMP-043', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'GDPR: No data portability/export feature',
|
|
2275
|
+
check({ files }) {
|
|
2276
|
+
const findings = [];
|
|
2277
|
+
const storesUserData = [...files.values()].some(c => /user\.create|createUser/i.test(c));
|
|
2278
|
+
const hasExport = [...files.values()].some(c => /exportData|data.*export|portability|downloadMyData|gdpr.*export/i.test(c));
|
|
2279
|
+
if (storesUserData && !hasExport) {
|
|
2280
|
+
findings.push({ ruleId: 'COMP-043', category: 'compliance', severity: 'medium', title: 'No data portability feature — GDPR Article 20 (Right to Data Portability)', description: 'GDPR Article 20 gives users the right to receive their personal data in a machine-readable format. Implement a data export endpoint.', fix: null });
|
|
2281
|
+
}
|
|
2282
|
+
return findings;
|
|
2283
|
+
},
|
|
2284
|
+
});
|
|
2285
|
+
|
|
2286
|
+
// COMP-044: HIPAA - PHI sent via email without encryption
|
|
2287
|
+
rules.push({
|
|
2288
|
+
id: 'COMP-044', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'HIPAA: Health data potentially sent via email without encryption',
|
|
2289
|
+
check({ files }) {
|
|
2290
|
+
const findings = [];
|
|
2291
|
+
for (const [fp, c] of files) {
|
|
2292
|
+
if (!isSourceFile(fp)) continue;
|
|
2293
|
+
if (/sendMail|sendEmail|nodemailer|ses\.sendEmail/i.test(c)) {
|
|
2294
|
+
if (/patient|diagnosis|medication|health|medical/i.test(c) && !/encrypt|tls|starttls/i.test(c)) {
|
|
2295
|
+
findings.push({ ruleId: 'COMP-044', category: 'compliance', severity: 'critical', title: 'PHI potentially included in email without encryption', description: 'HIPAA prohibits transmitting PHI via unencrypted email. Use S/MIME encryption or a HIPAA-compliant messaging service. Never include PHI in email body.', file: fp, fix: null });
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
return findings;
|
|
2300
|
+
},
|
|
2301
|
+
});
|
|
2302
|
+
|
|
2303
|
+
// COMP-045: PCI-DSS - Cross-frame scripting protection missing
|
|
2304
|
+
rules.push({
|
|
2305
|
+
id: 'COMP-045', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'PCI-DSS: No Content-Security-Policy for payment pages',
|
|
2306
|
+
check({ files }) {
|
|
2307
|
+
const findings = [];
|
|
2308
|
+
const hasPaymentPage = [...files.values()].some(c => /checkout|payment|card.*form|stripe|braintree/i.test(c));
|
|
2309
|
+
const hasCSP = [...files.values()].some(c => /Content-Security-Policy|contentSecurityPolicy|helmetCsp|cspHeader/i.test(c));
|
|
2310
|
+
if (hasPaymentPage && !hasCSP) {
|
|
2311
|
+
findings.push({ ruleId: 'COMP-045', category: 'compliance', severity: 'medium', title: 'Payment page without Content-Security-Policy — PCI-DSS 6.4.3 requires strict CSP', description: 'PCI-DSS v4.0 Requirement 6.4.3 requires a CSP to be implemented on payment pages to prevent unauthorized scripts from stealing card data.', fix: null });
|
|
2312
|
+
}
|
|
2313
|
+
return findings;
|
|
2314
|
+
},
|
|
2315
|
+
});
|
|
2316
|
+
|
|
2317
|
+
// COMP-046: SOC2 - No incident response plan indicators
|
|
2318
|
+
rules.push({
|
|
2319
|
+
id: 'COMP-046', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'SOC2: No incident response contact or runbook found',
|
|
2320
|
+
check({ files }) {
|
|
2321
|
+
const findings = [];
|
|
2322
|
+
const hasProduction = [...files.values()].some(c => /NODE_ENV.*production|ENVIRONMENT.*prod/i.test(c));
|
|
2323
|
+
const hasIncidentResponse = [...files.keys()].some(f => f.match(/INCIDENT|RUNBOOK|ON_CALL|ESCALATION|incident-response|runbook/i));
|
|
2324
|
+
if (hasProduction && !hasIncidentResponse) {
|
|
2325
|
+
findings.push({ ruleId: 'COMP-046', category: 'compliance', severity: 'low', title: 'No incident response runbook — SOC2 CC7.3 requires defined incident response', description: 'SOC2 CC7.3 requires documented incident response procedures. Create an INCIDENT_RESPONSE.md or runbook documenting escalation paths and response steps.', fix: null });
|
|
2326
|
+
}
|
|
2327
|
+
return findings;
|
|
2328
|
+
},
|
|
2329
|
+
});
|
|
2330
|
+
|
|
2331
|
+
// COMP-047: GDPR - No data breach notification plan
|
|
2332
|
+
rules.push({
|
|
2333
|
+
id: 'COMP-047', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'GDPR: No data breach notification mechanism',
|
|
2334
|
+
check({ files }) {
|
|
2335
|
+
const findings = [];
|
|
2336
|
+
const storesPII = [...files.values()].some(c => /email.*save|createUser|userRecord/i.test(c));
|
|
2337
|
+
const hasBreachNotification = [...files.values()].some(c => /breach.*notif|dataBreachReport|security.*incident|GDPR.*72.*hour|notify.*dpa/i.test(c));
|
|
2338
|
+
if (storesPII && !hasBreachNotification) {
|
|
2339
|
+
findings.push({ ruleId: 'COMP-047', category: 'compliance', severity: 'medium', title: 'No data breach notification procedure — GDPR Article 33 requires 72-hour notification to DPA', description: 'GDPR requires notifying the supervisory authority within 72 hours of a data breach. Document and implement breach detection and notification procedures.', fix: null });
|
|
2340
|
+
}
|
|
2341
|
+
return findings;
|
|
2342
|
+
},
|
|
2343
|
+
});
|
|
2344
|
+
|
|
2345
|
+
// COMP-048: Accessibility - Missing skip navigation link
|
|
2346
|
+
rules.push({
|
|
2347
|
+
id: 'COMP-048', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'Accessibility: No skip navigation link for keyboard users',
|
|
2348
|
+
check({ files }) {
|
|
2349
|
+
const findings = [];
|
|
2350
|
+
for (const [fp, c] of files) {
|
|
2351
|
+
if (!fp.match(/\.(jsx|tsx|html)$/)) continue;
|
|
2352
|
+
if (/<nav\b/.test(c) && !/<a[^>]*skip|skip.*navigation|skip.*main|skip.*content/i.test(c)) {
|
|
2353
|
+
findings.push({ ruleId: 'COMP-048', category: 'compliance', severity: 'low', title: 'Navigation without skip link — keyboard users must tab through all nav items', description: 'Add a "Skip to main content" link as the first focusable element. This allows keyboard users to bypass repetitive navigation. WCAG 2.4.1.', file: fp, fix: null });
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
return findings;
|
|
2357
|
+
},
|
|
2358
|
+
});
|
|
2359
|
+
|
|
2360
|
+
// COMP-049: FERPA - Student records without access control
|
|
2361
|
+
rules.push({
|
|
2362
|
+
id: 'COMP-049', category: 'compliance', severity: 'high', confidence: 'likely', title: 'FERPA: Student data accessed without role-based access control',
|
|
2363
|
+
check({ files }) {
|
|
2364
|
+
const findings = [];
|
|
2365
|
+
const hasStudentData = [...files.values()].some(c => /student|enrollment|grade|transcript|course_record/i.test(c));
|
|
2366
|
+
const hasAccessControl = [...files.values()].some(c => /role.*admin|instructor.*permission|authorize|FERPA|educationRecord/i.test(c));
|
|
2367
|
+
if (hasStudentData && !hasAccessControl) {
|
|
2368
|
+
findings.push({ ruleId: 'COMP-049', category: 'compliance', severity: 'high', title: 'Student education records without FERPA access controls', description: 'FERPA requires restricting access to student education records. Implement role-based access control to ensure only authorized users (instructors, admins) can access student data.', fix: null });
|
|
2369
|
+
}
|
|
2370
|
+
return findings;
|
|
2371
|
+
},
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
// COMP-050: Missing security.txt file
|
|
2375
|
+
rules.push({
|
|
2376
|
+
id: 'COMP-050', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'No security.txt file — no responsible disclosure channel',
|
|
2377
|
+
check({ files }) {
|
|
2378
|
+
const findings = [];
|
|
2379
|
+
const hasWebServer = [...files.values()].some(c => /express|fastify|koa|hapi/i.test(c));
|
|
2380
|
+
const hasSecurityTxt = [...files.keys()].some(f => f.match(/security\.txt$/i));
|
|
2381
|
+
if (hasWebServer && !hasSecurityTxt) {
|
|
2382
|
+
findings.push({ ruleId: 'COMP-050', category: 'compliance', severity: 'low', title: 'No security.txt (RFC 9116) — no standard way for researchers to report vulnerabilities', description: 'Create /.well-known/security.txt following RFC 9116 to provide contact information for security researchers to report vulnerabilities responsibly.', fix: null });
|
|
2383
|
+
}
|
|
2384
|
+
return findings;
|
|
2385
|
+
},
|
|
2386
|
+
});
|
|
2387
|
+
|
|
2388
|
+
// COMP-051: Missing CCPA opt-out mechanism
|
|
2389
|
+
rules.push({
|
|
2390
|
+
id: 'COMP-051', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'CCPA: No "Do Not Sell" opt-out link found',
|
|
2391
|
+
check({ files }) {
|
|
2392
|
+
const findings = [];
|
|
2393
|
+
const hasCalifornia = [...files.keys()].some(f => /california|ccpa|donotsell|do-not-sell/i.test(f));
|
|
2394
|
+
const hasOptOut = [...files.values()].some(c => /do.not.sell|doNotSell|opt.out|optout/i.test(c));
|
|
2395
|
+
if (!hasCalifornia && !hasOptOut) {
|
|
2396
|
+
const jsFiles = [...files.keys()].filter(f => f.endsWith('.js') || f.endsWith('.jsx') || f.endsWith('.ts') || f.endsWith('.tsx'));
|
|
2397
|
+
if (jsFiles.length > 0) findings.push({ ruleId: 'COMP-051', category: 'compliance', severity: 'medium', title: 'CCPA: No "Do Not Sell My Personal Information" opt-out mechanism', description: 'California CCPA requires a clear "Do Not Sell My Personal Information" link for businesses that sell consumer data.', file: jsFiles[0], fix: null });
|
|
2398
|
+
}
|
|
2399
|
+
return findings;
|
|
2400
|
+
},
|
|
2401
|
+
});
|
|
2402
|
+
|
|
2403
|
+
// COMP-052: Missing accessibility for error messages
|
|
2404
|
+
rules.push({
|
|
2405
|
+
id: 'COMP-052', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'WCAG: Form error messages not associated with fields (aria-describedby)',
|
|
2406
|
+
check({ files }) {
|
|
2407
|
+
const findings = [];
|
|
2408
|
+
for (const [fp, c] of files) {
|
|
2409
|
+
if (!fp.match(/\.[jt]sx?$|\.html$/)) continue;
|
|
2410
|
+
const lines = c.split('\n');
|
|
2411
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2412
|
+
if (/error|invalid|required/i.test(lines[i]) && /<(?:p|span|div)[^>]*class[^>]*error/i.test(lines[i]) && !/aria-describedby|role="alert"/.test(lines[i])) {
|
|
2413
|
+
findings.push({ ruleId: 'COMP-052', category: 'compliance', severity: 'medium', title: 'Error message without aria-describedby or role="alert" — screen readers may miss it', description: 'Add role="alert" to error messages or use aria-describedby on the input field to link it to the error.', file: fp, line: i + 1, fix: null });
|
|
2414
|
+
break;
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
return findings;
|
|
2419
|
+
},
|
|
2420
|
+
});
|
|
2421
|
+
|
|
2422
|
+
// COMP-053: Hardcoded strings in UI (i18n violations)
|
|
2423
|
+
rules.push({
|
|
2424
|
+
id: 'COMP-053', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'Hardcoded UI strings without i18n support',
|
|
2425
|
+
check({ files }) {
|
|
2426
|
+
const findings = [];
|
|
2427
|
+
for (const [fp, c] of files) {
|
|
2428
|
+
if (!fp.match(/\.[jt]sx$/) || fp.includes('test') || fp.includes('spec')) continue;
|
|
2429
|
+
if (/<[a-z]+[^>]*>[A-Z][a-zA-Z\s]{5,}<\//.test(c) && !/i18n|useTranslation|intl|t\(|formatMessage/.test(c)) {
|
|
2430
|
+
findings.push({ ruleId: 'COMP-053', category: 'compliance', severity: 'low', title: 'Hardcoded UI text without internationalization — accessibility/compliance issue for global apps', description: 'Use an i18n library (react-intl, i18next) to externalize user-facing strings for localization.', file: fp, fix: null });
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
return findings;
|
|
2434
|
+
},
|
|
2435
|
+
});
|
|
2436
|
+
|
|
2437
|
+
// COMP-054: Missing data processing agreement indicator
|
|
2438
|
+
rules.push({
|
|
2439
|
+
id: 'COMP-054', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'GDPR: Third-party data processor used without DPA indication',
|
|
2440
|
+
check({ files }) {
|
|
2441
|
+
const findings = [];
|
|
2442
|
+
const thirdParties = ['segment', 'amplitude', 'mixpanel', 'intercom', 'hubspot', 'salesforce'];
|
|
2443
|
+
for (const [fp, c] of files) {
|
|
2444
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2445
|
+
for (const tp of thirdParties) {
|
|
2446
|
+
if (new RegExp(`require.*${tp}|from.*${tp}|${tp}\\.track|${tp}\.identify`, 'i').test(c) && !/dpa|data.processing|gdpr/i.test(c)) {
|
|
2447
|
+
findings.push({ ruleId: 'COMP-054', category: 'compliance', severity: 'medium', title: `GDPR: ${tp} integration without DPA documentation`, description: `Ensure a Data Processing Agreement is in place with ${tp} and document data flows for GDPR compliance.`, file: fp, fix: null });
|
|
2448
|
+
break;
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
return findings;
|
|
2453
|
+
},
|
|
2454
|
+
});
|
|
2455
|
+
|
|
2456
|
+
// COMP-055: Accessibility - missing lang attribute
|
|
2457
|
+
rules.push({
|
|
2458
|
+
id: 'COMP-055', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'WCAG 3.1.1: HTML document without lang attribute',
|
|
2459
|
+
check({ files }) {
|
|
2460
|
+
const findings = [];
|
|
2461
|
+
for (const [fp, c] of files) {
|
|
2462
|
+
if (!fp.endsWith('.html')) continue;
|
|
2463
|
+
if (/<html[^>]*>/i.test(c) && !/<html[^>]*lang=/i.test(c)) findings.push({ ruleId: 'COMP-055', category: 'compliance', severity: 'medium', title: 'HTML document without lang attribute — screen readers use wrong language', description: 'Add lang attribute to the <html> element: <html lang="en">.', file: fp, fix: null });
|
|
2464
|
+
}
|
|
2465
|
+
return findings;
|
|
2466
|
+
},
|
|
2467
|
+
});
|
|
2468
|
+
|
|
2469
|
+
// COMP-056: HIPAA - medical data without audit trail
|
|
2470
|
+
rules.push({
|
|
2471
|
+
id: 'COMP-056', category: 'compliance', severity: 'high', confidence: 'likely', title: 'HIPAA: Medical record access without audit logging',
|
|
2472
|
+
check({ files }) {
|
|
2473
|
+
const findings = [];
|
|
2474
|
+
for (const [fp, c] of files) {
|
|
2475
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2476
|
+
if (/(?:diagnosis|prescription|medical.record|patient.data|health.record)/i.test(c) && !/audit|log\.\w+|logger\.\w+/i.test(c)) {
|
|
2477
|
+
findings.push({ ruleId: 'COMP-056', category: 'compliance', severity: 'high', title: 'HIPAA: Medical data access without audit trail', description: 'Log all access to medical records with user identity, timestamp, and action for HIPAA audit requirements.', file: fp, fix: null });
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
return findings;
|
|
2481
|
+
},
|
|
2482
|
+
});
|
|
2483
|
+
|
|
2484
|
+
// COMP-057: PCI-DSS - card data in memory longer than needed
|
|
2485
|
+
rules.push({
|
|
2486
|
+
id: 'COMP-057', category: 'compliance', severity: 'high', confidence: 'likely', title: 'PCI-DSS: Card data stored in variable after processing',
|
|
2487
|
+
check({ files }) {
|
|
2488
|
+
const findings = [];
|
|
2489
|
+
for (const [fp, c] of files) {
|
|
2490
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2491
|
+
const lines = c.split('\n');
|
|
2492
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2493
|
+
if (/(?:cardNumber|card_number|creditCard|credit_card)\s*=/.test(lines[i]) && !/null|undefined|delete|void/.test(lines[i])) {
|
|
2494
|
+
const block = lines.slice(i, Math.min(lines.length, i + 20)).join('\n');
|
|
2495
|
+
if (!/delete\s+\w+|= null|= undefined/.test(block)) findings.push({ ruleId: 'COMP-057', category: 'compliance', severity: 'high', title: 'PCI-DSS: Card number stored in variable without explicit clearing', description: 'Clear card data from memory immediately after use. Set variable to null and avoid storing in state.', file: fp, line: i + 1, fix: null });
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
return findings;
|
|
2500
|
+
},
|
|
2501
|
+
});
|
|
2502
|
+
|
|
2503
|
+
// COMP-058: Missing cookie notice
|
|
2504
|
+
rules.push({
|
|
2505
|
+
id: 'COMP-058', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'GDPR/ePrivacy: No cookie consent mechanism found',
|
|
2506
|
+
check({ files }) {
|
|
2507
|
+
const findings = [];
|
|
2508
|
+
const hasCookieConsent = [...files.values()].some(c => /cookie.consent|cookieConsent|cookie-consent|cookieBanner|cookie.notice|CookiePolicy/i.test(c));
|
|
2509
|
+
const hasCookieSet = [...files.values()].some(c => /document\.cookie\s*=|res\.cookie\s*\(|setCookie/i.test(c));
|
|
2510
|
+
if (hasCookieSet && !hasCookieConsent) {
|
|
2511
|
+
const cookieFile = [...files.keys()].find(fp => /document\.cookie\s*=|res\.cookie\s*\(/.test(files.get(fp) || ''));
|
|
2512
|
+
if (cookieFile) findings.push({ ruleId: 'COMP-058', category: 'compliance', severity: 'medium', title: 'Cookies set without consent mechanism — GDPR/ePrivacy compliance required', description: 'Implement a cookie consent banner and only set non-essential cookies after user consent.', file: cookieFile, fix: null });
|
|
2513
|
+
}
|
|
2514
|
+
return findings;
|
|
2515
|
+
},
|
|
2516
|
+
});
|
|
2517
|
+
|
|
2518
|
+
// COMP-059: Accessibility - interactive elements not keyboard accessible
|
|
2519
|
+
rules.push({
|
|
2520
|
+
id: 'COMP-059', category: 'compliance', severity: 'high', confidence: 'likely', title: 'WCAG 2.1.1: onClick handler on non-interactive element without keyboard support',
|
|
2521
|
+
check({ files }) {
|
|
2522
|
+
const findings = [];
|
|
2523
|
+
for (const [fp, c] of files) {
|
|
2524
|
+
if (!fp.match(/\.[jt]sx?$/)) continue;
|
|
2525
|
+
const lines = c.split('\n');
|
|
2526
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2527
|
+
if (/<(?:div|span)[^>]*onClick/.test(lines[i]) && !/onKeyDown|onKeyPress|onKeyUp|role=|tabIndex/.test(lines[i])) {
|
|
2528
|
+
findings.push({ ruleId: 'COMP-059', category: 'compliance', severity: 'high', title: 'div/span with onClick but no keyboard handler — not keyboard accessible', description: 'Add onKeyDown handler, role="button", and tabIndex={0} to make clickable divs keyboard accessible.', file: fp, line: i + 1, fix: null });
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
return findings;
|
|
2533
|
+
},
|
|
2534
|
+
});
|
|
2535
|
+
|
|
2536
|
+
// COMP-060: SOC2 - missing data encryption in transit
|
|
2537
|
+
rules.push({
|
|
2538
|
+
id: 'COMP-060', category: 'compliance', severity: 'high', confidence: 'likely', title: 'SOC2: HTTP connection to external service without TLS',
|
|
2539
|
+
check({ files }) {
|
|
2540
|
+
const findings = [];
|
|
2541
|
+
for (const [fp, c] of files) {
|
|
2542
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2543
|
+
const lines = c.split('\n');
|
|
2544
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2545
|
+
if (/(?:fetch|axios\.get|axios\.post|http\.get|http\.request)\s*\(\s*['"]http:\/\//i.test(lines[i]) && !/localhost|127\.0\.0\.1|test|spec/.test(lines[i])) {
|
|
2546
|
+
findings.push({ ruleId: 'COMP-060', category: 'compliance', severity: 'high', title: 'HTTP (non-TLS) connection to external endpoint — data in transit unencrypted', description: 'Use HTTPS for all external service connections to ensure data in transit encryption.', file: fp, line: i + 1, fix: null });
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
return findings;
|
|
2551
|
+
},
|
|
2552
|
+
});
|
|
2553
|
+
|
|
2554
|
+
// COMP-061: COPPA - no age gate on sign-up
|
|
2555
|
+
rules.push({
|
|
2556
|
+
id: 'COMP-061', category: 'compliance', severity: 'high', confidence: 'likely', title: 'COPPA: User registration without age verification',
|
|
2557
|
+
check({ files }) {
|
|
2558
|
+
const findings = [];
|
|
2559
|
+
for (const [fp, c] of files) {
|
|
2560
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts') && !fp.match(/\.[jt]sx?$/)) continue;
|
|
2561
|
+
if (/register|signup|sign.up/i.test(c) && !/age|birthdate|birth.date|dob|dateOfBirth|coppa|13/i.test(c)) {
|
|
2562
|
+
findings.push({ ruleId: 'COMP-061', category: 'compliance', severity: 'high', title: 'COPPA: Registration form without age verification', description: 'If your service may be used by children under 13, implement COPPA-compliant age verification.', file: fp, fix: null });
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
return findings;
|
|
2566
|
+
},
|
|
2567
|
+
});
|
|
2568
|
+
|
|
2569
|
+
// COMP-062: Missing Terms of Service acceptance tracking
|
|
2570
|
+
rules.push({
|
|
2571
|
+
id: 'COMP-062', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'User registration without Terms of Service acceptance',
|
|
2572
|
+
check({ files }) {
|
|
2573
|
+
const findings = [];
|
|
2574
|
+
for (const [fp, c] of files) {
|
|
2575
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2576
|
+
if (/(?:router|app)\.post\s*\([^)]*(?:register|signup)/i.test(c) && !/tos|terms|termsAccepted|terms_accepted|acceptTerms/i.test(c)) {
|
|
2577
|
+
findings.push({ ruleId: 'COMP-062', category: 'compliance', severity: 'medium', title: 'Registration endpoint without ToS acceptance tracking', description: 'Record user acceptance of Terms of Service with timestamp for legal compliance.', file: fp, fix: null });
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
return findings;
|
|
2581
|
+
},
|
|
2582
|
+
});
|
|
2583
|
+
|
|
2584
|
+
// COMP-063: GDPR - no data export endpoint
|
|
2585
|
+
rules.push({
|
|
2586
|
+
id: 'COMP-063', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'GDPR Article 20: No data portability/export endpoint',
|
|
2587
|
+
check({ files }) {
|
|
2588
|
+
const findings = [];
|
|
2589
|
+
const hasExport = [...files.keys()].some(f => /export|download|portab/i.test(f)) ||
|
|
2590
|
+
[...files.values()].some(c => /(?:router|app)\.get\s*\([^)]*(?:export|download|portab)/i.test(c));
|
|
2591
|
+
if (!hasExport) {
|
|
2592
|
+
const routerFiles = [...files.keys()].filter(f => f.endsWith('.js') || f.endsWith('.ts'));
|
|
2593
|
+
if (routerFiles.length > 0) findings.push({ ruleId: 'COMP-063', category: 'compliance', severity: 'medium', title: 'GDPR Article 20: No data export/portability endpoint found', description: 'Implement a data export endpoint allowing users to download their personal data in a portable format.', file: routerFiles[0], fix: null });
|
|
2594
|
+
}
|
|
2595
|
+
return findings;
|
|
2596
|
+
},
|
|
2597
|
+
});
|
|
2598
|
+
|
|
2599
|
+
// COMP-064: HIPAA - PHI in debug logs
|
|
2600
|
+
rules.push({
|
|
2601
|
+
id: 'COMP-064', category: 'compliance', severity: 'critical', confidence: 'definite', title: 'HIPAA: PHI fields may appear in debug logs',
|
|
2602
|
+
check({ files }) {
|
|
2603
|
+
const findings = [];
|
|
2604
|
+
for (const [fp, c] of files) {
|
|
2605
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts')) continue;
|
|
2606
|
+
const lines = c.split('\n');
|
|
2607
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2608
|
+
if (/console\.(?:log|debug|info)\s*\([^)]*(?:patient|diagnosis|medication|ssn|dob|dateOfBirth)/i.test(lines[i])) {
|
|
2609
|
+
findings.push({ ruleId: 'COMP-064', category: 'compliance', severity: 'critical', title: 'HIPAA: PHI logged to console — PHI must not appear in logs', description: 'Remove PHI from log statements. Mask or omit health information from all log output.', file: fp, line: i + 1, fix: null });
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
return findings;
|
|
2614
|
+
},
|
|
2615
|
+
});
|
|
2616
|
+
|
|
2617
|
+
// COMP-065: ISO 27001 - no vulnerability disclosure policy
|
|
2618
|
+
rules.push({
|
|
2619
|
+
id: 'COMP-065', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'ISO 27001: No SECURITY.md or vulnerability disclosure policy',
|
|
2620
|
+
check({ files }) {
|
|
2621
|
+
const findings = [];
|
|
2622
|
+
const hasSecurity = [...files.keys()].some(f => /SECURITY\.md|security\.txt/i.test(f));
|
|
2623
|
+
if (!hasSecurity) {
|
|
2624
|
+
const pkgJson = [...files.keys()].find(f => f.endsWith('package.json'));
|
|
2625
|
+
if (pkgJson) findings.push({ ruleId: 'COMP-065', category: 'compliance', severity: 'low', title: 'No SECURITY.md or security.txt — no vulnerability disclosure channel', description: 'Add a SECURITY.md file with instructions for reporting security vulnerabilities responsibly.', file: pkgJson, fix: null });
|
|
2626
|
+
}
|
|
2627
|
+
return findings;
|
|
2628
|
+
},
|
|
2629
|
+
});
|
|
2630
|
+
|
|
2631
|
+
// COMP-066: Missing content security policy
|
|
2632
|
+
rules.push({
|
|
2633
|
+
id: 'COMP-066', category: 'compliance', severity: 'high', confidence: 'likely', title: 'No Content-Security-Policy header configured',
|
|
2634
|
+
check({ files }) {
|
|
2635
|
+
const findings = [];
|
|
2636
|
+
const hasCSP = [...files.values()].some(c => /Content-Security-Policy|contentSecurityPolicy|helmet\s*\(/i.test(c));
|
|
2637
|
+
if (!hasCSP) {
|
|
2638
|
+
const serverFiles = [...files.keys()].filter(f => /server|app|index/.test(f) && (f.endsWith('.js') || f.endsWith('.ts')));
|
|
2639
|
+
if (serverFiles.length > 0) findings.push({ ruleId: 'COMP-066', category: 'compliance', severity: 'high', title: 'No Content-Security-Policy header — XSS attacks not mitigated', description: 'Configure CSP headers using helmet or manually setting Content-Security-Policy response header.', file: serverFiles[0], fix: null });
|
|
2640
|
+
}
|
|
2641
|
+
return findings;
|
|
2642
|
+
},
|
|
2643
|
+
});
|
|
2644
|
+
|
|
2645
|
+
// COMP-067: GDPR - sensitive data used for analytics without consent
|
|
2646
|
+
rules.push({
|
|
2647
|
+
id: 'COMP-067', category: 'compliance', severity: 'high', confidence: 'likely', title: 'GDPR: Email/name sent to analytics without consent check',
|
|
2648
|
+
check({ files }) {
|
|
2649
|
+
const findings = [];
|
|
2650
|
+
for (const [fp, c] of files) {
|
|
2651
|
+
if (!fp.endsWith('.js') && !fp.endsWith('.ts') && !fp.match(/\.[jt]sx?$/)) continue;
|
|
2652
|
+
if (/(?:analytics|gtag|ga|segment)\s*\.\s*(?:track|identify)\s*\([^)]*(?:email|name|phone)/i.test(c) && !/consent|hasConsent|gdpr|analyticsEnabled/i.test(c)) {
|
|
2653
|
+
findings.push({ ruleId: 'COMP-067', category: 'compliance', severity: 'high', title: 'PII sent to analytics without consent check — GDPR violation', description: 'Only send personal data to analytics services after obtaining explicit user consent.', file: fp, fix: null });
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
return findings;
|
|
2657
|
+
},
|
|
2658
|
+
});
|
|
2659
|
+
|
|
2660
|
+
// COMP-068: Missing WCAG focus indicator
|
|
2661
|
+
rules.push({
|
|
2662
|
+
id: 'COMP-068', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'WCAG 2.4.7: CSS outline:none removes keyboard focus indicator',
|
|
2663
|
+
check({ files }) {
|
|
2664
|
+
const findings = [];
|
|
2665
|
+
for (const [fp, c] of files) {
|
|
2666
|
+
if (!fp.endsWith('.css') && !fp.endsWith('.scss') && !fp.endsWith('.sass')) continue;
|
|
2667
|
+
if (/outline\s*:\s*none|outline\s*:\s*0/.test(c) && !/focus-visible/.test(c)) findings.push({ ruleId: 'COMP-068', category: 'compliance', severity: 'medium', title: 'CSS outline:none removes focus indicator — keyboard users cannot see focus', description: 'Replace outline:none with :focus-visible styles to maintain keyboard navigation accessibility.', file: fp, fix: null });
|
|
2668
|
+
}
|
|
2669
|
+
return findings;
|
|
2670
|
+
},
|
|
2671
|
+
});
|
|
2672
|
+
|
|
2673
|
+
// COMP-069: SOC2 - no log retention policy
|
|
2674
|
+
rules.push({
|
|
2675
|
+
id: 'COMP-069', category: 'compliance', severity: 'medium', confidence: 'likely', title: 'SOC2: No log retention configuration found',
|
|
2676
|
+
check({ files }) {
|
|
2677
|
+
const findings = [];
|
|
2678
|
+
const hasRetention = [...files.values()].some(c => /retention|log.*ttl|logRetention|retentionDays/i.test(c)) ||
|
|
2679
|
+
[...files.keys()].some(f => /cloudwatch|logging\.ya?ml|log4j|logback/.test(f));
|
|
2680
|
+
if (!hasRetention) {
|
|
2681
|
+
const tfFiles = [...files.keys()].filter(f => f.endsWith('.tf'));
|
|
2682
|
+
if (tfFiles.length > 0) findings.push({ ruleId: 'COMP-069', category: 'compliance', severity: 'medium', title: 'SOC2: No log retention policy configured', description: 'Configure log retention periods (typically 90-365 days) to meet SOC2 audit evidence requirements.', file: tfFiles[0], fix: null });
|
|
2683
|
+
}
|
|
2684
|
+
return findings;
|
|
2685
|
+
},
|
|
2686
|
+
});
|
|
2687
|
+
|
|
2688
|
+
// COMP-070: Missing robots.txt to protect sensitive paths
|
|
2689
|
+
rules.push({
|
|
2690
|
+
id: 'COMP-070', category: 'compliance', severity: 'low', confidence: 'suggestion', title: 'No robots.txt to restrict search engine crawling of sensitive paths',
|
|
2691
|
+
check({ files }) {
|
|
2692
|
+
const findings = [];
|
|
2693
|
+
const hasRobots = [...files.keys()].some(f => /robots\.txt/.test(f));
|
|
2694
|
+
if (!hasRobots) {
|
|
2695
|
+
const pkgJson = [...files.keys()].find(f => f.endsWith('package.json'));
|
|
2696
|
+
if (pkgJson) findings.push({ ruleId: 'COMP-070', category: 'compliance', severity: 'low', title: 'No robots.txt file — admin/api paths may be indexed by search engines', description: 'Create a robots.txt file to prevent search engines from indexing sensitive application paths.', file: pkgJson, fix: null });
|
|
2697
|
+
}
|
|
2698
|
+
return findings;
|
|
2699
|
+
},
|
|
2700
|
+
});
|
|
2701
|
+
|
|
2702
|
+
// Aggregate all compliance rules (after all rules.push() calls above)
|
|
2703
|
+
const allComplianceRules = [
|
|
2704
|
+
...rules,
|
|
2705
|
+
...healthcareRules,
|
|
2706
|
+
...regionalEuRules,
|
|
2707
|
+
...regionalIntlRules,
|
|
2708
|
+
...educationRules,
|
|
2709
|
+
...financialRules,
|
|
2710
|
+
...frameworksRules,
|
|
2711
|
+
...accessibilityExtRules,
|
|
2712
|
+
];
|
|
2713
|
+
|
|
2714
|
+
export default allComplianceRules;
|