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.
Files changed (3) hide show
  1. package/README.md +36 -1
  2. package/package.json +2 -2
  3. package/src/analyze.js +103 -13
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # redosray
2
2
 
3
+ [![CI](https://github.com/aurelio-nakamura/redosray/actions/workflows/ci.yml/badge.svg)](https://github.com/aurelio-nakamura/redosray/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/redosray.svg)](https://www.npmjs.com/package/redosray)
5
+ [![node](https://img.shields.io/node/v/redosray.svg)](https://www.npmjs.com/package/redosray)
6
+ [![license](https://img.shields.io/npm/l/redosray.svg)](./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.0.0",
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/**/*.test.js",
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`, a descendant unbounded repeat over an atom-ish body.
41
- function findInnerRepeat(node) {
42
- let found = null;
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 = { rep: n, atom: b };
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 inner = findInnerRepeat(node.body);
107
- if (inner) {
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 = failChar(inner.atom);
113
- push(makeAttack('nested-quantifier', shapeOf(node), pump, suffix));
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 = failChar(node.body);
125
- push(makeAttack('ambiguous-alternation', shapeOf(node), pump, suffix));
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 suffix = failChar(reps[a].rep.body);
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
- push(makeAttack('sequential-quantifier', shape, pump, suffix));
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
  }