@rezalabs/safe-regex2 6.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/.gitattributes ADDED
@@ -0,0 +1,2 @@
1
+ # Set default behavior to automatically convert line endings
2
+ * text=auto eol=lf
package/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ ## 6.0.0 — 2026-05-18
4
+
5
+ Maintained fork of [fastify/safe-regex2](https://github.com/fastify/safe-regex2). First release under `@rezalabs/safe-regex2`.
6
+
7
+ ### Breaking changes
8
+
9
+ - **Dependency changed** — Uses `@rezalabs/ret` instead of `ret`. The parser is a drop-in replacement but the package name differs.
10
+
11
+ - **New exports** — `analyze()` and `fix()` added alongside the existing default export. Code that iterates over `require('safe-regex2')` keys may see new properties.
12
+
13
+ ### New features
14
+
15
+ - **Alternation-based ReDoS detection** — Alternatives inside a quantifier where one is a literal prefix of another (e.g., `(a|aa|aaa)+`) are now flagged as unsafe. Previously only nested repetition (star height > 1) was detected.
16
+
17
+ - **`analyze(re)` export** — Returns a detailed risk assessment with severity level (`none`, `low`, `high`, `critical`), specific reasons, diagnostic metrics (star height, repetition count, alternation overlap), anchoring and static suffix detection, and an auto-fix suggestion if available.
18
+
19
+ - **`fix(re)` export** — Attempts to produce a safe version of an unsafe regex by stripping redundant outer quantifiers or collapsing same-character alternatives. Every fix is verified safe before being returned.
20
+
21
+ - **Severity scoring** — `analyze()` assigns severity based on star height depth, alternation overlap, and repetition count. Patterns anchored with `^...$` or ending with a literal suffix have reduced severity.
22
+
23
+ ### Bug fixes
24
+
25
+ - **`reconstruct()` max handling** — Fixed `reconstruct()` producing `{1,null}` instead of `+` by converting `null` max values to `Infinity` when building fixed AST nodes.
26
+
27
+ ### Meta
28
+
29
+ - Package renamed to `@rezalabs/safe-regex2` (scoped) for npm publishing.
30
+ - Repository updated to `github.com/rezalabs/safe-regex2`.
31
+ - Original author and Fastify contributors credited in contributors section.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019-present The Fastify team <https://github.com/fastify/fastify#team>
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,204 @@
1
+ # safe-regex2
2
+
3
+ [![CI](https://github.com/rezalabs/safe-regex2/actions/workflows/ci.yml/badge.svg)](https://github.com/rezalabs/safe-regex2/actions/workflows/ci.yml)
4
+ [![NPM version](https://img.shields.io/npm/v/@rezalabs/safe-regex2.svg?style=flat)](https://www.npmjs.com/package/@rezalabs/safe-regex2)
5
+
6
+ Detect, analyze, and automatically fix regular expressions vulnerable to
7
+ catastrophic backtracking (ReDoS).
8
+
9
+ This package is a fork of [fastify/safe-regex2](https://github.com/fastify/safe-regex2),
10
+ which was itself a fork of the original `safe-regex` by James Halliday (substack).
11
+ The original author's GitHub account is no longer available, but the code lives on.
12
+
13
+ ## What it does
14
+
15
+ Three functions:
16
+
17
+ | Function | Returns | Purpose |
18
+ |----------|---------|---------|
19
+ | `safeRegex(re)` | `boolean` | Quick check: is this regex safe or not? |
20
+ | `analyze(re)` | `object` | Detailed risk assessment with severity scoring |
21
+ | `fix(re)` | `object` | Attempt to produce a safe version of an unsafe regex |
22
+
23
+ It detects two classes of ReDoS vulnerability:
24
+
25
+ 1. **Nested repetition** (star height > 1). Patterns like `(a+)+` or `(x+x+)+y`
26
+ where quantifiers are nested inside other quantifiers, creating exponential
27
+ backtracking paths.
28
+
29
+ 2. **Alternation prefix overlap**. Patterns like `(a|aa|aaa)+` where alternatives
30
+ inside a quantifier share a literal prefix, allowing the engine to partition
31
+ the same input in exponentially many ways.
32
+
33
+ ## Install
34
+
35
+ ```sh
36
+ npm install @rezalabs/safe-regex2
37
+ ```
38
+
39
+ ## Quick check
40
+
41
+ ```js
42
+ const safe = require('@rezalabs/safe-regex2')
43
+
44
+ safe('(beep|boop)*') // true
45
+ safe('(a+)+') // false
46
+ safe('(a|aa|aaa)+') // false
47
+ ```
48
+
49
+ The input can be a `RegExp` object or a string. Invalid regex strings return `false`.
50
+
51
+ ## Analyze
52
+
53
+ The `analyze()` function returns a detailed report instead of a boolean.
54
+
55
+ ```js
56
+ const { analyze } = require('@rezalabs/safe-regex2')
57
+
58
+ const result = analyze('(a+)+y')
59
+ ```
60
+
61
+ Returns:
62
+
63
+ ```js
64
+ {
65
+ safe: false,
66
+ severity: 'low', // 'none' | 'low' | 'high' | 'critical'
67
+ reasons: [
68
+ 'Nested repetition detected (star height 2)'
69
+ ],
70
+ starHeight: 2,
71
+ repCount: 2,
72
+ hasAlternationReDoS: false,
73
+ anchored: false,
74
+ hasStaticSuffix: true, // the 'y' at the end reduces practical risk
75
+ fix: '(a+)y' // auto-generated safe version
76
+ }
77
+ ```
78
+
79
+ ### Severity levels
80
+
81
+ | Level | Meaning |
82
+ |-------|---------|
83
+ | `none` | No issues detected. The regex is safe. |
84
+ | `low` | Minor issues or structural issues mitigated by anchoring or a static suffix. |
85
+ | `high` | Nested repetition or alternation prefix overlap. Real ReDoS risk. |
86
+ | `critical` | Deeply nested repetition (star height 3+). Extreme risk. |
87
+
88
+ Mitigating factors lower severity by one level. If a pattern is anchored
89
+ (`^...$`) or ends with a literal character suffix, the practical risk of
90
+ catastrophic backtracking is reduced because the engine's backtracking is
91
+ constrained.
92
+
93
+ ## Auto-fix
94
+
95
+ The `fix()` function attempts to produce a safe version of an unsafe regex.
96
+
97
+ ```js
98
+ const { fix } = require('@rezalabs/safe-regex2')
99
+
100
+ fix('(a+)+y')
101
+ // { safe: false, fixed: '(a+)y', original: '(a+)+y' }
102
+
103
+ fix('(a|aa|aaa)+')
104
+ // { safe: false, fixed: 'a+', original: '(a|aa|aaa)+' }
105
+
106
+ fix('^[a-z]+$')
107
+ // { safe: true, fixed: null, original: '^[a-z]+$' }
108
+ ```
109
+
110
+ Current fix strategies:
111
+
112
+ - **Strip redundant outer quantifiers.** `(a+)+y` becomes `(a+)y`. The inner
113
+ quantifier already provides the repetition; the outer one only creates
114
+ backtracking paths.
115
+
116
+ - **Collapse same-character alternatives.** `(a|aa|aaa)+` becomes `a+`. When
117
+ all alternatives are sequences of the same character, a single quantifier
118
+ covers all of them.
119
+
120
+ Every suggested fix is verified to be safe before being returned. If no safe
121
+ fix can be generated, `fixed` is `null`.
122
+
123
+ Note: auto-fix preserves matching behavior but may change capture group
124
+ semantics. Verify the fix against your intended behavior.
125
+
126
+ ## CLI
127
+
128
+ ```sh
129
+ npx @rezalabs/safe-regex2 '(x+x+)+y'
130
+ ```
131
+
132
+ ## Options
133
+
134
+ Both `safeRegex()`, `analyze()`, and `fix()` accept an optional `limit` parameter:
135
+
136
+ ```js
137
+ safe(pattern, { limit: 50 })
138
+ analyze(pattern, { limit: 50 })
139
+ fix(pattern, { limit: 50 })
140
+ ```
141
+
142
+ `limit` controls the maximum number of repetitions allowed across the entire
143
+ regex. Default is `25`. Patterns exceeding this limit are flagged.
144
+
145
+ ## API
146
+
147
+ ```js
148
+ const safe = require('@rezalabs/safe-regex2')
149
+ ```
150
+
151
+ ### `safe(re, opts?)` -> `boolean`
152
+
153
+ Returns `true` if the regex is safe, `false` if it is potentially catastrophic
154
+ or syntactically invalid.
155
+
156
+ ### `safe.analyze(re, opts?)` -> `object`
157
+
158
+ Returns a detailed risk assessment. See the Analyze section above.
159
+
160
+ ### `safe.fix(re, opts?)` -> `object`
161
+
162
+ Returns `{ safe, fixed, original }`. See the Auto-fix section above.
163
+
164
+ ## How it works
165
+
166
+ The regex pattern is parsed into an AST using `@rezalabs/ret`. The walker then
167
+ traverses the tree and checks:
168
+
169
+ 1. **Star height.** Each `REPETITION` node increments the star height counter.
170
+ When it exceeds 1, the regex has nested quantifiers and exponential
171
+ backtracking paths.
172
+
173
+ 2. **Repetition count.** The total number of `REPETITION` nodes is compared
174
+ against the limit. Too many quantifiers in one pattern is a risk indicator.
175
+
176
+ 3. **Alternation overlap.** For each quantifier containing alternatives,
177
+ the literal prefixes of each alternative are compared. If one prefix is
178
+ a prefix of another, the engine can partition matching input in
179
+ combinatorially many ways.
180
+
181
+ ## Limitations
182
+
183
+ This is a heuristic analyzer, not a formal verification tool. It has both
184
+ false positives (flagging safe patterns) and false negatives (missing unsafe
185
+ ones). Known gaps:
186
+
187
+ - Alternation overlap is only detected for literal character prefixes.
188
+ Patterns like `(\\d|\\w)+` where character classes overlap are not caught.
189
+ - The auto-fix cannot safely rewrite general alternation overlap like
190
+ `(ab|abc)+`. Such patterns require a non-regex parser.
191
+ - Some patterns with star height 2 are flagged but are actually safe in
192
+ practice due to their structure.
193
+
194
+ ## Credits
195
+
196
+ - **James Halliday** (substack) wrote the original `safe-regex` package.
197
+ - The **Fastify** team maintained it as `safe-regex2` with TypeScript types,
198
+ modern tooling, and updated dependencies.
199
+ - **RezaLabs** added alternation-based detection, risk analysis, auto-fix,
200
+ and continues maintenance.
201
+
202
+ ## License
203
+
204
+ [MIT](./LICENSE)
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ const { parseArgs } = require('node:util')
5
+ const { safeRegex } = require('../index.js')
6
+
7
+ const { version } = require('../package.json')
8
+
9
+ const { values: options, positionals } = parseArgs({
10
+ allowPositionals: true,
11
+ options: {
12
+ version: {
13
+ type: 'boolean',
14
+ short: 'v',
15
+ default: false,
16
+ },
17
+ help: {
18
+ type: 'boolean',
19
+ short: 'h',
20
+ default: false,
21
+ }
22
+ },
23
+ })
24
+
25
+ function help () {
26
+ console.log(`Usage: safe-regex2 [options] <regex>
27
+
28
+ Check if a regular expression is safe to use in a production environment.
29
+
30
+ Options:
31
+ -v, --version Display the version number
32
+ -h, --help Display this help message
33
+ <regex> The regular expression to check`
34
+ )
35
+ }
36
+
37
+ if (options.help) {
38
+ help()
39
+ } else if (options.version) {
40
+ console.log(version)
41
+ } else {
42
+ if (positionals.length === 0) {
43
+ help()
44
+ } else if (positionals.length > 1) {
45
+ console.error('Error: Too many positional arguments.')
46
+ help()
47
+ } else {
48
+ const regex = positionals[0]
49
+ const isSafe = safeRegex(regex)
50
+ if (isSafe === false) {
51
+ console.error('Provided regex is invalid or unsafe.')
52
+ process.exit(1)
53
+ } else {
54
+ console.log('Provided regex is safe.')
55
+ process.exit(0)
56
+ }
57
+ }
58
+ }
package/index.js ADDED
@@ -0,0 +1,663 @@
1
+ 'use strict'
2
+
3
+ const parse = require('@rezalabs/ret')
4
+ const { types } = require('@rezalabs/ret')
5
+ const { reconstruct } = require('@rezalabs/ret')
6
+
7
+ /**
8
+ * Extracts the leading literal character code points from a token stack.
9
+ * Stops at the first non-CHAR token (e.g., SET, GROUP, REPETITION).
10
+ * Used to compare literal prefixes between alternatives for ReDoS detection.
11
+ *
12
+ * @param {Array} stack - Array of AST tokens forming one alternative
13
+ * @returns {number[]} Array of character code points
14
+ */
15
+ function getLiteralPrefix (stack) {
16
+ const chars = []
17
+ for (let i = 0; i < stack.length; i++) {
18
+ if (stack[i].type === types.CHAR) {
19
+ chars.push(stack[i].value)
20
+ } else {
21
+ break
22
+ }
23
+ }
24
+ return chars
25
+ }
26
+
27
+ /**
28
+ * Checks whether alternatives inside a quantifier have overlapping literal
29
+ * prefixes, which causes catastrophic backtracking.
30
+ *
31
+ * When one alternative is a prefix of another (e.g., `a` vs `aa` inside `+`),
32
+ * the regex engine can partition the same input in exponentially many ways.
33
+ * Example: `(a|aa|aaa)+` on a string of `a`s — 2^(n-1) paths.
34
+ *
35
+ * @param {Array} options - Array of alternative token stacks
36
+ * @returns {boolean} true if any pair has a prefix-overlap problem
37
+ */
38
+ function hasAlternationReDoS (options) {
39
+ if (!Array.isArray(options) || options.length < 2) return false
40
+
41
+ const prefixes = options.map(function (opt) {
42
+ return getLiteralPrefix(opt)
43
+ }).filter(function (p) {
44
+ return p.length > 0
45
+ })
46
+
47
+ // Need at least two alternatives with literal prefixes to have overlap
48
+ if (prefixes.length < 2) return false
49
+
50
+ for (let i = 0; i < prefixes.length; i++) {
51
+ for (let j = i + 1; j < prefixes.length; j++) {
52
+ const a = prefixes[i]
53
+ const b = prefixes[j]
54
+
55
+ // Check if one is a prefix of the other (shorter is prefix of longer)
56
+ const shorter = a.length <= b.length ? a : b
57
+ const longer = a.length <= b.length ? b : a
58
+
59
+ let prefixMatch = true
60
+ for (let k = 0; k < shorter.length; k++) {
61
+ if (shorter[k] !== longer[k]) {
62
+ prefixMatch = false
63
+ break
64
+ }
65
+ }
66
+
67
+ if (prefixMatch) return true
68
+ }
69
+ }
70
+
71
+ return false
72
+ }
73
+
74
+ /**
75
+ * Recursively searches a subtree for alternatives with overlapping literal
76
+ * prefixes. Used to detect alternation-based ReDoS that may be nested
77
+ * inside groups within a quantifier (e.g., `(?:(a|aa|aaa))+` where the
78
+ * alternatives are one level deeper than the REPETITION node).
79
+ *
80
+ * @param {*} node - AST node to search
81
+ * @returns {boolean} true if overlapping alternatives are found
82
+ */
83
+ function findOverlappingAlternatives (node) {
84
+ if (!node || typeof node !== 'object') return false
85
+
86
+ // If this node has alternatives (options), check for prefix overlap
87
+ if (node.options && hasAlternationReDoS(node.options)) return true
88
+
89
+ // Recurse into linear children (stack)
90
+ if (node.stack) {
91
+ for (let i = 0; i < node.stack.length; i++) {
92
+ if (findOverlappingAlternatives(node.stack[i])) return true
93
+ }
94
+ }
95
+
96
+ // Recurse into value (REPETITION's inner node, or any other value child)
97
+ if (node.value) {
98
+ if (findOverlappingAlternatives(node.value)) return true
99
+ }
100
+
101
+ return false
102
+ }
103
+
104
+ /**
105
+ * Recursively traverses the parsed regex AST, tracking repetition nesting depth
106
+ * and detecting alternation-based catastrophic backtracking.
107
+ *
108
+ * Detects two classes of ReDoS vulnerability:
109
+ * 1. Nested repetition (star height > 1) — e.g., (a+)+, (x+x+)+y
110
+ * 2. Alternation prefix overlap — e.g., (a|aa|aaa)+, where one alternative
111
+ * is a literal prefix of another, causing O(2^n) partitioning paths
112
+ *
113
+ * @param {*} node - Current node in the regex AST
114
+ * @param {object} opts - Accumulator and limit configuration
115
+ * @param {number} opts.reps - Count of repetition nodes visited so far in traversal
116
+ * @param {number} opts.limit - Maximum allowed repetitions across the entire regex
117
+ * @param {number} starHeight - Current nesting depth of repetition operators in the AST
118
+ * @returns {boolean} true if the regex subtree is safe, false if catastrophic
119
+ */
120
+ function walk (node, opts, starHeight) {
121
+ let i
122
+ let ok
123
+ let len
124
+
125
+ if (node.type === types.REPETITION) {
126
+ starHeight++
127
+ opts.reps++
128
+
129
+ // Star height > 1 indicates nested repetition (e.g., (a+)+), which creates
130
+ // exponential backtracking paths — a hallmark of catastrophic regexes
131
+ if (starHeight > 1) return false
132
+ if (opts.reps > opts.limit) return false
133
+
134
+ // Check for alternation-based ReDoS: alternatives inside this quantifier
135
+ // where one literal prefix is a prefix of another (e.g., (a|aa|aaa)+)
136
+ // Recursively searches through the value subtree for nested alternatives
137
+ if (findOverlappingAlternatives(node.value)) return false
138
+ }
139
+
140
+ const options = node.options || node.value?.options
141
+ if (options) {
142
+ for (i = 0, len = options.length; i < len; i++) {
143
+ ok = walk({ stack: options[i] }, opts, starHeight)
144
+ if (!ok) return false
145
+ }
146
+ }
147
+ const stack = node.stack || node.value?.stack
148
+ if (!stack) return true
149
+
150
+ for (i = 0, len = stack.length; i < len; i++) {
151
+ ok = walk(stack[i], opts, starHeight)
152
+ if (!ok) return false
153
+ }
154
+
155
+ return true
156
+ }
157
+
158
+ /**
159
+ * Checks whether a regular expression is safe from catastrophic backtracking
160
+ * by parsing it and walking the AST for excessive repetition nesting.
161
+ *
162
+ * @param {string|RegExp} re - Regular expression to validate, as a string or RegExp instance
163
+ * @param {object} [options]
164
+ * @param {number} [options.limit=25] - Maximum number of repetitions allowed across the regex
165
+ * @returns {boolean} true if the regex is safe, false if catastrophic or invalid
166
+ */
167
+ function safeRegex (re, options) {
168
+ const opts = {
169
+ reps: 0,
170
+ limit: options?.limit ?? 25
171
+ }
172
+
173
+ if (isRegExp(re)) re = re.source
174
+ else if (typeof re !== 'string') re = String(re)
175
+
176
+ try {
177
+ return walk(parse(re), opts, 0)
178
+ } catch {
179
+ return false
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Cross-realm-safe check for whether a value is a RegExp instance.
185
+ * Uses Object.prototype.toString instead of instanceof to work across
186
+ * different JavaScript realms (e.g., iframes, vm contexts).
187
+ *
188
+ * @param {*} x - Value to check
189
+ * @returns {x is RegExp} true if x is a RegExp object
190
+ */
191
+ function isRegExp (x) {
192
+ return Object.prototype.toString.call(x) === '[object RegExp]'
193
+ }
194
+
195
+ // ── Auto-fix ────────────────────────────────────────────────────────
196
+
197
+ /**
198
+ * Attempts to produce a safe version of an unsafe regex by modifying the
199
+ * parsed AST and reconstructing the pattern string.
200
+ *
201
+ * Current fix strategies:
202
+ * 1. Strip redundant outer quantifiers — (a+)+ → a+, (x+x+)+y → (x+x+)y
203
+ * 2. Replace overlapping alternatives with canonical covering — (a|aa|aaa)+ → a+
204
+ *
205
+ * @param {string|RegExp} re - Regular expression to fix
206
+ * @param {object} [options]
207
+ * @param {number} [options.limit=25] - Repetition limit (passed to walk)
208
+ * @returns {{ safe: boolean, fixed: string|null, original: string }}
209
+ * Returns { safe: true, fixed: null } if already safe.
210
+ * Returns { safe: false, fixed: '...' } with a suggested fix.
211
+ * Returns { safe: false, fixed: null } if cannot auto-fix.
212
+ */
213
+ function fixRegex (re, options) {
214
+ const limit = options?.limit ?? 25
215
+
216
+ let source
217
+ if (isRegExp(re)) source = re.source
218
+ else if (typeof re !== 'string') source = String(re)
219
+ else source = re
220
+
221
+ let ast
222
+ try {
223
+ ast = parse(source)
224
+ } catch {
225
+ return { safe: false, fixed: null, original: source }
226
+ }
227
+
228
+ // Check if already safe
229
+ if (walk(ast, { reps: 0, limit }, 0)) {
230
+ return { safe: true, fixed: null, original: source }
231
+ }
232
+
233
+ // Try to fix: clone the AST and apply transforms
234
+ const fixedAst = fixNode(ast, limit)
235
+ if (!fixedAst) {
236
+ return { safe: false, fixed: null, original: source }
237
+ }
238
+
239
+ let fixed
240
+ try {
241
+ fixed = reconstruct(fixedAst)
242
+ } catch {
243
+ return { safe: false, fixed: null, original: source }
244
+ }
245
+
246
+ // Verify the fix is actually safe
247
+ const stillUnsafe = !walk(parse(fixed), { reps: 0, limit }, 0)
248
+ if (stillUnsafe) {
249
+ return { safe: false, fixed: null, original: source }
250
+ }
251
+
252
+ return { safe: false, fixed, original: source }
253
+ }
254
+
255
+ /**
256
+ * Checks whether a node's subtree contains a REPETITION that would
257
+ * cause starHeight > maxDepth, indicating nested repetition ReDoS.
258
+ *
259
+ * @param {*} node - AST node to check
260
+ * @param {number} depth - Current star height depth
261
+ * @param {number} maxDepth - Maximum allowed star height (typically 1)
262
+ * @returns {boolean} true if any descendant exceeds maxDepth
263
+ */
264
+ function hasDeepRepetition (node, depth, maxDepth) {
265
+ if (!node || typeof node !== 'object') return false
266
+
267
+ if (node.type === types.REPETITION) {
268
+ depth++
269
+ if (depth > maxDepth) return true
270
+ }
271
+
272
+ // Check options (alternatives)
273
+ const options = node.options || node.value?.options
274
+ if (options) {
275
+ for (let i = 0; i < options.length; i++) {
276
+ if (hasDeepRepetition({ stack: options[i] }, depth, maxDepth)) return true
277
+ }
278
+ }
279
+
280
+ // Check stack (linear children)
281
+ const stack = node.stack || node.value?.stack
282
+ if (stack) {
283
+ for (let i = 0; i < stack.length; i++) {
284
+ if (hasDeepRepetition(stack[i], depth, maxDepth)) return true
285
+ }
286
+ }
287
+
288
+ return false
289
+ }
290
+
291
+ /**
292
+ * Attempts to transform a (sub)tree to eliminate ReDoS vulnerabilities.
293
+ * Works top-down: if a REPETITION's descendants would exceed star height,
294
+ * the outer REPETITION is stripped instead of inner quantifiers.
295
+ *
296
+ * @param {*} node - AST node to fix
297
+ * @param {number} limit - Repetition limit
298
+ * @returns {*|null} Fixed node, or null if unfixable
299
+ */
300
+ function fixNode (node, limit) {
301
+ if (!node || typeof node !== 'object') return node
302
+
303
+ if (node.type === types.REPETITION) {
304
+ // Strategy 1: Strip outer quantifier if descendants have nested repetition.
305
+ // Top-down check: if any descendant causes starHeight > 1, remove THIS
306
+ // quantifier rather than inner ones (which preserves semantics better).
307
+ if (hasDeepRepetition(node.value, 1, 1)) {
308
+ const inner = node.value
309
+ if (!inner) return null
310
+ // Return the inner value as-is. Inner quantifiers preserved.
311
+ return fixNode(inner, limit)
312
+ }
313
+
314
+ // Strategy 2: Fix alternation prefix overlap inside this quantifier
315
+ if (findOverlappingAlternatives(node.value)) {
316
+ return fixAlternationReDoS(node, limit)
317
+ }
318
+ }
319
+
320
+ // Recursively fix children (preserve current structure, just clean children)
321
+ const result = { ...node }
322
+
323
+ if (result.options) {
324
+ result.options = result.options.map(function (opt) {
325
+ return fixNode({ stack: opt }, limit)
326
+ }).map(function (n) {
327
+ return n.stack || []
328
+ })
329
+ }
330
+
331
+ if (result.stack) {
332
+ result.stack = result.stack.map(function (child) {
333
+ return fixNode(child, limit)
334
+ })
335
+ }
336
+
337
+ if (result.value) {
338
+ result.value = fixNode(result.value, limit)
339
+ }
340
+
341
+ return result
342
+ }
343
+
344
+ /**
345
+ * Finds the first GROUP node with alternatives (options) in a subtree.
346
+ * Used to locate overlapping alternatives nested inside other groups.
347
+ *
348
+ * @param {*} node - AST node to search
349
+ * @returns {{ group: object, options: Array }|null}
350
+ */
351
+ function findGroupWithOptions (node) {
352
+ if (!node || typeof node !== 'object') return null
353
+ if (node.options && node.options.length >= 2) {
354
+ return { group: node, options: node.options }
355
+ }
356
+ const stack = node.stack || node.value?.stack
357
+ if (stack) {
358
+ for (let i = 0; i < stack.length; i++) {
359
+ const found = findGroupWithOptions(stack[i])
360
+ if (found) return found
361
+ }
362
+ }
363
+ return null
364
+ }
365
+
366
+ /**
367
+ * Fixes alternation-based ReDoS inside a quantifier by replacing
368
+ * overlapping alternatives with a canonical covering pattern.
369
+ *
370
+ * For (a|aa|aaa)+ — all alternatives are sequences of the same char,
371
+ * so the fix is just char+ (e.g., a+).
372
+ *
373
+ * @param {*} repNode - The REPETITION node containing alternation
374
+ * @param {number} limit - Repetition limit
375
+ * @returns {*|null} Fixed AST node, or null if unfixable
376
+ */
377
+ function fixAlternationReDoS (repNode, limit) {
378
+ const found = findGroupWithOptions(repNode.value)
379
+ if (!found) return null
380
+
381
+ const { options } = found
382
+
383
+ // Collect all literal chars from all alternatives
384
+ let allSameChar = true
385
+ let firstChar = null
386
+
387
+ for (let i = 0; i < options.length; i++) {
388
+ const opt = options[i]
389
+ const prefix = getLiteralPrefix(opt)
390
+ if (prefix.length === 0) {
391
+ allSameChar = false
392
+ break
393
+ }
394
+ for (let j = 0; j < prefix.length; j++) {
395
+ if (firstChar === null) firstChar = prefix[j]
396
+ if (prefix[j] !== firstChar) allSameChar = false
397
+ }
398
+ }
399
+
400
+ // If all alternatives are sequences of the same character, replace with char+
401
+ if (allSameChar && firstChar !== null) {
402
+ const newRep = {
403
+ type: types.REPETITION,
404
+ min: repNode.min,
405
+ max: repNode.max === null ? Infinity : repNode.max,
406
+ value: { type: types.CHAR, value: firstChar }
407
+ }
408
+ return newRep
409
+ }
410
+
411
+ // General prefix overlap (e.g., ab|abc) cannot be safely rewritten as a
412
+ // regex. Optional groups like (?:ab(?:c)?)+ still create the same
413
+ // exponential partitioning paths. These patterns require a non-regex
414
+ // parser or a fundamentally different approach.
415
+ return null
416
+ }
417
+
418
+ /**
419
+ * Checks whether a pattern's AST ends with one or more literal characters,
420
+ * possibly preceded by anchor positions ($). A static suffix constrains
421
+ * backtracking because the engine must match those exact characters at the end.
422
+ *
423
+ * E.g., `(a+)+y` has static suffix 'y'; `(a+)+y$` also has suffix 'y'.
424
+ *
425
+ * @param {*} node - Root AST node
426
+ * @returns {boolean}
427
+ */
428
+ function detectStaticSuffix (node) {
429
+ const stack = node.stack || node.value?.stack
430
+ if (!stack || stack.length === 0) return false
431
+
432
+ // Walk backwards from the end, skipping anchor positions
433
+ for (let i = stack.length - 1; i >= 0; i--) {
434
+ const child = stack[i]
435
+ if (child.type === types.POSITION) continue // skip $ ^ \b \B
436
+ if (child.type === types.CHAR || child.type === types.SET) return true
437
+ break
438
+ }
439
+ return false
440
+ }
441
+
442
+ /**
443
+ * Checks whether a pattern is anchored (starts with ^ and ends with $).
444
+ *
445
+ * @param {*} node - Root AST node
446
+ * @returns {boolean}
447
+ */
448
+ function detectAnchored (node) {
449
+ const stack = node.stack || node.value?.stack
450
+ if (!stack || stack.length === 0) return false
451
+
452
+ let start = false
453
+ let end = false
454
+
455
+ if (stack[0].type === types.POSITION && stack[0].value === '^') start = true
456
+ const last = stack[stack.length - 1]
457
+ if (last.type === types.POSITION && last.value === '$') end = true
458
+
459
+ return start && end
460
+ }
461
+
462
+ // ── Analyze / risk scoring ───────────────────────────────────────────
463
+
464
+ /**
465
+ * Maps diagnostic data to a severity level.
466
+ *
467
+ * Severity levels:
468
+ * - none: No issues detected
469
+ * - low: Minor issues (e.g., many repetitions but no nesting)
470
+ * - high: Significant risk (nested repetition or alternation overlap)
471
+ * - critical: Extreme risk (deeply nested repetition, multiple factors)
472
+ *
473
+ * Mitigating factors (anchoring, static suffix) reduce severity by one level.
474
+ *
475
+ * @param {object} info - Diagnostic information from walkAnalyze
476
+ * @returns {string} Severity level
477
+ */
478
+ function assessSeverity (info) {
479
+ const { starHeight, repCount, limit, hasAlternation, anchored, hasStaticSuffix } = info
480
+
481
+ let severity = 'none'
482
+ let canMitigate = false
483
+
484
+ // Determine base severity from issues found
485
+ if (starHeight >= 3) {
486
+ severity = 'critical'
487
+ } else if (starHeight >= 2) {
488
+ severity = 'high'
489
+ canMitigate = true
490
+ } else if (hasAlternation) {
491
+ severity = 'high'
492
+ canMitigate = true
493
+ } else if (repCount > limit * 2) {
494
+ severity = 'high'
495
+ } else if (repCount > limit) {
496
+ severity = 'low'
497
+ }
498
+
499
+ // Mitigating factors (anchoring, static suffix) only reduce severity
500
+ // for structural ReDoS (nested rep, alternation), not rep-count issues
501
+ if (canMitigate && severity !== 'critical') {
502
+ const mitigations = (anchored ? 1 : 0) + (hasStaticSuffix ? 1 : 0)
503
+ if (mitigations >= 1) {
504
+ if (severity === 'high') severity = 'low'
505
+ }
506
+ }
507
+
508
+ return severity
509
+ }
510
+
511
+ /**
512
+ * Walks the AST collecting diagnostics for severity assessment.
513
+ * Unlike walk() which short-circuits on first problem, this function
514
+ * continues traversal to collect full diagnostic data.
515
+ *
516
+ * @param {*} node - Current AST node
517
+ * @param {object} info - Diagnostic accumulator
518
+ * @param {number} info.starHeight - Current repetition nesting depth
519
+ * @param {number} info.maxStarHeight - Maximum star height seen
520
+ * @param {number} info.repCount - Total repetition count
521
+ * @param {number} info.limit - Repetition limit
522
+ */
523
+ function walkAnalyze (node, info) {
524
+ if (!node || typeof node !== 'object') return
525
+
526
+ if (node.type === types.REPETITION) {
527
+ info.starHeight++
528
+ if (info.starHeight > info.maxStarHeight) {
529
+ info.maxStarHeight = info.starHeight
530
+ }
531
+ info.repCount++
532
+ walkAnalyze(node.value, info)
533
+ info.starHeight--
534
+ return
535
+ }
536
+
537
+ // Check options (alternatives)
538
+ const options = node.options || node.value?.options
539
+ if (options) {
540
+ for (let i = 0; i < options.length; i++) {
541
+ walkAnalyze({ stack: options[i] }, info)
542
+ }
543
+ }
544
+
545
+ // Check stack (linear children)
546
+ const stack = node.stack || node.value?.stack
547
+ if (stack) {
548
+ for (let i = 0; i < stack.length; i++) {
549
+ walkAnalyze(stack[i], info)
550
+ }
551
+ }
552
+ }
553
+
554
+ /**
555
+ * Analyzes a regular expression and returns a detailed risk assessment
556
+ * including severity level, diagnostic data, and suggested fix.
557
+ *
558
+ * @param {string|RegExp} re - Regular expression to analyze
559
+ * @param {object} [options]
560
+ * @param {number} [options.limit=25] - Maximum repetitions allowed
561
+ * @returns {{
562
+ * safe: boolean,
563
+ * severity: string,
564
+ * reasons: string[],
565
+ * starHeight: number,
566
+ * repCount: number,
567
+ * hasAlternationReDoS: boolean,
568
+ * anchored: boolean,
569
+ * hasStaticSuffix: boolean,
570
+ * fix: string|null
571
+ * }}
572
+ */
573
+ function analyze (re, options) {
574
+ const limit = options?.limit ?? 25
575
+
576
+ let source
577
+ if (isRegExp(re)) source = re.source
578
+ else if (typeof re !== 'string') source = String(re)
579
+ else source = re
580
+
581
+ let ast
582
+ try {
583
+ ast = parse(source)
584
+ } catch (err) {
585
+ return {
586
+ safe: false,
587
+ severity: 'high',
588
+ reasons: ['Invalid regex syntax: ' + err.message],
589
+ starHeight: 0,
590
+ repCount: 0,
591
+ hasAlternationReDoS: false,
592
+ anchored: false,
593
+ hasStaticSuffix: false,
594
+ fix: null
595
+ }
596
+ }
597
+
598
+ // Collect diagnostics
599
+ const info = {
600
+ starHeight: 0,
601
+ maxStarHeight: 0,
602
+ repCount: 0,
603
+ limit
604
+ }
605
+
606
+ walkAnalyze(ast, info)
607
+
608
+ // Check alternation ReDoS separately (uses existing functions)
609
+ const hasAlternation = findOverlappingAlternatives(ast)
610
+
611
+ // Check anchoring and suffix directly on the AST
612
+ const anchored = detectAnchored(ast)
613
+ const hasStaticSuffix = detectStaticSuffix(ast)
614
+
615
+ // Build severity
616
+ const severity = assessSeverity({
617
+ starHeight: info.maxStarHeight,
618
+ repCount: info.repCount,
619
+ limit,
620
+ hasAlternation,
621
+ anchored,
622
+ hasStaticSuffix
623
+ })
624
+
625
+ // Build reasons
626
+ const reasons = []
627
+ if (info.maxStarHeight >= 2) {
628
+ reasons.push('Nested repetition detected (star height ' + info.maxStarHeight + ')')
629
+ }
630
+ if (hasAlternation) {
631
+ reasons.push('Alternatives with overlapping prefixes inside quantifier')
632
+ }
633
+ if (info.repCount > limit) {
634
+ reasons.push('Exceeded repetition limit: ' + info.repCount + ' > ' + limit)
635
+ }
636
+
637
+ const safe = severity === 'none'
638
+
639
+ // Try to fix if unsafe
640
+ let fix = null
641
+ if (!safe) {
642
+ const fixResult = fixRegex(source, { limit })
643
+ if (fixResult.fixed) fix = fixResult.fixed
644
+ }
645
+
646
+ return {
647
+ safe,
648
+ severity,
649
+ reasons,
650
+ starHeight: info.maxStarHeight,
651
+ repCount: info.repCount,
652
+ hasAlternationReDoS: hasAlternation,
653
+ anchored,
654
+ hasStaticSuffix,
655
+ fix
656
+ }
657
+ }
658
+
659
+ module.exports = safeRegex
660
+ module.exports.default = safeRegex
661
+ module.exports.safeRegex = safeRegex
662
+ module.exports.fix = fixRegex
663
+ module.exports.analyze = analyze
package/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "@rezalabs/safe-regex2",
3
+ "version": "6.0.0",
4
+ "description": "Detect and fix catastrophic backtracking (ReDoS) in regular expressions with severity scoring and auto-fix",
5
+ "main": "index.js",
6
+ "type": "commonjs",
7
+ "types": "types/index.d.ts",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "bin": {
12
+ "safe-regex2": "bin/safe-regex2.js"
13
+ },
14
+ "dependencies": {
15
+ "@rezalabs/ret": "^1.0.0"
16
+ },
17
+ "devDependencies": {
18
+ "c8": "^11.0.0",
19
+ "eslint": "^9.17.0",
20
+ "neostandard": "^0.13.0",
21
+ "tstyche": "^7.0.0"
22
+ },
23
+ "scripts": {
24
+ "lint": "eslint",
25
+ "lint:fix": "eslint --fix",
26
+ "test": "npm run test:unit && npm run test:typescript",
27
+ "test:typescript": "tstyche",
28
+ "test:unit": "c8 --100 node --test"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git://github.com/rezalabs/safe-regex2.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/rezalabs/safe-regex2/issues"
36
+ },
37
+ "homepage": "https://github.com/rezalabs/safe-regex2",
38
+ "funding": [
39
+ {
40
+ "type": "github",
41
+ "url": "https://github.com/sponsors/rezalabs"
42
+ },
43
+ {
44
+ "type": "opencollective",
45
+ "url": "https://opencollective.com/fastify"
46
+ }
47
+ ],
48
+ "keywords": [
49
+ "catastrophic",
50
+ "exponential",
51
+ "regex",
52
+ "regexp",
53
+ "redos",
54
+ "safe",
55
+ "security",
56
+ "backtracking",
57
+ "star-height",
58
+ "auto-fix",
59
+ "lint"
60
+ ],
61
+ "author": "RezaLabs",
62
+ "contributors": [
63
+ {
64
+ "name": "James Halliday",
65
+ "comment": "Original safe-regex package"
66
+ },
67
+ {
68
+ "name": "Matteo Collina",
69
+ "email": "hello@matteocollina.com",
70
+ "comment": "safe-regex2 fork maintainer (fastify)"
71
+ },
72
+ {
73
+ "name": "Gürgün Dayıoğlu",
74
+ "email": "hey@gurgun.day",
75
+ "url": "https://heyhey.to/G"
76
+ },
77
+ {
78
+ "name": "James Sumners",
79
+ "url": "https://james.sumners.info"
80
+ },
81
+ {
82
+ "name": "Frazer Smith",
83
+ "email": "frazer.dev@icloud.com",
84
+ "url": "https://github.com/fdawgs"
85
+ }
86
+ ],
87
+ "license": "MIT",
88
+ "engines": {
89
+ "node": ">=14"
90
+ }
91
+ }
@@ -0,0 +1,30 @@
1
+ type SafeRegexOptions = { limit?: number }
2
+
3
+ type FixResult = {
4
+ safe: boolean
5
+ fixed: string | null
6
+ original: string
7
+ }
8
+
9
+ type AnalyzeResult = {
10
+ safe: boolean
11
+ severity: 'none' | 'low' | 'high' | 'critical'
12
+ reasons: string[]
13
+ starHeight: number
14
+ repCount: number
15
+ hasAlternationReDoS: boolean
16
+ anchored: boolean
17
+ hasStaticSuffix: boolean
18
+ fix: string | null
19
+ }
20
+
21
+ type SafeFn = {
22
+ (re: string | RegExp, opts?: SafeRegexOptions): boolean
23
+ safeRegex: SafeFn
24
+ fix: (re: string | RegExp, opts?: SafeRegexOptions) => FixResult
25
+ analyze: (re: string | RegExp, opts?: SafeRegexOptions) => AnalyzeResult
26
+ default: SafeFn
27
+ }
28
+
29
+ declare const _: SafeFn
30
+ export = _