resafe 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 ofabiodev
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,99 @@
1
+ <p align="center">
2
+ <img src="https://github.com/ofabiodev/resafe/blob/main/.github/assets/logo.svg" align="center" width="200" alt="Resafe Logo">
3
+ <h1 align="center">Resafe</h1>
4
+ <p align="center">
5
+ Lightweight package to detect unsafe regex patterns and prevent ReDoS.
6
+ </p>
7
+ </p>
8
+ <br/>
9
+
10
+ <p align="center">
11
+ <a href="https://github.com/ofabiodev/resafe/actions?query=branch%3Amain" rel="nofollow"><img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/ofabiodev/resafe/test.yml?branch=main&event=push"></a>
12
+ <a href="https://opensource.org/licenses/MIT" rel="nofollow"><img alt="GitHub License" src="https://img.shields.io/github/license/ofabiodev/resafe"></a>
13
+ <a href="https://www.npmjs.com/package/resafe" rel="nofollow"><img alt="NPM Downloads" src="https://img.shields.io/npm/dw/resafe"></a>
14
+ </p>
15
+
16
+ <div align="center">
17
+ <a href="https://resafe.js.org">▪ Docs ▪</a>
18
+ </div>
19
+ <br/>
20
+
21
+ ## What is Resafe?
22
+ **Resafe** is a mathematical ReDoS detection engine that uses **Thompson NFA construction**, **epsilon transition elimination**, and **spectral radius analysis** to detect exponential backtracking vulnerabilities. By analyzing the automaton's adjacency matrix eigenvalues, Resafe determines if a regex has exponential growth patterns without executing test strings.
23
+
24
+ ```ts
25
+ import { check } from "resafe";
26
+
27
+ check("(a+)+$");
28
+
29
+ /*
30
+ x [Resafe] Unsafe pattern: /(a+)+$/
31
+ ! Spectral radius: 4.0 (threshold: 1.0)
32
+ Simplify regex structure to eliminate exponential paths
33
+ */
34
+ ```
35
+
36
+ ## Features
37
+
38
+ - **Pure Mathematical Analysis**: Thompson NFA construction with spectral radius computation
39
+ - **Deterministic Detection**: Analyzes automaton structure, not pattern matching heuristics
40
+ - **Spectral Radius**: Detects exponential growth when eigenvalue > 1.0
41
+ - **Fast Analysis**: Average analysis time <1ms per pattern
42
+
43
+ ## Installation
44
+
45
+ ```sh
46
+ # Using Bun (Recommended)
47
+ bun add resafe
48
+
49
+ # Using NPM
50
+ npm install resafe
51
+
52
+ # Using Yarn
53
+ yarn add resafe
54
+ ```
55
+
56
+ ## Basic Usage
57
+
58
+ ### Simple Analysis
59
+ By default, Resafe logs warnings and errors to the console if a pattern is unsafe.
60
+
61
+ ```ts
62
+ import { check } from "resafe";
63
+
64
+ check(/([a-zA-Z0-9]+)*$/);
65
+ ```
66
+
67
+ ### Production Guard
68
+ Prevent unsafe regex from being used by throwing an error.
69
+
70
+ ```ts
71
+ import { check } from "resafe";
72
+
73
+ const safeRegex = check("^[0-9]+$", {
74
+ throwErr: true,
75
+ silent: true
76
+ });
77
+ ```
78
+
79
+ ### Advanced Configuration
80
+ Configure detection threshold.
81
+
82
+ ```ts
83
+ import { check } from "resafe";
84
+
85
+ check("a+a+", {
86
+ threshold: 1.5
87
+ });
88
+ ```
89
+
90
+ ## Why Resafe?
91
+
92
+ Regular expressions are powerful but can be a security bottleneck. A single poorly crafted regex can freeze a Node.js/Bun event loop. Resafe helps you:
93
+ 1. **Educate**: Developers learn why a regex is bad through the "Solution" hints.
94
+ 2. **Automate**: Run checks during CI/CD to catch ReDoS early.
95
+ 3. **Secure**: Stop malicious or accidental "Catastrophic Backtracking" patterns.
96
+
97
+ ## License
98
+
99
+ [MIT](LICENSE) © [ofabiodev](https://github.com/ofabiodev)
package/dist/index.cjs ADDED
@@ -0,0 +1,336 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ check: () => check,
24
+ checkAsync: () => checkAsync
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+
28
+ // src/core/spectral.ts
29
+ function buildNFA(pattern) {
30
+ let stateId = 0;
31
+ const newState = () => ({
32
+ id: stateId++,
33
+ transitions: /* @__PURE__ */ new Map(),
34
+ epsilon: []
35
+ });
36
+ const parse = (expr, start, end) => {
37
+ if (start >= end) {
38
+ const s = newState();
39
+ const e = newState();
40
+ s.epsilon.push(e.id);
41
+ return { states: [s, e], start: s.id, accept: e.id };
42
+ }
43
+ const fragments = [];
44
+ let i = start;
45
+ while (i < end) {
46
+ const char = expr[i];
47
+ if (char === void 0) break;
48
+ if (char === "(") {
49
+ let depth = 1;
50
+ let j = i + 1;
51
+ while (j < end && depth > 0) {
52
+ if (expr[j] === "\\") {
53
+ j += 2;
54
+ continue;
55
+ }
56
+ if (expr[j] === "(") depth++;
57
+ if (expr[j] === ")") depth--;
58
+ j++;
59
+ }
60
+ const groupNFA = parse(expr, i + 1, j - 1);
61
+ const nextChar = expr[j];
62
+ if (j < end && nextChar !== void 0 && /[*+?]/.test(nextChar)) {
63
+ fragments.push(quantify(groupNFA, nextChar));
64
+ i = j + 1;
65
+ } else {
66
+ fragments.push(groupNFA);
67
+ i = j;
68
+ }
69
+ } else if (char === "[") {
70
+ let j = i + 1;
71
+ while (j < end && expr[j] !== "]") {
72
+ if (expr[j] === "\\") j++;
73
+ j++;
74
+ }
75
+ j++;
76
+ const s = newState();
77
+ const e = newState();
78
+ s.transitions.set(expr.slice(i, j), [e.id]);
79
+ const nfa = { states: [s, e], start: s.id, accept: e.id };
80
+ const nextChar = expr[j];
81
+ if (j < end && nextChar !== void 0 && /[*+?]/.test(nextChar)) {
82
+ fragments.push(quantify(nfa, nextChar));
83
+ i = j + 1;
84
+ } else {
85
+ fragments.push(nfa);
86
+ i = j;
87
+ }
88
+ } else if (char === "|") {
89
+ const left = concat(fragments);
90
+ const right = parse(expr, i + 1, end);
91
+ return alternate(left, right);
92
+ } else if (!/[*+?]/.test(char)) {
93
+ const s = newState();
94
+ const e = newState();
95
+ const symbol = char === "\\" && i + 1 < end ? expr.slice(i, i + 2) : char;
96
+ s.transitions.set(symbol, [e.id]);
97
+ const nfa = { states: [s, e], start: s.id, accept: e.id };
98
+ if (char === "\\") i++;
99
+ i++;
100
+ const nextChar = expr[i];
101
+ if (i < end && nextChar !== void 0 && /[*+?]/.test(nextChar)) {
102
+ fragments.push(quantify(nfa, nextChar));
103
+ i++;
104
+ } else {
105
+ fragments.push(nfa);
106
+ }
107
+ } else {
108
+ i++;
109
+ }
110
+ }
111
+ return concat(fragments);
112
+ };
113
+ const concat = (nfas) => {
114
+ if (nfas.length === 0) {
115
+ const s = newState();
116
+ const e = newState();
117
+ s.epsilon.push(e.id);
118
+ return { states: [s, e], start: s.id, accept: e.id };
119
+ }
120
+ if (nfas.length === 1 && nfas[0]) return nfas[0];
121
+ const states = [];
122
+ for (let i = 0; i < nfas.length; i++) {
123
+ const nfa = nfas[i];
124
+ if (!nfa) continue;
125
+ states.push(...nfa.states);
126
+ if (i < nfas.length - 1) {
127
+ const nextNfa = nfas[i + 1];
128
+ if (!nextNfa) continue;
129
+ const accept = states.find((s) => s.id === nfa.accept);
130
+ if (accept) accept.epsilon.push(nextNfa.start);
131
+ }
132
+ }
133
+ const first = nfas[0];
134
+ const last = nfas[nfas.length - 1];
135
+ if (!first || !last) {
136
+ const s = newState();
137
+ const e = newState();
138
+ s.epsilon.push(e.id);
139
+ return { states: [s, e], start: s.id, accept: e.id };
140
+ }
141
+ return { states, start: first.start, accept: last.accept };
142
+ };
143
+ const alternate = (nfa1, nfa2) => {
144
+ const s = newState();
145
+ const e = newState();
146
+ s.epsilon.push(nfa1.start, nfa2.start);
147
+ const accept1 = nfa1.states.find((st) => st.id === nfa1.accept);
148
+ const accept2 = nfa2.states.find((st) => st.id === nfa2.accept);
149
+ if (accept1) accept1.epsilon.push(e.id);
150
+ if (accept2) accept2.epsilon.push(e.id);
151
+ return {
152
+ states: [s, ...nfa1.states, ...nfa2.states, e],
153
+ start: s.id,
154
+ accept: e.id
155
+ };
156
+ };
157
+ const quantify = (nfa, q) => {
158
+ const s = newState();
159
+ const e = newState();
160
+ const accept = nfa.states.find((st) => st.id === nfa.accept);
161
+ if (q === "*") {
162
+ s.epsilon.push(nfa.start, e.id);
163
+ if (accept) accept.epsilon.push(nfa.start, e.id);
164
+ } else if (q === "+") {
165
+ s.epsilon.push(nfa.start);
166
+ if (accept) accept.epsilon.push(nfa.start, e.id);
167
+ } else if (q === "?") {
168
+ s.epsilon.push(nfa.start, e.id);
169
+ if (accept) accept.epsilon.push(e.id);
170
+ }
171
+ return { states: [s, ...nfa.states, e], start: s.id, accept: e.id };
172
+ };
173
+ const clean = pattern.replace(/^\^|\$$/g, "");
174
+ return parse(clean, 0, clean.length);
175
+ }
176
+ function removeEpsilon(nfa) {
177
+ const closure = (id) => {
178
+ const result = /* @__PURE__ */ new Set([id]);
179
+ const stack = [id];
180
+ while (stack.length > 0) {
181
+ const current = stack.pop();
182
+ if (current === void 0) break;
183
+ const state = nfa.states.find((s) => s.id === current);
184
+ if (state) {
185
+ for (const eps of state.epsilon) {
186
+ if (!result.has(eps)) {
187
+ result.add(eps);
188
+ stack.push(eps);
189
+ }
190
+ }
191
+ }
192
+ }
193
+ return result;
194
+ };
195
+ const states = nfa.states.map((s) => ({
196
+ id: s.id,
197
+ transitions: /* @__PURE__ */ new Map(),
198
+ epsilon: []
199
+ }));
200
+ for (const state of nfa.states) {
201
+ const cls = closure(state.id);
202
+ const newState = states.find((s) => s.id === state.id);
203
+ if (!newState) continue;
204
+ for (const clsId of cls) {
205
+ const clsState = nfa.states.find((s) => s.id === clsId);
206
+ if (clsState) {
207
+ for (const [symbol, targets] of clsState.transitions) {
208
+ const existing = newState.transitions.get(symbol) || [];
209
+ for (const target of targets) {
210
+ const targetCls = closure(target);
211
+ for (const t of targetCls) {
212
+ if (!existing.includes(t)) existing.push(t);
213
+ }
214
+ }
215
+ newState.transitions.set(symbol, existing);
216
+ }
217
+ }
218
+ }
219
+ }
220
+ return { states, start: nfa.start, accept: nfa.accept };
221
+ }
222
+ function buildMatrix(nfa) {
223
+ const n = nfa.states.length;
224
+ const matrix = Array(n).fill(0).map(() => Array(n).fill(0));
225
+ for (const state of nfa.states) {
226
+ for (const targets of state.transitions.values()) {
227
+ for (const target of targets) {
228
+ const row = matrix[state.id];
229
+ if (row) {
230
+ const current = row[target];
231
+ if (current !== void 0) {
232
+ row[target] = current + 1;
233
+ }
234
+ }
235
+ }
236
+ }
237
+ }
238
+ return matrix;
239
+ }
240
+ function spectralRadius(matrix) {
241
+ const n = matrix.length;
242
+ if (n === 0) return 0;
243
+ let v = Array(n).fill(1 / Math.sqrt(n));
244
+ for (let iter = 0; iter < 100; iter++) {
245
+ const newV = Array(n).fill(0);
246
+ for (let i = 0; i < n; i++) {
247
+ const row = matrix[i];
248
+ if (row) {
249
+ for (let j = 0; j < n; j++) {
250
+ const cell = row[j];
251
+ const vec = v[j];
252
+ if (cell !== void 0 && vec !== void 0) {
253
+ newV[i] += cell * vec;
254
+ }
255
+ }
256
+ }
257
+ }
258
+ const norm = Math.sqrt(newV.reduce((sum, val) => sum + val * val, 0));
259
+ if (norm === 0) return 0;
260
+ v = newV.map((val) => val / norm);
261
+ }
262
+ let eigenvalue = 0;
263
+ for (let i = 0; i < n; i++) {
264
+ let sum = 0;
265
+ const row = matrix[i];
266
+ if (row) {
267
+ for (let j = 0; j < n; j++) {
268
+ const cell = row[j];
269
+ const vec = v[j];
270
+ if (cell !== void 0 && vec !== void 0) {
271
+ sum += cell * vec;
272
+ }
273
+ }
274
+ }
275
+ const vecI = v[i];
276
+ if (vecI !== void 0) {
277
+ eigenvalue += sum * vecI;
278
+ }
279
+ }
280
+ return Math.abs(eigenvalue);
281
+ }
282
+
283
+ // src/core/analyzer.ts
284
+ function analyze(pattern, config = {}) {
285
+ const threshold = config.threshold ?? 1;
286
+ try {
287
+ const nfa = buildNFA(pattern);
288
+ const epsilonFree = removeEpsilon(nfa);
289
+ const matrix = buildMatrix(epsilonFree);
290
+ const radius = Math.round(spectralRadius(matrix) * 10) / 10;
291
+ return { safe: radius <= threshold, radius };
292
+ } catch {
293
+ return { safe: true, radius: 0 };
294
+ }
295
+ }
296
+
297
+ // src/utils/logger.ts
298
+ var C = {
299
+ reset: "\x1B[0m",
300
+ yellow: "\x1B[33m",
301
+ red: "\x1B[31m",
302
+ gray: "\x1B[90m"
303
+ };
304
+ var S = { WARN: "!", ERROR: "x" };
305
+ var log = {
306
+ warn: (m) => console.log(`${C.yellow}${S.WARN}${C.reset} ${m}`),
307
+ error: (m) => console.log(`${C.red}${S.ERROR}${C.reset} ${m}`),
308
+ hint: (m) => console.log(`${C.gray}${m}${C.reset}`)
309
+ };
310
+
311
+ // src/index.ts
312
+ function check(regex, options = {}) {
313
+ const pattern = typeof regex === "string" ? regex : regex.source;
314
+ const result = analyze(pattern, options);
315
+ if (!result.safe && !options.silent) {
316
+ log.error(`[Resafe] Unsafe pattern: /${pattern}/`);
317
+ log.warn(
318
+ `Spectral radius: ${result.radius.toFixed(4)} (threshold: ${options.threshold ?? 1})`
319
+ );
320
+ log.hint("Simplify regex structure to eliminate exponential paths");
321
+ }
322
+ if (!result.safe && options.throwErr) {
323
+ throw new Error(
324
+ `[Resafe] Unsafe pattern with spectral radius ${result.radius.toFixed(4)}`
325
+ );
326
+ }
327
+ return result;
328
+ }
329
+ async function checkAsync(regex, options = {}) {
330
+ return Promise.resolve(check(regex, options));
331
+ }
332
+ // Annotate the CommonJS export names for ESM import in node:
333
+ 0 && (module.exports = {
334
+ check,
335
+ checkAsync
336
+ });
@@ -0,0 +1,16 @@
1
+ interface Config {
2
+ threshold?: number;
3
+ }
4
+ interface Result {
5
+ safe: boolean;
6
+ radius: number;
7
+ }
8
+
9
+ interface Options extends Config {
10
+ silent?: boolean;
11
+ throwErr?: boolean;
12
+ }
13
+ declare function check(regex: string | RegExp, options?: Options): Result;
14
+ declare function checkAsync(regex: string | RegExp, options?: Options): Promise<Result>;
15
+
16
+ export { type Config, type Options, type Result, check, checkAsync };
@@ -0,0 +1,16 @@
1
+ interface Config {
2
+ threshold?: number;
3
+ }
4
+ interface Result {
5
+ safe: boolean;
6
+ radius: number;
7
+ }
8
+
9
+ interface Options extends Config {
10
+ silent?: boolean;
11
+ throwErr?: boolean;
12
+ }
13
+ declare function check(regex: string | RegExp, options?: Options): Result;
14
+ declare function checkAsync(regex: string | RegExp, options?: Options): Promise<Result>;
15
+
16
+ export { type Config, type Options, type Result, check, checkAsync };
package/dist/index.js ADDED
@@ -0,0 +1,308 @@
1
+ // src/core/spectral.ts
2
+ function buildNFA(pattern) {
3
+ let stateId = 0;
4
+ const newState = () => ({
5
+ id: stateId++,
6
+ transitions: /* @__PURE__ */ new Map(),
7
+ epsilon: []
8
+ });
9
+ const parse = (expr, start, end) => {
10
+ if (start >= end) {
11
+ const s = newState();
12
+ const e = newState();
13
+ s.epsilon.push(e.id);
14
+ return { states: [s, e], start: s.id, accept: e.id };
15
+ }
16
+ const fragments = [];
17
+ let i = start;
18
+ while (i < end) {
19
+ const char = expr[i];
20
+ if (char === void 0) break;
21
+ if (char === "(") {
22
+ let depth = 1;
23
+ let j = i + 1;
24
+ while (j < end && depth > 0) {
25
+ if (expr[j] === "\\") {
26
+ j += 2;
27
+ continue;
28
+ }
29
+ if (expr[j] === "(") depth++;
30
+ if (expr[j] === ")") depth--;
31
+ j++;
32
+ }
33
+ const groupNFA = parse(expr, i + 1, j - 1);
34
+ const nextChar = expr[j];
35
+ if (j < end && nextChar !== void 0 && /[*+?]/.test(nextChar)) {
36
+ fragments.push(quantify(groupNFA, nextChar));
37
+ i = j + 1;
38
+ } else {
39
+ fragments.push(groupNFA);
40
+ i = j;
41
+ }
42
+ } else if (char === "[") {
43
+ let j = i + 1;
44
+ while (j < end && expr[j] !== "]") {
45
+ if (expr[j] === "\\") j++;
46
+ j++;
47
+ }
48
+ j++;
49
+ const s = newState();
50
+ const e = newState();
51
+ s.transitions.set(expr.slice(i, j), [e.id]);
52
+ const nfa = { states: [s, e], start: s.id, accept: e.id };
53
+ const nextChar = expr[j];
54
+ if (j < end && nextChar !== void 0 && /[*+?]/.test(nextChar)) {
55
+ fragments.push(quantify(nfa, nextChar));
56
+ i = j + 1;
57
+ } else {
58
+ fragments.push(nfa);
59
+ i = j;
60
+ }
61
+ } else if (char === "|") {
62
+ const left = concat(fragments);
63
+ const right = parse(expr, i + 1, end);
64
+ return alternate(left, right);
65
+ } else if (!/[*+?]/.test(char)) {
66
+ const s = newState();
67
+ const e = newState();
68
+ const symbol = char === "\\" && i + 1 < end ? expr.slice(i, i + 2) : char;
69
+ s.transitions.set(symbol, [e.id]);
70
+ const nfa = { states: [s, e], start: s.id, accept: e.id };
71
+ if (char === "\\") i++;
72
+ i++;
73
+ const nextChar = expr[i];
74
+ if (i < end && nextChar !== void 0 && /[*+?]/.test(nextChar)) {
75
+ fragments.push(quantify(nfa, nextChar));
76
+ i++;
77
+ } else {
78
+ fragments.push(nfa);
79
+ }
80
+ } else {
81
+ i++;
82
+ }
83
+ }
84
+ return concat(fragments);
85
+ };
86
+ const concat = (nfas) => {
87
+ if (nfas.length === 0) {
88
+ const s = newState();
89
+ const e = newState();
90
+ s.epsilon.push(e.id);
91
+ return { states: [s, e], start: s.id, accept: e.id };
92
+ }
93
+ if (nfas.length === 1 && nfas[0]) return nfas[0];
94
+ const states = [];
95
+ for (let i = 0; i < nfas.length; i++) {
96
+ const nfa = nfas[i];
97
+ if (!nfa) continue;
98
+ states.push(...nfa.states);
99
+ if (i < nfas.length - 1) {
100
+ const nextNfa = nfas[i + 1];
101
+ if (!nextNfa) continue;
102
+ const accept = states.find((s) => s.id === nfa.accept);
103
+ if (accept) accept.epsilon.push(nextNfa.start);
104
+ }
105
+ }
106
+ const first = nfas[0];
107
+ const last = nfas[nfas.length - 1];
108
+ if (!first || !last) {
109
+ const s = newState();
110
+ const e = newState();
111
+ s.epsilon.push(e.id);
112
+ return { states: [s, e], start: s.id, accept: e.id };
113
+ }
114
+ return { states, start: first.start, accept: last.accept };
115
+ };
116
+ const alternate = (nfa1, nfa2) => {
117
+ const s = newState();
118
+ const e = newState();
119
+ s.epsilon.push(nfa1.start, nfa2.start);
120
+ const accept1 = nfa1.states.find((st) => st.id === nfa1.accept);
121
+ const accept2 = nfa2.states.find((st) => st.id === nfa2.accept);
122
+ if (accept1) accept1.epsilon.push(e.id);
123
+ if (accept2) accept2.epsilon.push(e.id);
124
+ return {
125
+ states: [s, ...nfa1.states, ...nfa2.states, e],
126
+ start: s.id,
127
+ accept: e.id
128
+ };
129
+ };
130
+ const quantify = (nfa, q) => {
131
+ const s = newState();
132
+ const e = newState();
133
+ const accept = nfa.states.find((st) => st.id === nfa.accept);
134
+ if (q === "*") {
135
+ s.epsilon.push(nfa.start, e.id);
136
+ if (accept) accept.epsilon.push(nfa.start, e.id);
137
+ } else if (q === "+") {
138
+ s.epsilon.push(nfa.start);
139
+ if (accept) accept.epsilon.push(nfa.start, e.id);
140
+ } else if (q === "?") {
141
+ s.epsilon.push(nfa.start, e.id);
142
+ if (accept) accept.epsilon.push(e.id);
143
+ }
144
+ return { states: [s, ...nfa.states, e], start: s.id, accept: e.id };
145
+ };
146
+ const clean = pattern.replace(/^\^|\$$/g, "");
147
+ return parse(clean, 0, clean.length);
148
+ }
149
+ function removeEpsilon(nfa) {
150
+ const closure = (id) => {
151
+ const result = /* @__PURE__ */ new Set([id]);
152
+ const stack = [id];
153
+ while (stack.length > 0) {
154
+ const current = stack.pop();
155
+ if (current === void 0) break;
156
+ const state = nfa.states.find((s) => s.id === current);
157
+ if (state) {
158
+ for (const eps of state.epsilon) {
159
+ if (!result.has(eps)) {
160
+ result.add(eps);
161
+ stack.push(eps);
162
+ }
163
+ }
164
+ }
165
+ }
166
+ return result;
167
+ };
168
+ const states = nfa.states.map((s) => ({
169
+ id: s.id,
170
+ transitions: /* @__PURE__ */ new Map(),
171
+ epsilon: []
172
+ }));
173
+ for (const state of nfa.states) {
174
+ const cls = closure(state.id);
175
+ const newState = states.find((s) => s.id === state.id);
176
+ if (!newState) continue;
177
+ for (const clsId of cls) {
178
+ const clsState = nfa.states.find((s) => s.id === clsId);
179
+ if (clsState) {
180
+ for (const [symbol, targets] of clsState.transitions) {
181
+ const existing = newState.transitions.get(symbol) || [];
182
+ for (const target of targets) {
183
+ const targetCls = closure(target);
184
+ for (const t of targetCls) {
185
+ if (!existing.includes(t)) existing.push(t);
186
+ }
187
+ }
188
+ newState.transitions.set(symbol, existing);
189
+ }
190
+ }
191
+ }
192
+ }
193
+ return { states, start: nfa.start, accept: nfa.accept };
194
+ }
195
+ function buildMatrix(nfa) {
196
+ const n = nfa.states.length;
197
+ const matrix = Array(n).fill(0).map(() => Array(n).fill(0));
198
+ for (const state of nfa.states) {
199
+ for (const targets of state.transitions.values()) {
200
+ for (const target of targets) {
201
+ const row = matrix[state.id];
202
+ if (row) {
203
+ const current = row[target];
204
+ if (current !== void 0) {
205
+ row[target] = current + 1;
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }
211
+ return matrix;
212
+ }
213
+ function spectralRadius(matrix) {
214
+ const n = matrix.length;
215
+ if (n === 0) return 0;
216
+ let v = Array(n).fill(1 / Math.sqrt(n));
217
+ for (let iter = 0; iter < 100; iter++) {
218
+ const newV = Array(n).fill(0);
219
+ for (let i = 0; i < n; i++) {
220
+ const row = matrix[i];
221
+ if (row) {
222
+ for (let j = 0; j < n; j++) {
223
+ const cell = row[j];
224
+ const vec = v[j];
225
+ if (cell !== void 0 && vec !== void 0) {
226
+ newV[i] += cell * vec;
227
+ }
228
+ }
229
+ }
230
+ }
231
+ const norm = Math.sqrt(newV.reduce((sum, val) => sum + val * val, 0));
232
+ if (norm === 0) return 0;
233
+ v = newV.map((val) => val / norm);
234
+ }
235
+ let eigenvalue = 0;
236
+ for (let i = 0; i < n; i++) {
237
+ let sum = 0;
238
+ const row = matrix[i];
239
+ if (row) {
240
+ for (let j = 0; j < n; j++) {
241
+ const cell = row[j];
242
+ const vec = v[j];
243
+ if (cell !== void 0 && vec !== void 0) {
244
+ sum += cell * vec;
245
+ }
246
+ }
247
+ }
248
+ const vecI = v[i];
249
+ if (vecI !== void 0) {
250
+ eigenvalue += sum * vecI;
251
+ }
252
+ }
253
+ return Math.abs(eigenvalue);
254
+ }
255
+
256
+ // src/core/analyzer.ts
257
+ function analyze(pattern, config = {}) {
258
+ const threshold = config.threshold ?? 1;
259
+ try {
260
+ const nfa = buildNFA(pattern);
261
+ const epsilonFree = removeEpsilon(nfa);
262
+ const matrix = buildMatrix(epsilonFree);
263
+ const radius = Math.round(spectralRadius(matrix) * 10) / 10;
264
+ return { safe: radius <= threshold, radius };
265
+ } catch {
266
+ return { safe: true, radius: 0 };
267
+ }
268
+ }
269
+
270
+ // src/utils/logger.ts
271
+ var C = {
272
+ reset: "\x1B[0m",
273
+ yellow: "\x1B[33m",
274
+ red: "\x1B[31m",
275
+ gray: "\x1B[90m"
276
+ };
277
+ var S = { WARN: "!", ERROR: "x" };
278
+ var log = {
279
+ warn: (m) => console.log(`${C.yellow}${S.WARN}${C.reset} ${m}`),
280
+ error: (m) => console.log(`${C.red}${S.ERROR}${C.reset} ${m}`),
281
+ hint: (m) => console.log(`${C.gray}${m}${C.reset}`)
282
+ };
283
+
284
+ // src/index.ts
285
+ function check(regex, options = {}) {
286
+ const pattern = typeof regex === "string" ? regex : regex.source;
287
+ const result = analyze(pattern, options);
288
+ if (!result.safe && !options.silent) {
289
+ log.error(`[Resafe] Unsafe pattern: /${pattern}/`);
290
+ log.warn(
291
+ `Spectral radius: ${result.radius.toFixed(4)} (threshold: ${options.threshold ?? 1})`
292
+ );
293
+ log.hint("Simplify regex structure to eliminate exponential paths");
294
+ }
295
+ if (!result.safe && options.throwErr) {
296
+ throw new Error(
297
+ `[Resafe] Unsafe pattern with spectral radius ${result.radius.toFixed(4)}`
298
+ );
299
+ }
300
+ return result;
301
+ }
302
+ async function checkAsync(regex, options = {}) {
303
+ return Promise.resolve(check(regex, options));
304
+ }
305
+ export {
306
+ check,
307
+ checkAsync
308
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "resafe",
3
+ "version": "1.0.0",
4
+ "description": "🛡️ Detects ReDoS vulnerabilities in regexes using Thompson NFA construction and spectral radius analysis",
5
+ "license": "MIT",
6
+ "homepage": "https://resafe.js.org",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ofabiodev/resafe.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/ofabiodev/resafe/issues"
13
+ },
14
+ "author": {
15
+ "name": "ofabiodev",
16
+ "url": "https://github.com/ofabiodev"
17
+ },
18
+ "type": "module",
19
+ "main": "./dist/index.cjs",
20
+ "module": "./dist/index.js",
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "exports": {
25
+ ".": {
26
+ "import": "./dist/index.js",
27
+ "require": "./dist/index.cjs",
28
+ "types": "./dist/index.d.ts"
29
+ }
30
+ },
31
+ "scripts": {
32
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
33
+ "prepublishOnly": "npm run build",
34
+ "test": "poku"
35
+ },
36
+ "keywords": [
37
+ "regex",
38
+ "redos",
39
+ "regexp",
40
+ "security",
41
+ "vulnerability",
42
+ "performance",
43
+ "analyzer",
44
+ "validation",
45
+ "backtracking",
46
+ "lightweight"
47
+ ],
48
+ "devDependencies": {
49
+ "@types/bun": "latest",
50
+ "@types/node": "^25.0.3",
51
+ "poku": "^3.0.2",
52
+ "tsup": "^8.5.1"
53
+ },
54
+ "peerDependencies": {
55
+ "typescript": "^5"
56
+ }
57
+ }