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.
- package/CHANGELOG.md +227 -0
- package/README.md +148 -575
- package/bin/scan.js +19 -0
- package/docs/commands/README.md +23 -0
- package/docs/commands/action.md +21 -0
- package/docs/commands/agents.md +38 -0
- package/docs/commands/deps.md +115 -0
- package/docs/commands/fix.md +53 -0
- package/docs/commands/hook.md +69 -0
- package/docs/commands/install.md +53 -0
- package/docs/commands/scan.md +100 -0
- package/docs/commands/skill.md +58 -0
- package/docs/compare/README.md +25 -0
- package/docs/compare/github.md +60 -0
- package/docs/compare/semgrep.md +54 -0
- package/docs/compare/snyk.md +62 -0
- package/docs/compare/socket.md +78 -0
- package/docs/compare/zap.md +59 -0
- package/docs/compliance-cra.md +141 -0
- package/docs/compliance-nis2.md +3 -1
- package/docs/dashboard.md +244 -0
- package/docs/evidence.md +8 -4
- package/docs/examples/github-actions-deps.yml +4 -4
- package/docs/examples/github-actions-scan.yml +4 -4
- package/docs/index.md +11 -2
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +101 -0
- package/docs/privacy.md +38 -0
- package/docs/references.bib +10 -0
- package/docs/references.md +1 -0
- package/docs/research/socket-positioning-notes.md +183 -0
- package/docs/use-cases/README.md +14 -0
- package/docs/use-cases/agencies.md +71 -0
- package/docs/use-cases/ai-coding-teams.md +81 -0
- package/docs/use-cases/eu-sme-compliance.md +68 -0
- package/docs/use-cases/open-source-maintainers.md +75 -0
- package/docs/use-cases/solo-founder.md +65 -0
- package/lib/blackbox/email-dns.js +5 -0
- package/lib/compliance/cra.js +525 -0
- package/lib/compliance/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +314 -30
- package/lib/deps/license.js +329 -0
- package/lib/deps/lockfile.js +29 -3
- package/lib/deps/popular-pypi.json +1 -0
- package/lib/deps/python/capabilities.js +447 -0
- package/lib/deps/python/index.js +234 -0
- package/lib/deps/python/lockfiles.js +359 -0
- package/lib/deps/python/source.js +396 -0
- package/lib/deps/python/toml.js +219 -0
- package/lib/deps/report.js +54 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/spdx.json +1 -0
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +345 -0
- package/lib/hook/policy.js +62 -0
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +27 -7
- package/lib/jurisdictions/nl.json +9 -0
- package/lib/memory/repo-suppress.js +11 -3
- package/lib/references.json +77 -0
- package/lib/remediation/index.js +9 -0
- package/lib/retirejs.js +69 -17
- package/lib/rules/regex-guard.js +368 -0
- package/lib/templates.js +13 -6
- package/lib/tls.js +7 -0
- package/lib/whitebox/aibom.js +384 -0
- package/lib/whitebox/frameworks.js +8 -5
- package/lib/whitebox/sbom.js +45 -10
- package/lib/whitebox/secrets.js +31 -8
- package/package.json +8 -3
- package/skills/penthera/SKILL.md +55 -8
- package/src/cli/deps.js +81 -6
- package/src/cli/evidence.js +14 -4
- package/src/cli/hook.js +131 -0
- package/src/cli/install.js +14 -5
- package/src/cli/report-outputs.js +25 -2
- package/src/cli/ui.js +89 -0
- package/src/cli.js +81 -11
- package/src/report/audience.js +10 -10
- package/src/report/evidence.js +33 -14
- package/src/report/footer.js +11 -0
- package/src/report/markdown.js +6 -3
- package/src/report/md-escape.js +16 -0
- package/src/reporter.js +1 -0
- package/src/scan-url.js +5 -1
- package/src/ui/api/data.js +309 -0
- package/src/ui/api/demo.js +464 -0
- package/src/ui/api/routes.js +134 -0
- package/src/ui/api/runs.js +243 -0
- package/src/ui/api/summarize.js +451 -0
- package/src/ui/security.js +153 -0
- package/src/ui/server.js +186 -0
- package/src/ui/static/app.css +723 -0
- package/src/ui/static/favicon.svg +1 -0
- package/src/ui/static/index.html +57 -0
- package/src/ui/static/js/api.js +29 -0
- package/src/ui/static/js/app.js +138 -0
- package/src/ui/static/js/charts.js +207 -0
- package/src/ui/static/js/copy.js +79 -0
- package/src/ui/static/js/dom.js +120 -0
- package/src/ui/static/js/format.js +42 -0
- package/src/ui/static/js/theme.js +10 -0
- package/src/ui/static/js/typer.js +99 -0
- package/src/ui/static/js/views/ai.js +68 -0
- package/src/ui/static/js/views/deps.js +96 -0
- package/src/ui/static/js/views/evidence.js +115 -0
- package/src/ui/static/js/views/findings.js +165 -0
- package/src/ui/static/js/views/history.js +92 -0
- package/src/ui/static/js/views/overview.js +184 -0
- package/src/ui/static/js/views/run.js +349 -0
- 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(
|
|
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)) {
|
package/lib/references.json
CHANGED
|
@@ -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
|
}
|
package/lib/remediation/index.js
CHANGED
|
@@ -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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
154
|
+
if (hasNestedQuantifier(pattern)) continue;
|
|
153
155
|
const capped = target.length > REGEX_INPUT_CAP ? target.slice(0, REGEX_INPUT_CAP) : target;
|
|
154
|
-
|
|
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;
|