redosray 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/src/cli.js ADDED
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { scanPaths } = require('./scanFiles');
6
+ const { scanRegex } = require('./scan');
7
+
8
+ const VERSION = require('../package.json').version;
9
+
10
+ const HELP = `redosray — find ReDoS-vulnerable regexes and prove them, offline.
11
+
12
+ USAGE
13
+ redosray [paths...] scan files/directories for vulnerable regexes
14
+ redosray -e '<regex>' test a single regex pattern
15
+ cat file | redosray -e - read the regex from stdin
16
+
17
+ OPTIONS
18
+ -e, --regex <pat> test one regex instead of scanning paths ('-' = stdin)
19
+ -f, --flags <fl> regex flags for -e mode (e.g. i, gm)
20
+ --json machine-readable JSON output
21
+ --ci exit non-zero (2) if any vulnerability is confirmed
22
+ --timeout <ms> per-match hang threshold (default 1000)
23
+ --no-color disable ANSI colors
24
+ -h, --help show this help
25
+ -v, --version show version
26
+
27
+ Every reported vulnerability is a REAL measured hang: redosray finds candidate
28
+ patterns statically, then confirms each by timing a match against a growing
29
+ input inside an isolated worker. The smallest input that crosses the timeout is
30
+ printed as shareable proof — no false-positive claims.
31
+
32
+ Maintained by the AI agent "Aurelio Nakamura". MIT licensed.
33
+ `;
34
+
35
+ function parseArgs(argv) {
36
+ const opts = {
37
+ paths: [], regex: null, flags: '', json: false, ci: false,
38
+ timeoutMs: 1000, color: true, help: false, version: false,
39
+ };
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const a = argv[i];
42
+ switch (a) {
43
+ case '-h': case '--help': opts.help = true; break;
44
+ case '-v': case '--version': opts.version = true; break;
45
+ case '--json': opts.json = true; break;
46
+ case '--ci': opts.ci = true; break;
47
+ case '--no-color': opts.color = false; break;
48
+ case '-e': case '--regex': opts.regex = argv[++i]; break;
49
+ case '-f': case '--flags': opts.flags = argv[++i] || ''; break;
50
+ case '--timeout': opts.timeoutMs = parseInt(argv[++i], 10) || 1000; break;
51
+ default:
52
+ if (a.startsWith('-') && a !== '-') {
53
+ process.stderr.write(`redosray: unknown option ${a}\n`);
54
+ process.exitCode = 2;
55
+ opts._error = true;
56
+ } else {
57
+ opts.paths.push(a);
58
+ }
59
+ }
60
+ }
61
+ return opts;
62
+ }
63
+
64
+ // --- minimal ANSI helpers ---
65
+ function mkColor(enabled) {
66
+ const wrap = (code) => (s) => (enabled ? `\u001b[${code}m${s}\u001b[0m` : String(s));
67
+ return {
68
+ red: wrap('31'), yellow: wrap('33'), green: wrap('32'),
69
+ cyan: wrap('36'), gray: wrap('90'), bold: wrap('1'), magenta: wrap('35'),
70
+ };
71
+ }
72
+
73
+ function fmtMs(ms) {
74
+ if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
75
+ return `${Math.round(ms)}ms`;
76
+ }
77
+
78
+ function truncate(s, n) {
79
+ if (s.length <= n) return s;
80
+ return s.slice(0, n - 1) + '…';
81
+ }
82
+
83
+ // A tiny sparkline of the timing curve (samples: [{length, ms, timedOut}]).
84
+ function sparkline(samples) {
85
+ const bars = '▁▂▃▄▅▆▇█';
86
+ const vals = samples.map((s) => s.timedOut ? Infinity : s.ms);
87
+ const finite = vals.filter((v) => Number.isFinite(v));
88
+ const max = finite.length ? Math.max(...finite, 1) : 1;
89
+ return vals.map((v) => (v === Infinity ? '█' : bars[Math.min(bars.length - 1, Math.floor((v / max) * (bars.length - 1)))])).join('');
90
+ }
91
+
92
+ function describeInput(proof) {
93
+ // Show the proof input compactly: collapse long runs.
94
+ const input = proof.input;
95
+ if (input.length <= 60) return JSON.stringify(input);
96
+ return `${JSON.stringify(input.slice(0, 40))} … (${input.length} chars)`;
97
+ }
98
+
99
+ function printFinding(c, f, index) {
100
+ const tag = f.complexity === 'exponential'
101
+ ? c.red(c.bold(' EXPONENTIAL '))
102
+ : c.yellow(c.bold(' POLYNOMIAL '));
103
+ const kind = c.gray(`(${f.kind})`);
104
+ const out = [];
105
+ out.push(`${c.bold(`#${index}`)} ${tag} ${kind}`);
106
+ out.push(` ${c.cyan('/' + f.source + '/' + f.flags)}`);
107
+ if (f.locations && f.locations.length) {
108
+ const shown = f.locations.slice(0, 5);
109
+ for (const loc of shown) {
110
+ out.push(` ${c.gray('at')} ${loc.file}:${loc.line}:${loc.column}`);
111
+ }
112
+ if (f.locations.length > shown.length) {
113
+ out.push(c.gray(` … +${f.locations.length - shown.length} more location(s)`));
114
+ }
115
+ }
116
+ if (f.proof) {
117
+ const last = f.samples && f.samples.length ? f.samples[f.samples.length - 1] : null;
118
+ out.push(` ${c.magenta('proof')} input ${describeInput(f.proof)}`);
119
+ out.push(` ${f.proof.length} chars → hung past ${f.proof.timeoutMs}ms` +
120
+ (last && last.timedOut ? c.gray(` (${last.ms >= f.proof.timeoutMs ? '≥' : ''}${fmtMs(last.ms)})`) : ''));
121
+ if (f.samples && f.samples.length > 1) {
122
+ out.push(` ${c.gray('curve')} ${sparkline(f.samples)} ${c.gray(`${f.samples[0].length}→${f.samples[f.samples.length - 1].length} chars`)}`);
123
+ }
124
+ }
125
+ return out.join('\n');
126
+ }
127
+
128
+ async function runSingle(opts, c) {
129
+ let source = opts.regex;
130
+ if (source === '-') {
131
+ source = fs.readFileSync(0, 'utf8').trim();
132
+ }
133
+ // Allow the user to paste /pattern/flags form too.
134
+ let flags = opts.flags;
135
+ const m = /^\/(.*)\/([a-z]*)$/s.exec(source);
136
+ if (m) { source = m[1]; if (!flags) flags = m[2]; }
137
+
138
+ let res;
139
+ try {
140
+ res = await scanRegex(source, flags, { timeoutMs: opts.timeoutMs });
141
+ } catch (e) {
142
+ if (opts.json) { process.stdout.write(JSON.stringify({ error: String(e.message || e) }) + '\n'); }
143
+ else process.stderr.write(c.red(`redosray: could not analyze regex: ${e.message || e}\n`));
144
+ return 2;
145
+ }
146
+
147
+ if (opts.json) {
148
+ process.stdout.write(JSON.stringify(res, null, 2) + '\n');
149
+ return res.vulnerable && opts.ci ? 2 : 0;
150
+ }
151
+
152
+ if (!res.vulnerable) {
153
+ process.stdout.write(`${c.green('✓ safe')} ${c.cyan('/' + source + '/' + flags)} ${c.gray(`— ${res.candidates} candidate(s), no hang up to the tested bound`)}\n`);
154
+ return 0;
155
+ }
156
+ const finding = {
157
+ source, flags, complexity: res.complexity, kind: res.kind,
158
+ proof: res.proof, samples: res.samples, locations: [],
159
+ };
160
+ process.stdout.write(printFinding(c, finding, 1) + '\n');
161
+ return opts.ci ? 2 : 0;
162
+ }
163
+
164
+ async function runScan(opts, c) {
165
+ const roots = opts.paths.length ? opts.paths : ['.'];
166
+ for (const r of roots) {
167
+ if (!fs.existsSync(r)) {
168
+ process.stderr.write(c.red(`redosray: path not found: ${r}\n`));
169
+ return 2;
170
+ }
171
+ }
172
+
173
+ const isTTY = process.stderr.isTTY;
174
+ const report = await scanPaths(roots, {
175
+ timeoutMs: opts.timeoutMs,
176
+ onProgress: opts.json ? undefined : ({ index, total }) => {
177
+ if (isTTY) process.stderr.write(`\r${c.gray(`scanning regex ${index}/${total}…`)}\u001b[K`);
178
+ },
179
+ });
180
+ if (!opts.json && isTTY) process.stderr.write('\r\u001b[K');
181
+
182
+ if (opts.json) {
183
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
184
+ return report.vulnerable && opts.ci ? 2 : 0;
185
+ }
186
+
187
+ const head = `${c.gray('scanned')} ${report.filesScanned} file(s), ` +
188
+ `${report.distinctRegexes} distinct regex(es)`;
189
+ process.stdout.write(head + '\n');
190
+
191
+ if (report.vulnerable === 0) {
192
+ process.stdout.write(c.green('✓ no ReDoS-vulnerable regexes confirmed\n'));
193
+ return 0;
194
+ }
195
+
196
+ process.stdout.write('\n');
197
+ report.findings.forEach((f, i) => {
198
+ process.stdout.write(printFinding(c, f, i + 1) + '\n\n');
199
+ });
200
+
201
+ const exp = report.findings.filter((f) => f.complexity === 'exponential').length;
202
+ const poly = report.findings.length - exp;
203
+ process.stdout.write(
204
+ c.bold(`${report.vulnerable} vulnerable regex(es): `) +
205
+ `${c.red(`${exp} exponential`)}, ${c.yellow(`${poly} polynomial`)}\n`,
206
+ );
207
+ return opts.ci ? 2 : 0;
208
+ }
209
+
210
+ async function main(argv) {
211
+ const opts = parseArgs(argv);
212
+ const c = mkColor(opts.color && process.stdout.isTTY && !process.env.NO_COLOR);
213
+ if (opts._error) return 2;
214
+ if (opts.help) { process.stdout.write(HELP); return 0; }
215
+ if (opts.version) { process.stdout.write(`redosray ${VERSION}\n`); return 0; }
216
+
217
+ if (opts.regex !== null) return runSingle(opts, c);
218
+ return runScan(opts, c);
219
+ }
220
+
221
+ module.exports = { main, parseArgs, sparkline };
222
+
223
+ if (require.main === module) {
224
+ main(process.argv.slice(2)).then((code) => { process.exit(code || 0); }).catch((e) => {
225
+ process.stderr.write(`redosray: ${e && e.stack || e}\n`);
226
+ process.exit(1);
227
+ });
228
+ }
package/src/confirm.js ADDED
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+ const path = require('node:path');
3
+ const { Worker } = require('node:worker_threads');
4
+
5
+ const WORKER = path.join(__dirname, 'worker.js');
6
+
7
+ /**
8
+ * Run one regex match against `input` in an isolated worker, timing it and
9
+ * killing it if it exceeds `timeoutMs`. A timeout is itself the signal that the
10
+ * regex catastrophically backtracks on that input.
11
+ * @returns {Promise<{timedOut:boolean, ms:number, matched:boolean}>}
12
+ */
13
+ function timeMatch(source, flags, input, timeoutMs) {
14
+ return new Promise((resolve) => {
15
+ const w = new Worker(WORKER, { workerData: { source, flags, input } });
16
+ const start = Date.now();
17
+ const timer = setTimeout(() => {
18
+ w.terminate();
19
+ resolve({ timedOut: true, ms: Date.now() - start, matched: false });
20
+ }, timeoutMs);
21
+ w.once('message', (m) => {
22
+ clearTimeout(timer);
23
+ w.terminate();
24
+ resolve({ timedOut: false, ms: m.ns / 1e6, matched: m.matched });
25
+ });
26
+ w.once('error', () => {
27
+ clearTimeout(timer);
28
+ resolve({ timedOut: false, ms: Date.now() - start, matched: false, error: true });
29
+ });
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Dynamically CONFIRM a ReDoS by feeding the regex an attack string of growing
35
+ * size and watching for super-linear blow-up. Returns the smallest input that
36
+ * pushed match time past `timeoutMs` (the shareable "proof"), or null if the
37
+ * regex stayed fast at every size (no confirmed vulnerability).
38
+ *
39
+ * @param attack {{prefix?:string, pump:string, suffix?:string}}
40
+ */
41
+ async function confirm(source, flags, attack, opts = {}) {
42
+ const timeoutMs = opts.timeoutMs ?? 1000;
43
+ const maxPumps = opts.maxPumps ?? 100000;
44
+ const prefix = attack.prefix ?? '';
45
+ const suffix = attack.suffix ?? '';
46
+ const samples = [];
47
+ // Grow geometrically so exponential blow-up is caught in a handful of steps
48
+ // and polynomial blow-up within a few dozen.
49
+ for (let n = 10; n <= maxPumps; n = Math.ceil(n * 1.6)) {
50
+ const input = prefix + attack.pump.repeat(n) + suffix;
51
+ const r = await timeMatch(source, flags, input, timeoutMs);
52
+ samples.push({ pumps: n, length: input.length, ms: r.ms, timedOut: r.timedOut });
53
+ if (r.timedOut) {
54
+ return {
55
+ vulnerable: true,
56
+ proof: { input, pumps: n, length: input.length, timeoutMs },
57
+ samples,
58
+ };
59
+ }
60
+ }
61
+ return { vulnerable: false, samples };
62
+ }
63
+
64
+ module.exports = { timeMatch, confirm };
package/src/extract.js ADDED
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Extract regex literals from source code, dependency-free, language-aware.
5
+ *
6
+ * The goal is NOT a full parser: it is to reliably find regex *sources* to feed
7
+ * the analyzer, with file:line:column, while avoiding obvious false extractions
8
+ * (division operators, comments, strings). We keep this conservative: it is fine
9
+ * to miss an odd construction (false negative) but we must not mis-slice code
10
+ * into a bogus "regex" that then wastes a dynamic confirmation.
11
+ *
12
+ * Supported:
13
+ * - JS/TS/JSX/TSX: `/pattern/flags` literals + `new RegExp("...", "flags")`
14
+ * - Python: `re.compile("...")`, `re.match/search/fullmatch/... ("...")`
15
+ * Returns array of { source, flags, line, column, raw, kind } (kind = 'literal'|'ctor').
16
+ */
17
+
18
+ const JS_EXT = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts', '.cts']);
19
+ const PY_EXT = new Set(['.py', '.pyi']);
20
+
21
+ function extForPath(p) {
22
+ const m = /(\.[^.\/\\]+)$/.exec(p);
23
+ return m ? m[1].toLowerCase() : '';
24
+ }
25
+
26
+ function languageForPath(p) {
27
+ const ext = extForPath(p);
28
+ if (JS_EXT.has(ext)) return 'js';
29
+ if (PY_EXT.has(ext)) return 'py';
30
+ return null;
31
+ }
32
+
33
+ // Precompute line-start offsets so we can map an index -> {line, column}.
34
+ function lineIndexer(text) {
35
+ const starts = [0];
36
+ for (let i = 0; i < text.length; i++) {
37
+ if (text[i] === '\n') starts.push(i + 1);
38
+ }
39
+ return function at(index) {
40
+ // binary search for the greatest start <= index
41
+ let lo = 0, hi = starts.length - 1;
42
+ while (lo < hi) {
43
+ const mid = (lo + hi + 1) >> 1;
44
+ if (starts[mid] <= index) lo = mid; else hi = mid - 1;
45
+ }
46
+ return { line: lo + 1, column: index - starts[lo] + 1 };
47
+ };
48
+ }
49
+
50
+ /**
51
+ * A tiny JS tokenizing scanner that walks the source char-by-char, tracking
52
+ * whether we are inside a string / template / comment, so that a `/` is only
53
+ * treated as a regex start when the previous significant token allows it.
54
+ */
55
+ function scanJs(text) {
56
+ const out = [];
57
+ const n = text.length;
58
+ let i = 0;
59
+ // Track the last significant (non-space, non-comment) character to decide if
60
+ // a `/` begins a regex (after operators, `(`, `,`, `=`, `:`, `[`, `!`, `&`,
61
+ // `|`, `?`, `{`, `;`, `return`, etc.) versus division (after value/ident/`)`).
62
+ let prevSig = '';
63
+ let prevWord = '';
64
+
65
+ const regexAllowedAfter = new Set([
66
+ '', '(', ',', '=', ':', '[', '!', '&', '|', '?', '{', ';', '+', '-', '*',
67
+ '%', '<', '>', '^', '~', '}', 'return',
68
+ ]);
69
+
70
+ function isRegexStart() {
71
+ if (regexAllowedAfter.has(prevSig)) return true;
72
+ if (/^(return|typeof|instanceof|in|of|new|do|else|yield|await|case|void|delete)$/.test(prevWord)) return true;
73
+ return false;
74
+ }
75
+
76
+ while (i < n) {
77
+ const c = text[i];
78
+ // line comment
79
+ if (c === '/' && text[i + 1] === '/') {
80
+ i += 2;
81
+ while (i < n && text[i] !== '\n') i++;
82
+ continue;
83
+ }
84
+ // block comment
85
+ if (c === '/' && text[i + 1] === '*') {
86
+ i += 2;
87
+ while (i < n && !(text[i] === '*' && text[i + 1] === '/')) i++;
88
+ i += 2;
89
+ continue;
90
+ }
91
+ // strings
92
+ if (c === '"' || c === "'" || c === '`') {
93
+ const quote = c;
94
+ i++;
95
+ while (i < n) {
96
+ if (text[i] === '\\') { i += 2; continue; }
97
+ if (text[i] === quote) { i++; break; }
98
+ // naive template handling: skip ${...} not needed for our purpose
99
+ i++;
100
+ }
101
+ prevSig = 'str'; prevWord = '';
102
+ continue;
103
+ }
104
+ // regex literal
105
+ if (c === '/' && isRegexStart()) {
106
+ const start = i;
107
+ i++;
108
+ let inClass = false;
109
+ let ok = false;
110
+ let body = '';
111
+ while (i < n) {
112
+ const d = text[i];
113
+ if (d === '\\') { body += d + (text[i + 1] || ''); i += 2; continue; }
114
+ if (d === '\n') break; // unterminated -> not a regex
115
+ if (d === '[') inClass = true;
116
+ else if (d === ']') inClass = false;
117
+ else if (d === '/' && !inClass) { ok = true; break; }
118
+ body += d;
119
+ i++;
120
+ }
121
+ if (ok) {
122
+ i++; // consume closing /
123
+ let flags = '';
124
+ while (i < n && /[a-z]/i.test(text[i])) { flags += text[i]; i++; }
125
+ // ignore trivially-empty or clearly-not-regex bodies
126
+ if (body.length > 0) {
127
+ out.push({ index: start, source: body, flags, raw: '/' + body + '/' + flags, kind: 'literal' });
128
+ }
129
+ prevSig = 'regex'; prevWord = '';
130
+ continue;
131
+ } else {
132
+ // treat as division
133
+ i = start + 1;
134
+ prevSig = '/'; prevWord = '';
135
+ continue;
136
+ }
137
+ }
138
+ // new RegExp("...", "flags") / RegExp('...')
139
+ if ((c === 'R') && /RegExp/.test(text.slice(i, i + 6)) && /\bRegExp$/.test(text.slice(0, i + 6))) {
140
+ // fallthrough to word handling below; ctor handled by regex on whole text later
141
+ }
142
+ // identifiers / words
143
+ if (/[A-Za-z_$]/.test(c)) {
144
+ let w = '';
145
+ const s = i;
146
+ while (i < n && /[A-Za-z0-9_$]/.test(text[i])) { w += text[i]; i++; }
147
+ prevWord = w; prevSig = 'ident';
148
+ continue;
149
+ }
150
+ // whitespace
151
+ if (/\s/.test(c)) { i++; continue; }
152
+ // any other single significant char
153
+ prevSig = c; prevWord = '';
154
+ i++;
155
+ }
156
+ return out;
157
+ }
158
+
159
+ // new RegExp("src" [, "flags"]) — string-literal args only (dynamic args are
160
+ // out of scope; we cannot statically know their value).
161
+ function scanCtorArgs(text, ctorRe) {
162
+ const out = [];
163
+ let m;
164
+ const re = new RegExp(ctorRe.source, 'g');
165
+ while ((m = re.exec(text)) !== null) {
166
+ const quote = m[1];
167
+ let raw = m[2];
168
+ const flags = m[4] || '';
169
+ // Unescape the string-literal one level so it becomes a real regex source.
170
+ let source;
171
+ try {
172
+ source = quote === '`'
173
+ ? raw
174
+ : JSON.parse('"' + raw.replace(/\\'/g, "'").replace(/"/g, '\\"') + '"');
175
+ } catch {
176
+ source = raw.replace(/\\(.)/g, '$1');
177
+ }
178
+ if (source && source.length > 0) {
179
+ out.push({ index: m.index, source, flags, raw: m[0], kind: 'ctor' });
180
+ }
181
+ }
182
+ return out;
183
+ }
184
+
185
+ function extractJs(text) {
186
+ const found = scanJs(text);
187
+ // new RegExp("...", "...") — allow single/double/backtick quotes
188
+ const ctorRe = /\bRegExp\s*\(\s*(["'`])((?:\\.|(?!\1).)*)\1\s*(?:,\s*(["'`])([a-z]*)\3\s*)?\)/;
189
+ return found.concat(scanCtorArgs(text, ctorRe));
190
+ }
191
+
192
+ function extractPy(text) {
193
+ // re.compile(r"...") / re.match(r'...') / re.search("...") etc.
194
+ const reCall = /\bre\.(?:compile|match|search|fullmatch|findall|finditer|sub|subn|split)\s*\(\s*(r?)(["'])((?:\\.|(?!\2).)*)\2/g;
195
+ const out = [];
196
+ let m;
197
+ while ((m = reCall.exec(text)) !== null) {
198
+ const rawFlag = m[1];
199
+ const body = m[3];
200
+ let source = body;
201
+ if (!rawFlag) {
202
+ // non-raw string: collapse one level of Python escapes for regex meaning
203
+ source = body.replace(/\\(.)/g, (mm, ch) => (ch === '\\' ? '\\\\' : '\\' + ch));
204
+ }
205
+ if (source && source.length > 0) {
206
+ out.push({ index: m.index, source, flags: '', raw: m[0], kind: 'ctor' });
207
+ }
208
+ }
209
+ return out;
210
+ }
211
+
212
+ /**
213
+ * Extract regex sources from a file's text.
214
+ * @param {string} text file contents
215
+ * @param {string} path file path (used for language detection)
216
+ * @returns {Array<{source,flags,line,column,raw,kind}>}
217
+ */
218
+ function extractFromText(text, path) {
219
+ const lang = languageForPath(path) || 'js';
220
+ const raw = lang === 'py' ? extractPy(text) : extractJs(text);
221
+ const at = lineIndexer(text);
222
+ return raw.map((r) => {
223
+ const pos = at(r.index);
224
+ return { source: r.source, flags: r.flags, line: pos.line, column: pos.column, raw: r.raw, kind: r.kind };
225
+ });
226
+ }
227
+
228
+ module.exports = { extractFromText, languageForPath, JS_EXT, PY_EXT };
package/src/index.js ADDED
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+ // Public library API for redosray.
3
+ const { scanRegex } = require('./scan');
4
+ const { findCandidates, shapeOf } = require('./analyze');
5
+ const { parse } = require('./parser');
6
+ const { confirm, timeMatch } = require('./confirm');
7
+ const { scanPaths, collectFiles } = require('./scanFiles');
8
+ const { extractFromText } = require('./extract');
9
+
10
+ module.exports = {
11
+ scanRegex, // async: static candidates + dynamic confirmation -> proof
12
+ scanPaths, // async: scan files/dirs for vulnerable regexes -> report
13
+ collectFiles, // list scannable source files under roots
14
+ extractFromText,// pull regex literals (+file position) from source text
15
+ findCandidates, // static only: list attack candidates
16
+ parse, // regex -> AST
17
+ shapeOf, // AST -> source-ish string
18
+ confirm, // dynamic confirmation of one attack
19
+ timeMatch, // time a single regex match in an isolated worker
20
+ };