penthera 1.4.0 → 1.6.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +148 -575
  3. package/bin/scan.js +19 -0
  4. package/docs/commands/README.md +23 -0
  5. package/docs/commands/action.md +21 -0
  6. package/docs/commands/agents.md +38 -0
  7. package/docs/commands/deps.md +115 -0
  8. package/docs/commands/fix.md +53 -0
  9. package/docs/commands/hook.md +69 -0
  10. package/docs/commands/install.md +53 -0
  11. package/docs/commands/scan.md +100 -0
  12. package/docs/commands/skill.md +58 -0
  13. package/docs/compare/README.md +25 -0
  14. package/docs/compare/github.md +60 -0
  15. package/docs/compare/semgrep.md +54 -0
  16. package/docs/compare/snyk.md +62 -0
  17. package/docs/compare/socket.md +78 -0
  18. package/docs/compare/zap.md +59 -0
  19. package/docs/compliance-cra.md +141 -0
  20. package/docs/compliance-nis2.md +3 -1
  21. package/docs/dashboard.md +244 -0
  22. package/docs/evidence.md +8 -4
  23. package/docs/examples/github-actions-deps.yml +4 -4
  24. package/docs/examples/github-actions-scan.yml +4 -4
  25. package/docs/index.md +11 -2
  26. package/docs/internal-audit.md +83 -0
  27. package/docs/marketing.md +151 -0
  28. package/docs/plan.md +101 -0
  29. package/docs/privacy.md +38 -0
  30. package/docs/references.bib +10 -0
  31. package/docs/references.md +1 -0
  32. package/docs/research/socket-positioning-notes.md +183 -0
  33. package/docs/use-cases/README.md +14 -0
  34. package/docs/use-cases/agencies.md +71 -0
  35. package/docs/use-cases/ai-coding-teams.md +81 -0
  36. package/docs/use-cases/eu-sme-compliance.md +68 -0
  37. package/docs/use-cases/open-source-maintainers.md +75 -0
  38. package/docs/use-cases/solo-founder.md +65 -0
  39. package/lib/blackbox/email-dns.js +5 -0
  40. package/lib/compliance/cra.js +525 -0
  41. package/lib/compliance/questionnaire.js +4 -2
  42. package/lib/deps/capabilities.js +49 -12
  43. package/lib/deps/index.js +314 -30
  44. package/lib/deps/license.js +329 -0
  45. package/lib/deps/lockfile.js +29 -3
  46. package/lib/deps/popular-pypi.json +1 -0
  47. package/lib/deps/python/capabilities.js +447 -0
  48. package/lib/deps/python/index.js +234 -0
  49. package/lib/deps/python/lockfiles.js +359 -0
  50. package/lib/deps/python/source.js +396 -0
  51. package/lib/deps/python/toml.js +219 -0
  52. package/lib/deps/report.js +54 -6
  53. package/lib/deps/source.js +149 -24
  54. package/lib/deps/spdx.json +1 -0
  55. package/lib/deps/typosquat.js +24 -13
  56. package/lib/fix/apply.js +83 -19
  57. package/lib/hook/index.js +345 -0
  58. package/lib/hook/policy.js +62 -0
  59. package/lib/install/index.js +59 -18
  60. package/lib/install/policy.js +27 -7
  61. package/lib/jurisdictions/nl.json +9 -0
  62. package/lib/memory/repo-suppress.js +11 -3
  63. package/lib/references.json +77 -0
  64. package/lib/remediation/index.js +9 -0
  65. package/lib/retirejs.js +69 -17
  66. package/lib/rules/regex-guard.js +368 -0
  67. package/lib/templates.js +13 -6
  68. package/lib/tls.js +7 -0
  69. package/lib/whitebox/aibom.js +384 -0
  70. package/lib/whitebox/frameworks.js +8 -5
  71. package/lib/whitebox/sbom.js +45 -10
  72. package/lib/whitebox/secrets.js +31 -8
  73. package/package.json +8 -3
  74. package/skills/penthera/SKILL.md +55 -8
  75. package/src/cli/deps.js +81 -6
  76. package/src/cli/evidence.js +14 -4
  77. package/src/cli/hook.js +131 -0
  78. package/src/cli/install.js +14 -5
  79. package/src/cli/report-outputs.js +25 -2
  80. package/src/cli/ui.js +89 -0
  81. package/src/cli.js +81 -11
  82. package/src/report/audience.js +10 -10
  83. package/src/report/evidence.js +33 -14
  84. package/src/report/footer.js +11 -0
  85. package/src/report/markdown.js +6 -3
  86. package/src/report/md-escape.js +16 -0
  87. package/src/reporter.js +1 -0
  88. package/src/scan-url.js +5 -1
  89. package/src/ui/api/data.js +309 -0
  90. package/src/ui/api/demo.js +464 -0
  91. package/src/ui/api/routes.js +134 -0
  92. package/src/ui/api/runs.js +243 -0
  93. package/src/ui/api/summarize.js +451 -0
  94. package/src/ui/security.js +153 -0
  95. package/src/ui/server.js +186 -0
  96. package/src/ui/static/app.css +723 -0
  97. package/src/ui/static/favicon.svg +1 -0
  98. package/src/ui/static/index.html +57 -0
  99. package/src/ui/static/js/api.js +29 -0
  100. package/src/ui/static/js/app.js +138 -0
  101. package/src/ui/static/js/charts.js +207 -0
  102. package/src/ui/static/js/copy.js +79 -0
  103. package/src/ui/static/js/dom.js +120 -0
  104. package/src/ui/static/js/format.js +42 -0
  105. package/src/ui/static/js/theme.js +10 -0
  106. package/src/ui/static/js/typer.js +99 -0
  107. package/src/ui/static/js/views/ai.js +68 -0
  108. package/src/ui/static/js/views/deps.js +96 -0
  109. package/src/ui/static/js/views/evidence.js +115 -0
  110. package/src/ui/static/js/views/findings.js +165 -0
  111. package/src/ui/static/js/views/history.js +92 -0
  112. package/src/ui/static/js/views/overview.js +184 -0
  113. package/src/ui/static/js/views/run.js +349 -0
  114. package/src/ui/static/js/views/settings.js +67 -0
@@ -38,6 +38,15 @@
38
38
  "content": "Final report: detailed description, root cause, applied and ongoing mitigations, and any cross-border impact."
39
39
  }
40
40
  ],
41
+ "cra": {
42
+ "law": "Cyber Resilience Act, Regulation (EU) 2024/2847, Article 14 (reporting applies from 11 September 2026)",
43
+ "csirtCoordinator": "NCSC-NL (Nationaal Cyber Security Centrum)",
44
+ "csirtContact": "https://www.ncsc.nl/contact",
45
+ "srp": "https://portal.cra-srp.enisa.europa.eu/",
46
+ "timezone": "Europe/Amsterdam",
47
+ "source": "https://www.enisa.europa.eu/topics/product-security/vulnerability-services/eu-incident-response-and-cyber-crisis-management/single-reporting-platform-srp/list-of-csirts-designated-as-coordinators",
48
+ "note": "NCSC-NL is the Dutch CSIRT designated as coordinator on ENISA's list (checked 2026-10-19). It applies if your main establishment is in the Netherlands, i.e. where cybersecurity decisions about your products are predominantly taken (Art. 14(7))."
49
+ },
41
50
  "penalties": "Up to €10M or 2% of global annual turnover. Management can be held personally accountable.",
42
51
  "pointers": [
43
52
  "Determine whether you are an essential or important entity, and your sector's competent authority.",
@@ -25,15 +25,23 @@ const MAX_ENTRIES = 500;
25
25
  * entries are dropped with a warning, never guessed at.
26
26
  */
27
27
  export function loadRepoSuppressions(repoPath, { now = new Date().toISOString() } = {}) {
28
- const warnings = [];
29
28
  const path = join(repoPath, REPO_SUPPRESS_FILE);
30
- if (!existsSync(path)) return { entries: [], warnings, path: null };
29
+ if (!existsSync(path)) return { entries: [], warnings: [], path: null };
31
30
  const st = statSync(path);
32
31
  if (!st.isFile()) return { entries: [], warnings: [`${REPO_SUPPRESS_FILE} is not a regular file, ignored`], path };
33
32
  if (st.size > MAX_BYTES) return { entries: [], warnings: [`${REPO_SUPPRESS_FILE} is larger than 64 KB, ignored`], path };
33
+ return parseRepoSuppressions(readFileSync(path), { now, path });
34
+ }
34
35
 
36
+ /**
37
+ * Validate the file's bytes (from disk, or from the git index for the
38
+ * pre-commit hook). Same rules and result shape as loadRepoSuppressions().
39
+ */
40
+ export function parseRepoSuppressions(buf, { now = new Date().toISOString(), path = REPO_SUPPRESS_FILE } = {}) {
41
+ const warnings = [];
42
+ if (buf.length > MAX_BYTES) return { entries: [], warnings: [`${REPO_SUPPRESS_FILE} is larger than 64 KB, ignored`], path };
35
43
  let doc;
36
- try { doc = JSON.parse(readFileSync(path, "utf-8")); } catch (e) {
44
+ try { doc = JSON.parse(buf.toString("utf-8")); } catch (e) {
37
45
  return { entries: [], warnings: [`${REPO_SUPPRESS_FILE} is not valid JSON (${e.message}), ignored`], path };
38
46
  }
39
47
  if (!doc || typeof doc !== "object" || doc.version !== 1 || !Array.isArray(doc.suppress)) {
@@ -127,6 +127,78 @@
127
127
  ],
128
128
  "attack-chain": [
129
129
  "shostack2014threat"
130
+ ],
131
+ "dependency-risk": [
132
+ "ladisa2023sok",
133
+ "ohm2020backstabber",
134
+ "zimmermann2019small"
135
+ ],
136
+ "license": [
137
+ "spdxlicenselist"
138
+ ],
139
+ "agent-config": [
140
+ "owasp2025agentic",
141
+ "owasp2025mcptop10",
142
+ "greshake2023signed"
143
+ ],
144
+ "machine": [
145
+ "scarfone2008sp800115",
146
+ "saltzer1975protection"
147
+ ],
148
+ "machine-integrity": [
149
+ "scarfone2008sp800115",
150
+ "thompson1984reflections"
151
+ ],
152
+ "machine-config": [
153
+ "scarfone2008sp800115",
154
+ "saltzer1975protection"
155
+ ],
156
+ "suspicious-process": [
157
+ "scarfone2008sp800115"
158
+ ],
159
+ "ssh-config": [
160
+ "scarfone2008sp800115",
161
+ "saltzer1975protection"
162
+ ],
163
+ "rootkit": [
164
+ "scarfone2008sp800115",
165
+ "thompson1984reflections"
166
+ ],
167
+ "persistence": [
168
+ "scarfone2008sp800115"
169
+ ],
170
+ "network": [
171
+ "scarfone2008sp800115"
172
+ ],
173
+ "system-commands": [
174
+ "scarfone2008sp800115"
175
+ ],
176
+ "suspicious-files": [
177
+ "scarfone2008sp800115"
178
+ ],
179
+ "rkhunter": [
180
+ "scarfone2008sp800115"
181
+ ],
182
+ "permissions": [
183
+ "scarfone2008sp800115",
184
+ "saltzer1975protection"
185
+ ],
186
+ "malware": [
187
+ "scarfone2008sp800115"
188
+ ],
189
+ "kernel": [
190
+ "scarfone2008sp800115",
191
+ "saltzer1975protection"
192
+ ],
193
+ "hidden-files": [
194
+ "scarfone2008sp800115"
195
+ ],
196
+ "code-signing": [
197
+ "scarfone2008sp800115",
198
+ "thompson1984reflections"
199
+ ],
200
+ "browser-extensions": [
201
+ "scarfone2008sp800115"
130
202
  ]
131
203
  },
132
204
  "sources": {
@@ -369,6 +441,11 @@
369
441
  "cite": "MITRE CWE",
370
442
  "title": "CWE: Common Weakness Enumeration",
371
443
  "url": "https://cwe.mitre.org/"
444
+ },
445
+ "spdxlicenselist": {
446
+ "cite": "SPDX License List",
447
+ "title": "SPDX License List",
448
+ "url": "https://spdx.org/licenses/"
372
449
  }
373
450
  }
374
451
  }
@@ -111,6 +111,15 @@ const RULES = [
111
111
  effort: "quick",
112
112
  ownerAction: false,
113
113
  },
114
+ {
115
+ id: "license",
116
+ match: (f) => f.category === "license",
117
+ action: "Replace or approve the dependency whose license breaks your policy",
118
+ why: "A dependency under a license your policy does not allow (strong copyleft such as AGPL in a closed product, or no license at all, which means no rights to use it) is a legal exposure that ships with every release.",
119
+ fix: "Swap the package for one under an allowed license, or, if legal has reviewed it, add it to `allowPackages` in the `licenses` section of penthera.policy.json with a reason. An UNLICENSED or missing license needs the author to publish one; until then treat it as not usable.",
120
+ effort: "moderate",
121
+ ownerAction: true, // a legal call, not a code change
122
+ },
114
123
  {
115
124
  id: "dependency-risk",
116
125
  match: (f) => f.category === "dependency-risk",
package/lib/retirejs.js CHANGED
@@ -139,31 +139,83 @@ function extractVersion(text, patterns) {
139
139
 
140
140
  /**
141
141
  * Extract <script src="..."> URLs and inline script blocks from HTML.
142
+ *
143
+ * A small tokenizer rather than regexes: the HTML comes from the scanned
144
+ * target, so tag names match case-insensitively, attributes may use either
145
+ * quote or none, and a closing tag may carry whitespace or junk before its `>`
146
+ * (`</script >`, `</SCRIPT foo>`), as browsers accept. This only reads; it
147
+ * never rewrites or "sanitises" HTML.
142
148
  */
143
149
  export function extractScripts(html, baseUrl) {
144
150
  const scripts = { external: [], inline: [] };
145
-
146
- // External scripts
147
- const srcRe = /<script[^>]+src=["']([^"']+)["']/gi;
148
- let match;
149
- while ((match = srcRe.exec(html))) {
150
- let src = match[1];
151
- // Resolve relative URLs
152
- if (src.startsWith("//")) src = "https:" + src;
153
- else if (src.startsWith("/")) src = baseUrl + src;
154
- else if (!src.startsWith("http")) src = baseUrl + "/" + src;
155
- scripts.external.push(src);
151
+ const lower = String(html ?? "").toLowerCase();
152
+ const src0 = String(html ?? "");
153
+ let pos = 0;
154
+ while ((pos = lower.indexOf("<script", pos)) !== -1) {
155
+ const after = lower[pos + 7];
156
+ // `<scripts>` or `<script-x>` is a different tag.
157
+ if (after !== undefined && after !== ">" && after !== "/" && !/\s/.test(after)) { pos += 7; continue; }
158
+ const { attrs, end } = readTag(src0, pos + 7);
159
+ if (end === -1) break;
160
+ const src = attrs.src;
161
+ if (src) {
162
+ let url = src;
163
+ if (url.startsWith("//")) url = "https:" + url;
164
+ else if (url.startsWith("/")) url = baseUrl + url;
165
+ else if (!url.startsWith("http")) url = baseUrl + "/" + url;
166
+ scripts.external.push(url);
167
+ }
168
+ const close = findScriptClose(lower, end);
169
+ const body = src0.slice(end, close.start);
170
+ if (body.trim().length > 10) scripts.inline.push(body);
171
+ pos = close.end;
156
172
  }
173
+ return scripts;
174
+ }
157
175
 
158
- // Inline scripts (for version detection)
159
- const inlineRe = /<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/gi;
160
- while ((match = inlineRe.exec(html))) {
161
- if (match[1].trim().length > 10) {
162
- scripts.inline.push(match[1]);
176
+ /** Read attributes from just after `<script` to the tag's `>`. */
177
+ function readTag(s, i) {
178
+ const attrs = Object.create(null);
179
+ const n = s.length;
180
+ while (i < n) {
181
+ while (i < n && (/\s/.test(s[i]) || s[i] === "/")) i++;
182
+ if (i >= n) return { attrs, end: -1 };
183
+ if (s[i] === ">") return { attrs, end: i + 1 };
184
+ let name = "";
185
+ while (i < n && !/[\s=>/]/.test(s[i])) name += s[i++];
186
+ while (i < n && /\s/.test(s[i])) i++;
187
+ let value = "";
188
+ if (s[i] === "=") {
189
+ i++;
190
+ while (i < n && /\s/.test(s[i])) i++;
191
+ const q = s[i];
192
+ if (q === '"' || q === "'") {
193
+ const close = s.indexOf(q, i + 1);
194
+ if (close === -1) return { attrs, end: -1 };
195
+ value = s.slice(i + 1, close);
196
+ i = close + 1;
197
+ } else {
198
+ while (i < n && !/[\s>]/.test(s[i])) value += s[i++];
199
+ }
163
200
  }
201
+ name = name.toLowerCase();
202
+ if (name && !(name in attrs)) attrs[name] = value;
164
203
  }
204
+ return { attrs, end: -1 };
205
+ }
165
206
 
166
- return scripts;
207
+ /** Find `</script ...>` (any case, anything up to `>`); unclosed runs to EOF. */
208
+ function findScriptClose(lower, from) {
209
+ let i = from;
210
+ while ((i = lower.indexOf("</script", i)) !== -1) {
211
+ const c = lower[i + 8];
212
+ if (c === undefined || c === ">" || c === "/" || /\s/.test(c)) {
213
+ const gt = lower.indexOf(">", i + 8);
214
+ return { start: i, end: gt === -1 ? lower.length : gt + 1 };
215
+ }
216
+ i += 8;
217
+ }
218
+ return { start: lower.length, end: lower.length };
167
219
  }
168
220
 
169
221
  // ── Main scanner ─────────────────────────────────────────────────────────
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Static ReDoS guard for regexes that come from data: rule packs, community
3
+ * Nuclei templates. Rule packs scan hostile input (dependency source, agent
4
+ * configs, response bodies), so a pack must not be able to hang the scanner
5
+ * by shipping a pattern that backtracks catastrophically.
6
+ *
7
+ * V8's regex engine backtracks, and its linear-time engine (the `l` flag)
8
+ * refuses ordinary bounded repeats like `{64}`, so the guard is structural:
9
+ * it parses the pattern and refuses the shapes that blow up.
10
+ *
11
+ * 1. Length cap (MAX_LENGTH).
12
+ * 2. No backreferences and no lookaround. Detection rules do not need them,
13
+ * and both defeat simple reasoning about cost.
14
+ * 3. Star height 1: a quantified group may not contain a quantifier
15
+ * (`(a+)+`, `(\w*\s?)*`). The exponential case.
16
+ * 4. A group under an unbounded quantifier may not contain alternation
17
+ * (`(a|aa)+`, `(a|a)*`). The other exponential case.
18
+ * 5. Bounded repeats are capped at MAX_REPEAT.
19
+ * 6. An unbounded quantifier that is followed by something mandatory may
20
+ * not be reachable from every start position (`\s*x`, `[a-z]+@`): each
21
+ * start would rescan the rest of the input. Something mandatory before
22
+ * it that cannot occur inside its run (`x\s*:`) breaks the chain.
23
+ * 7. Two quantifiers that can match the same characters, with nothing
24
+ * mandatory between them that the first cannot also match (`.*b.*`,
25
+ * `\s*\s*`), multiply their cost. A chain with an unbounded member is
26
+ * refused; a bounded chain must stay under CHAIN_BUDGET.
27
+ *
28
+ * The guard is conservative: it refuses some safe patterns. That is the right
29
+ * trade for data that arrives from outside the codebase. tests/rule-packs.test.js
30
+ * fuzzes it: every pattern it accepts must run in bounded time on adversarial
31
+ * input.
32
+ */
33
+
34
+ export const MAX_LENGTH = 512;
35
+ export const MAX_REPEAT = 256;
36
+ export const CHAIN_BUDGET = 1024;
37
+ export const ALLOWED_FLAGS = /^[imsu]*$/;
38
+
39
+ // The narrower check for community templates (lib/templates.js, added in
40
+ // v1.1.1 as a regex): refuse a quantifier applied to a group that contains a
41
+ // quantifier, at any depth, or a quantifier stacked on another (`a+*`). It
42
+ // keeps user templates working that the strict checkRegex below would refuse.
43
+ // A linear scan rather than a regex, because a regex inspecting a pattern
44
+ // string is itself open to backtracking on input like "((((((".
45
+ const QUANT_START = (src, i) => src[i] === "*" || src[i] === "+" || (src[i] === "{" && /^\{\d+(,\d*)?\}/.test(src.slice(i, i + 24)));
46
+
47
+ export function hasNestedQuantifier(pattern) {
48
+ const src = String(pattern);
49
+ const groups = []; // per open group: does it contain a quantifier?
50
+ let prevQuant = false;
51
+ for (let i = 0; i < src.length; i++) {
52
+ const c = src[i];
53
+ if (c === "\\") { i++; prevQuant = false; continue; }
54
+ if (c === "[") {
55
+ for (i++; i < src.length && src[i] !== "]"; i++) if (src[i] === "\\") i++;
56
+ prevQuant = false;
57
+ continue;
58
+ }
59
+ if (c === "(") { groups.push(false); prevQuant = false; continue; }
60
+ if (c === ")") {
61
+ const inner = groups.pop() || false;
62
+ if (inner && QUANT_START(src, i + 1)) return true;
63
+ if (inner && groups.length) groups[groups.length - 1] = true;
64
+ prevQuant = false;
65
+ continue;
66
+ }
67
+ if (QUANT_START(src, i) || c === "?") {
68
+ const isQ = c !== "?" || (i > 0 && src[i - 1] !== "(" && !prevQuant);
69
+ if (c !== "?" && prevQuant) return true; // a+* / a{2}+
70
+ if (isQ && c !== "?" && groups.length) groups[groups.length - 1] = true;
71
+ if (c === "{") i = src.indexOf("}", i);
72
+ prevQuant = c !== "?" ? true : prevQuant;
73
+ continue;
74
+ }
75
+ prevQuant = false;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ class GuardError extends Error {}
81
+ const refuse = (msg) => { throw new GuardError(msg); };
82
+
83
+ // ── Parser ────────────────────────────────────────────────────────────────
84
+ // A small parser for the subset of JS regex syntax a detection rule needs.
85
+ // Nodes: { type: "alt", branches: Item[][] }, items are { atom, min, max, src }
86
+ // where atom is { type: "char"|"class"|"any"|"anchor"|"group", src, alt? }.
87
+
88
+ function parse(src, unicode) {
89
+ let i = 0;
90
+ const peek = () => src[i];
91
+
92
+ function parseAlt(depth) {
93
+ if (depth > 8) refuse("groups nested deeper than 8");
94
+ const branches = [[]];
95
+ while (i < src.length) {
96
+ const c = peek();
97
+ if (c === ")") break;
98
+ if (c === "|") { i++; branches.push([]); continue; }
99
+ const start = i;
100
+ const atom = parseAtom(depth);
101
+ const q = parseQuantifier(atom);
102
+ branches[branches.length - 1].push({ atom, ...q, src: src.slice(start, i) });
103
+ }
104
+ return { type: "alt", branches };
105
+ }
106
+
107
+ function parseAtom(depth) {
108
+ const c = src[i];
109
+ if (c === "(") {
110
+ i++;
111
+ if (src.startsWith("?:", i)) i += 2;
112
+ else if (src.startsWith("?=", i) || src.startsWith("?!", i) || src.startsWith("?<=", i) || src.startsWith("?<!", i)) refuse("lookaround is not allowed");
113
+ else if (src.startsWith("?<", i)) {
114
+ const end = src.indexOf(">", i);
115
+ if (end < 0) refuse("unterminated group name");
116
+ i = end + 1;
117
+ } else if (c === "(" && src[i] === "?") refuse("unsupported group syntax");
118
+ const alt = parseAlt(depth + 1);
119
+ if (src[i] !== ")") refuse("unterminated group");
120
+ i++;
121
+ return { type: "group", alt };
122
+ }
123
+ if (c === "[") return parseClass();
124
+ if (c === "\\") return parseEscape();
125
+ if (c === ".") { i++; return { type: "any", src: "." }; }
126
+ if (c === "^" || c === "$") { i++; return { type: "anchor", src: c }; }
127
+ if (c === "*" || c === "+" || c === "?" || c === "{" && /^\{\d/.test(src.slice(i))) refuse("quantifier with nothing to repeat");
128
+ if (c === "]" || c === "}") { if (unicode) refuse(`lone ${c}`); }
129
+ i++;
130
+ return { type: "char", src: c };
131
+ }
132
+
133
+ function parseEscape() {
134
+ const start = i;
135
+ i++;
136
+ const c = src[i];
137
+ if (c === undefined) refuse("trailing backslash");
138
+ if (/[1-9]/.test(c)) refuse("backreferences are not allowed");
139
+ if (c === "k" && src[i + 1] === "<") refuse("backreferences are not allowed");
140
+ if (c === "b" || c === "B") { i++; return { type: "anchor", src: src.slice(start, i) }; }
141
+ if (c === "x") i += 3;
142
+ else if (c === "u" && src[i + 1] === "{") i = src.indexOf("}", i) + 1 || refuse("bad \\u{} escape");
143
+ else if (c === "u") i += 5;
144
+ else if ((c === "p" || c === "P") && src[i + 1] === "{") i = src.indexOf("}", i) + 1 || refuse("bad \\p{} escape");
145
+ else if (c === "c") i += 2;
146
+ else i++;
147
+ const s = src.slice(start, i);
148
+ return { type: /^\\[dDwWsSpP]/.test(s) ? "class" : "char", src: s };
149
+ }
150
+
151
+ function parseClass() {
152
+ const start = i;
153
+ i++;
154
+ if (src[i] === "^") i++;
155
+ if (src[i] === "]") i++;
156
+ while (i < src.length && src[i] !== "]") {
157
+ if (src[i] === "\\") {
158
+ if (/[1-9]/.test(src[i + 1] || "")) refuse("backreferences are not allowed");
159
+ i += 2;
160
+ } else if (src[i] === "[" && unicode) refuse("nested classes are not allowed");
161
+ else i++;
162
+ }
163
+ if (src[i] !== "]") refuse("unterminated character class");
164
+ i++;
165
+ return { type: "class", src: src.slice(start, i) };
166
+ }
167
+
168
+ function parseQuantifier(atom) {
169
+ let min = 1;
170
+ let max = 1;
171
+ const c = src[i];
172
+ if (c === "*") { min = 0; max = Infinity; i++; }
173
+ else if (c === "+") { min = 1; max = Infinity; i++; }
174
+ else if (c === "?") { min = 0; max = 1; i++; }
175
+ else if (c === "{") {
176
+ const m = /^\{(\d+)(,(\d*))?\}/.exec(src.slice(i));
177
+ if (!m) return { min, max };
178
+ min = Number(m[1]);
179
+ max = m[2] ? (m[3] === "" ? Infinity : Number(m[3])) : min;
180
+ if (max < min) refuse("quantifier range out of order");
181
+ if (min > MAX_REPEAT || (max !== Infinity && max > MAX_REPEAT)) refuse(`repeat count above ${MAX_REPEAT}`);
182
+ i += m[0].length;
183
+ } else return { min, max };
184
+ if (atom.type === "anchor") refuse("quantified anchor");
185
+ if (src[i] === "?") i++; // lazy: same cost bound as greedy
186
+ if (src[i] === "*" || src[i] === "+" || src[i] === "?" || (src[i] === "{" && /^\{\d+(,\d*)?\}/.test(src.slice(i)))) refuse("stacked quantifiers");
187
+ return { min, max };
188
+ }
189
+
190
+ const ast = parseAlt(0);
191
+ if (i < src.length) refuse("unbalanced parenthesis");
192
+ return ast;
193
+ }
194
+
195
+ // ── Character sets ────────────────────────────────────────────────────────
196
+ // Approximated by membership over a sample alphabet: all of ASCII plus
197
+ // representatives of the Unicode classes rules tend to touch. Two atoms
198
+ // "overlap" if some sample character matches both.
199
+
200
+ const SAMPLE = (() => {
201
+ const out = [];
202
+ for (let c = 0; c < 128; c++) out.push(String.fromCharCode(c));
203
+ out.push(" ", "é", "ß", "İ", "
", "​", "中", "а", "", "\u{1F600}");
204
+ return out;
205
+ })();
206
+
207
+ const setCache = new Map();
208
+ function charSet(src, flags) {
209
+ const key = `${flags}\u0000${src}`;
210
+ let set = setCache.get(key);
211
+ if (set) return set;
212
+ const re = new RegExp(`^(?:${src})$`, flags.replace(/[gy]/g, ""));
213
+ set = new Uint8Array(SAMPLE.length);
214
+ for (let k = 0; k < SAMPLE.length; k++) set[k] = re.test(SAMPLE[k]) ? 1 : 0;
215
+ if (setCache.size > 5000) setCache.clear();
216
+ setCache.set(key, set);
217
+ return set;
218
+ }
219
+ const overlaps = (a, b) => a.some((v, k) => v && b[k]);
220
+ const subset = (a, b) => a.every((v, k) => !v || b[k]);
221
+ const union = (sets) => sets.reduce((acc, s) => acc.map((v, k) => v | s[k]), new Uint8Array(SAMPLE.length));
222
+
223
+ /** Characters an item can consume (any one position), or null for anchors. */
224
+ function itemSet(item, flags) {
225
+ const a = item.atom;
226
+ if (a.type === "anchor") return null;
227
+ if (a.type !== "group") return charSet(a.src, flags);
228
+ const sets = [];
229
+ for (const br of a.alt.branches) for (const it of br) { const s = itemSet(it, flags); if (s) sets.push(s); }
230
+ return union(sets);
231
+ }
232
+
233
+ function hasQuantifier(alt) {
234
+ return alt.branches.some((br) => br.some((it) => it.max > 1 || (it.atom.type === "group" && hasQuantifier(it.atom.alt))));
235
+ }
236
+
237
+ /** Largest max among quantifiers inside a group (Infinity if any unbounded). */
238
+ function innerMax(alt) {
239
+ let m = 1;
240
+ for (const br of alt.branches) for (const it of br) {
241
+ m = Math.max(m, it.max);
242
+ if (it.atom.type === "group") m = Math.max(m, innerMax(it.atom.alt));
243
+ }
244
+ return m;
245
+ }
246
+
247
+ // ── Rules ─────────────────────────────────────────────────────────────────
248
+
249
+ function checkStructure(alt) {
250
+ for (const br of alt.branches) for (const it of br) {
251
+ if (it.atom.type !== "group") continue;
252
+ const g = it.atom.alt;
253
+ if (it.max > 1 && hasQuantifier(g)) refuse(`nested quantifier in \`${it.src}\``);
254
+ if (it.max === Infinity && g.branches.length > 1) refuse(`alternation under an unbounded quantifier in \`${it.src}\``);
255
+ checkStructure(g);
256
+ }
257
+ }
258
+
259
+ const MAX_PATHS = 256;
260
+
261
+ /**
262
+ * Expand a branch into the linear sequences of leaf items it can match.
263
+ * A group that is not repeated (`(?:a|b)`, `(?:sudo\s+)?`) is inlined once
264
+ * per alternative, plus a "skipped" path when it is optional, so quantifiers
265
+ * on either side of it are compared along each real path. A repeated group
266
+ * becomes one leaf (rule 3 guarantees it holds no quantifier). Past MAX_PATHS
267
+ * the remaining groups collapse to one conservative leaf each.
268
+ */
269
+ function expand(branch, flags) {
270
+ let seqs = [[]];
271
+ for (const it of branch) {
272
+ const a = it.atom;
273
+ if (a.type === "anchor") continue;
274
+ if (a.type === "group" && it.max <= 1) {
275
+ const alts = a.alt.branches.flatMap((b) => expand(b, flags));
276
+ if (it.min === 0) alts.push([]);
277
+ if (seqs.length * alts.length <= MAX_PATHS) {
278
+ seqs = seqs.flatMap((s) => alts.map((x) => s.concat(x)));
279
+ continue;
280
+ }
281
+ const leaf = { set: itemSet(it, flags), min: it.min, max: innerMax(a.alt), src: it.src };
282
+ seqs = seqs.map((s) => s.concat([leaf]));
283
+ continue;
284
+ }
285
+ const leaf = { set: itemSet(it, flags), min: it.min, max: it.max, src: it.src };
286
+ seqs = seqs.map((s) => s.concat([leaf]));
287
+ }
288
+ return seqs;
289
+ }
290
+
291
+ /**
292
+ * Rule 6. An unanchored search tries every start position. An unbounded
293
+ * quantifier followed by something mandatory rescans its run from each start
294
+ * that can reach it, which is quadratic when everything mandatory before it
295
+ * can also sit inside that run (`\s*x`, `[^a]{1,40}\S[ab]* ` on "bbbb").
296
+ * A greedy quantifier with nothing mandatory after it succeeds at once.
297
+ */
298
+ function checkRescan(seq, anchored) {
299
+ if (anchored) return;
300
+ for (let q = 0; q < seq.length; q++) {
301
+ const it = seq[q];
302
+ if (it.max !== Infinity) continue;
303
+ if (!seq.slice(q + 1).some((x) => x.min > 0)) continue;
304
+ if (seq.slice(0, q).every((x) => x.min === 0 || overlaps(x.set, it.set))) {
305
+ refuse(q === 0 || seq.slice(0, q).every((x) => x.min === 0)
306
+ ? `pattern starts with an unbounded quantifier (\`${it.src}\`)`
307
+ : `unbounded quantifier \`${it.src}\` can be rescanned from every start position`);
308
+ }
309
+ }
310
+ }
311
+
312
+ function checkChains(alt, flags) {
313
+ for (const br of alt.branches) for (const seq of expand(br, flags)) {
314
+ checkRescan(seq, br[0]?.atom.type === "anchor" && br[0].atom.src === "^" && !flags.includes("m"));
315
+ for (let a = 0; a < seq.length; a++) {
316
+ const head = seq[a];
317
+ if (head.max <= 1) continue;
318
+ let cost = head.max;
319
+ const mids = []; // mandatory atoms between head and the current item
320
+ for (let b = a + 1; b < seq.length; b++) {
321
+ const it = seq[b];
322
+ // The costs multiply when the head can stop inside the item's run:
323
+ // they share characters, and every mandatory atom in between can sit
324
+ // in that run too (`.*b.*` on "bbbb"). `[^\n]*\|\s*` stays linear,
325
+ // because `|` cannot occur inside a run of spaces.
326
+ if (it.max > 1 && overlaps(head.set, it.set) && mids.every((m) => overlaps(m, it.set))) {
327
+ cost = cost * it.max;
328
+ if (cost === Infinity) refuse(`overlapping quantifiers \`${head.src}\` … \`${it.src}\` (polynomial backtracking)`);
329
+ if (cost > CHAIN_BUDGET) refuse(`overlapping quantifiers \`${head.src}\` … \`${it.src}\` exceed the backtracking budget`);
330
+ }
331
+ // A mandatory atom the head cannot match ends the head's reach.
332
+ if (it.min > 0) {
333
+ if (!subset(it.set, head.set)) break;
334
+ mids.push(it.set);
335
+ }
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Check a regex from data. Returns { ok: true } or { ok: false, reason }.
343
+ * Never throws.
344
+ * @param {string} source pattern source (no slashes)
345
+ * @param {string} [flags] only i, m, s, u are accepted
346
+ */
347
+ export function checkRegex(source, flags = "") {
348
+ try {
349
+ if (typeof source !== "string" || source.length === 0) refuse("pattern must be a non-empty string");
350
+ if (typeof flags !== "string" || !ALLOWED_FLAGS.test(flags)) refuse(`flags must be a subset of "imsu" (got "${flags}")`);
351
+ if (source.length > MAX_LENGTH) refuse(`pattern longer than ${MAX_LENGTH} characters`);
352
+ try { new RegExp(source, flags); } catch (e) { refuse(`invalid regex: ${e.message}`); }
353
+ const ast = parse(source, flags.includes("u"));
354
+ checkStructure(ast);
355
+ checkChains(ast, flags);
356
+ return { ok: true };
357
+ } catch (e) {
358
+ if (e instanceof GuardError) return { ok: false, reason: e.message };
359
+ return { ok: false, reason: `guard error: ${e.message}` };
360
+ }
361
+ }
362
+
363
+ /** Compile a regex from data, or throw with the guard's reason. */
364
+ export function compileSafe(source, flags = "") {
365
+ const r = checkRegex(source, flags);
366
+ if (!r.ok) throw new Error(`unsafe regex /${String(source).slice(0, 80)}/: ${r.reason}`);
367
+ return new RegExp(source, flags);
368
+ }
package/lib/templates.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { joinUrl } from "../src/utils/url.js";
8
8
  import { request } from "../src/utils/http.js";
9
+ import { hasNestedQuantifier } from "./rules/regex-guard.js";
9
10
 
10
11
  /**
11
12
  * Execute a single template check against a target.
@@ -88,10 +89,11 @@ export async function executeTemplate(baseUrl, template) {
88
89
  // the input and the pattern. This is a mitigation, not an immunity proof: real
89
90
  // guarantees need a non-backtracking engine or a killable worker.
90
91
  const REGEX_INPUT_CAP = 256 * 1024; // 256 KB is far past any real matcher's needs
91
- const NESTED_QUANTIFIER = /(\([^)]*[+*][^)]*\)|\[[^\]]*\][^)]*)[+*]\s*[+*]|\((?:\?:)?[^)]*[+*]\)[+*]/;
92
+ // hasNestedQuantifier lives in lib/rules/regex-guard.js. Templates from rule
93
+ // packs pass the stricter checkRegex there when the pack loads.
92
94
 
93
95
  function safeRegexTest(pattern, target, flags = "") {
94
- if (NESTED_QUANTIFIER.test(String(pattern))) return false; // refuse the shape that blows up
96
+ if (hasNestedQuantifier(pattern)) return false; // refuse the shape that blows up
95
97
  try {
96
98
  const input = target.length > REGEX_INPUT_CAP ? target.slice(0, REGEX_INPUT_CAP) : target;
97
99
  return new RegExp(pattern, flags).test(input);
@@ -100,7 +102,7 @@ function safeRegexTest(pattern, target, flags = "") {
100
102
  }
101
103
  }
102
104
 
103
- function runMatcher(matcher, ctx) {
105
+ export function runMatcher(matcher, ctx) {
104
106
  const part = matcher.part || "body";
105
107
  const target = part === "header" ? ctx.headers : ctx.body;
106
108
 
@@ -142,16 +144,21 @@ function runMatcher(matcher, ctx) {
142
144
  /**
143
145
  * Run a single extractor against response data.
144
146
  */
145
- function runExtractor(extractor, ctx) {
147
+ export function runExtractor(extractor, ctx) {
146
148
  const part = extractor.part || "body";
147
149
  const target = part === "header" ? ctx.headers : ctx.body;
148
150
 
149
151
  switch (extractor.type) {
150
152
  case "regex": {
151
153
  for (const pattern of extractor.regex || []) {
152
- if (NESTED_QUANTIFIER.test(String(pattern))) continue;
154
+ if (hasNestedQuantifier(pattern)) continue;
153
155
  const capped = target.length > REGEX_INPUT_CAP ? target.slice(0, REGEX_INPUT_CAP) : target;
154
- const match = capped.match(new RegExp(pattern));
156
+ let match;
157
+ try {
158
+ match = capped.match(new RegExp(pattern));
159
+ } catch {
160
+ continue; // invalid pattern in a template must not abort the scan
161
+ }
155
162
  if (match) return match[extractor.group || 0];
156
163
  }
157
164
  return null;