a11yscan 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emmanuel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # a11yscan
2
+
3
+ Like `git status` for accessibility. Run `a11yscan .` and see which files need fixing.
4
+
5
+
6
+ [![Node.js >= 18](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
7
+ [![npm version](https://img.shields.io/npm/v/a11yscan)](https://www.npmjs.com/package/a11yscan)
8
+
9
+ ---
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install -g a11yscan
15
+ ```
16
+
17
+ ```bash
18
+ npx a11yscan .
19
+ ```
20
+
21
+ ---
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ a11yscan . # scan current directory
27
+ a11yscan ./src # scan a specific folder
28
+ a11yscan ./src/Button.jsx # scan a single file
29
+ ```
30
+
31
+ ---
32
+
33
+ ## Output
34
+
35
+ ```
36
+ Scanning 6 files...
37
+
38
+ src/components/Navbar.jsx
39
+ ❌ line 14 img missing alt attribute [critical]
40
+ ❌ line 27 button has no accessible name [critical]
41
+
42
+ src/pages/Home.jsx
43
+ ⚠️ line 8 heading order skipped (h1 → h3) [moderate]
44
+
45
+ public/index.html
46
+ ❌ line 3 <html> missing lang attribute [serious]
47
+
48
+ ✅ src/components/Button.jsx
49
+
50
+ ✅ src/pages/About.jsx
51
+
52
+ ✅ public/404.html
53
+
54
+ ─────────────────────────────────────────────────────────
55
+ 3 critical · 1 serious · 1 moderate · 0 minor
56
+ 5 issues across 3 files
57
+ ```
58
+
59
+ When everything is clean:
60
+
61
+ ```
62
+ Scanning 3 files...
63
+
64
+ ✅ src/components/Button.jsx
65
+
66
+ ✅ src/pages/About.jsx
67
+
68
+ ✅ public/404.html
69
+
70
+ ─────────────────────────────────────────────────────────
71
+ 0 critical · 0 serious · 0 moderate · 0 minor
72
+ ✅ All clear. No accessibility issues found.
73
+ ```
74
+
75
+ ---
76
+
77
+ ## Flags
78
+
79
+ | Flag | Description |
80
+ |------|-------------|
81
+ | `--format json` | Output as JSON instead of terminal display |
82
+ | `--ignore <rule>` | Ignore a rule by name (repeatable) |
83
+ | `--quiet` | Show summary only, no per-file details |
84
+ | `--version` | Show version number |
85
+
86
+ ---
87
+
88
+ ## Config
89
+
90
+ Create a `.a11yscanrc` file in your project root:
91
+
92
+ ```json
93
+ {
94
+ "ignore": ["color-contrast", "label"],
95
+ "ignoreFiles": ["src/legacy/**", "**/*.stories.jsx"]
96
+ }
97
+ ```
98
+
99
+ ---
100
+
101
+ ## CI
102
+
103
+ ```yaml
104
+ - name: Check accessibility
105
+ run: npx a11yscan ./src
106
+ ```
107
+
108
+ Exits with code `1` if any violations are found, `0` if clean.
109
+
110
+ ---
111
+
112
+ ## License
113
+
114
+ © 2026 Emmanuel Oyeyipo
package/bin/cli.js ADDED
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('path');
5
+ const { Command } = require('commander');
6
+ const { cosmiconfig } = require('cosmiconfig');
7
+ const { findFiles } = require('../src/scanner');
8
+ const { runFile } = require('../src/runner');
9
+ const { report } = require('../src/reporter');
10
+ const { sanitizePath, isWithinCwd, pathExists, validateConfig } = require('../src/utils');
11
+ const chalk = require('chalk');
12
+
13
+ const pkg = require('../package.json');
14
+
15
+ async function main() {
16
+ const program = new Command();
17
+
18
+ program
19
+ .name('a11yscan')
20
+ .description('Detect accessibility (a11y) violations in HTML, JSX, TSX, and Vue files')
21
+ .version(pkg.version, '-v, --version', 'Output the current version')
22
+ .argument('[path]', 'File or directory to scan', '.')
23
+ .option('--format <type>', 'Output format: terminal or json', 'terminal')
24
+ .option('--ignore <rules...>', 'Ignore specific rules by name (repeatable)')
25
+ .option('--quiet', 'Only show the summary, not individual file violations')
26
+ .helpOption('-h, --help', 'Display help for command')
27
+ .parse(process.argv);
28
+
29
+ const opts = program.opts();
30
+ const [targetArg = '.'] = program.args;
31
+
32
+ // ── Load config from .a11yscanrc / cosmiconfig ────────────────────────────
33
+ let fileConfig = {};
34
+ try {
35
+ const explorer = cosmiconfig('a11yscan');
36
+ const result = await explorer.search();
37
+ if (result && result.config) {
38
+ // [SECURITY] Validate config shape and reject prototype pollution attempts
39
+ const validation = validateConfig(result.config);
40
+ if (!validation.valid) {
41
+ process.stderr.write(
42
+ chalk.red(`Error: invalid config in ${result.filepath}: ${validation.reason}\n`)
43
+ );
44
+ process.exit(1);
45
+ }
46
+ fileConfig = validation.config;
47
+ }
48
+ } catch (err) {
49
+ process.stderr.write(`Warning: could not load config file: ${err.message}\n`);
50
+ }
51
+
52
+ // Merge config: CLI flags take priority over file config
53
+ const ignore = [
54
+ ...(fileConfig.ignore || []),
55
+ ...(opts.ignore || []),
56
+ ];
57
+
58
+ // ── Validate and resolve target path ──────────────────────────────────────
59
+ const resolvedPath = sanitizePath(targetArg, process.cwd());
60
+
61
+ if (!resolvedPath) {
62
+ process.stderr.write(chalk.red(`Error: invalid path: ${targetArg}\n`));
63
+ process.exit(1);
64
+ }
65
+
66
+ // [SECURITY] Reject paths that escape the current working directory.
67
+ // This prevents directory traversal (e.g. ../../etc/passwd).
68
+ if (!isWithinCwd(resolvedPath)) {
69
+ process.stderr.write(
70
+ chalk.red(`Error: path is outside the current working directory: ${targetArg}\n`) +
71
+ chalk.yellow(` Tip: cd into the target directory and run a11yscan . from there.\n`)
72
+ );
73
+ process.exit(1);
74
+ }
75
+
76
+ if (!pathExists(resolvedPath)) {
77
+ process.stderr.write(chalk.red(`Error: path does not exist: ${resolvedPath}\n`));
78
+ process.exit(1);
79
+ }
80
+
81
+ // ── Discover files ────────────────────────────────────────────────────────
82
+ let files;
83
+ try {
84
+ files = await findFiles(resolvedPath, fileConfig);
85
+ } catch (err) {
86
+ process.stderr.write(chalk.red(`Error: could not scan path: ${err.message}\n`));
87
+ process.exit(1);
88
+ }
89
+
90
+ if (files.length === 0) {
91
+ process.stdout.write(
92
+ chalk.yellow('No supported files found (*.html, *.jsx, *.tsx, *.vue).\n')
93
+ );
94
+ process.exit(0);
95
+ }
96
+
97
+ process.stdout.write(`Scanning ${chalk.bold(files.length)} file${files.length !== 1 ? 's' : ''}...\n`);
98
+
99
+ // ── Run scanners ──────────────────────────────────────────────────────────
100
+ const allViolations = [];
101
+ const resultsByFile = new Map(); // file → violations[]
102
+ const runOptions = { ignore };
103
+
104
+ for (const file of files) {
105
+ let violations;
106
+ try {
107
+ violations = await runFile(file, runOptions);
108
+ } catch (err) {
109
+ process.stderr.write(`Warning: unexpected error scanning ${file}: ${err.message}\n`);
110
+ violations = [];
111
+ }
112
+
113
+ resultsByFile.set(file, violations);
114
+ allViolations.push(...violations);
115
+ }
116
+
117
+ // ── Report results ────────────────────────────────────────────────────────
118
+ const exitCode = report(allViolations, {
119
+ format: opts.format,
120
+ quiet: opts.quiet,
121
+ resultsByFile,
122
+ });
123
+
124
+ process.exit(exitCode);
125
+ }
126
+
127
+ main().catch((err) => {
128
+ process.stderr.write(chalk.red(`Fatal: ${err.message}\n`));
129
+ if (process.env.DEBUG) process.stderr.write(err.stack + '\n');
130
+ process.exit(1);
131
+ });
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "a11yscan",
3
+ "version": "0.1.0",
4
+ "description": "A fast, zero-config CLI tool for detecting accessibility (a11y) violations in HTML, JSX, TSX, and Vue files",
5
+ "keywords": [
6
+ "accessibility",
7
+ "a11y",
8
+ "cli",
9
+ "linter",
10
+ "wcag",
11
+ "jsx",
12
+ "html",
13
+ "vue"
14
+ ],
15
+ "author": "Emmanuel",
16
+ "license": "MIT",
17
+ "main": "src/runner.js",
18
+ "files": [
19
+ "bin/",
20
+ "src/",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "bin": {
25
+ "a11yscan": "bin/cli.js"
26
+ },
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test tests/runner.test.js"
32
+ },
33
+ "dependencies": {
34
+ "@typescript-eslint/parser": "^7.18.0",
35
+ "axe-core": "^4.9.1",
36
+ "chalk": "^4.1.2",
37
+ "commander": "^12.1.0",
38
+ "cosmiconfig": "^8.3.6",
39
+ "eslint": "^8.57.1",
40
+ "eslint-plugin-jsx-a11y": "^6.9.0",
41
+ "glob": "^10.4.5",
42
+ "jsdom": "^24.1.3"
43
+ }
44
+ }
@@ -0,0 +1,186 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+ const { severityEmoji, SEVERITY_ORDER } = require('./utils');
5
+
6
+ // ─── Colour helpers ───────────────────────────────────────────────────────────
7
+
8
+ function colourSeverity(text, severity) {
9
+ switch (severity) {
10
+ case 'critical': return chalk.red.bold(text);
11
+ case 'serious': return chalk.red(text);
12
+ case 'moderate': return chalk.yellow(text);
13
+ case 'minor': return chalk.cyan(text);
14
+ default: return text;
15
+ }
16
+ }
17
+
18
+ function colourBracket(severity) {
19
+ return colourSeverity(`[${severity}]`, severity);
20
+ }
21
+
22
+ // ─── Grouping ─────────────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Group violations by file path.
26
+ * @param {Array} violations
27
+ * @returns {Map<string, Array>}
28
+ */
29
+ function groupByFile(violations) {
30
+ const map = new Map();
31
+ for (const v of violations) {
32
+ if (!map.has(v.file)) map.set(v.file, []);
33
+ map.get(v.file).push(v);
34
+ }
35
+ return map;
36
+ }
37
+
38
+ /**
39
+ * Compute severity counts from a list of violations.
40
+ */
41
+ function computeSummary(violations) {
42
+ const counts = { critical: 0, serious: 0, moderate: 0, minor: 0 };
43
+ for (const v of violations) {
44
+ if (v.severity in counts) counts[v.severity]++;
45
+ }
46
+ return counts;
47
+ }
48
+
49
+ // ─── Terminal (pretty) output ─────────────────────────────────────────────────
50
+
51
+ function printTerminal(groupedByFile, summary, violations, options = {}) {
52
+ const { quiet = false, resultsByFile = new Map() } = options;
53
+
54
+ if (!quiet) {
55
+ // Iterate all scanned files in order so clean files appear alongside bad ones
56
+ for (const [file, fileViolations] of resultsByFile) {
57
+ if (fileViolations.length === 0) {
58
+ // Clean file — one line with a checkmark, blank line after (consistent with violation blocks)
59
+ process.stdout.write(`${chalk.underline.bold(file)}\n ${chalk.green('✅ All clear. No accessibility issues found.\n')}`);
60
+ continue;
61
+ }
62
+
63
+ // Print filename as a header
64
+ process.stdout.write(chalk.underline.bold(file) + '\n');
65
+
66
+ // Sort by line number within the file
67
+ const sorted = [...fileViolations].sort((a, b) => (a.line || 0) - (b.line || 0));
68
+
69
+ for (const v of sorted) {
70
+ const emoji = severityEmoji(v.severity);
71
+ const lineStr = String(v.line || '?').padEnd(4);
72
+ const msgStr = v.message.padEnd(44);
73
+ const bracket = colourBracket(v.severity);
74
+
75
+ process.stdout.write(
76
+ ` ${emoji} line ${lineStr} ${msgStr} ${bracket}\n`
77
+ );
78
+ }
79
+
80
+ process.stdout.write('\n');
81
+ }
82
+ }
83
+
84
+ // Summary line
85
+ const divider = chalk.gray('─'.repeat(57));
86
+ process.stdout.write(divider + '\n');
87
+
88
+ const summaryText = chalk.bold('Summary📝')
89
+ process.stdout.write(summaryText + '\n')
90
+
91
+ const parts = [
92
+ chalk.red.bold(`${summary.critical} critical`),
93
+ chalk.red(`${summary.serious} serious`),
94
+ chalk.yellow(`${summary.moderate} moderate`),
95
+ chalk.cyan(`${summary.minor} minor`),
96
+ ];
97
+ process.stdout.write(parts.join(' · ') + '\n');
98
+
99
+ const total = violations.length;
100
+ const fileCount = groupedByFile.size;
101
+
102
+ if (total === 0) {
103
+ process.stdout.write(chalk.green('✅All clear. No accessibility issues found.\n'));
104
+ } else {
105
+ process.stdout.write(
106
+ chalk.bold(`${total} issue${total !== 1 ? 's' : ''} across ${fileCount} file${fileCount !== 1 ? 's' : ''}`) + '\n'
107
+ );
108
+ }
109
+ }
110
+
111
+ // ─── JSON output ──────────────────────────────────────────────────────────────
112
+
113
+ function printJson(groupedByFile, summary, violations) {
114
+ const files = [];
115
+ for (const [file, fileViolations] of groupedByFile) {
116
+ files.push({
117
+ path: file,
118
+ violations: fileViolations.map((v) => ({
119
+ line: v.line || 1,
120
+ message: v.message,
121
+ rule: v.rule,
122
+ severity: v.severity,
123
+ })),
124
+ });
125
+ }
126
+
127
+ const output = {
128
+ files,
129
+ summary: {
130
+ ...summary,
131
+ total: violations.length,
132
+ filesWithIssues: groupedByFile.size,
133
+ },
134
+ };
135
+
136
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
137
+ }
138
+
139
+ // ─── Public API ───────────────────────────────────────────────────────────────
140
+
141
+ /**
142
+ * Report violations to stdout in the requested format.
143
+ *
144
+ * @param {Array} violations - Flat array of violation objects
145
+ * @param {object} options - { format: 'terminal'|'json', quiet: boolean }
146
+ * @returns {number} - Exit code (0 = clean, 1 = issues found)
147
+ */
148
+ function report(violations, options = {}) {
149
+ const { format = 'terminal', quiet = false, resultsByFile = new Map() } = options;
150
+
151
+ if (violations.length === 0) {
152
+ if (format === 'json') {
153
+ printJson(new Map(), { critical: 0, serious: 0, moderate: 0, minor: 0 }, []);
154
+ } else {
155
+ // Still show per-file ✅ lines even when everything is clean
156
+ if (!quiet) {
157
+ for (const file of resultsByFile.keys()) {
158
+ process.stdout.write(`${chalk.green('✅')} ${chalk.dim(file)}\n`);
159
+ }
160
+ }
161
+ process.stdout.write('\n' + chalk.green('✅ All clear. No accessibility issues found.\n'));
162
+ }
163
+ return 0;
164
+ }
165
+
166
+ // Sort violations: by file, then by severity, then by line
167
+ const sorted = [...violations].sort((a, b) => {
168
+ if (a.file !== b.file) return a.file.localeCompare(b.file);
169
+ const sevDiff = (SEVERITY_ORDER[a.severity] ?? 99) - (SEVERITY_ORDER[b.severity] ?? 99);
170
+ if (sevDiff !== 0) return sevDiff;
171
+ return (a.line || 0) - (b.line || 0);
172
+ });
173
+
174
+ const grouped = groupByFile(sorted);
175
+ const summary = computeSummary(sorted);
176
+
177
+ if (format === 'json') {
178
+ printJson(grouped, summary, sorted);
179
+ } else {
180
+ printTerminal(grouped, summary, sorted, { quiet, resultsByFile });
181
+ }
182
+
183
+ return 1;
184
+ }
185
+
186
+ module.exports = { report, computeSummary };
package/src/runner.js ADDED
@@ -0,0 +1,311 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { JSDOM, VirtualConsole } = require('jsdom');
5
+ const axe = require('axe-core');
6
+
7
+ // Rules that require browser APIs unavailable in jsdom (e.g. Canvas for color-contrast)
8
+ const AXE_JSDOM_UNSUPPORTED_RULES = ['color-contrast', 'color-contrast-enhanced'];
9
+ const { ESLint } = require('eslint');
10
+ const jsxA11yPlugin = require('eslint-plugin-jsx-a11y');
11
+ const { safeReadFile, getFileType } = require('./utils');
12
+
13
+ // ─── Severity mapping ────────────────────────────────────────────────────────
14
+
15
+ function mapAxeImpact(impact) {
16
+ const map = { critical: 'critical', serious: 'serious', moderate: 'moderate', minor: 'minor' };
17
+ return map[impact] || 'minor';
18
+ }
19
+
20
+ function mapEslintSeverity(severity) {
21
+ // ESLint severity: 2 = error, 1 = warn
22
+ return severity === 2 ? 'critical' : 'moderate';
23
+ }
24
+
25
+ // ─── Line-number helpers ─────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Best-effort search for the source line of a DOM element.
29
+ * axe-core gives us the outer HTML of the violating node; we search the source
30
+ * for a distinctive substring to determine the approximate line number.
31
+ */
32
+ function findLineInSource(sourceLines, nodeHtml) {
33
+ if (!nodeHtml) return 1;
34
+
35
+ const tagMatch = nodeHtml.match(/^<([a-zA-Z][a-zA-Z0-9-]*)/);
36
+ if (!tagMatch) return 1;
37
+
38
+ const tagName = tagMatch[1].toLowerCase();
39
+
40
+ // Prefer matching on a unique attribute so we pinpoint the right element
41
+ const idMatch = nodeHtml.match(/\sid="([^"]+)"/i);
42
+ const nameMatch = nodeHtml.match(/\sname="([^"]+)"/i);
43
+ const ariaMatch = nodeHtml.match(/\s(aria-\w+)="([^"]+)"/i);
44
+ const srcMatch = nodeHtml.match(/\s(?:src|href)="([^"]+)"/i);
45
+ const classMatch = nodeHtml.match(/\sclass="([^"]+)"/i);
46
+
47
+ for (let i = 0; i < sourceLines.length; i++) {
48
+ const line = sourceLines[i];
49
+ if (!line.toLowerCase().includes(`<${tagName}`)) continue;
50
+
51
+ if (idMatch && line.includes(idMatch[1])) return i + 1;
52
+ if (nameMatch && line.includes(nameMatch[1])) return i + 1;
53
+ if (ariaMatch && line.toLowerCase().includes(ariaMatch[2])) return i + 1;
54
+ if (srcMatch && line.includes(srcMatch[1].substring(0, 30))) return i + 1;
55
+ if (classMatch && line.includes(classMatch[1].split(' ')[0])) return i + 1;
56
+
57
+ // No distinguishing attribute — return first tag occurrence
58
+ return i + 1;
59
+ }
60
+
61
+ return 1;
62
+ }
63
+
64
+ // ─── HTML scanner (axe-core + jsdom) ─────────────────────────────────────────
65
+
66
+ async function scanHtml(filePath, content, options = {}) {
67
+ const violations = [];
68
+
69
+ // [SECURITY] Remove <script> tags so user JavaScript is never evaluated.
70
+ // runScripts: 'outside-only' is set below as a second layer, but stripping
71
+ // is defense-in-depth — the content never reaches the JS engine at all.
72
+ const safeContent = content.replace(
73
+ /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
74
+ '<!-- script removed -->'
75
+ );
76
+
77
+ const absolutePath = path.resolve(filePath);
78
+ // file:/// URL required by jsdom; normalise Windows back-slashes
79
+ const fileUrl = `file:///${absolutePath.replace(/\\/g, '/')}`;
80
+
81
+ // Silence jsdom's "Not implemented" messages (e.g. canvas) — they are not errors
82
+ const virtualConsole = new VirtualConsole();
83
+
84
+ // [SECURITY] Wrap JSDOM construction — a malformed file should never crash the process
85
+ let dom;
86
+ try {
87
+ dom = new JSDOM(safeContent, {
88
+ runScripts: 'outside-only', // prevents HTML's own scripts; allows our eval below
89
+ url: fileUrl,
90
+ virtualConsole,
91
+ });
92
+ } catch (err) {
93
+ process.stderr.write(`Warning: could not parse HTML in ${filePath}: ${err.message}\n`);
94
+ return [];
95
+ }
96
+
97
+ // [SECURITY] Inject axe-core from our trusted npm bundle — NOT from user content.
98
+ // axe.source is a pre-built string shipped with the axe-core package.
99
+ // runScripts: 'outside-only' means only this explicit eval() is permitted;
100
+ // any scripts embedded in the user's HTML file are inert.
101
+ try {
102
+ dom.window.eval(axe.source);
103
+ } catch (err) {
104
+ process.stderr.write(`Warning: could not inject axe-core for ${filePath}: ${err.message}\n`);
105
+ return [];
106
+ }
107
+
108
+ const axeOptions = { resultTypes: ['violations'] };
109
+
110
+ // Always disable rules that require browser Canvas (not available in jsdom)
111
+ axeOptions.rules = {};
112
+ AXE_JSDOM_UNSUPPORTED_RULES.forEach((rule) => {
113
+ axeOptions.rules[rule] = { enabled: false };
114
+ });
115
+
116
+ if (options.ignore && options.ignore.length > 0) {
117
+ options.ignore.forEach((rule) => {
118
+ axeOptions.rules[rule] = { enabled: false };
119
+ });
120
+ }
121
+
122
+ let results;
123
+ try {
124
+ results = await dom.window.axe.run(dom.window.document, axeOptions);
125
+ } catch (err) {
126
+ process.stderr.write(`Warning: axe-core failed on ${filePath}: ${err.message}\n`);
127
+ return [];
128
+ }
129
+
130
+ const sourceLines = content.split('\n');
131
+
132
+ for (const violation of results.violations) {
133
+ const severity = mapAxeImpact(violation.impact);
134
+ const message = violation.help || violation.description;
135
+
136
+ for (const node of violation.nodes) {
137
+ const line = findLineInSource(sourceLines, node.html);
138
+ violations.push({
139
+ file: filePath,
140
+ line,
141
+ message,
142
+ rule: violation.id,
143
+ severity,
144
+ });
145
+ }
146
+ }
147
+
148
+ return violations;
149
+ }
150
+
151
+ // ─── JSX/TSX scanner (eslint-plugin-jsx-a11y) ────────────────────────────────
152
+
153
+ /**
154
+ * Build the rule set from eslint-plugin-jsx-a11y recommended config,
155
+ * then supplement with any remaining rules as warnings.
156
+ */
157
+ function buildJsxRules(ignoreRules = []) {
158
+ const recommended = jsxA11yPlugin.configs.recommended.rules || {};
159
+ const rules = {};
160
+
161
+ // Start from recommended
162
+ Object.entries(recommended).forEach(([rule, value]) => {
163
+ if (value !== 'off' && value !== 0) {
164
+ rules[rule] = value;
165
+ }
166
+ });
167
+
168
+ // Add remaining plugin rules not covered by recommended
169
+ Object.keys(jsxA11yPlugin.rules).forEach((ruleName) => {
170
+ const fullRule = `jsx-a11y/${ruleName}`;
171
+ if (!(fullRule in rules)) {
172
+ rules[fullRule] = 'warn';
173
+ }
174
+ });
175
+
176
+ // Apply caller-supplied ignores
177
+ ignoreRules.forEach((rule) => {
178
+ const fullRule = rule.includes('/') ? rule : `jsx-a11y/${rule}`;
179
+ if (fullRule in rules) rules[fullRule] = 'off';
180
+ });
181
+
182
+ return rules;
183
+ }
184
+
185
+ async function scanJsx(filePath, content, options = {}) {
186
+ const violations = [];
187
+ const isTypescript = filePath.endsWith('.tsx');
188
+
189
+ const overrideConfig = {
190
+ plugins: ['jsx-a11y'],
191
+ rules: buildJsxRules(options.ignore || []),
192
+ parserOptions: {
193
+ ecmaVersion: 2022,
194
+ ecmaFeatures: { jsx: true },
195
+ sourceType: 'module',
196
+ },
197
+ env: { browser: true, es2022: true },
198
+ };
199
+
200
+ if (isTypescript) {
201
+ try {
202
+ require.resolve('@typescript-eslint/parser');
203
+ overrideConfig.parser = '@typescript-eslint/parser';
204
+ overrideConfig.parserOptions.project = false; // disable type-checking
205
+ } catch {
206
+ process.stderr.write(`Warning: @typescript-eslint/parser not found; skipping ${filePath}\n`);
207
+ return [];
208
+ }
209
+ }
210
+
211
+ let eslint;
212
+ try {
213
+ eslint = new ESLint({
214
+ useEslintrc: false,
215
+ allowInlineConfig: false,
216
+ // Resolve plugins relative to this package so they're always found,
217
+ // regardless of where the user runs the command.
218
+ resolvePluginsRelativeTo: path.join(__dirname, '..'),
219
+ overrideConfig,
220
+ });
221
+ } catch (err) {
222
+ process.stderr.write(`Warning: ESLint init failed for ${filePath}: ${err.message}\n`);
223
+ return [];
224
+ }
225
+
226
+ let results;
227
+ try {
228
+ // [SECURITY] lintText() treats `content` as plain source text — it is parsed
229
+ // by ESLint's parser into an AST; the text itself is never eval'd or executed.
230
+ results = await eslint.lintText(content, { filePath });
231
+ } catch (err) {
232
+ process.stderr.write(`Warning: ESLint parse error in ${filePath}: ${err.message}\n`);
233
+ return [];
234
+ }
235
+
236
+ for (const result of results) {
237
+ for (const msg of result.messages) {
238
+ if (!msg.ruleId || !msg.ruleId.startsWith('jsx-a11y/')) continue;
239
+
240
+ violations.push({
241
+ file: filePath,
242
+ line: msg.line || 1,
243
+ message: msg.message,
244
+ rule: msg.ruleId,
245
+ severity: mapEslintSeverity(msg.severity),
246
+ });
247
+ }
248
+ }
249
+
250
+ return violations;
251
+ }
252
+
253
+ // ─── Vue scanner (extract <template>, run through axe) ───────────────────────
254
+
255
+ async function scanVue(filePath, content, options = {}) {
256
+ // [SECURITY] Only the <template> block is extracted; <script> and <style>
257
+ // blocks in Vue SFCs are never read or evaluated.
258
+ let templateMatch;
259
+ try {
260
+ templateMatch = content.match(/<template[^>]*>([\s\S]*?)<\/template>/i);
261
+ } catch (err) {
262
+ process.stderr.write(`Warning: could not extract template from ${filePath}: ${err.message}\n`);
263
+ return [];
264
+ }
265
+
266
+ if (!templateMatch) return [];
267
+
268
+ const templateContent = templateMatch[1];
269
+ const templateStartLine =
270
+ content.substring(0, content.indexOf(templateMatch[0])).split('\n').length;
271
+
272
+ // Wrap the extracted template in a minimal HTML document for axe scanning
273
+ const htmlWrapper = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Vue Template</title></head><body>${templateContent}</body></html>`;
274
+
275
+ const violations = await scanHtml(filePath, htmlWrapper, options);
276
+
277
+ // Shift line numbers back to the Vue SFC coordinate space
278
+ return violations.map((v) => ({
279
+ ...v,
280
+ line: Math.max(1, (v.line || 1) + templateStartLine - 1),
281
+ }));
282
+ }
283
+
284
+ // ─── Public API ───────────────────────────────────────────────────────────────
285
+
286
+ /**
287
+ * Run the appropriate a11y scanner for a single file.
288
+ * Returns an array of violation objects, or [] on error.
289
+ *
290
+ * @param {string} filePath - Absolute path to the file
291
+ * @param {object} options - { ignore: string[] }
292
+ * @returns {Promise<Array<{file, line, message, rule, severity}>>}
293
+ */
294
+ async function runFile(filePath, options = {}) {
295
+ // [SECURITY] safeReadFile checks: symlink → skip, >500KB → skip, unreadable → warn+skip
296
+ const content = safeReadFile(filePath);
297
+ if (content === null) return [];
298
+
299
+ const type = getFileType(filePath);
300
+
301
+ // [SECURITY] All branches below treat `content` as plain text only.
302
+ // axe-core / ESLint parse it into an AST; nothing is eval'd or executed.
303
+ switch (type) {
304
+ case 'html': return scanHtml(filePath, content, options);
305
+ case 'jsx': return scanJsx(filePath, content, options);
306
+ case 'vue': return scanVue(filePath, content, options);
307
+ default: return [];
308
+ }
309
+ }
310
+
311
+ module.exports = { runFile, scanHtml, scanJsx, scanVue };
package/src/scanner.js ADDED
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const { glob } = require('glob');
6
+ const { SUPPORTED_EXTENSIONS } = require('./utils');
7
+
8
+ const SUPPORTED_GLOB = '**/*.{html,jsx,tsx,vue}';
9
+
10
+ // Directories that should always be excluded from scanning
11
+ const DEFAULT_IGNORE = [
12
+ '**/node_modules/**',
13
+ '**/dist/**',
14
+ '**/build/**',
15
+ '**/.git/**',
16
+ '**/coverage/**',
17
+ '**/.next/**',
18
+ '**/.nuxt/**',
19
+ '**/out/**',
20
+ '**/.cache/**',
21
+ ];
22
+
23
+ /**
24
+ * Find all supported files under the given directory path.
25
+ * For a single file path, validates it directly.
26
+ *
27
+ * @param {string} targetPath - Absolute path to scan
28
+ * @param {object} config - Loaded cosmiconfig options
29
+ * @returns {Promise<string[]>} Sorted list of absolute file paths
30
+ */
31
+ async function findFiles(targetPath, config = {}) {
32
+ // [SECURITY] Use lstatSync (not statSync) so symlinked directories are detected
33
+ // and not silently followed into unknown parts of the filesystem.
34
+ let stat;
35
+ try {
36
+ stat = fs.lstatSync(targetPath);
37
+ } catch (err) {
38
+ throw new Error(`Cannot stat path "${targetPath}": ${err.message}`);
39
+ }
40
+
41
+ // [SECURITY] Reject symlinks outright — they may point outside cwd
42
+ if (stat.isSymbolicLink()) {
43
+ process.stderr.write(`Warning: skipping symlink: ${targetPath}\n`);
44
+ return [];
45
+ }
46
+
47
+ // Single file mode
48
+ if (stat.isFile()) {
49
+ const ext = path.extname(targetPath).toLowerCase();
50
+ if (!SUPPORTED_EXTENSIONS.has(ext)) {
51
+ return [];
52
+ }
53
+ return [targetPath];
54
+ }
55
+
56
+ // Directory mode — use glob
57
+ const ignorePatterns = [
58
+ ...DEFAULT_IGNORE,
59
+ ...(Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []),
60
+ ];
61
+
62
+ const matches = await glob(SUPPORTED_GLOB, {
63
+ cwd: targetPath,
64
+ absolute: true,
65
+ ignore: ignorePatterns,
66
+ nodir: true,
67
+ follow: false, // [SECURITY] Never follow symlinks during recursive scan
68
+ });
69
+
70
+ return matches.sort();
71
+ }
72
+
73
+ module.exports = { findFiles };
package/src/utils.js ADDED
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+
6
+ const SUPPORTED_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue']);
7
+
8
+ const SEVERITY_ORDER = { critical: 0, serious: 1, moderate: 2, minor: 3 };
9
+
10
+ // 500 KB — files larger than this are skipped to prevent ReDoS / parser crashes
11
+ const MAX_FILE_SIZE_BYTES = 500 * 1024;
12
+
13
+ // Keys that indicate a prototype pollution attempt in config objects
14
+ const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
15
+
16
+ /**
17
+ * Sanitize a file path to prevent directory traversal attacks.
18
+ * Returns the resolved absolute path, or null if the path is unsafe.
19
+ */
20
+ function sanitizePath(inputPath, basePath) {
21
+ if (typeof inputPath !== 'string' || inputPath.trim() === '') {
22
+ return null;
23
+ }
24
+
25
+ // Resolve relative to provided base or CWD
26
+ const base = basePath ? path.resolve(basePath) : process.cwd();
27
+ const resolved = path.resolve(base, inputPath);
28
+
29
+ // Reject null bytes
30
+ if (resolved.includes('\0')) {
31
+ return null;
32
+ }
33
+
34
+ return resolved;
35
+ }
36
+
37
+ /**
38
+ * Enforce that a resolved path stays within the current working directory.
39
+ * Returns true if safe, false if the path escapes cwd.
40
+ */
41
+ function isWithinCwd(resolvedPath) {
42
+ const cwd = process.cwd();
43
+ // Allow exact match (scanning cwd itself) or a child path
44
+ return resolvedPath === cwd || resolvedPath.startsWith(cwd + path.sep);
45
+ }
46
+
47
+ /**
48
+ * Check that a path exists and is accessible.
49
+ */
50
+ function pathExists(targetPath) {
51
+ try {
52
+ fs.accessSync(targetPath, fs.constants.F_OK);
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Determine the scanner type based on file extension.
61
+ * Returns 'html', 'jsx', or 'vue', or null if unsupported.
62
+ */
63
+ function getFileType(filePath) {
64
+ const ext = path.extname(filePath).toLowerCase();
65
+ if (!SUPPORTED_EXTENSIONS.has(ext)) return null;
66
+ if (ext === '.html') return 'html';
67
+ if (ext === '.vue') return 'vue';
68
+ return 'jsx'; // covers .jsx and .tsx
69
+ }
70
+
71
+ /**
72
+ * Safely read a file, returning null and logging a warning if it fails.
73
+ *
74
+ * Security checks applied before reading:
75
+ * 1. lstat — reject symlinks silently
76
+ * 2. size — reject files > 500 KB with a warning
77
+ */
78
+ function safeReadFile(filePath) {
79
+ try {
80
+ // [SECURITY] Check for symlinks — do not follow them
81
+ const lstat = fs.lstatSync(filePath);
82
+ if (lstat.isSymbolicLink()) {
83
+ // Skip silently; symlinks could point anywhere on the filesystem
84
+ return null;
85
+ }
86
+
87
+ // [SECURITY] Enforce file size limit to prevent ReDoS / parser crashes
88
+ if (lstat.size > MAX_FILE_SIZE_BYTES) {
89
+ process.stderr.write(
90
+ `Warning: skipping ${filePath} — file exceeds 500 KB (${Math.round(lstat.size / 1024)} KB)\n`
91
+ );
92
+ return null;
93
+ }
94
+
95
+ return fs.readFileSync(filePath, 'utf-8');
96
+ } catch (err) {
97
+ if (err.code === 'EACCES') {
98
+ process.stderr.write(`Warning: permission denied reading ${filePath}\n`);
99
+ } else if (err.code === 'ENOENT') {
100
+ process.stderr.write(`Warning: file not found ${filePath}\n`);
101
+ } else {
102
+ process.stderr.write(`Warning: could not read ${filePath}: ${err.message}\n`);
103
+ }
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Validate a config object loaded from cosmiconfig.
110
+ * Guards against prototype pollution via __proto__, constructor, or prototype keys.
111
+ * Also ensures the expected fields are the correct types.
112
+ *
113
+ * Returns { valid: true, config } on success, or { valid: false, reason } on failure.
114
+ */
115
+ function validateConfig(raw) {
116
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
117
+ return { valid: false, reason: 'config must be a plain object' };
118
+ }
119
+
120
+ // [SECURITY] Reject prototype pollution keys at the top level and nested
121
+ for (const key of Object.keys(raw)) {
122
+ if (DANGEROUS_KEYS.has(key)) {
123
+ return { valid: false, reason: `config contains forbidden key: "${key}"` };
124
+ }
125
+ }
126
+
127
+ // Validate "ignore" — must be an array of strings if present
128
+ if ('ignore' in raw) {
129
+ if (!Array.isArray(raw.ignore) || !raw.ignore.every((v) => typeof v === 'string')) {
130
+ return { valid: false, reason: '"ignore" must be an array of strings' };
131
+ }
132
+ // Check nested values for dangerous patterns
133
+ for (const v of raw.ignore) {
134
+ if (DANGEROUS_KEYS.has(v)) {
135
+ return { valid: false, reason: `forbidden value in "ignore": "${v}"` };
136
+ }
137
+ }
138
+ }
139
+
140
+ // Validate "ignoreFiles" — must be an array of strings if present
141
+ if ('ignoreFiles' in raw) {
142
+ if (!Array.isArray(raw.ignoreFiles) || !raw.ignoreFiles.every((v) => typeof v === 'string')) {
143
+ return { valid: false, reason: '"ignoreFiles" must be an array of strings' };
144
+ }
145
+ }
146
+
147
+ // Build a clean config with only the known safe keys
148
+ const config = Object.create(null);
149
+ if (raw.ignore) config.ignore = raw.ignore.slice();
150
+ if (raw.ignoreFiles) config.ignoreFiles = raw.ignoreFiles.slice();
151
+
152
+ return { valid: true, config };
153
+ }
154
+
155
+ /**
156
+ * Compare two severity values, returns negative if a < b (a is more severe).
157
+ */
158
+ function compareSeverity(a, b) {
159
+ return (SEVERITY_ORDER[a] ?? 99) - (SEVERITY_ORDER[b] ?? 99);
160
+ }
161
+
162
+ /**
163
+ * Return an emoji for a given severity level.
164
+ */
165
+ function severityEmoji(severity) {
166
+ if (severity === 'critical' || severity === 'serious') return '❌';
167
+ if (severity === 'moderate') return '⚠️';
168
+ return 'ℹ️';
169
+ }
170
+
171
+ /**
172
+ * Strip ANSI escape codes from a string (for JSON output).
173
+ */
174
+ function stripAnsi(str) {
175
+ return str.replace(/\x1B\[[0-9;]*m/g, '');
176
+ }
177
+
178
+ module.exports = {
179
+ sanitizePath,
180
+ isWithinCwd,
181
+ pathExists,
182
+ getFileType,
183
+ safeReadFile,
184
+ validateConfig,
185
+ compareSeverity,
186
+ severityEmoji,
187
+ stripAnsi,
188
+ SUPPORTED_EXTENSIONS,
189
+ SEVERITY_ORDER,
190
+ MAX_FILE_SIZE_BYTES,
191
+ };