@ps-neko/nekowork 0.2.0-alpha.8 → 0.2.0-alpha.9

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/README.md CHANGED
@@ -10,11 +10,12 @@ hardcoded credentials, auto-push/commit, test/security disables, risky package
10
10
  hooks, eval, insecure TLS, CORS wildcard, basic SQL/command injection, and AST
11
11
  dataflow taint for variable-mediated injection) and routes everything else to a
12
12
  human decision. It is **not an exhaustive security audit** — the AST rule is
13
- intraprocedural (single-function, JS/TS); cross-function and whole-program dataflow
14
- are out of scope. The verdict is deterministic (same diff, same result), and it never
13
+ inter-procedural (intra-module, JS/TS): it follows taint across functions within a
14
+ single file (local-helper returns, sink aliasing); cross-file and whole-program
15
+ dataflow are out of scope. The verdict is deterministic (same diff, same result), and it never
15
16
  commits, pushes, or deploys on its own. **You** make the final call.
16
17
 
17
- > Note: the published `@alpha` (0.2.0-alpha.7) now ships all **11 rules** described
18
+ > Note: the published `@alpha` (0.2.0-alpha.8) now ships all **11 rules** described
18
19
  > above (incl. eval, insecure TLS, CORS wildcard, SQL/command injection, AST dataflow)
19
20
  > and adds **one tiny, well-known dependency** (`acorn`, the JS parser — MIT, zero
20
21
  > transitive dependencies) for the AST engine. Always install with the **`@alpha`**
@@ -85,7 +86,7 @@ step — it is not triggered by `decision.json`.
85
86
 
86
87
  - [Quickstart](https://github.com/Ps-Neko/NEKOWORK/blob/main/packages/nekowork-cli/docs/QUICKSTART.md)
87
88
  - [How verification works](https://github.com/Ps-Neko/NEKOWORK/blob/main/packages/nekowork-cli/docs/SCOPE-1.0.md)
88
- - [Benchmark](https://github.com/Ps-Neko/NEKOWORK/blob/main/packages/nekowork-cli/docs/BENCHMARK.md) — 11 rules, 184/184 (100%) recall, 0/120 FP; 30 real OSS positives on `secret-fallback`, the newer rules (incl. sql/command injection and `ast-dataflow`) are synthetic-only
89
+ - [Benchmark](https://github.com/Ps-Neko/NEKOWORK/blob/main/packages/nekowork-cli/docs/BENCHMARK.md) — 11 rules, 234/234 (100%) recall, 0/130 FP; ~82 real OSS positives across rules (incl. 30 on `secret-fallback`), synthetic share 63%; `hardcoded-credential` stays synthetic-only by design
89
90
  - [Integration](https://github.com/Ps-Neko/NEKOWORK/blob/main/packages/nekowork-cli/docs/INTEGRATION.md)
90
91
 
91
92
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ps-neko/nekowork",
3
- "version": "0.2.0-alpha.8",
3
+ "version": "0.2.0-alpha.9",
4
4
  "description": "Local verification gate for AI-written code diffs. Deterministic rules decide the verdict, never the LLM. No auto-commit, push, or deploy — you decide at the Human Gate.",
5
5
  "keywords": [
6
6
  "ai-code-review",
package/scripts/check.js CHANGED
@@ -72,20 +72,73 @@ function checkHasCommit() {
72
72
  }
73
73
  }
74
74
 
75
+ // Mirror scripts/lib/diff-parser.js isSelfOutput: verify-pr drops its own output
76
+ // (REPORT.md + .nekowork/**) from every diff source, so those artifacts must not
77
+ // count as "working-tree changes" here either. Case-insensitive to match the
78
+ // parser (Windows/macOS case-insensitive filesystems resolve REPORT.MD etc. to
79
+ // the same files).
80
+ function isSelfOutput(relPath) {
81
+ const lower = String(relPath).toLowerCase();
82
+ return lower === 'report.md' || lower.startsWith('.nekowork/');
83
+ }
84
+
85
+ // Parse one `git status --porcelain` line into its repo-relative path. Porcelain
86
+ // v1 format is `XY <path>` (2 status chars + space + path); renames use
87
+ // `XY old -> new`, where the post-rename path is what verify-pr would scan.
88
+ function porcelainPath(line) {
89
+ let p = line.slice(3);
90
+ const arrow = p.indexOf(' -> ');
91
+ if (arrow !== -1) p = p.slice(arrow + ' -> '.length);
92
+ // Porcelain quotes paths with special chars; strip surrounding quotes.
93
+ if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
94
+ return p.replace(/\\/g, '/');
95
+ }
96
+
75
97
  function checkDiff() {
98
+ // Use `git status --porcelain` (NOT `git diff`): plain `git diff` omits
99
+ // UNTRACKED new files, but verify-pr DOES scan them (synthesizeUntrackedDiff).
100
+ // Reporting "no diff" while verify-pr finds untracked criticals is a misleading
101
+ // false-negative. Porcelain lists untracked with `??`, so it matches verify-pr's
102
+ // diff scope. We then drop nekowork's own output so its artifacts don't count.
76
103
  const r = spawnSync('git', ['status', '--porcelain'], { encoding: 'utf8' });
77
104
  if (r.status !== 0) {
78
105
  record('git-diff', STATUSES.WARN, 'could not check working-tree state');
79
106
  return;
80
107
  }
81
- const lines = r.stdout.split('\n').filter(l => l && !l.startsWith('??'));
82
- if (lines.length > 0) {
83
- record('git-diff', STATUSES.PASS, `${lines.length} modified file(s) — verify-pr will scan these`);
108
+ const changed = r.stdout
109
+ .split('\n')
110
+ .filter(Boolean)
111
+ .map(porcelainPath)
112
+ .filter(p => p && !isSelfOutput(p));
113
+ if (changed.length > 0) {
114
+ record('git-diff', STATUSES.PASS, `working-tree changes detected (${changed.length} file(s)) — verify-pr will scan them`);
84
115
  } else {
85
- record('git-diff', STATUSES.WARN, 'no working-tree diff — `verify-pr` will report no changes');
116
+ record('git-diff', STATUSES.WARN, 'no changes to scan — `verify-pr` will report no changes');
86
117
  }
87
118
  }
88
119
 
120
+ // Gentle, non-blocking hint: verify-pr leaves its evidence output (.nekowork/ and
121
+ // REPORT.md) in the user's repo, which then shows up in `git status`. If those
122
+ // artifacts already exist AND are not gitignored, suggest adding them. Returns a
123
+ // hint string or null. Never a check/failure — just a nudge.
124
+ function gitignoreHint() {
125
+ const artifacts = ['.nekowork/', 'REPORT.md'];
126
+ const present = artifacts.filter(a => {
127
+ try { return fs.existsSync(path.resolve(process.cwd(), a.replace(/\/$/, ''))); } catch { return false; }
128
+ });
129
+ if (present.length === 0) return null;
130
+ // git check-ignore exits 0 if the path IS ignored, 1 if not. Hint only for
131
+ // artifacts that exist but are NOT ignored.
132
+ const notIgnored = present.filter(a => {
133
+ const r = spawnSync('git', ['check-ignore', '-q', a], { encoding: 'utf8' });
134
+ return r.status !== 0;
135
+ });
136
+ if (notIgnored.length === 0) return null;
137
+ return 'Tip: NEKOWORK wrote evidence (.nekowork/, REPORT.md) into this repo. '
138
+ + 'Add them to .gitignore so they don\'t clutter `git status`:\n'
139
+ + ' echo -e ".nekowork/\\nREPORT.md" >> .gitignore';
140
+ }
141
+
89
142
  checkNode();
90
143
  checkGitBinary();
91
144
  checkInsideRepo();
@@ -121,6 +174,15 @@ if (json) {
121
174
  } else {
122
175
  console.log('Ready. Next: `nekowork verify-pr`');
123
176
  }
177
+ // Only meaningful inside a repo (where check-ignore works). git-repo PASS implies that.
178
+ const repoOk = checks.find(c => c.name === 'git-repo')?.status === STATUSES.PASS;
179
+ if (repoOk) {
180
+ const hint = gitignoreHint();
181
+ if (hint) {
182
+ console.log('');
183
+ console.log(` [i] ${hint}`);
184
+ }
185
+ }
124
186
  }
125
187
 
126
188
  process.exit(worstRank);
@@ -6,6 +6,9 @@
6
6
  // - exec("ls " + userInput)
7
7
  // - execSync(`rm -rf ${path}`)
8
8
  // - spawn(`sh -c ${cmd}`, { shell: true })
9
+ // - subprocess.run(f"git checkout {branch}", shell=True) (Python)
10
+ // - os.system("rm -rf " + path) (Python)
11
+ // - exec.Command("sh", "-c", "tar " + name) (Go)
9
12
  //
10
13
  // SAFE forms that must NOT fire (FP=0 against a diverse negative set):
11
14
  // - array-arg spawn/execFile: spawn('ls', ['-la', dir]) (no shell parsing)
@@ -13,6 +16,12 @@
13
16
  // - exec with a plain variable that is itself the whole command and was
14
17
  // validated elsewhere is out of scope (we only flag interpolation INTO a
15
18
  // command string, which is the unambiguous injection shape).
19
+ // - subprocess.run(["ls", "-la"]) / subprocess.run("ls", shell=False) (Py)
20
+ // - os.system("ls -la") (static literal, Python)
21
+ // - exec.Command("ls", "-la") (arg array, Go)
22
+ //
23
+ // Multi-language coverage mirrors insecure-tls.js: each language gets its own
24
+ // regex pattern; the JS engine never sees the Python/Go forms and vice versa.
16
25
  //
17
26
  // Severity: high (critical when force flags / rm appear is left to other rules).
18
27
 
@@ -25,6 +34,23 @@ import { makeRegexScanner } from './_helpers.js';
25
34
  const CONCAT_STR = '(?:"[^"\\n]*"|\'[^\'\\n]*\')\\s*\\+'; // "lit" + / 'lit' +
26
35
  const TEMPLATE_INTERP = '`[^`\\n]*\\$\\{[^}]+\\}[^`\\n]*`'; // `...${x}...`
27
36
 
37
+ // Python dynamic-command shapes. A command argument is dynamic when it is:
38
+ // - an f-string: f"... {x} ..." / f'... {x} ...'
39
+ // - a concatenation: "..." + x (string literal followed by `+`)
40
+ // - a %-format: "..." % x (string literal followed by `%`)
41
+ // - a bare variable: cmd (identifier, not a quote) — only for
42
+ // the shell=True / os.system / os.popen sinks where a
43
+ // non-literal first arg is the injectable shape.
44
+ // A pure string literal (`"ls -la"`) or a list literal (`["ls", "-la"]`) is
45
+ // the safe shape and must NOT match.
46
+ const PY_FSTRING = 'f(?:"[^"\\n]*\\{[^}]+\\}[^"\\n]*"|\'[^\'\\n]*\\{[^}]+\\}[^\'\\n]*\')';
47
+ const PY_CONCAT = '(?:"[^"\\n]*"|\'[^\'\\n]*\')\\s*[+%]'; // "lit" + x / "lit" % x
48
+ // A non-literal, non-list first argument: an identifier (variable) optionally
49
+ // with attribute/subscript access. Excludes a leading quote (string literal)
50
+ // and a leading `[` (list args).
51
+ const PY_VAR = '[A-Za-z_][\\w.\\[\\]\'"]*';
52
+ const PY_DYNAMIC = `(?:${PY_FSTRING}|${PY_CONCAT})`;
53
+
28
54
  const PATTERNS = [
29
55
  {
30
56
  // child_process exec / execSync with a concatenated command string.
@@ -59,6 +85,63 @@ const PATTERNS = [
59
85
  description: 'spawn/exec with shell:true parses shell metacharacters; the command is built from interpolated/concatenated input — a command-injection / RCE vector.',
60
86
  recommendation: 'Drop shell:true and pass an argument array, or strictly validate the input. Never combine shell:true with assembled command strings.',
61
87
  },
88
+ {
89
+ // Python: subprocess.run / call / Popen with shell=True AND a dynamic
90
+ // command (f-string / concat / %-format / bare variable). shell=True hands
91
+ // the command string to /bin/sh, so an assembled command is injectable.
92
+ // A list-literal first arg (`subprocess.run(["ls","-la"])`) or shell=False
93
+ // never matches — the regex requires a non-list dynamic command followed by
94
+ // shell=True within the same call.
95
+ // subprocess.run(f"git checkout {branch}", shell=True)
96
+ // subprocess.Popen("rm -rf " + path, shell=True)
97
+ // subprocess.call(cmd, shell=True)
98
+ id: 'py-subprocess-shell-true',
99
+ re: new RegExp(`\\bsubprocess\\.(?:run|call|Popen|check_output|check_call)\\s*\\(\\s*(?:${PY_DYNAMIC}|${PY_VAR})[\\s\\S]{0,200}?shell\\s*=\\s*True`, 'g'),
100
+ severity: 'critical',
101
+ title: 'Python subprocess with shell=True and a dynamic command',
102
+ description: 'subprocess.run/call/Popen with shell=True runs the command string through the shell; the command is built from an f-string / concatenation / variable — an OS-command-injection vector.',
103
+ recommendation: 'Drop shell=True and pass an argument list (subprocess.run(["git", "checkout", branch])), or strictly validate the input.',
104
+ },
105
+ {
106
+ // Python: os.system with a dynamic command (f-string / concat / %-format).
107
+ // A static literal (os.system("ls -la")) is the safe shape and is excluded
108
+ // by requiring an f-string or a literal-then-(+/%) concatenation.
109
+ // os.system(f"rm -rf {path}") os.system("tar " + name)
110
+ id: 'py-os-system',
111
+ re: new RegExp(`\\bos\\.system\\s*\\(\\s*(?:${PY_DYNAMIC})`, 'g'),
112
+ severity: 'critical',
113
+ title: 'Python os.system with a dynamic command',
114
+ description: 'os.system runs the string through the shell; the command is assembled from an f-string / concatenation / %-format of a variable — an OS-command-injection vector.',
115
+ recommendation: 'Use subprocess.run with an argument list and shell=False, or strictly validate the input. Never feed os.system an assembled command.',
116
+ },
117
+ {
118
+ // Python: os.popen with a dynamic command (f-string / concat / %-format /
119
+ // bare variable). os.popen always goes through the shell, so any non-literal
120
+ // command is injectable. A static literal first arg does not match.
121
+ // os.popen(f"ls {dir}") os.popen("grep " + pat) os.popen(cmd)
122
+ id: 'py-os-popen',
123
+ re: new RegExp(`\\bos\\.popen\\s*\\(\\s*(?:${PY_DYNAMIC}|${PY_VAR}\\s*[,)])`, 'g'),
124
+ severity: 'high',
125
+ title: 'Python os.popen with a dynamic command',
126
+ description: 'os.popen runs the command through the shell; the command is built from an f-string / concatenation / variable — an OS-command-injection vector.',
127
+ recommendation: 'Use subprocess.run with an argument list (shell=False). Do not pass an assembled command to os.popen.',
128
+ },
129
+ {
130
+ // Go: exec.Command("sh", "-c", <dynamic>) / ("bash", "-c", <dynamic>).
131
+ // Passing a shell with `-c` and a concatenated / Sprintf'd / variable third
132
+ // argument re-introduces shell parsing — the Go command-injection shape.
133
+ // exec.Command("ls", "-la") (a real binary + literal args) does NOT match
134
+ // because the first arg must be a shell (sh/bash) followed by -c.
135
+ // exec.Command("sh", "-c", "tar " + name)
136
+ // exec.Command("bash", "-c", fmt.Sprintf("rm %s", path))
137
+ // exec.Command("sh", "-c", cmd)
138
+ id: 'go-exec-shell-c',
139
+ re: /\bexec\.Command\s*\(\s*"(?:sh|bash|\/bin\/sh|\/bin\/bash)"\s*,\s*"-c"\s*,\s*(?:"[^"\n]*"\s*\+|fmt\.Sprintf\s*\(|[A-Za-z_]\w*\s*[,)])/g,
140
+ severity: 'critical',
141
+ title: 'Go exec.Command with sh -c and a dynamic command',
142
+ description: 'exec.Command("sh", "-c", <dynamic>) runs the third argument through the shell; it is built from concatenation / fmt.Sprintf / a variable — an OS-command-injection vector.',
143
+ recommendation: 'Invoke the target binary directly with separate argument strings (exec.Command("git", "checkout", branch)) instead of routing through sh -c.',
144
+ },
62
145
  ];
63
146
 
64
147
  const SCANNER = makeRegexScanner({
@@ -4,6 +4,15 @@
4
4
  // vector when fed anything that is not a compile-time constant:
5
5
  // - eval(<non-literal>) // string-built code executed at runtime
6
6
  // - new Function(<...>) // the Function constructor = eval by proxy
7
+ // - exec(<non-literal>) // Python builtin exec(); runs a code string
8
+ //
9
+ // Note: the language-agnostic `eval(` token means Python `eval(user_input)` is
10
+ // already caught by the JS eval-call pattern below. Python's SAFE alternative
11
+ // ast.literal_eval(x) does NOT fire because eval-call's `(?<![.\w$])` lookbehind
12
+ // rejects the `.eval` member form. The Python `exec()` builtin gets its own
13
+ // pattern (exec-call) with the same lookbehind + static-literal filter, so
14
+ // member calls like RegExp.exec / cursor.exec / child_process exec("ls") never
15
+ // match.
7
16
  //
8
17
  // Comment-stripping (default in makeRegexScanner) removes the word "eval" in
9
18
  // comments and the disable-directive `// eslint-disable ... no-eval` lines, so
@@ -13,6 +22,17 @@
13
22
 
14
23
  import { makeRegexScanner } from './_helpers.js';
15
24
 
25
+ // Shared filter: reject a pure single string-literal / static template argument
26
+ // (low-signal static eval/exec). Any concatenation / interpolation / variable
27
+ // is dynamic and is kept.
28
+ const isDynamicArg = (m) => {
29
+ const arg = (m[1] || '').trim();
30
+ if (!arg) return false;
31
+ if (/^(["'])(?:[^"'\\\n]|\\.)*\1\s*$/.test(arg)) return false; // "lit" / 'lit'
32
+ if (/^`[^`$]*`\s*$/.test(arg)) return false; // `lit` (no ${})
33
+ return true;
34
+ };
35
+
16
36
  const PATTERNS = [
17
37
  {
18
38
  // eval( <arg> ) where the first non-space char of the argument is NOT a
@@ -36,6 +56,21 @@ const PATTERNS = [
36
56
  return true;
37
57
  },
38
58
  },
59
+ {
60
+ // Python builtin exec( <arg> ) with a non-literal argument: exec(code),
61
+ // exec("x = " + val), exec(f"...{x}..."). The `(?<![.\w$])` lookbehind keeps
62
+ // member calls out (RegExp.exec, cursor.exec, cp.exec — child_process exec
63
+ // is a command-injection concern, handled by that rule, not eval-usage). A
64
+ // pure static literal (exec("pass")) is filtered as low-signal, matching the
65
+ // eval-call behavior. ast.literal_eval / .execute(...) never match.
66
+ id: 'exec-call',
67
+ re: /(?<![.\w$])exec\s*\(\s*([^)]*)/g,
68
+ severity: 'high',
69
+ title: 'exec() with dynamic input detected',
70
+ description: 'Python exec() runs a string as code. With any runtime-assembled or external input this is a code-injection / RCE vector.',
71
+ recommendation: 'Remove exec(). Use ast.literal_eval for data, a lookup table / getattr for dispatch, or a real parser.',
72
+ filter: isDynamicArg,
73
+ },
39
74
  {
40
75
  // new Function('a','b','return a+b') — the Function constructor compiles a
41
76
  // string body into a function. Always flagged: the body is a string and the
@@ -6,17 +6,24 @@
6
6
  // - db.query("SELECT * FROM users WHERE id = " + userId)
7
7
  // - db.query(`SELECT * FROM t WHERE x = ${req.body.x}`)
8
8
  // - conn.execute("DELETE FROM logs WHERE owner='" + name + "'")
9
+ // - cursor.execute(f"SELECT * FROM users WHERE id = {uid}") (Python f-string)
10
+ // - cur.execute("DELETE FROM t WHERE id = " + uid) (Python concat)
11
+ // - cursor.execute("SELECT ... %s" % uid) (Python %-format)
9
12
  //
10
13
  // SAFE forms that must NOT fire (FP=0 against a diverse negative set):
11
14
  // - parameterized query: query("SELECT ... WHERE id = $1", [id])
12
15
  // - placeholder query: query("SELECT ... WHERE id = ?", [id])
13
16
  // - fully static SQL: query("SELECT 1")
14
17
  // - ORM / builder calls: repo.find({ where: { id } }) / qb.where('id = :id')
18
+ // - Python parameterized: cursor.execute("SELECT ... %s", (id,)) (2-arg form)
15
19
  //
16
20
  // The gate that keeps FP low: the dynamic string must contain a SQL DML/DDL
17
21
  // keyword (SELECT/INSERT/UPDATE/DELETE/...) AND mix in a concatenation or a
18
- // ${...} interpolation. A query() call with a static string + params array is
19
- // the safe shape and is explicitly excluded (no concat / no interpolation).
22
+ // ${...} / f-string interpolation / %-format. A query() call with a static
23
+ // string + params array is the safe shape and is explicitly excluded (no
24
+ // concat / no interpolation). The Python %-format pattern requires the `%` to
25
+ // be a string-format operator (literal `%` operand), NOT the safe 2-arg
26
+ // `.execute(sql, params)` call where params follow a comma.
20
27
  //
21
28
  // Severity: high.
22
29
 
@@ -55,6 +62,36 @@ const PATTERNS = [
55
62
  description: 'A SQL query template literal interpolates a variable with ${...} and is passed to query/execute/raw — a SQL-injection vector.',
56
63
  recommendation: 'Use parameterized queries (placeholders + a params array), not template interpolation.',
57
64
  },
65
+ {
66
+ // Python f-string SQL: cursor.execute(f"SELECT ... {x} ...").
67
+ // The f-string must contain a SQL keyword AND a {..} interpolation. The
68
+ // safe Python 2-arg form (cursor.execute("SELECT ... %s", (id,))) uses a
69
+ // plain string literal (no `f` prefix, no {..}) and never matches.
70
+ // cursor.execute(f"SELECT * FROM users WHERE id = {uid}")
71
+ // cur.execute(f'DELETE FROM t WHERE name = {name}')
72
+ id: 'sql-py-fstring',
73
+ re: new RegExp(`\\.${SINK}\\s*\\(\\s*f(?:"[^"\\n]*${SQL_KW}[^"\\n]*\\{[^}]+\\}|'[^'\\n]*${SQL_KW}[^'\\n]*\\{[^}]+\\})`, 'gi'),
74
+ severity: 'high',
75
+ title: 'SQL string built by Python f-string interpolation',
76
+ description: 'A Python f-string SQL query interpolates a variable with {..} and is passed to cursor.execute — a SQL-injection vector.',
77
+ recommendation: 'Use a parameterized query: cursor.execute("SELECT ... WHERE id = %s", (id,)). Never build SQL with an f-string.',
78
+ },
79
+ {
80
+ // Python %-format SQL: cursor.execute("SELECT ... %s ..." % x). The string
81
+ // literal contains a SQL keyword and is followed by a `%` FORMAT operator
82
+ // (string-format), distinct from the safe 2-arg `.execute(sql, params)`
83
+ // where params follow a COMMA. We require the literal to be immediately
84
+ // followed by `%` and then a non-`)` operand (a variable / tuple), so a
85
+ // literal that simply ends the call does not match.
86
+ // cursor.execute("SELECT * FROM users WHERE id = %s" % uid)
87
+ // cur.execute("DELETE FROM t WHERE name = '%s'" % (name,))
88
+ id: 'sql-py-percent',
89
+ re: new RegExp(`\\.${SINK}\\s*\\(\\s*(?:"[^"\\n]*${SQL_KW}[^"\\n]*"|'[^'\\n]*${SQL_KW}[^'\\n]*')\\s*%\\s*(?![\\s)])`, 'gi'),
90
+ severity: 'high',
91
+ title: 'SQL string built by Python %-format',
92
+ description: 'A Python SQL query is assembled with the %-format operator (string % value) and passed to cursor.execute — a SQL-injection vector. This is NOT the safe 2-arg execute(sql, params) form.',
93
+ recommendation: 'Use the 2-argument parameterized form: cursor.execute("SELECT ... %s", (id,)) where the driver binds the params — not Python string formatting.',
94
+ },
58
95
  ];
59
96
 
60
97
  const SCANNER = makeRegexScanner({