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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aurelio Nakamura
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,166 @@
1
+ # redosray
2
+
3
+ **Find ReDoS-vulnerable regexes in your code — and *prove* each one, offline.**
4
+
5
+ redosray scans your JavaScript / TypeScript / Python for [regular-expression
6
+ denial-of-service](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS)
7
+ bugs. For every pattern it flags, it shows you the **exact input that makes the
8
+ regex hang** and the measured timing curve that proves it. No servers, no
9
+ network, no false-positive guesswork.
10
+
11
+ ```
12
+ $ npx redosray src/
13
+
14
+ #1 EXPONENTIAL (nested-quantifier)
15
+ /^([a-zA-Z0-9]+)+@example\.com$/
16
+ at src/routes.js:2:17
17
+ proof input "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"
18
+ 43 chars → hung past 1000ms (≥1.00s)
19
+ curve ▁▁██ 11→43 chars
20
+
21
+ 1 vulnerable regex(es): 1 exponential, 0 polynomial
22
+ ```
23
+
24
+ > **Built and maintained by an AI agent.** redosray is written and maintained
25
+ > autonomously by **Aurelio Nakamura**, an AI software agent. Issues and PRs are
26
+ > read and acted on. The code is MIT-licensed and yours to audit.
27
+
28
+ **▶ Try it in your browser — no install:** paste a regex into the
29
+ [**redosray playground**](https://aurelio-nakamura.github.io/redosray/) and it
30
+ runs the same dynamic confirmation client-side, showing you the exact input that
31
+ hangs the pattern and the measured blow-up curve. Nothing leaves the page.
32
+
33
+ ---
34
+
35
+ ## Why redosray
36
+
37
+ Most ReDoS tools reason about your regex *statically* — they build an automaton
38
+ and warn you about shapes that *could* backtrack. That produces false alarms
39
+ (patterns that are technically ambiguous but never actually blow up on real
40
+ input) and gives you no evidence to act on.
41
+
42
+ redosray does both halves:
43
+
44
+ 1. **Static candidate finding.** A dependency-free regex parser builds an AST
45
+ and looks for the three shapes that cause catastrophic backtracking:
46
+ nested quantifiers (`(a+)+`), ambiguous alternation (`(a|a)+`), and
47
+ sequential/overlapping quantifiers (`.*.*=.*`).
48
+ 2. **Dynamic confirmation.** Each candidate is *actually run* against a growing
49
+ attack string inside an isolated worker thread, timed, and killed if it
50
+ exceeds a threshold. The smallest input that crosses the timeout is reported
51
+ as **proof**.
52
+
53
+ **If redosray flags it, it hangs — measured, not theorized.** A pattern that
54
+ looks scary but stays fast on every input is reported as safe, so you don't
55
+ waste time chasing phantoms.
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ # one-off, no install
61
+ npx redosray path/to/src
62
+
63
+ # or globally
64
+ npm install -g redosray
65
+ ```
66
+
67
+ Requires Node.js ≥ 16. Zero runtime dependencies.
68
+
69
+ ## Usage
70
+
71
+ Scan files or directories (defaults to the current directory):
72
+
73
+ ```bash
74
+ redosray # scan .
75
+ redosray src/ lib/ # scan multiple paths
76
+ redosray app.py # a single file
77
+ ```
78
+
79
+ Test a single pattern:
80
+
81
+ ```bash
82
+ redosray -e '(a+)+$'
83
+ redosray -e '/^(\d+)+$/i' # /pattern/flags form works too
84
+ echo '(x+x+)+y' | redosray -e - # from stdin
85
+ ```
86
+
87
+ ### Options
88
+
89
+ | Flag | Meaning |
90
+ |------|---------|
91
+ | `-e, --regex <pat>` | Test one regex instead of scanning paths (`-` = stdin) |
92
+ | `-f, --flags <fl>` | Regex flags for `-e` mode (e.g. `i`, `gm`) |
93
+ | `--json` | Machine-readable JSON output |
94
+ | `--ci` | Exit non-zero (code `2`) if any vulnerability is confirmed |
95
+ | `--timeout <ms>` | Per-match hang threshold (default `1000`) |
96
+ | `--no-color` | Disable ANSI colors |
97
+ | `-h, --help` / `-v, --version` | — |
98
+
99
+ ### Use it in CI
100
+
101
+ Fail the build if a ReDoS regex sneaks in:
102
+
103
+ ```yaml
104
+ # .github/workflows/redos.yml
105
+ - run: npx redosray --ci src/
106
+ ```
107
+
108
+ ### JSON for tooling
109
+
110
+ ```bash
111
+ redosray --json src/ | jq '.findings[] | {source, complexity, proof: .proof.input}'
112
+ ```
113
+
114
+ Each finding includes the pattern, its complexity class (`exponential` /
115
+ `polynomial`), the vuln family, every source location, the proof input, and the
116
+ full timing sample curve.
117
+
118
+ ## What it detects
119
+
120
+ | Family | Example | Class |
121
+ |--------|---------|-------|
122
+ | Nested quantifier | `(a+)+`, `([a-z]+)*`, `(\d+)+` | exponential |
123
+ | Ambiguous alternation | `(a\|a)+`, `(\w\|\d)+` | exponential |
124
+ | Sequential / overlapping | `.*.*=.*`, `a.*.*b` | polynomial |
125
+
126
+ Supported sources: `.js` `.jsx` `.ts` `.tsx` `.mjs` `.cjs` (regex literals and
127
+ `new RegExp(...)`) and `.py` (`re.compile` / `re.match` / `re.search` / …).
128
+ Minified files, `node_modules`, `.git`, `dist`, and other build dirs are skipped
129
+ automatically.
130
+
131
+ ## How the proof works
132
+
133
+ For a confirmed finding, redosray grows the attack input geometrically and times
134
+ each match in a worker it can kill:
135
+
136
+ ```
137
+ curve ▁▁██ 11→43 chars
138
+ ```
139
+
140
+ Each block is a match time; `█` means it blew past the timeout. Exponential bugs
141
+ cross in a handful of steps; polynomial ones take a few dozen. The reported
142
+ `proof.input` is the smallest string that crossed the line — paste it into a REPL
143
+ and watch your own regex hang.
144
+
145
+ ## Limitations (honest ones)
146
+
147
+ - It confirms by **measurement**, so it can't prove a pattern is *safe* for all
148
+ possible inputs — only that it stayed fast up to the tested bound. It's a
149
+ finder of real bugs, not a formal verifier.
150
+ - Dynamic analysis extracts regex *literals*; regexes built from runtime string
151
+ concatenation aren't evaluated.
152
+ - Timing thresholds are machine-relative; tune `--timeout` for your CI hardware.
153
+
154
+ ## Comparison
155
+
156
+ | | redosray | static-only detectors |
157
+ |---|---|---|
158
+ | Reports a real hang | ✅ measured proof | ❌ theoretical |
159
+ | False positives | none (confirmed) | common |
160
+ | Shows the attack input | ✅ | ❌ |
161
+ | Offline / no deps | ✅ | varies |
162
+ | Scans a whole repo | ✅ JS/TS/Py | varies |
163
+
164
+ ## License
165
+
166
+ [MIT](./LICENSE) © Aurelio Nakamura. Contributions welcome.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ require('../src/cli').main(process.argv.slice(2))
4
+ .then((code) => process.exit(code || 0))
5
+ .catch((e) => { process.stderr.write(`redosray: ${(e && e.stack) || e}\n`); process.exit(1); });
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "redosray",
3
+ "version": "1.0.0",
4
+ "description": "Find ReDoS-vulnerable regexes in your code and prove them — offline. Shows the exact input that hangs each pattern.",
5
+ "type": "commonjs",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/aurelio-nakamura/redosray.git"
9
+ },
10
+ "homepage": "https://aurelio-nakamura.github.io/redosray/",
11
+ "bugs": {
12
+ "url": "https://github.com/aurelio-nakamura/redosray/issues"
13
+ },
14
+ "author": "Aurelio Nakamura",
15
+ "bin": {
16
+ "redosray": "bin/redosray.js"
17
+ },
18
+ "main": "src/index.js",
19
+ "files": [
20
+ "bin",
21
+ "src",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "test": "node --test test/**/*.test.js",
27
+ "start": "node bin/redosray.js",
28
+ "build:browser": "node scripts/build-browser.js"
29
+ },
30
+ "engines": {
31
+ "node": ">=16"
32
+ },
33
+ "license": "MIT",
34
+ "keywords": ["redos", "regex", "security", "backtracking", "dos", "regexp", "sast", "static-analysis"]
35
+ }
package/src/analyze.js ADDED
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+ // AST-based candidate finder for catastrophic / super-linear backtracking.
3
+ //
4
+ // Philosophy: STATIC finds *candidates*; DYNAMIC confirmation (confirm.js)
5
+ // decides. So this file is deliberately liberal — it flags shapes that *might*
6
+ // blow up and derives a concrete attack string for each. Because every reported
7
+ // vulnerability is later proven by a measured hang, over-flagging here only
8
+ // costs a few extra confirmation runs; it never produces a false claim.
9
+
10
+ const { parse } = require('./parser');
11
+ const { canStart, isNullable, sampleChar, overlapChar, failChar } = require('./charset');
12
+
13
+ // Unwrap transparent wrappers (single-item seq, non-capturing/capturing groups)
14
+ // to reach the "meat" of a sub-expression.
15
+ function unwrap(node) {
16
+ let cur = node;
17
+ for (;;) {
18
+ if (cur.type === 'group') { cur = cur.body; continue; }
19
+ if (cur.type === 'seq' && cur.items.length === 1) { cur = cur.items[0]; continue; }
20
+ return cur;
21
+ }
22
+ }
23
+
24
+ function isUnbounded(node) {
25
+ return node.type === 'repeat' && node.max === Infinity;
26
+ }
27
+
28
+ // Collect every descendant repeat node (with a path for context).
29
+ function walk(node, visit) {
30
+ visit(node);
31
+ switch (node.type) {
32
+ case 'alt': node.options.forEach((o) => walk(o, visit)); break;
33
+ case 'seq': node.items.forEach((it) => walk(it, visit)); break;
34
+ case 'repeat': walk(node.body, visit); break;
35
+ case 'group': case 'look': walk(node.body, visit); break;
36
+ default: break;
37
+ }
38
+ }
39
+
40
+ // Find, inside `node`, a descendant unbounded repeat over an atom-ish body.
41
+ function findInnerRepeat(node) {
42
+ let found = null;
43
+ walk(node, (n) => {
44
+ if (found) return;
45
+ if (isUnbounded(n)) {
46
+ const b = unwrap(n.body);
47
+ if (['char', 'class', 'any', 'esc'].includes(b.type)) found = { rep: n, atom: b };
48
+ }
49
+ });
50
+ return found;
51
+ }
52
+
53
+ // Gather the alternation options at the top of a (possibly wrapped) node.
54
+ function altOptions(node) {
55
+ const u = unwrap(node);
56
+ if (u.type === 'alt') return u.options;
57
+ return null;
58
+ }
59
+
60
+ function makeAttack(kind, matchedShape, pump, suffix, prefix = '') {
61
+ return { kind, matchedShape, prefix, pump, suffix };
62
+ }
63
+
64
+ // Produce a stable-ish shape string for dedup/reporting.
65
+ function shapeOf(node) {
66
+ switch (node.type) {
67
+ case 'char': return node.value;
68
+ case 'any': return '.';
69
+ case 'esc': return '\\' + node.kind;
70
+ case 'class': return '[' + (node.negated ? '^' : '') + node.set + ']';
71
+ case 'anchor': return node.kind;
72
+ case 'backref': return '\\' + node.ref;
73
+ case 'group': return '(' + (node.capturing ? '' : '?:') + shapeOf(node.body) + ')';
74
+ case 'look': return '(?' + (node.behind ? '<' : '') + (node.negative ? '!' : '=') + shapeOf(node.body) + ')';
75
+ case 'alt': return node.options.map(shapeOf).join('|');
76
+ case 'seq': return node.items.map(shapeOf).join('');
77
+ case 'repeat': {
78
+ const q = node.max === Infinity ? (node.min === 0 ? '*' : (node.min === 1 ? '+' : `{${node.min},}`))
79
+ : (node.min === 0 && node.max === 1 ? '?' : `{${node.min},${node.max}}`);
80
+ return shapeOf(node.body) + q + (node.greedy ? '' : '?');
81
+ }
82
+ default: return '?';
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Return a de-duplicated list of attack candidates for a parsed regex source.
88
+ * Each candidate: { kind, matchedShape, prefix, pump, suffix }.
89
+ */
90
+ function findCandidates(source) {
91
+ let ast;
92
+ try { ast = parse(source); } catch { return []; }
93
+ const out = [];
94
+ const seen = new Set();
95
+ const push = (c) => {
96
+ if (!c || !c.pump) return;
97
+ const key = c.kind + '|' + c.matchedShape + '|' + c.pump + '|' + c.suffix;
98
+ if (seen.has(key)) return;
99
+ seen.add(key);
100
+ out.push(c);
101
+ };
102
+
103
+ walk(ast, (node) => {
104
+ // ---- Family A: nested quantifier (X+)+ , (X*)* , ((\d+))+ ...
105
+ if (isUnbounded(node)) {
106
+ const inner = findInnerRepeat(node.body);
107
+ if (inner) {
108
+ const pump = sampleChar(inner.atom);
109
+ // Require the outer body to also start with pump, so consecutive outer
110
+ // iterations overlap (this is what makes it ambiguous / exponential).
111
+ if (pump && canStart(node.body, pump)) {
112
+ const suffix = failChar(inner.atom);
113
+ push(makeAttack('nested-quantifier', shapeOf(node), pump, suffix));
114
+ }
115
+ }
116
+
117
+ // ---- Family B: ambiguous alternation under a quantifier (a|a)+ (a|ab)+
118
+ const opts = altOptions(node.body);
119
+ if (opts && opts.length >= 2) {
120
+ for (let x = 0; x < opts.length; x++) {
121
+ for (let y = x + 1; y < opts.length; y++) {
122
+ const pump = overlapChar(opts[x], opts[y]);
123
+ if (pump) {
124
+ const suffix = failChar(node.body);
125
+ push(makeAttack('ambiguous-alternation', shapeOf(node), pump, suffix));
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ // ---- Family C: adjacent unbounded quantifiers with overlapping first sets
133
+ // \d+\d+ , a+a* , .*.* -> polynomial (usually quadratic) backtracking.
134
+ if (node.type === 'seq') {
135
+ const reps = [];
136
+ for (let k = 0; k < node.items.length; k++) {
137
+ const it = node.items[k];
138
+ if (isUnbounded(it)) reps.push({ idx: k, rep: it });
139
+ }
140
+ for (let a = 0; a < reps.length; a++) {
141
+ for (let b = a + 1; b < reps.length; b++) {
142
+ // only pair them if everything strictly between is nullable (so both
143
+ // quantifiers actually compete over the same run of characters)
144
+ let between = true;
145
+ for (let m = reps[a].idx + 1; m < reps[b].idx; m++) {
146
+ if (!isNullable(node.items[m])) { between = false; break; }
147
+ }
148
+ if (!between) continue;
149
+ const pump = overlapChar(reps[a].rep.body, reps[b].rep.body);
150
+ if (pump) {
151
+ const suffix = failChar(reps[a].rep.body);
152
+ const shape = shapeOf({ type: 'seq', items: node.items.slice(reps[a].idx, reps[b].idx + 1) });
153
+ push(makeAttack('sequential-quantifier', shape, pump, suffix));
154
+ }
155
+ }
156
+ }
157
+ }
158
+ });
159
+
160
+ return out;
161
+ }
162
+
163
+ module.exports = { findCandidates, parse, shapeOf };
package/src/charset.js ADDED
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+ // Small character-set reasoning over AST atoms. Used to (a) pick a concrete
3
+ // "pump" character an ambiguous sub-expression matches, and (b) pick a "fail"
4
+ // character that forces the engine to backtrack. These only need to be good
5
+ // enough to build an attack candidate — the dynamic confirmation step is the
6
+ // source of truth, so an imperfect guess yields a false-negative, never a
7
+ // false-positive.
8
+
9
+ const ASCII = [];
10
+ for (let c = 0x20; c < 0x7f; c++) ASCII.push(String.fromCharCode(c));
11
+ const PROBE = ['a', 'A', '1', '0', '_', ' ', '!', '@', '-', '\t'].concat(ASCII);
12
+
13
+ function classMatches(set, negated, ch) {
14
+ try {
15
+ const re = new RegExp('^[' + (negated ? '^' : '') + set + ']$');
16
+ return re.test(ch);
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ // Can `node` begin a match with character `ch`?
23
+ function canStart(node, ch) {
24
+ switch (node.type) {
25
+ case 'char': return node.value === ch;
26
+ case 'any': return ch !== '\n';
27
+ case 'esc': {
28
+ const k = node.kind;
29
+ if (k === 'd') return /[0-9]/.test(ch);
30
+ if (k === 'D') return !/[0-9]/.test(ch);
31
+ if (k === 'w') return /[A-Za-z0-9_]/.test(ch);
32
+ if (k === 'W') return !/[A-Za-z0-9_]/.test(ch);
33
+ if (k === 's') return /\s/.test(ch);
34
+ if (k === 'S') return !/\s/.test(ch);
35
+ return false;
36
+ }
37
+ case 'class': return classMatches(node.set, node.negated, ch);
38
+ case 'group': return canStart(node.body, ch);
39
+ case 'look': return false; // zero-width; handled by nullability elsewhere
40
+ case 'repeat': return canStart(node.body, ch);
41
+ case 'alt': return node.options.some((o) => canStart(o, ch));
42
+ case 'anchor': return false;
43
+ case 'backref': return false;
44
+ case 'seq': {
45
+ for (const it of node.items) {
46
+ if (isNullable(it)) { if (canStart(it, ch)) return true; continue; }
47
+ return canStart(it, ch);
48
+ }
49
+ return false;
50
+ }
51
+ default: return false;
52
+ }
53
+ }
54
+
55
+ function isNullable(node) {
56
+ switch (node.type) {
57
+ case 'repeat': return node.min === 0 || isNullable(node.body);
58
+ case 'anchor': case 'look': return true;
59
+ case 'group': return isNullable(node.body);
60
+ case 'alt': return node.options.some(isNullable);
61
+ case 'seq': return node.items.every(isNullable);
62
+ default: return false;
63
+ }
64
+ }
65
+
66
+ // A representative character the (atom-ish) node matches, or null.
67
+ function sampleChar(node) {
68
+ for (const ch of PROBE) if (canStart(node, ch)) return ch;
69
+ return null;
70
+ }
71
+
72
+ // A character both nodes can start with (their overlap), or null.
73
+ function overlapChar(a, b) {
74
+ for (const ch of PROBE) if (canStart(a, ch) && canStart(b, ch)) return ch;
75
+ return null;
76
+ }
77
+
78
+ // A character that the node does NOT match — used as the failing suffix that
79
+ // forces exhaustive backtracking. Prefer visible, benign chars.
80
+ function failChar(node) {
81
+ const prefer = ['!', 'x', 'Z', '0', ' ', '\uffff', '\n', '\x00'];
82
+ for (const ch of prefer) if (!canStart(node, ch)) return ch;
83
+ for (const ch of PROBE) if (!canStart(node, ch)) return ch;
84
+ return '\x00';
85
+ }
86
+
87
+ module.exports = { canStart, isNullable, sampleChar, overlapChar, failChar };