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 +21 -0
- package/README.md +114 -0
- package/bin/cli.js +131 -0
- package/package.json +44 -0
- package/src/reporter.js +186 -0
- package/src/runner.js +311 -0
- package/src/scanner.js +73 -0
- package/src/utils.js +191 -0
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
|
+
[](https://nodejs.org)
|
|
7
|
+
[](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
|
+
}
|
package/src/reporter.js
ADDED
|
@@ -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
|
+
};
|