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/cli.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { scanPaths } = require('./scanFiles');
|
|
6
|
+
const { scanRegex } = require('./scan');
|
|
7
|
+
|
|
8
|
+
const VERSION = require('../package.json').version;
|
|
9
|
+
|
|
10
|
+
const HELP = `redosray — find ReDoS-vulnerable regexes and prove them, offline.
|
|
11
|
+
|
|
12
|
+
USAGE
|
|
13
|
+
redosray [paths...] scan files/directories for vulnerable regexes
|
|
14
|
+
redosray -e '<regex>' test a single regex pattern
|
|
15
|
+
cat file | redosray -e - read the regex from stdin
|
|
16
|
+
|
|
17
|
+
OPTIONS
|
|
18
|
+
-e, --regex <pat> test one regex instead of scanning paths ('-' = stdin)
|
|
19
|
+
-f, --flags <fl> regex flags for -e mode (e.g. i, gm)
|
|
20
|
+
--json machine-readable JSON output
|
|
21
|
+
--ci exit non-zero (2) if any vulnerability is confirmed
|
|
22
|
+
--timeout <ms> per-match hang threshold (default 1000)
|
|
23
|
+
--no-color disable ANSI colors
|
|
24
|
+
-h, --help show this help
|
|
25
|
+
-v, --version show version
|
|
26
|
+
|
|
27
|
+
Every reported vulnerability is a REAL measured hang: redosray finds candidate
|
|
28
|
+
patterns statically, then confirms each by timing a match against a growing
|
|
29
|
+
input inside an isolated worker. The smallest input that crosses the timeout is
|
|
30
|
+
printed as shareable proof — no false-positive claims.
|
|
31
|
+
|
|
32
|
+
Maintained by the AI agent "Aurelio Nakamura". MIT licensed.
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
function parseArgs(argv) {
|
|
36
|
+
const opts = {
|
|
37
|
+
paths: [], regex: null, flags: '', json: false, ci: false,
|
|
38
|
+
timeoutMs: 1000, color: true, help: false, version: false,
|
|
39
|
+
};
|
|
40
|
+
for (let i = 0; i < argv.length; i++) {
|
|
41
|
+
const a = argv[i];
|
|
42
|
+
switch (a) {
|
|
43
|
+
case '-h': case '--help': opts.help = true; break;
|
|
44
|
+
case '-v': case '--version': opts.version = true; break;
|
|
45
|
+
case '--json': opts.json = true; break;
|
|
46
|
+
case '--ci': opts.ci = true; break;
|
|
47
|
+
case '--no-color': opts.color = false; break;
|
|
48
|
+
case '-e': case '--regex': opts.regex = argv[++i]; break;
|
|
49
|
+
case '-f': case '--flags': opts.flags = argv[++i] || ''; break;
|
|
50
|
+
case '--timeout': opts.timeoutMs = parseInt(argv[++i], 10) || 1000; break;
|
|
51
|
+
default:
|
|
52
|
+
if (a.startsWith('-') && a !== '-') {
|
|
53
|
+
process.stderr.write(`redosray: unknown option ${a}\n`);
|
|
54
|
+
process.exitCode = 2;
|
|
55
|
+
opts._error = true;
|
|
56
|
+
} else {
|
|
57
|
+
opts.paths.push(a);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return opts;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- minimal ANSI helpers ---
|
|
65
|
+
function mkColor(enabled) {
|
|
66
|
+
const wrap = (code) => (s) => (enabled ? `\u001b[${code}m${s}\u001b[0m` : String(s));
|
|
67
|
+
return {
|
|
68
|
+
red: wrap('31'), yellow: wrap('33'), green: wrap('32'),
|
|
69
|
+
cyan: wrap('36'), gray: wrap('90'), bold: wrap('1'), magenta: wrap('35'),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fmtMs(ms) {
|
|
74
|
+
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
|
|
75
|
+
return `${Math.round(ms)}ms`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function truncate(s, n) {
|
|
79
|
+
if (s.length <= n) return s;
|
|
80
|
+
return s.slice(0, n - 1) + '…';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A tiny sparkline of the timing curve (samples: [{length, ms, timedOut}]).
|
|
84
|
+
function sparkline(samples) {
|
|
85
|
+
const bars = '▁▂▃▄▅▆▇█';
|
|
86
|
+
const vals = samples.map((s) => s.timedOut ? Infinity : s.ms);
|
|
87
|
+
const finite = vals.filter((v) => Number.isFinite(v));
|
|
88
|
+
const max = finite.length ? Math.max(...finite, 1) : 1;
|
|
89
|
+
return vals.map((v) => (v === Infinity ? '█' : bars[Math.min(bars.length - 1, Math.floor((v / max) * (bars.length - 1)))])).join('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function describeInput(proof) {
|
|
93
|
+
// Show the proof input compactly: collapse long runs.
|
|
94
|
+
const input = proof.input;
|
|
95
|
+
if (input.length <= 60) return JSON.stringify(input);
|
|
96
|
+
return `${JSON.stringify(input.slice(0, 40))} … (${input.length} chars)`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function printFinding(c, f, index) {
|
|
100
|
+
const tag = f.complexity === 'exponential'
|
|
101
|
+
? c.red(c.bold(' EXPONENTIAL '))
|
|
102
|
+
: c.yellow(c.bold(' POLYNOMIAL '));
|
|
103
|
+
const kind = c.gray(`(${f.kind})`);
|
|
104
|
+
const out = [];
|
|
105
|
+
out.push(`${c.bold(`#${index}`)} ${tag} ${kind}`);
|
|
106
|
+
out.push(` ${c.cyan('/' + f.source + '/' + f.flags)}`);
|
|
107
|
+
if (f.locations && f.locations.length) {
|
|
108
|
+
const shown = f.locations.slice(0, 5);
|
|
109
|
+
for (const loc of shown) {
|
|
110
|
+
out.push(` ${c.gray('at')} ${loc.file}:${loc.line}:${loc.column}`);
|
|
111
|
+
}
|
|
112
|
+
if (f.locations.length > shown.length) {
|
|
113
|
+
out.push(c.gray(` … +${f.locations.length - shown.length} more location(s)`));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (f.proof) {
|
|
117
|
+
const last = f.samples && f.samples.length ? f.samples[f.samples.length - 1] : null;
|
|
118
|
+
out.push(` ${c.magenta('proof')} input ${describeInput(f.proof)}`);
|
|
119
|
+
out.push(` ${f.proof.length} chars → hung past ${f.proof.timeoutMs}ms` +
|
|
120
|
+
(last && last.timedOut ? c.gray(` (${last.ms >= f.proof.timeoutMs ? '≥' : ''}${fmtMs(last.ms)})`) : ''));
|
|
121
|
+
if (f.samples && f.samples.length > 1) {
|
|
122
|
+
out.push(` ${c.gray('curve')} ${sparkline(f.samples)} ${c.gray(`${f.samples[0].length}→${f.samples[f.samples.length - 1].length} chars`)}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out.join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function runSingle(opts, c) {
|
|
129
|
+
let source = opts.regex;
|
|
130
|
+
if (source === '-') {
|
|
131
|
+
source = fs.readFileSync(0, 'utf8').trim();
|
|
132
|
+
}
|
|
133
|
+
// Allow the user to paste /pattern/flags form too.
|
|
134
|
+
let flags = opts.flags;
|
|
135
|
+
const m = /^\/(.*)\/([a-z]*)$/s.exec(source);
|
|
136
|
+
if (m) { source = m[1]; if (!flags) flags = m[2]; }
|
|
137
|
+
|
|
138
|
+
let res;
|
|
139
|
+
try {
|
|
140
|
+
res = await scanRegex(source, flags, { timeoutMs: opts.timeoutMs });
|
|
141
|
+
} catch (e) {
|
|
142
|
+
if (opts.json) { process.stdout.write(JSON.stringify({ error: String(e.message || e) }) + '\n'); }
|
|
143
|
+
else process.stderr.write(c.red(`redosray: could not analyze regex: ${e.message || e}\n`));
|
|
144
|
+
return 2;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (opts.json) {
|
|
148
|
+
process.stdout.write(JSON.stringify(res, null, 2) + '\n');
|
|
149
|
+
return res.vulnerable && opts.ci ? 2 : 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!res.vulnerable) {
|
|
153
|
+
process.stdout.write(`${c.green('✓ safe')} ${c.cyan('/' + source + '/' + flags)} ${c.gray(`— ${res.candidates} candidate(s), no hang up to the tested bound`)}\n`);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
const finding = {
|
|
157
|
+
source, flags, complexity: res.complexity, kind: res.kind,
|
|
158
|
+
proof: res.proof, samples: res.samples, locations: [],
|
|
159
|
+
};
|
|
160
|
+
process.stdout.write(printFinding(c, finding, 1) + '\n');
|
|
161
|
+
return opts.ci ? 2 : 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function runScan(opts, c) {
|
|
165
|
+
const roots = opts.paths.length ? opts.paths : ['.'];
|
|
166
|
+
for (const r of roots) {
|
|
167
|
+
if (!fs.existsSync(r)) {
|
|
168
|
+
process.stderr.write(c.red(`redosray: path not found: ${r}\n`));
|
|
169
|
+
return 2;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const isTTY = process.stderr.isTTY;
|
|
174
|
+
const report = await scanPaths(roots, {
|
|
175
|
+
timeoutMs: opts.timeoutMs,
|
|
176
|
+
onProgress: opts.json ? undefined : ({ index, total }) => {
|
|
177
|
+
if (isTTY) process.stderr.write(`\r${c.gray(`scanning regex ${index}/${total}…`)}\u001b[K`);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
if (!opts.json && isTTY) process.stderr.write('\r\u001b[K');
|
|
181
|
+
|
|
182
|
+
if (opts.json) {
|
|
183
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
184
|
+
return report.vulnerable && opts.ci ? 2 : 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const head = `${c.gray('scanned')} ${report.filesScanned} file(s), ` +
|
|
188
|
+
`${report.distinctRegexes} distinct regex(es)`;
|
|
189
|
+
process.stdout.write(head + '\n');
|
|
190
|
+
|
|
191
|
+
if (report.vulnerable === 0) {
|
|
192
|
+
process.stdout.write(c.green('✓ no ReDoS-vulnerable regexes confirmed\n'));
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
process.stdout.write('\n');
|
|
197
|
+
report.findings.forEach((f, i) => {
|
|
198
|
+
process.stdout.write(printFinding(c, f, i + 1) + '\n\n');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const exp = report.findings.filter((f) => f.complexity === 'exponential').length;
|
|
202
|
+
const poly = report.findings.length - exp;
|
|
203
|
+
process.stdout.write(
|
|
204
|
+
c.bold(`${report.vulnerable} vulnerable regex(es): `) +
|
|
205
|
+
`${c.red(`${exp} exponential`)}, ${c.yellow(`${poly} polynomial`)}\n`,
|
|
206
|
+
);
|
|
207
|
+
return opts.ci ? 2 : 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function main(argv) {
|
|
211
|
+
const opts = parseArgs(argv);
|
|
212
|
+
const c = mkColor(opts.color && process.stdout.isTTY && !process.env.NO_COLOR);
|
|
213
|
+
if (opts._error) return 2;
|
|
214
|
+
if (opts.help) { process.stdout.write(HELP); return 0; }
|
|
215
|
+
if (opts.version) { process.stdout.write(`redosray ${VERSION}\n`); return 0; }
|
|
216
|
+
|
|
217
|
+
if (opts.regex !== null) return runSingle(opts, c);
|
|
218
|
+
return runScan(opts, c);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = { main, parseArgs, sparkline };
|
|
222
|
+
|
|
223
|
+
if (require.main === module) {
|
|
224
|
+
main(process.argv.slice(2)).then((code) => { process.exit(code || 0); }).catch((e) => {
|
|
225
|
+
process.stderr.write(`redosray: ${e && e.stack || e}\n`);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
});
|
|
228
|
+
}
|
package/src/confirm.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { Worker } = require('node:worker_threads');
|
|
4
|
+
|
|
5
|
+
const WORKER = path.join(__dirname, 'worker.js');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run one regex match against `input` in an isolated worker, timing it and
|
|
9
|
+
* killing it if it exceeds `timeoutMs`. A timeout is itself the signal that the
|
|
10
|
+
* regex catastrophically backtracks on that input.
|
|
11
|
+
* @returns {Promise<{timedOut:boolean, ms:number, matched:boolean}>}
|
|
12
|
+
*/
|
|
13
|
+
function timeMatch(source, flags, input, timeoutMs) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const w = new Worker(WORKER, { workerData: { source, flags, input } });
|
|
16
|
+
const start = Date.now();
|
|
17
|
+
const timer = setTimeout(() => {
|
|
18
|
+
w.terminate();
|
|
19
|
+
resolve({ timedOut: true, ms: Date.now() - start, matched: false });
|
|
20
|
+
}, timeoutMs);
|
|
21
|
+
w.once('message', (m) => {
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
w.terminate();
|
|
24
|
+
resolve({ timedOut: false, ms: m.ns / 1e6, matched: m.matched });
|
|
25
|
+
});
|
|
26
|
+
w.once('error', () => {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
resolve({ timedOut: false, ms: Date.now() - start, matched: false, error: true });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Dynamically CONFIRM a ReDoS by feeding the regex an attack string of growing
|
|
35
|
+
* size and watching for super-linear blow-up. Returns the smallest input that
|
|
36
|
+
* pushed match time past `timeoutMs` (the shareable "proof"), or null if the
|
|
37
|
+
* regex stayed fast at every size (no confirmed vulnerability).
|
|
38
|
+
*
|
|
39
|
+
* @param attack {{prefix?:string, pump:string, suffix?:string}}
|
|
40
|
+
*/
|
|
41
|
+
async function confirm(source, flags, attack, opts = {}) {
|
|
42
|
+
const timeoutMs = opts.timeoutMs ?? 1000;
|
|
43
|
+
const maxPumps = opts.maxPumps ?? 100000;
|
|
44
|
+
const prefix = attack.prefix ?? '';
|
|
45
|
+
const suffix = attack.suffix ?? '';
|
|
46
|
+
const samples = [];
|
|
47
|
+
// Grow geometrically so exponential blow-up is caught in a handful of steps
|
|
48
|
+
// and polynomial blow-up within a few dozen.
|
|
49
|
+
for (let n = 10; n <= maxPumps; n = Math.ceil(n * 1.6)) {
|
|
50
|
+
const input = prefix + attack.pump.repeat(n) + suffix;
|
|
51
|
+
const r = await timeMatch(source, flags, input, timeoutMs);
|
|
52
|
+
samples.push({ pumps: n, length: input.length, ms: r.ms, timedOut: r.timedOut });
|
|
53
|
+
if (r.timedOut) {
|
|
54
|
+
return {
|
|
55
|
+
vulnerable: true,
|
|
56
|
+
proof: { input, pumps: n, length: input.length, timeoutMs },
|
|
57
|
+
samples,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { vulnerable: false, samples };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { timeMatch, confirm };
|
package/src/extract.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract regex literals from source code, dependency-free, language-aware.
|
|
5
|
+
*
|
|
6
|
+
* The goal is NOT a full parser: it is to reliably find regex *sources* to feed
|
|
7
|
+
* the analyzer, with file:line:column, while avoiding obvious false extractions
|
|
8
|
+
* (division operators, comments, strings). We keep this conservative: it is fine
|
|
9
|
+
* to miss an odd construction (false negative) but we must not mis-slice code
|
|
10
|
+
* into a bogus "regex" that then wastes a dynamic confirmation.
|
|
11
|
+
*
|
|
12
|
+
* Supported:
|
|
13
|
+
* - JS/TS/JSX/TSX: `/pattern/flags` literals + `new RegExp("...", "flags")`
|
|
14
|
+
* - Python: `re.compile("...")`, `re.match/search/fullmatch/... ("...")`
|
|
15
|
+
* Returns array of { source, flags, line, column, raw, kind } (kind = 'literal'|'ctor').
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const JS_EXT = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts', '.cts']);
|
|
19
|
+
const PY_EXT = new Set(['.py', '.pyi']);
|
|
20
|
+
|
|
21
|
+
function extForPath(p) {
|
|
22
|
+
const m = /(\.[^.\/\\]+)$/.exec(p);
|
|
23
|
+
return m ? m[1].toLowerCase() : '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function languageForPath(p) {
|
|
27
|
+
const ext = extForPath(p);
|
|
28
|
+
if (JS_EXT.has(ext)) return 'js';
|
|
29
|
+
if (PY_EXT.has(ext)) return 'py';
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Precompute line-start offsets so we can map an index -> {line, column}.
|
|
34
|
+
function lineIndexer(text) {
|
|
35
|
+
const starts = [0];
|
|
36
|
+
for (let i = 0; i < text.length; i++) {
|
|
37
|
+
if (text[i] === '\n') starts.push(i + 1);
|
|
38
|
+
}
|
|
39
|
+
return function at(index) {
|
|
40
|
+
// binary search for the greatest start <= index
|
|
41
|
+
let lo = 0, hi = starts.length - 1;
|
|
42
|
+
while (lo < hi) {
|
|
43
|
+
const mid = (lo + hi + 1) >> 1;
|
|
44
|
+
if (starts[mid] <= index) lo = mid; else hi = mid - 1;
|
|
45
|
+
}
|
|
46
|
+
return { line: lo + 1, column: index - starts[lo] + 1 };
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A tiny JS tokenizing scanner that walks the source char-by-char, tracking
|
|
52
|
+
* whether we are inside a string / template / comment, so that a `/` is only
|
|
53
|
+
* treated as a regex start when the previous significant token allows it.
|
|
54
|
+
*/
|
|
55
|
+
function scanJs(text) {
|
|
56
|
+
const out = [];
|
|
57
|
+
const n = text.length;
|
|
58
|
+
let i = 0;
|
|
59
|
+
// Track the last significant (non-space, non-comment) character to decide if
|
|
60
|
+
// a `/` begins a regex (after operators, `(`, `,`, `=`, `:`, `[`, `!`, `&`,
|
|
61
|
+
// `|`, `?`, `{`, `;`, `return`, etc.) versus division (after value/ident/`)`).
|
|
62
|
+
let prevSig = '';
|
|
63
|
+
let prevWord = '';
|
|
64
|
+
|
|
65
|
+
const regexAllowedAfter = new Set([
|
|
66
|
+
'', '(', ',', '=', ':', '[', '!', '&', '|', '?', '{', ';', '+', '-', '*',
|
|
67
|
+
'%', '<', '>', '^', '~', '}', 'return',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
function isRegexStart() {
|
|
71
|
+
if (regexAllowedAfter.has(prevSig)) return true;
|
|
72
|
+
if (/^(return|typeof|instanceof|in|of|new|do|else|yield|await|case|void|delete)$/.test(prevWord)) return true;
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
while (i < n) {
|
|
77
|
+
const c = text[i];
|
|
78
|
+
// line comment
|
|
79
|
+
if (c === '/' && text[i + 1] === '/') {
|
|
80
|
+
i += 2;
|
|
81
|
+
while (i < n && text[i] !== '\n') i++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// block comment
|
|
85
|
+
if (c === '/' && text[i + 1] === '*') {
|
|
86
|
+
i += 2;
|
|
87
|
+
while (i < n && !(text[i] === '*' && text[i + 1] === '/')) i++;
|
|
88
|
+
i += 2;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// strings
|
|
92
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
93
|
+
const quote = c;
|
|
94
|
+
i++;
|
|
95
|
+
while (i < n) {
|
|
96
|
+
if (text[i] === '\\') { i += 2; continue; }
|
|
97
|
+
if (text[i] === quote) { i++; break; }
|
|
98
|
+
// naive template handling: skip ${...} not needed for our purpose
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
prevSig = 'str'; prevWord = '';
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// regex literal
|
|
105
|
+
if (c === '/' && isRegexStart()) {
|
|
106
|
+
const start = i;
|
|
107
|
+
i++;
|
|
108
|
+
let inClass = false;
|
|
109
|
+
let ok = false;
|
|
110
|
+
let body = '';
|
|
111
|
+
while (i < n) {
|
|
112
|
+
const d = text[i];
|
|
113
|
+
if (d === '\\') { body += d + (text[i + 1] || ''); i += 2; continue; }
|
|
114
|
+
if (d === '\n') break; // unterminated -> not a regex
|
|
115
|
+
if (d === '[') inClass = true;
|
|
116
|
+
else if (d === ']') inClass = false;
|
|
117
|
+
else if (d === '/' && !inClass) { ok = true; break; }
|
|
118
|
+
body += d;
|
|
119
|
+
i++;
|
|
120
|
+
}
|
|
121
|
+
if (ok) {
|
|
122
|
+
i++; // consume closing /
|
|
123
|
+
let flags = '';
|
|
124
|
+
while (i < n && /[a-z]/i.test(text[i])) { flags += text[i]; i++; }
|
|
125
|
+
// ignore trivially-empty or clearly-not-regex bodies
|
|
126
|
+
if (body.length > 0) {
|
|
127
|
+
out.push({ index: start, source: body, flags, raw: '/' + body + '/' + flags, kind: 'literal' });
|
|
128
|
+
}
|
|
129
|
+
prevSig = 'regex'; prevWord = '';
|
|
130
|
+
continue;
|
|
131
|
+
} else {
|
|
132
|
+
// treat as division
|
|
133
|
+
i = start + 1;
|
|
134
|
+
prevSig = '/'; prevWord = '';
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// new RegExp("...", "flags") / RegExp('...')
|
|
139
|
+
if ((c === 'R') && /RegExp/.test(text.slice(i, i + 6)) && /\bRegExp$/.test(text.slice(0, i + 6))) {
|
|
140
|
+
// fallthrough to word handling below; ctor handled by regex on whole text later
|
|
141
|
+
}
|
|
142
|
+
// identifiers / words
|
|
143
|
+
if (/[A-Za-z_$]/.test(c)) {
|
|
144
|
+
let w = '';
|
|
145
|
+
const s = i;
|
|
146
|
+
while (i < n && /[A-Za-z0-9_$]/.test(text[i])) { w += text[i]; i++; }
|
|
147
|
+
prevWord = w; prevSig = 'ident';
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// whitespace
|
|
151
|
+
if (/\s/.test(c)) { i++; continue; }
|
|
152
|
+
// any other single significant char
|
|
153
|
+
prevSig = c; prevWord = '';
|
|
154
|
+
i++;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// new RegExp("src" [, "flags"]) — string-literal args only (dynamic args are
|
|
160
|
+
// out of scope; we cannot statically know their value).
|
|
161
|
+
function scanCtorArgs(text, ctorRe) {
|
|
162
|
+
const out = [];
|
|
163
|
+
let m;
|
|
164
|
+
const re = new RegExp(ctorRe.source, 'g');
|
|
165
|
+
while ((m = re.exec(text)) !== null) {
|
|
166
|
+
const quote = m[1];
|
|
167
|
+
let raw = m[2];
|
|
168
|
+
const flags = m[4] || '';
|
|
169
|
+
// Unescape the string-literal one level so it becomes a real regex source.
|
|
170
|
+
let source;
|
|
171
|
+
try {
|
|
172
|
+
source = quote === '`'
|
|
173
|
+
? raw
|
|
174
|
+
: JSON.parse('"' + raw.replace(/\\'/g, "'").replace(/"/g, '\\"') + '"');
|
|
175
|
+
} catch {
|
|
176
|
+
source = raw.replace(/\\(.)/g, '$1');
|
|
177
|
+
}
|
|
178
|
+
if (source && source.length > 0) {
|
|
179
|
+
out.push({ index: m.index, source, flags, raw: m[0], kind: 'ctor' });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function extractJs(text) {
|
|
186
|
+
const found = scanJs(text);
|
|
187
|
+
// new RegExp("...", "...") — allow single/double/backtick quotes
|
|
188
|
+
const ctorRe = /\bRegExp\s*\(\s*(["'`])((?:\\.|(?!\1).)*)\1\s*(?:,\s*(["'`])([a-z]*)\3\s*)?\)/;
|
|
189
|
+
return found.concat(scanCtorArgs(text, ctorRe));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function extractPy(text) {
|
|
193
|
+
// re.compile(r"...") / re.match(r'...') / re.search("...") etc.
|
|
194
|
+
const reCall = /\bre\.(?:compile|match|search|fullmatch|findall|finditer|sub|subn|split)\s*\(\s*(r?)(["'])((?:\\.|(?!\2).)*)\2/g;
|
|
195
|
+
const out = [];
|
|
196
|
+
let m;
|
|
197
|
+
while ((m = reCall.exec(text)) !== null) {
|
|
198
|
+
const rawFlag = m[1];
|
|
199
|
+
const body = m[3];
|
|
200
|
+
let source = body;
|
|
201
|
+
if (!rawFlag) {
|
|
202
|
+
// non-raw string: collapse one level of Python escapes for regex meaning
|
|
203
|
+
source = body.replace(/\\(.)/g, (mm, ch) => (ch === '\\' ? '\\\\' : '\\' + ch));
|
|
204
|
+
}
|
|
205
|
+
if (source && source.length > 0) {
|
|
206
|
+
out.push({ index: m.index, source, flags: '', raw: m[0], kind: 'ctor' });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Extract regex sources from a file's text.
|
|
214
|
+
* @param {string} text file contents
|
|
215
|
+
* @param {string} path file path (used for language detection)
|
|
216
|
+
* @returns {Array<{source,flags,line,column,raw,kind}>}
|
|
217
|
+
*/
|
|
218
|
+
function extractFromText(text, path) {
|
|
219
|
+
const lang = languageForPath(path) || 'js';
|
|
220
|
+
const raw = lang === 'py' ? extractPy(text) : extractJs(text);
|
|
221
|
+
const at = lineIndexer(text);
|
|
222
|
+
return raw.map((r) => {
|
|
223
|
+
const pos = at(r.index);
|
|
224
|
+
return { source: r.source, flags: r.flags, line: pos.line, column: pos.column, raw: r.raw, kind: r.kind };
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = { extractFromText, languageForPath, JS_EXT, PY_EXT };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Public library API for redosray.
|
|
3
|
+
const { scanRegex } = require('./scan');
|
|
4
|
+
const { findCandidates, shapeOf } = require('./analyze');
|
|
5
|
+
const { parse } = require('./parser');
|
|
6
|
+
const { confirm, timeMatch } = require('./confirm');
|
|
7
|
+
const { scanPaths, collectFiles } = require('./scanFiles');
|
|
8
|
+
const { extractFromText } = require('./extract');
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
scanRegex, // async: static candidates + dynamic confirmation -> proof
|
|
12
|
+
scanPaths, // async: scan files/dirs for vulnerable regexes -> report
|
|
13
|
+
collectFiles, // list scannable source files under roots
|
|
14
|
+
extractFromText,// pull regex literals (+file position) from source text
|
|
15
|
+
findCandidates, // static only: list attack candidates
|
|
16
|
+
parse, // regex -> AST
|
|
17
|
+
shapeOf, // AST -> source-ish string
|
|
18
|
+
confirm, // dynamic confirmation of one attack
|
|
19
|
+
timeMatch, // time a single regex match in an isolated worker
|
|
20
|
+
};
|