@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 +2 -0
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +204 -0
- package/bin/safe-regex2.js +58 -0
- package/index.js +663 -0
- package/package.json +91 -0
- package/types/index.d.ts +30 -0
package/.gitattributes
ADDED
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
|
+
[](https://github.com/rezalabs/safe-regex2/actions/workflows/ci.yml)
|
|
4
|
+
[](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
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -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 = _
|