redosray 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 +166 -0
- package/bin/redosray.js +5 -0
- package/package.json +35 -0
- package/src/analyze.js +163 -0
- package/src/charset.js +87 -0
- package/src/cli.js +228 -0
- package/src/confirm.js +64 -0
- package/src/extract.js +228 -0
- package/src/index.js +20 -0
- package/src/parser.js +172 -0
- package/src/scan.js +53 -0
- package/src/scanFiles.js +134 -0
- package/src/worker.js +11 -0
package/src/parser.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// A small, dependency-free parser for JavaScript-flavoured regular expressions.
|
|
3
|
+
// It produces an AST that the analyzer walks to find catastrophic-backtracking
|
|
4
|
+
// shapes. It intentionally covers the constructs that matter for ReDoS
|
|
5
|
+
// (groups, alternation, quantifiers, char classes, anchors, lookaround) and
|
|
6
|
+
// degrades gracefully on anything exotic by throwing, so the caller can fall
|
|
7
|
+
// back to "unknown" rather than emit a wrong result.
|
|
8
|
+
//
|
|
9
|
+
// AST node shapes:
|
|
10
|
+
// { type: 'alt', options: Node[] } a|b|c
|
|
11
|
+
// { type: 'seq', items: Node[] } abc
|
|
12
|
+
// { type: 'repeat', min, max, greedy, body: Node } a* a+ a? a{2,5}
|
|
13
|
+
// { type: 'group', capturing, name, body: Node } (a) (?:a) (?<x>a)
|
|
14
|
+
// { type: 'look', negative, behind, body: Node } (?=a) (?!a) (?<=a) (?<!a)
|
|
15
|
+
// { type: 'char', value: string } literal char
|
|
16
|
+
// { type: 'class', negated, set: string } [a-z] (set is raw body)
|
|
17
|
+
// { type: 'any' } .
|
|
18
|
+
// { type: 'anchor', kind: string } ^ $ \b \B
|
|
19
|
+
// { type: 'esc', kind: string } \d \w \s \D \W \S
|
|
20
|
+
// { type: 'backref',ref: string } \1 \k<name>
|
|
21
|
+
|
|
22
|
+
function parse(source) {
|
|
23
|
+
let i = 0;
|
|
24
|
+
const n = source.length;
|
|
25
|
+
|
|
26
|
+
function peek() { return source[i]; }
|
|
27
|
+
function eof() { return i >= n; }
|
|
28
|
+
|
|
29
|
+
function parseAlternation() {
|
|
30
|
+
const options = [parseSequence()];
|
|
31
|
+
while (!eof() && peek() === '|') {
|
|
32
|
+
i++; // consume |
|
|
33
|
+
options.push(parseSequence());
|
|
34
|
+
}
|
|
35
|
+
return options.length === 1 ? options[0] : { type: 'alt', options };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseSequence() {
|
|
39
|
+
const items = [];
|
|
40
|
+
while (!eof() && peek() !== '|' && peek() !== ')') {
|
|
41
|
+
items.push(parseQuantified());
|
|
42
|
+
}
|
|
43
|
+
if (items.length === 1) return items[0];
|
|
44
|
+
return { type: 'seq', items };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseQuantified() {
|
|
48
|
+
const atom = parseAtom();
|
|
49
|
+
if (eof()) return atom;
|
|
50
|
+
const c = peek();
|
|
51
|
+
let min, max;
|
|
52
|
+
if (c === '*') { min = 0; max = Infinity; i++; }
|
|
53
|
+
else if (c === '+') { min = 1; max = Infinity; i++; }
|
|
54
|
+
else if (c === '?') { min = 0; max = 1; i++; }
|
|
55
|
+
else if (c === '{') {
|
|
56
|
+
const saved = i;
|
|
57
|
+
const q = tryParseBrace();
|
|
58
|
+
if (!q) return atom; // literal '{'
|
|
59
|
+
min = q.min; max = q.max;
|
|
60
|
+
} else {
|
|
61
|
+
return atom;
|
|
62
|
+
}
|
|
63
|
+
let greedy = true;
|
|
64
|
+
if (!eof() && peek() === '?') { greedy = false; i++; }
|
|
65
|
+
else if (!eof() && peek() === '+') { greedy = true; i++; } // possessive-ish; treat greedy
|
|
66
|
+
return { type: 'repeat', min, max, greedy, body: atom };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function tryParseBrace() {
|
|
70
|
+
// i points at '{'
|
|
71
|
+
const start = i;
|
|
72
|
+
const m = /^\{(\d+)(,(\d*)?)?\}/.exec(source.slice(i));
|
|
73
|
+
if (!m) return null;
|
|
74
|
+
i += m[0].length;
|
|
75
|
+
const min = parseInt(m[1], 10);
|
|
76
|
+
let max;
|
|
77
|
+
if (m[2] === undefined) max = min; // {n}
|
|
78
|
+
else if (m[3] === '' || m[3] === undefined) max = Infinity; // {n,}
|
|
79
|
+
else max = parseInt(m[3], 10); // {n,m}
|
|
80
|
+
return { min, max };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseAtom() {
|
|
84
|
+
const c = peek();
|
|
85
|
+
if (c === '(') return parseGroup();
|
|
86
|
+
if (c === '[') return parseClass();
|
|
87
|
+
if (c === '^' || c === '$') { i++; return { type: 'anchor', kind: c }; }
|
|
88
|
+
if (c === '.') { i++; return { type: 'any' }; }
|
|
89
|
+
if (c === '\\') return parseEscape();
|
|
90
|
+
if (c === '*' || c === '+' || c === '?') {
|
|
91
|
+
// dangling quantifier — treat as literal to be forgiving
|
|
92
|
+
i++; return { type: 'char', value: c };
|
|
93
|
+
}
|
|
94
|
+
i++;
|
|
95
|
+
return { type: 'char', value: c };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseGroup() {
|
|
99
|
+
i++; // consume (
|
|
100
|
+
let capturing = true;
|
|
101
|
+
let name = null;
|
|
102
|
+
let look = null;
|
|
103
|
+
if (peek() === '?') {
|
|
104
|
+
i++;
|
|
105
|
+
const k = peek();
|
|
106
|
+
if (k === ':') { i++; capturing = false; }
|
|
107
|
+
else if (k === '=') { i++; look = { negative: false, behind: false }; }
|
|
108
|
+
else if (k === '!') { i++; look = { negative: true, behind: false }; }
|
|
109
|
+
else if (k === '<') {
|
|
110
|
+
i++;
|
|
111
|
+
const k2 = peek();
|
|
112
|
+
if (k2 === '=') { i++; look = { negative: false, behind: true }; }
|
|
113
|
+
else if (k2 === '!') { i++; look = { negative: true, behind: true }; }
|
|
114
|
+
else {
|
|
115
|
+
// named group (?<name>...)
|
|
116
|
+
let nm = '';
|
|
117
|
+
while (!eof() && peek() !== '>') { nm += source[i++]; }
|
|
118
|
+
if (peek() === '>') i++;
|
|
119
|
+
name = nm; capturing = true;
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
// unknown group flag (e.g. (?i)) — skip until ) conservatively
|
|
123
|
+
capturing = false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const body = parseAlternation();
|
|
127
|
+
if (peek() === ')') i++;
|
|
128
|
+
else throw new Error('unbalanced group');
|
|
129
|
+
if (look) return { type: 'look', negative: look.negative, behind: look.behind, body };
|
|
130
|
+
return { type: 'group', capturing, name, body };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseClass() {
|
|
134
|
+
i++; // consume [
|
|
135
|
+
let negated = false;
|
|
136
|
+
if (peek() === '^') { negated = true; i++; }
|
|
137
|
+
let raw = '';
|
|
138
|
+
// a ] immediately after [ or [^ is a literal
|
|
139
|
+
if (peek() === ']') { raw += ']'; i++; }
|
|
140
|
+
while (!eof() && peek() !== ']') {
|
|
141
|
+
if (peek() === '\\') { raw += source[i++]; if (!eof()) raw += source[i++]; }
|
|
142
|
+
else raw += source[i++];
|
|
143
|
+
}
|
|
144
|
+
if (peek() === ']') i++;
|
|
145
|
+
else throw new Error('unterminated character class');
|
|
146
|
+
return { type: 'class', negated, set: raw };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseEscape() {
|
|
150
|
+
i++; // consume backslash
|
|
151
|
+
if (eof()) return { type: 'char', value: '\\' };
|
|
152
|
+
const c = source[i++];
|
|
153
|
+
if ('dwsDWS'.includes(c)) return { type: 'esc', kind: c };
|
|
154
|
+
if (c === 'b' || c === 'B') return { type: 'anchor', kind: '\\' + c };
|
|
155
|
+
if (/[0-9]/.test(c)) return { type: 'backref', ref: c };
|
|
156
|
+
if (c === 'k') {
|
|
157
|
+
// \k<name>
|
|
158
|
+
let nm = '';
|
|
159
|
+
if (peek() === '<') { i++; while (!eof() && peek() !== '>') nm += source[i++]; if (peek() === '>') i++; }
|
|
160
|
+
return { type: 'backref', ref: nm };
|
|
161
|
+
}
|
|
162
|
+
const map = { n: '\n', r: '\r', t: '\t', f: '\f', v: '\v', '0': '\0' };
|
|
163
|
+
if (c in map) return { type: 'char', value: map[c] };
|
|
164
|
+
return { type: 'char', value: c };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const ast = parseAlternation();
|
|
168
|
+
if (!eof()) throw new Error('unexpected trailing input at ' + i);
|
|
169
|
+
return ast;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = { parse };
|
package/src/scan.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { findCandidates } = require('./analyze');
|
|
3
|
+
const { confirm } = require('./confirm');
|
|
4
|
+
|
|
5
|
+
const CLASS_BY_KIND = {
|
|
6
|
+
'nested-quantifier': 'exponential',
|
|
7
|
+
'ambiguous-alternation': 'exponential',
|
|
8
|
+
'sequential-quantifier': 'polynomial',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Statically find candidate ReDoS shapes in a regex, then DYNAMICALLY confirm
|
|
13
|
+
* each by measuring a real hang in an isolated worker. Returns the first
|
|
14
|
+
* confirmed vulnerability (with a shareable proof), or a not-vulnerable result.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} source regex source (no slashes)
|
|
17
|
+
* @param {string} flags regex flags (e.g. 'i')
|
|
18
|
+
* @param {object} opts { timeoutMs, maxPumps }
|
|
19
|
+
*/
|
|
20
|
+
async function scanRegex(source, flags = '', opts = {}) {
|
|
21
|
+
const candidates = findCandidates(source);
|
|
22
|
+
if (candidates.length === 0) {
|
|
23
|
+
return { source, flags, vulnerable: false, candidates: 0, checked: 0 };
|
|
24
|
+
}
|
|
25
|
+
// Exponential candidates need only a few pumps; polynomial needs more.
|
|
26
|
+
let checked = 0;
|
|
27
|
+
for (const attack of candidates) {
|
|
28
|
+
const isPoly = CLASS_BY_KIND[attack.kind] === 'polynomial';
|
|
29
|
+
const runOpts = {
|
|
30
|
+
timeoutMs: opts.timeoutMs ?? 1000,
|
|
31
|
+
maxPumps: opts.maxPumps ?? (isPoly ? 100000 : 5000),
|
|
32
|
+
};
|
|
33
|
+
checked++;
|
|
34
|
+
const res = await confirm(source, flags, attack, runOpts);
|
|
35
|
+
if (res.vulnerable) {
|
|
36
|
+
return {
|
|
37
|
+
source,
|
|
38
|
+
flags,
|
|
39
|
+
vulnerable: true,
|
|
40
|
+
kind: attack.kind,
|
|
41
|
+
complexity: CLASS_BY_KIND[attack.kind] || 'unknown',
|
|
42
|
+
matchedShape: attack.matchedShape,
|
|
43
|
+
proof: res.proof,
|
|
44
|
+
samples: res.samples,
|
|
45
|
+
candidates: candidates.length,
|
|
46
|
+
checked,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { source, flags, vulnerable: false, candidates: candidates.length, checked };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { scanRegex };
|
package/src/scanFiles.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { extractFromText, languageForPath } = require('./extract');
|
|
6
|
+
const { scanRegex } = require('./scan');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_IGNORE = new Set([
|
|
9
|
+
'node_modules', '.git', 'dist', 'build', 'out', 'coverage',
|
|
10
|
+
'.next', '.nuxt', '.venv', 'venv', '__pycache__', 'vendor',
|
|
11
|
+
'.cache', 'target', '.svelte-kit',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
function isScannable(p) {
|
|
15
|
+
return languageForPath(p) !== null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Recursively collect scannable source files under a set of roots.
|
|
20
|
+
*/
|
|
21
|
+
function collectFiles(roots, { ignore = DEFAULT_IGNORE, maxBytes = 2_000_000 } = {}) {
|
|
22
|
+
const files = [];
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
const stack = Array.isArray(roots) ? [...roots] : [roots];
|
|
25
|
+
while (stack.length) {
|
|
26
|
+
const cur = stack.pop();
|
|
27
|
+
let st;
|
|
28
|
+
try { st = fs.statSync(cur); } catch { continue; }
|
|
29
|
+
if (st.isDirectory()) {
|
|
30
|
+
const base = path.basename(cur);
|
|
31
|
+
if (ignore.has(base)) continue;
|
|
32
|
+
let entries;
|
|
33
|
+
try { entries = fs.readdirSync(cur); } catch { continue; }
|
|
34
|
+
for (const e of entries) stack.push(path.join(cur, e));
|
|
35
|
+
} else if (st.isFile()) {
|
|
36
|
+
if (!isScannable(cur)) continue;
|
|
37
|
+
if (st.size > maxBytes) continue;
|
|
38
|
+
const real = fs.realpathSync(cur);
|
|
39
|
+
if (seen.has(real)) continue;
|
|
40
|
+
seen.add(real);
|
|
41
|
+
files.push(cur);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
files.sort();
|
|
45
|
+
return files;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Deduplicate regex literals by (source+flags) so we confirm each distinct
|
|
50
|
+
* pattern only once (dynamic confirmation is the expensive step), while still
|
|
51
|
+
* reporting every location the pattern appears.
|
|
52
|
+
*/
|
|
53
|
+
function dedupeRegexes(perFile) {
|
|
54
|
+
const map = new Map();
|
|
55
|
+
for (const { file, regexes } of perFile) {
|
|
56
|
+
for (const r of regexes) {
|
|
57
|
+
const key = r.source + '\u0000' + r.flags;
|
|
58
|
+
if (!map.has(key)) map.set(key, { source: r.source, flags: r.flags, locations: [] });
|
|
59
|
+
map.get(key).locations.push({ file, line: r.line, column: r.column, raw: r.raw, kind: r.kind });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [...map.values()];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Scan one or more files/directories for ReDoS-vulnerable regexes, confirming
|
|
67
|
+
* each dynamically. Returns a structured report.
|
|
68
|
+
*
|
|
69
|
+
* @param {string|string[]} roots file or directory paths
|
|
70
|
+
* @param {object} opts { timeoutMs, ignore, onProgress }
|
|
71
|
+
*/
|
|
72
|
+
async function scanPaths(roots, opts = {}) {
|
|
73
|
+
const files = collectFiles(roots, opts);
|
|
74
|
+
const perFile = [];
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
let text;
|
|
77
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
|
78
|
+
// skip minified files: huge single lines yield garbage extractions
|
|
79
|
+
if (isLikelyMinified(text)) continue;
|
|
80
|
+
const regexes = extractFromText(text, file);
|
|
81
|
+
if (regexes.length) perFile.push({ file, regexes });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const distinct = dedupeRegexes(perFile);
|
|
85
|
+
const findings = [];
|
|
86
|
+
let confirmed = 0;
|
|
87
|
+
let idx = 0;
|
|
88
|
+
for (const d of distinct) {
|
|
89
|
+
idx++;
|
|
90
|
+
if (opts.onProgress) opts.onProgress({ index: idx, total: distinct.length, source: d.source });
|
|
91
|
+
let res;
|
|
92
|
+
try {
|
|
93
|
+
res = await scanRegex(d.source, d.flags, { timeoutMs: opts.timeoutMs ?? 1000 });
|
|
94
|
+
} catch (e) {
|
|
95
|
+
continue; // invalid regex source we couldn't compile; skip silently
|
|
96
|
+
}
|
|
97
|
+
if (res.vulnerable) {
|
|
98
|
+
confirmed++;
|
|
99
|
+
findings.push({
|
|
100
|
+
source: d.source,
|
|
101
|
+
flags: d.flags,
|
|
102
|
+
complexity: res.complexity,
|
|
103
|
+
kind: res.kind,
|
|
104
|
+
matchedShape: res.matchedShape,
|
|
105
|
+
proof: res.proof,
|
|
106
|
+
samples: res.samples,
|
|
107
|
+
locations: d.locations,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Order findings: exponential first, then by number of locations.
|
|
113
|
+
findings.sort((a, b) => {
|
|
114
|
+
if (a.complexity !== b.complexity) return a.complexity === 'exponential' ? -1 : 1;
|
|
115
|
+
return b.locations.length - a.locations.length;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
filesScanned: files.length,
|
|
120
|
+
filesWithRegex: perFile.length,
|
|
121
|
+
distinctRegexes: distinct.length,
|
|
122
|
+
vulnerable: confirmed,
|
|
123
|
+
findings,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isLikelyMinified(text) {
|
|
128
|
+
if (text.length < 5000) return false;
|
|
129
|
+
const lines = text.split('\n');
|
|
130
|
+
const avg = text.length / lines.length;
|
|
131
|
+
return avg > 2000;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { scanPaths, collectFiles, DEFAULT_IGNORE };
|
package/src/worker.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Runs a single regex match in isolation so the parent can time it and
|
|
3
|
+
// terminate it if it hangs (catastrophic backtracking cannot be interrupted
|
|
4
|
+
// cooperatively, so isolation in a worker is the only safe way to measure it).
|
|
5
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
6
|
+
const { source, flags, input } = workerData;
|
|
7
|
+
const re = new RegExp(source, flags);
|
|
8
|
+
const t0 = process.hrtime.bigint();
|
|
9
|
+
const matched = re.test(input);
|
|
10
|
+
const t1 = process.hrtime.bigint();
|
|
11
|
+
parentPort.postMessage({ matched, ns: Number(t1 - t0) });
|