redosray 1.0.0 → 1.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/README.md +36 -1
- package/package.json +2 -2
- package/src/analyze.js +103 -13
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# redosray
|
|
2
2
|
|
|
3
|
+
[](https://github.com/aurelio-nakamura/redosray/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/redosray)
|
|
5
|
+
[](https://www.npmjs.com/package/redosray)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
3
8
|
**Find ReDoS-vulnerable regexes in your code — and *prove* each one, offline.**
|
|
4
9
|
|
|
5
10
|
redosray scans your JavaScript / TypeScript / Python for [regular-expression
|
|
@@ -98,13 +103,43 @@ echo '(x+x+)+y' | redosray -e - # from stdin
|
|
|
98
103
|
|
|
99
104
|
### Use it in CI
|
|
100
105
|
|
|
101
|
-
Fail the build if a ReDoS regex sneaks in
|
|
106
|
+
Fail the build if a ReDoS regex sneaks in.
|
|
107
|
+
|
|
108
|
+
**GitHub Actions** — drop in the action:
|
|
102
109
|
|
|
103
110
|
```yaml
|
|
104
111
|
# .github/workflows/redos.yml
|
|
112
|
+
name: ReDoS
|
|
113
|
+
on: [push, pull_request]
|
|
114
|
+
jobs:
|
|
115
|
+
redosray:
|
|
116
|
+
runs-on: ubuntu-latest
|
|
117
|
+
steps:
|
|
118
|
+
- uses: actions/checkout@v4
|
|
119
|
+
- uses: aurelio-nakamura/redosray@v1
|
|
120
|
+
with:
|
|
121
|
+
paths: src/ # optional (default: whole repo)
|
|
122
|
+
timeout: '1000' # optional, ms per match
|
|
123
|
+
# fail-on-vuln: false # report without failing the build
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Or just run the CLI directly in any CI:
|
|
127
|
+
|
|
128
|
+
```yaml
|
|
105
129
|
- run: npx redosray --ci src/
|
|
106
130
|
```
|
|
107
131
|
|
|
132
|
+
**pre-commit** — catch it before it's even committed:
|
|
133
|
+
|
|
134
|
+
```yaml
|
|
135
|
+
# .pre-commit-config.yaml
|
|
136
|
+
repos:
|
|
137
|
+
- repo: https://github.com/aurelio-nakamura/redosray
|
|
138
|
+
rev: v1.0.0
|
|
139
|
+
hooks:
|
|
140
|
+
- id: redosray
|
|
141
|
+
```
|
|
142
|
+
|
|
108
143
|
### JSON for tooling
|
|
109
144
|
|
|
110
145
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "redosray",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Find ReDoS-vulnerable regexes in your code and prove them — offline. Shows the exact input that hangs each pattern.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"repository": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"LICENSE"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
|
-
"test": "node --test test
|
|
26
|
+
"test": "node --test --test-concurrency=1 test/*.test.js",
|
|
27
27
|
"start": "node bin/redosray.js",
|
|
28
28
|
"build:browser": "node scripts/build-browser.js"
|
|
29
29
|
},
|
package/src/analyze.js
CHANGED
|
@@ -37,14 +37,17 @@ function walk(node, visit) {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
// Find, inside `node`,
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
// Find, inside `node`, ALL descendant unbounded repeats over an atom-ish body.
|
|
41
|
+
// (We can't know statically which inner loop drives the blow-up — e.g. in
|
|
42
|
+
// `(([_]+)?([a-z0-9]+))*` the exploitable overlap is on `[a-z0-9]+`, not the
|
|
43
|
+
// first-encountered `[_]+` — so we return every candidate and let dynamic
|
|
44
|
+
// confirmation pick the real one. Extra candidates only cost a probe run.)
|
|
45
|
+
function findInnerRepeats(node) {
|
|
46
|
+
const found = [];
|
|
43
47
|
walk(node, (n) => {
|
|
44
|
-
if (found) return;
|
|
45
48
|
if (isUnbounded(n)) {
|
|
46
49
|
const b = unwrap(n.body);
|
|
47
|
-
if (['char', 'class', 'any', 'esc'].includes(b.type)) found
|
|
50
|
+
if (['char', 'class', 'any', 'esc'].includes(b.type)) found.push({ rep: n, atom: b });
|
|
48
51
|
}
|
|
49
52
|
});
|
|
50
53
|
return found;
|
|
@@ -61,6 +64,76 @@ function makeAttack(kind, matchedShape, pump, suffix, prefix = '') {
|
|
|
61
64
|
return { kind, matchedShape, prefix, pump, suffix };
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
// A minimal concrete string that `node` can match (a "witness"). Used to build
|
|
68
|
+
// the prefix that lets the engine actually REACH a vulnerable loop buried in
|
|
69
|
+
// the middle of a pattern (e.g. the `<` and `[a-z]+` before `([^>]*)*`).
|
|
70
|
+
function witness(node) {
|
|
71
|
+
switch (node.type) {
|
|
72
|
+
case 'char': return node.value;
|
|
73
|
+
case 'any': return 'a';
|
|
74
|
+
case 'esc': {
|
|
75
|
+
const k = node.kind;
|
|
76
|
+
if (k === 'd') return '0';
|
|
77
|
+
if (k === 'D') return 'a';
|
|
78
|
+
if (k === 'w') return 'a';
|
|
79
|
+
if (k === 'W') return '!';
|
|
80
|
+
if (k === 's') return ' ';
|
|
81
|
+
if (k === 'S') return 'a';
|
|
82
|
+
if (k === 'b' || k === 'B') return '';
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
case 'class': return sampleChar(node) || 'a';
|
|
86
|
+
case 'anchor': case 'look': return '';
|
|
87
|
+
case 'backref': return '';
|
|
88
|
+
case 'group': return witness(node.body);
|
|
89
|
+
case 'alt': {
|
|
90
|
+
for (const o of node.options) { const w = witness(o); if (w !== '') return w; }
|
|
91
|
+
return witness(node.options[0]);
|
|
92
|
+
}
|
|
93
|
+
case 'seq': return node.items.map(witness).join('');
|
|
94
|
+
case 'repeat': {
|
|
95
|
+
const body = witness(node.body);
|
|
96
|
+
return body.repeat(Math.max(0, node.min));
|
|
97
|
+
}
|
|
98
|
+
default: return '';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Build a concrete string matching everything strictly BEFORE `target` in the
|
|
103
|
+
// pattern, so the engine reaches the vulnerable loop. Returns { found, prefix }.
|
|
104
|
+
function reachingPrefix(node, target) {
|
|
105
|
+
if (node === target) return { found: true, prefix: '' };
|
|
106
|
+
switch (node.type) {
|
|
107
|
+
case 'seq': {
|
|
108
|
+
let acc = '';
|
|
109
|
+
for (const it of node.items) {
|
|
110
|
+
const r = reachingPrefix(it, target);
|
|
111
|
+
if (r.found) return { found: true, prefix: acc + r.prefix };
|
|
112
|
+
acc += witness(it);
|
|
113
|
+
}
|
|
114
|
+
return { found: false, prefix: acc };
|
|
115
|
+
}
|
|
116
|
+
case 'group': {
|
|
117
|
+
const r = reachingPrefix(node.body, target);
|
|
118
|
+
return r.found ? { found: true, prefix: r.prefix } : { found: false, prefix: witness(node) };
|
|
119
|
+
}
|
|
120
|
+
case 'repeat': {
|
|
121
|
+
const r = reachingPrefix(node.body, target);
|
|
122
|
+
// entering the loop body once is enough to reach a target inside it
|
|
123
|
+
return r.found ? { found: true, prefix: r.prefix } : { found: false, prefix: witness(node) };
|
|
124
|
+
}
|
|
125
|
+
case 'alt': {
|
|
126
|
+
for (const o of node.options) {
|
|
127
|
+
const r = reachingPrefix(o, target);
|
|
128
|
+
if (r.found) return { found: true, prefix: r.prefix };
|
|
129
|
+
}
|
|
130
|
+
return { found: false, prefix: witness(node) };
|
|
131
|
+
}
|
|
132
|
+
default:
|
|
133
|
+
return { found: false, prefix: witness(node) };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
64
137
|
// Produce a stable-ish shape string for dedup/reporting.
|
|
65
138
|
function shapeOf(node) {
|
|
66
139
|
switch (node.type) {
|
|
@@ -100,17 +173,31 @@ function findCandidates(source) {
|
|
|
100
173
|
out.push(c);
|
|
101
174
|
};
|
|
102
175
|
|
|
176
|
+
// Suffixes to try after the pumped run. We over-generate: the true failing
|
|
177
|
+
// suffix might be a char the loop can't eat (forces `$`/next-token to fail),
|
|
178
|
+
// OR simply END-OF-STRING (empty) when a required trailing literal follows
|
|
179
|
+
// the loop (e.g. the final `>` in `<([a-z]+)([^>]*)*>`). Dynamic confirmation
|
|
180
|
+
// picks whichever actually hangs, so extra options only cost a probe run.
|
|
181
|
+
const suffixSet = (atom) => {
|
|
182
|
+
const s = new Set();
|
|
183
|
+
s.add(failChar(atom));
|
|
184
|
+
s.add('');
|
|
185
|
+
s.add('\uffff');
|
|
186
|
+
return [...s];
|
|
187
|
+
};
|
|
188
|
+
|
|
103
189
|
walk(ast, (node) => {
|
|
104
190
|
// ---- Family A: nested quantifier (X+)+ , (X*)* , ((\d+))+ ...
|
|
105
191
|
if (isUnbounded(node)) {
|
|
106
|
-
const
|
|
107
|
-
|
|
192
|
+
const pfx = reachingPrefix(ast, node).prefix;
|
|
193
|
+
for (const inner of findInnerRepeats(node.body)) {
|
|
108
194
|
const pump = sampleChar(inner.atom);
|
|
109
195
|
// Require the outer body to also start with pump, so consecutive outer
|
|
110
196
|
// iterations overlap (this is what makes it ambiguous / exponential).
|
|
111
197
|
if (pump && canStart(node.body, pump)) {
|
|
112
|
-
const suffix
|
|
113
|
-
|
|
198
|
+
for (const suffix of suffixSet(inner.atom)) {
|
|
199
|
+
push(makeAttack('nested-quantifier', shapeOf(node), pump, suffix, pfx));
|
|
200
|
+
}
|
|
114
201
|
}
|
|
115
202
|
}
|
|
116
203
|
|
|
@@ -121,8 +208,9 @@ function findCandidates(source) {
|
|
|
121
208
|
for (let y = x + 1; y < opts.length; y++) {
|
|
122
209
|
const pump = overlapChar(opts[x], opts[y]);
|
|
123
210
|
if (pump) {
|
|
124
|
-
const suffix
|
|
125
|
-
|
|
211
|
+
for (const suffix of suffixSet(node.body)) {
|
|
212
|
+
push(makeAttack('ambiguous-alternation', shapeOf(node), pump, suffix, pfx));
|
|
213
|
+
}
|
|
126
214
|
}
|
|
127
215
|
}
|
|
128
216
|
}
|
|
@@ -148,9 +236,11 @@ function findCandidates(source) {
|
|
|
148
236
|
if (!between) continue;
|
|
149
237
|
const pump = overlapChar(reps[a].rep.body, reps[b].rep.body);
|
|
150
238
|
if (pump) {
|
|
151
|
-
const
|
|
239
|
+
const pfx = reachingPrefix(ast, node.items[reps[a].idx]).prefix;
|
|
152
240
|
const shape = shapeOf({ type: 'seq', items: node.items.slice(reps[a].idx, reps[b].idx + 1) });
|
|
153
|
-
|
|
241
|
+
for (const suffix of suffixSet(reps[a].rep.body)) {
|
|
242
|
+
push(makeAttack('sequential-quantifier', shape, pump, suffix, pfx));
|
|
243
|
+
}
|
|
154
244
|
}
|
|
155
245
|
}
|
|
156
246
|
}
|