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,468 @@
|
|
|
1
|
+
function isSourceFile(f) { return ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'].some(e => f.endsWith(e)); }
|
|
2
|
+
function isWebFile(f) { return ['.html', '.htm', '.jsx', '.tsx', '.vue', '.svelte'].some(e => f.endsWith(e)); }
|
|
3
|
+
|
|
4
|
+
const rules = [
|
|
5
|
+
// --- Section 508 Rules ---
|
|
6
|
+
|
|
7
|
+
// COMP-SEC508-001: Video without captions
|
|
8
|
+
{
|
|
9
|
+
id: 'COMP-SEC508-001',
|
|
10
|
+
category: 'compliance',
|
|
11
|
+
severity: 'high',
|
|
12
|
+
confidence: 'likely',
|
|
13
|
+
title: 'Video Without Captions',
|
|
14
|
+
check({ files }) {
|
|
15
|
+
const findings = [];
|
|
16
|
+
for (const [filepath, content] of files.entries()) {
|
|
17
|
+
if (!isWebFile(filepath)) continue;
|
|
18
|
+
const lines = content.split('\n');
|
|
19
|
+
for (let i = 0; i < lines.length; i++) {
|
|
20
|
+
const line = lines[i];
|
|
21
|
+
|
|
22
|
+
// Detect <video> tags without an associated <track> element
|
|
23
|
+
if (/<video[\s>]/i.test(line)) {
|
|
24
|
+
// Look ahead up to 15 lines for a closing </video> and a <track element
|
|
25
|
+
const block = lines.slice(i, i + 15).join('\n');
|
|
26
|
+
const hasTrack = /<track\s/i.test(block);
|
|
27
|
+
const hasCaptionAttr = /captions|subtitles/i.test(block);
|
|
28
|
+
if (!hasTrack && !hasCaptionAttr) {
|
|
29
|
+
findings.push({
|
|
30
|
+
ruleId: 'COMP-SEC508-001', category: 'compliance', severity: 'high',
|
|
31
|
+
title: 'Video element without captions or subtitles track',
|
|
32
|
+
description: 'Section 508 requires synchronized captions for video content. Add a <track kind="captions"> element inside the <video> tag.',
|
|
33
|
+
file: filepath, line: i + 1, fix: null,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Detect iframe/embed pointing to video services without caption params
|
|
39
|
+
if (/<(?:iframe|embed)\s[^>]*(?:youtube|vimeo|dailymotion|wistia|vidyard)/i.test(line)) {
|
|
40
|
+
const hasCaption = /cc_load_policy|texttrack|subtitles|captions|cc=1/i.test(line);
|
|
41
|
+
if (!hasCaption) {
|
|
42
|
+
findings.push({
|
|
43
|
+
ruleId: 'COMP-SEC508-001', category: 'compliance', severity: 'high',
|
|
44
|
+
title: 'Embedded video player without captions enabled',
|
|
45
|
+
description: 'Section 508 requires captions for video. Enable captions via the embed URL parameters (e.g., cc_load_policy=1 for YouTube).',
|
|
46
|
+
file: filepath, line: i + 1, fix: null,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Detect video player libraries in source files without caption configuration
|
|
53
|
+
if (isSourceFile(filepath)) {
|
|
54
|
+
const usesVideoLib = /(?:video\.js|videojs|ReactPlayer|plyr|hls\.js|dash\.js|JWPlayer|jwplayer)/i.test(content);
|
|
55
|
+
if (usesVideoLib) {
|
|
56
|
+
const hasCaptionConfig = /tracks|captions|subtitles|textTracks|addTextTrack|cc|closedCaptions/i.test(content);
|
|
57
|
+
if (!hasCaptionConfig) {
|
|
58
|
+
findings.push({
|
|
59
|
+
ruleId: 'COMP-SEC508-001', category: 'compliance', severity: 'high',
|
|
60
|
+
title: 'Video player library used without caption configuration',
|
|
61
|
+
description: 'Section 508 requires synchronized captions. Configure caption/subtitle tracks in your video player setup.',
|
|
62
|
+
file: filepath, line: 1, fix: null,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return findings;
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// COMP-SEC508-002: Audio without text alternative
|
|
73
|
+
{
|
|
74
|
+
id: 'COMP-SEC508-002',
|
|
75
|
+
category: 'compliance',
|
|
76
|
+
severity: 'high',
|
|
77
|
+
confidence: 'likely',
|
|
78
|
+
title: 'Audio Without Text Alternative',
|
|
79
|
+
check({ files }) {
|
|
80
|
+
const findings = [];
|
|
81
|
+
for (const [filepath, content] of files.entries()) {
|
|
82
|
+
if (!isWebFile(filepath)) continue;
|
|
83
|
+
const lines = content.split('\n');
|
|
84
|
+
for (let i = 0; i < lines.length; i++) {
|
|
85
|
+
const line = lines[i];
|
|
86
|
+
|
|
87
|
+
if (/<audio[\s>]/i.test(line)) {
|
|
88
|
+
// Look for transcript references in the surrounding area (30 lines after)
|
|
89
|
+
const surroundingBlock = lines.slice(i, i + 30).join('\n');
|
|
90
|
+
const hasTranscript = /transcript|aria-describedby|aria-description|text.?alternative|\.vtt|\.srt/i.test(surroundingBlock);
|
|
91
|
+
if (!hasTranscript) {
|
|
92
|
+
// Also check the whole file for a transcript section
|
|
93
|
+
const fileHasTranscript = /transcript|text.?alternative|aria-describedby/i.test(content);
|
|
94
|
+
if (!fileHasTranscript) {
|
|
95
|
+
findings.push({
|
|
96
|
+
ruleId: 'COMP-SEC508-002', category: 'compliance', severity: 'high',
|
|
97
|
+
title: 'Audio element without transcript or text alternative',
|
|
98
|
+
description: 'Section 508 requires a text transcript for audio-only content. Provide a transcript link or aria-describedby reference near the audio element.',
|
|
99
|
+
file: filepath, line: i + 1, fix: null,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return findings;
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// COMP-SEC508-003: Time-based content without pause/stop
|
|
111
|
+
{
|
|
112
|
+
id: 'COMP-SEC508-003',
|
|
113
|
+
category: 'compliance',
|
|
114
|
+
severity: 'medium',
|
|
115
|
+
confidence: 'likely',
|
|
116
|
+
title: 'Auto-Playing Content Without Pause/Stop Control',
|
|
117
|
+
check({ files }) {
|
|
118
|
+
const findings = [];
|
|
119
|
+
for (const [filepath, content] of files.entries()) {
|
|
120
|
+
if (!isWebFile(filepath) && !isSourceFile(filepath)) continue;
|
|
121
|
+
const lines = content.split('\n');
|
|
122
|
+
for (let i = 0; i < lines.length; i++) {
|
|
123
|
+
const line = lines[i];
|
|
124
|
+
|
|
125
|
+
// Detect autoplay on video/audio elements
|
|
126
|
+
if (/<(?:video|audio)\s[^>]*autoplay/i.test(line)) {
|
|
127
|
+
const surroundingBlock = lines.slice(Math.max(0, i - 10), i + 20).join('\n');
|
|
128
|
+
const hasPauseControl = /pause|stop|controls[\s>=]/i.test(surroundingBlock);
|
|
129
|
+
if (!hasPauseControl) {
|
|
130
|
+
findings.push({
|
|
131
|
+
ruleId: 'COMP-SEC508-003', category: 'compliance', severity: 'medium',
|
|
132
|
+
title: 'Auto-playing media without pause/stop controls',
|
|
133
|
+
description: 'Section 508 requires a mechanism to pause, stop, or hide auto-playing content. Add the "controls" attribute or provide custom pause/stop buttons.',
|
|
134
|
+
file: filepath, line: i + 1, fix: null,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Detect auto-playing carousels/slideshows/animations in source files
|
|
141
|
+
if (isSourceFile(filepath)) {
|
|
142
|
+
const hasAutoCarousel = /autoplay|autoPlay|auto.?slide|auto.?scroll|auto.?rotate|setInterval\s*\([^)]*(?:slide|carousel|next|rotate)/i.test(content);
|
|
143
|
+
if (hasAutoCarousel) {
|
|
144
|
+
const hasPause = /pause|stop|clearInterval|isPaused|isPlaying|togglePlay|onMouseEnter/i.test(content);
|
|
145
|
+
if (!hasPause) {
|
|
146
|
+
findings.push({
|
|
147
|
+
ruleId: 'COMP-SEC508-003', category: 'compliance', severity: 'medium',
|
|
148
|
+
title: 'Auto-advancing content without pause mechanism',
|
|
149
|
+
description: 'Section 508 requires users be able to pause, stop, or hide moving/auto-updating content. Add pause/stop controls to carousels, slideshows, or animations.',
|
|
150
|
+
file: filepath, line: 1, fix: null,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return findings;
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
// COMP-SEC508-004: Missing skip navigation
|
|
161
|
+
{
|
|
162
|
+
id: 'COMP-SEC508-004',
|
|
163
|
+
category: 'compliance',
|
|
164
|
+
severity: 'high',
|
|
165
|
+
confidence: 'likely',
|
|
166
|
+
title: 'Missing Skip Navigation Link',
|
|
167
|
+
check({ files }) {
|
|
168
|
+
const findings = [];
|
|
169
|
+
const webFiles = [...files.entries()].filter(([f]) => isWebFile(f));
|
|
170
|
+
if (webFiles.length === 0) return findings;
|
|
171
|
+
|
|
172
|
+
// Only check files that look like full pages or layout components
|
|
173
|
+
for (const [filepath, content] of webFiles) {
|
|
174
|
+
const isPageLayout = /<(?:html|head|body|header|nav|Header|Navigation|Layout|AppLayout)/i.test(content) ||
|
|
175
|
+
/function\s+(?:Layout|App|Header|Page)\b/i.test(content) ||
|
|
176
|
+
/(?:export\s+default\s+)?(?:class|const)\s+(?:Layout|App|Header|Page)\b/i.test(content);
|
|
177
|
+
|
|
178
|
+
if (!isPageLayout) continue;
|
|
179
|
+
|
|
180
|
+
const hasNav = /<nav[\s>]|role=["']navigation/i.test(content);
|
|
181
|
+
if (!hasNav) continue;
|
|
182
|
+
|
|
183
|
+
const hasSkipLink = /skip.?(?:to|nav|content|main)|skiplink|skip.?link|#main.?content|#content|href=["']#main/i.test(content);
|
|
184
|
+
if (!hasSkipLink) {
|
|
185
|
+
findings.push({
|
|
186
|
+
ruleId: 'COMP-SEC508-004', category: 'compliance', severity: 'high',
|
|
187
|
+
title: 'Page layout with navigation but no skip navigation link',
|
|
188
|
+
description: 'Section 508 requires a mechanism to bypass repeated blocks of navigation. Add a "Skip to main content" link as the first focusable element.',
|
|
189
|
+
file: filepath, line: 1, fix: null,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return findings;
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
// COMP-SEC508-005: Documents without accessibility
|
|
198
|
+
{
|
|
199
|
+
id: 'COMP-SEC508-005',
|
|
200
|
+
category: 'compliance',
|
|
201
|
+
severity: 'medium',
|
|
202
|
+
confidence: 'likely',
|
|
203
|
+
title: 'PDF Generation Without Accessibility Options',
|
|
204
|
+
check({ files }) {
|
|
205
|
+
const findings = [];
|
|
206
|
+
for (const [filepath, content] of files.entries()) {
|
|
207
|
+
if (!isSourceFile(filepath)) continue;
|
|
208
|
+
|
|
209
|
+
// jsPDF without accessibility
|
|
210
|
+
if (/jsPDF|new\s+jspdf/i.test(content)) {
|
|
211
|
+
const hasA11y = /setDocumentProperties|setLanguage|tagged|structTree|markContent|addMetadata|accessibility/i.test(content);
|
|
212
|
+
if (!hasA11y) {
|
|
213
|
+
findings.push({
|
|
214
|
+
ruleId: 'COMP-SEC508-005', category: 'compliance', severity: 'medium',
|
|
215
|
+
title: 'jsPDF used without accessibility/tagging options',
|
|
216
|
+
description: 'Section 508 requires accessible (tagged) PDFs. Use jsPDF accessibility plugins or set document properties (title, language, structure tags).',
|
|
217
|
+
file: filepath, line: 1, fix: null,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// PDFKit without accessibility
|
|
223
|
+
if (/require\s*\(\s*['"]pdfkit['"]\)|from\s+['"]pdfkit['"]/i.test(content)) {
|
|
224
|
+
const hasA11y = /tagged|markContent|structParentTree|addStructure|\.info\s*=|pdfVersion.*['"]1\.[5-9]/i.test(content);
|
|
225
|
+
if (!hasA11y) {
|
|
226
|
+
findings.push({
|
|
227
|
+
ruleId: 'COMP-SEC508-005', category: 'compliance', severity: 'medium',
|
|
228
|
+
title: 'PDFKit used without tagged PDF or accessibility metadata',
|
|
229
|
+
description: 'Section 508 requires accessible PDFs. Set document metadata (title, language) and consider generating tagged PDF structure.',
|
|
230
|
+
file: filepath, line: 1, fix: null,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Puppeteer PDF generation without accessibility
|
|
236
|
+
if (/\.pdf\s*\(|page\.pdf\s*\(/i.test(content) && /puppeteer|playwright/i.test(content)) {
|
|
237
|
+
const hasA11y = /tagged|accessib|displayHeaderFooter|aria|--enable-tagged-pdf/i.test(content);
|
|
238
|
+
if (!hasA11y) {
|
|
239
|
+
findings.push({
|
|
240
|
+
ruleId: 'COMP-SEC508-005', category: 'compliance', severity: 'medium',
|
|
241
|
+
title: 'Browser-based PDF generation without accessibility options',
|
|
242
|
+
description: 'Section 508 requires accessible PDFs. When generating PDFs with Puppeteer/Playwright, use the tagged option and ensure the source HTML is accessible.',
|
|
243
|
+
file: filepath, line: 1, fix: null,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return findings;
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
// --- ADA Digital Compliance Rules ---
|
|
253
|
+
|
|
254
|
+
// COMP-ADA-001: No accessibility statement
|
|
255
|
+
{
|
|
256
|
+
id: 'COMP-ADA-001',
|
|
257
|
+
category: 'compliance',
|
|
258
|
+
severity: 'medium',
|
|
259
|
+
confidence: 'likely',
|
|
260
|
+
title: 'No Accessibility Statement',
|
|
261
|
+
check({ files }) {
|
|
262
|
+
const findings = [];
|
|
263
|
+
const fileNames = [...files.keys()];
|
|
264
|
+
const fileContents = [...files.values()];
|
|
265
|
+
|
|
266
|
+
// Only flag for web apps (has HTML or component files)
|
|
267
|
+
const isWebApp = fileNames.some(f => isWebFile(f)) ||
|
|
268
|
+
fileContents.some(c => /<html|<head|ReactDOM|createApp|createRoot/i.test(c));
|
|
269
|
+
if (!isWebApp) return findings;
|
|
270
|
+
|
|
271
|
+
const hasA11yPage = fileNames.some(f =>
|
|
272
|
+
/accessib/i.test(f) || /a11y/i.test(f)
|
|
273
|
+
);
|
|
274
|
+
const hasA11yLink = fileContents.some(c =>
|
|
275
|
+
/accessibility.?statement|accessibility.?policy|\/accessibility|\/a11y/i.test(c)
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
if (!hasA11yPage && !hasA11yLink) {
|
|
279
|
+
findings.push({
|
|
280
|
+
ruleId: 'COMP-ADA-001', category: 'compliance', severity: 'medium',
|
|
281
|
+
title: 'No accessibility statement page detected',
|
|
282
|
+
description: 'ADA best practice requires an accessibility statement describing your commitment to accessibility, conformance level, and contact information for feedback.',
|
|
283
|
+
fix: null,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return findings;
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
// COMP-ADA-002: Contact forms without accessible alternatives
|
|
291
|
+
{
|
|
292
|
+
id: 'COMP-ADA-002',
|
|
293
|
+
category: 'compliance',
|
|
294
|
+
severity: 'medium',
|
|
295
|
+
confidence: 'likely',
|
|
296
|
+
title: 'Contact Form Without Accessible Alternatives',
|
|
297
|
+
check({ files }) {
|
|
298
|
+
const findings = [];
|
|
299
|
+
for (const [filepath, content] of files.entries()) {
|
|
300
|
+
if (!isWebFile(filepath)) continue;
|
|
301
|
+
|
|
302
|
+
// Detect contact forms
|
|
303
|
+
const isContactForm = /contact.?form|contact.?us|<form[^>]*(?:contact|inquiry|feedback)/i.test(content);
|
|
304
|
+
if (!isContactForm) continue;
|
|
305
|
+
|
|
306
|
+
const hasAlternative = /(?:phone|telephone|tel:|call\s+us|tty|tdd|relay\s+service|email\s+us|mail\s+to|mailto:)/i.test(content);
|
|
307
|
+
if (!hasAlternative) {
|
|
308
|
+
findings.push({
|
|
309
|
+
ruleId: 'COMP-ADA-002', category: 'compliance', severity: 'medium',
|
|
310
|
+
title: 'Contact form without alternative contact methods',
|
|
311
|
+
description: 'ADA compliance recommends providing alternative contact methods (phone, email, TTY/TDD) alongside web forms so users with disabilities have multiple ways to reach you.',
|
|
312
|
+
file: filepath, line: 1, fix: null,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return findings;
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
// COMP-ADA-003: CAPTCHA without accessible alternative
|
|
321
|
+
{
|
|
322
|
+
id: 'COMP-ADA-003',
|
|
323
|
+
category: 'compliance',
|
|
324
|
+
severity: 'high',
|
|
325
|
+
confidence: 'likely',
|
|
326
|
+
title: 'CAPTCHA Without Accessible Alternative',
|
|
327
|
+
check({ files }) {
|
|
328
|
+
const findings = [];
|
|
329
|
+
for (const [filepath, content] of files.entries()) {
|
|
330
|
+
if (!isWebFile(filepath) && !isSourceFile(filepath)) continue;
|
|
331
|
+
|
|
332
|
+
const lines = content.split('\n');
|
|
333
|
+
for (let i = 0; i < lines.length; i++) {
|
|
334
|
+
const line = lines[i];
|
|
335
|
+
|
|
336
|
+
// Detect reCAPTCHA, hCaptcha, or generic CAPTCHA usage
|
|
337
|
+
const hasCaptcha = /recaptcha|hcaptcha|g-recaptcha|h-captcha|captcha/i.test(line);
|
|
338
|
+
if (!hasCaptcha) continue;
|
|
339
|
+
|
|
340
|
+
// Skip comment-only lines
|
|
341
|
+
if (/^\s*(?:\/\/|\/\*|\*|<!--)/.test(line)) continue;
|
|
342
|
+
|
|
343
|
+
// Check surrounding context for accessible alternatives
|
|
344
|
+
const block = lines.slice(Math.max(0, i - 5), i + 15).join('\n');
|
|
345
|
+
const hasAccessibleAlt = /audio|accessible|a11y|aria-|data-sitekey[^>]*invisible|data-size=["']invisible|challenge.?type.*audio|fallback|alternative/i.test(block);
|
|
346
|
+
|
|
347
|
+
// reCAPTCHA v3 and invisible reCAPTCHA are more accessible by design
|
|
348
|
+
const isAccessibleVersion = /recaptcha\/api.*render=|grecaptcha\.execute\(|captcha_invisible|invisible.?recaptcha|v3/i.test(block);
|
|
349
|
+
|
|
350
|
+
if (!hasAccessibleAlt && !isAccessibleVersion) {
|
|
351
|
+
findings.push({
|
|
352
|
+
ruleId: 'COMP-ADA-003', category: 'compliance', severity: 'high',
|
|
353
|
+
title: 'CAPTCHA without accessible alternative',
|
|
354
|
+
description: 'Visual CAPTCHAs create barriers for users with disabilities. Use reCAPTCHA v3 (invisible), provide an audio alternative, or offer an accessible fallback like email verification.',
|
|
355
|
+
file: filepath, line: i + 1, fix: null,
|
|
356
|
+
});
|
|
357
|
+
break; // One finding per file is sufficient
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return findings;
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
// COMP-ADA-004: Dynamic content without ARIA live regions
|
|
366
|
+
{
|
|
367
|
+
id: 'COMP-ADA-004',
|
|
368
|
+
category: 'compliance',
|
|
369
|
+
severity: 'medium',
|
|
370
|
+
confidence: 'likely',
|
|
371
|
+
title: 'Dynamic Content Without ARIA Live Region',
|
|
372
|
+
check({ files }) {
|
|
373
|
+
const findings = [];
|
|
374
|
+
for (const [filepath, content] of files.entries()) {
|
|
375
|
+
if (!isSourceFile(filepath) && !isWebFile(filepath)) continue;
|
|
376
|
+
|
|
377
|
+
// Detect dynamic DOM updates in source files
|
|
378
|
+
if (isSourceFile(filepath)) {
|
|
379
|
+
const hasDynamicUpdate = /innerHTML\s*[=+]|\.textContent\s*=|\.insertAdjacentHTML|\.append\(|setState\(|\.value\s*=.*(?:message|error|success|notification|toast|alert|status|result)/i.test(content);
|
|
380
|
+
if (!hasDynamicUpdate) continue;
|
|
381
|
+
|
|
382
|
+
// Check for notification/toast/alert patterns without ARIA
|
|
383
|
+
const hasNotificationPattern = /toast|notification|snackbar|alert.?message|flash.?message|status.?message|error.?message|success.?message/i.test(content);
|
|
384
|
+
if (!hasNotificationPattern) continue;
|
|
385
|
+
|
|
386
|
+
const hasAriaLive = /aria-live|role=["'](?:alert|status|log|timer|marquee)["']|ariaLive|role:\s*["'](?:alert|status)/i.test(content);
|
|
387
|
+
if (!hasAriaLive) {
|
|
388
|
+
findings.push({
|
|
389
|
+
ruleId: 'COMP-ADA-004', category: 'compliance', severity: 'medium',
|
|
390
|
+
title: 'Dynamic notification/status content without ARIA live region',
|
|
391
|
+
description: 'ADA compliance requires dynamic content updates (toasts, alerts, status messages) to be announced to screen readers. Use aria-live="polite" or role="alert" on the container.',
|
|
392
|
+
file: filepath, line: 1, fix: null,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Check web template files for dynamic areas lacking aria-live
|
|
398
|
+
if (isWebFile(filepath)) {
|
|
399
|
+
const hasNotificationArea = /class=["'][^"']*(?:toast|notification|snackbar|alert|flash|message|status)[^"']*["']/i.test(content);
|
|
400
|
+
if (!hasNotificationArea) continue;
|
|
401
|
+
|
|
402
|
+
const hasAriaLive = /aria-live|role=["'](?:alert|status|log)["']/i.test(content);
|
|
403
|
+
if (!hasAriaLive) {
|
|
404
|
+
findings.push({
|
|
405
|
+
ruleId: 'COMP-ADA-004', category: 'compliance', severity: 'medium',
|
|
406
|
+
title: 'Notification/alert container without ARIA live region',
|
|
407
|
+
description: 'Screen readers cannot detect visual-only dynamic updates. Add aria-live="polite" for non-urgent updates or role="alert" for important notifications.',
|
|
408
|
+
file: filepath, line: 1, fix: null,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return findings;
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
// COMP-ADA-005: Mobile app without screen reader support
|
|
418
|
+
{
|
|
419
|
+
id: 'COMP-ADA-005',
|
|
420
|
+
category: 'compliance',
|
|
421
|
+
severity: 'high',
|
|
422
|
+
confidence: 'likely',
|
|
423
|
+
title: 'Mobile App Without Screen Reader Support',
|
|
424
|
+
check({ files }) {
|
|
425
|
+
const findings = [];
|
|
426
|
+
for (const [filepath, content] of files.entries()) {
|
|
427
|
+
if (!isSourceFile(filepath) && !filepath.endsWith('.dart')) continue;
|
|
428
|
+
|
|
429
|
+
// React Native detection
|
|
430
|
+
const isReactNative = /from\s+['"]react-native['"]/i.test(content);
|
|
431
|
+
if (isReactNative) {
|
|
432
|
+
const hasInteractiveElements = /TouchableOpacity|TouchableHighlight|Pressable|Button|TextInput|Switch|<Image/i.test(content);
|
|
433
|
+
if (!hasInteractiveElements) continue;
|
|
434
|
+
|
|
435
|
+
const hasA11yProps = /accessibilityLabel|accessibilityHint|accessibilityRole|accessibilityState|accessible=|accessibilityValue|importantForAccessibility|aria-label/i.test(content);
|
|
436
|
+
if (!hasA11yProps) {
|
|
437
|
+
findings.push({
|
|
438
|
+
ruleId: 'COMP-ADA-005', category: 'compliance', severity: 'high',
|
|
439
|
+
title: 'React Native component without accessibility props',
|
|
440
|
+
description: 'ADA requires mobile apps to be usable with screen readers. Add accessibilityLabel, accessibilityHint, and accessibilityRole props to interactive React Native components.',
|
|
441
|
+
file: filepath, line: 1, fix: null,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Flutter detection
|
|
447
|
+
const isFlutter = /import\s+['"]package:flutter/i.test(content);
|
|
448
|
+
if (isFlutter) {
|
|
449
|
+
const hasInteractiveWidgets = /ElevatedButton|TextButton|IconButton|GestureDetector|InkWell|TextField|Switch|Checkbox|Image\./i.test(content);
|
|
450
|
+
if (!hasInteractiveWidgets) continue;
|
|
451
|
+
|
|
452
|
+
const hasSemantics = /Semantics\(|semanticsLabel|excludeFromSemantics|tooltip:/i.test(content);
|
|
453
|
+
if (!hasSemantics) {
|
|
454
|
+
findings.push({
|
|
455
|
+
ruleId: 'COMP-ADA-005', category: 'compliance', severity: 'high',
|
|
456
|
+
title: 'Flutter widget without Semantics wrapper',
|
|
457
|
+
description: 'ADA requires mobile apps to be usable with screen readers. Wrap interactive widgets with Semantics() or add semanticsLabel to provide screen reader descriptions.',
|
|
458
|
+
file: filepath, line: 1, fix: null,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return findings;
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
];
|
|
467
|
+
|
|
468
|
+
export default rules;
|