penthera 1.7.0 → 1.8.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 +315 -0
- package/README.md +139 -76
- package/SECURITY.md +8 -6
- package/docs/examples/pr-dependency-review.yml +1 -1
- package/lib/adaptive-probes.js +3 -3
- package/lib/advisories/evidence.js +248 -0
- package/lib/advisories/index.js +228 -0
- package/lib/advisories/products.js +83 -0
- package/lib/advisories/versions.js +65 -0
- package/lib/agents/hidden-content.js +122 -11
- package/lib/agents/index.js +26 -6
- package/lib/agents/lock.js +33 -5
- package/lib/agents/rules.js +33 -4
- package/lib/compliance/index.js +4 -4
- package/lib/crawler.js +12 -15
- package/lib/deps/capabilities.js +21 -5
- package/lib/deps/findings.js +309 -0
- package/lib/deps/index.js +13 -272
- package/lib/deps/iocs.js +74 -0
- package/lib/deps/lockfile.js +3 -8
- package/lib/deps/online.js +8 -3
- package/lib/deps/popular-packages.json.gz +0 -0
- package/lib/deps/popular-pypi.json.gz +0 -0
- package/lib/deps/python/capabilities.js +16 -3
- package/lib/deps/python/index.js +5 -1
- package/lib/deps/python/lockfiles.js +11 -5
- package/lib/deps/python/source.js +75 -26
- package/lib/deps/python/toml.js +15 -3
- package/lib/deps/source.js +28 -9
- package/lib/deps/typosquat.js +30 -5
- package/lib/fix/apply.js +253 -34
- package/lib/fix/index.js +7 -4
- package/lib/fix/model.js +25 -5
- package/lib/fix/util.js +6 -2
- package/lib/fuzzer.js +3 -3
- package/lib/git.js +65 -0
- package/lib/hook/index.js +157 -73
- package/lib/hook/policy.js +10 -4
- package/lib/install/index.js +4 -7
- package/lib/machine.js +0 -13
- package/lib/memory/store.js +11 -4
- package/lib/nuclei-loader.js +0 -25
- package/lib/owasp-wstg.js +4 -0
- package/lib/plugins.js +1 -1
- package/lib/provenance.js +1 -0
- package/lib/recon.js +12 -31
- package/lib/references.json +224 -0
- package/lib/retirejs.js +4 -4
- package/lib/rules/advisories/advisories.json +186 -0
- package/lib/rules/advisories/eol.json +413 -0
- package/lib/rules/advisories/pack.json +20 -0
- package/lib/rules/advisories/pack.json.sig +6 -0
- package/lib/rules/allowed_signers +13 -0
- package/lib/rules/core/agent-exec.json +29 -0
- package/lib/rules/core/agent-instructions.json +17 -0
- package/lib/rules/core/pack.json +30 -0
- package/lib/rules/core/pack.json.sig +6 -0
- package/lib/rules/core/secrets.json +161 -0
- package/lib/rules/core/templates.json +52 -0
- package/lib/rules/index.js +107 -0
- package/lib/rules/ioc-2025-2026/files.json +43 -0
- package/lib/rules/ioc-2025-2026/hosts.json +35 -0
- package/lib/rules/ioc-2025-2026/pack.json +25 -0
- package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
- package/lib/rules/ioc-2025-2026/packages.json +1048 -0
- package/lib/rules/pack.js +452 -0
- package/lib/rules/regex-guard.js +81 -9
- package/lib/rules/sshsig.js +205 -0
- package/lib/templates.js +24 -20
- package/lib/tmp.js +21 -0
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +117 -41
- package/package.json +12 -4
- package/scripts/rules/build-pack.mjs +80 -0
- package/scripts/rules/check-bundled.mjs +30 -0
- package/scripts/rules/sign-pack.sh +32 -0
- package/skills/penthera/SKILL.md +16 -15
- package/src/agent/git.js +147 -14
- package/src/agent/loop.js +165 -46
- package/src/cli/agent.js +11 -5
- package/src/cli/deps.js +0 -2
- package/src/cli/detect.js +14 -7
- package/src/cli/fix.js +26 -8
- package/src/cli/hook.js +2 -1
- package/src/cli/onboarding.js +14 -6
- package/src/cli/report-outputs.js +1 -0
- package/src/cli/rules.js +144 -0
- package/src/cli/ui.js +43 -10
- package/src/cli.js +24 -1
- package/src/report/evidence.js +105 -21
- package/src/report/footer.js +1 -1
- package/src/report/md-escape.js +8 -0
- package/src/scan-repo.js +6 -0
- package/src/scan-url.js +11 -3
- package/src/ui/api/data.js +39 -6
- package/src/ui/api/routes.js +44 -11
- package/src/ui/api/runs.js +32 -7
- package/src/ui/security.js +20 -23
- package/src/ui/server.js +52 -27
- package/src/ui/static/app.css +583 -213
- package/src/ui/static/favicon.svg +1 -1
- package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
- package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
- package/src/ui/static/index.html +5 -4
- package/src/ui/static/js/api.js +114 -6
- package/src/ui/static/js/app.js +81 -42
- package/src/ui/static/js/center.js +246 -133
- package/src/ui/static/js/charts.js +99 -18
- package/src/ui/static/js/copy.js +68 -9
- package/src/ui/static/js/dialog.js +152 -0
- package/src/ui/static/js/left.js +23 -60
- package/src/ui/static/js/menubar.js +170 -0
- package/src/ui/static/js/onboarding.js +104 -0
- package/src/ui/static/js/panels.js +186 -0
- package/src/ui/static/js/right.js +69 -188
- package/src/ui/static/js/state.js +36 -5
- package/src/ui/static/logo.svg +10 -0
- package/src/ui/static/mark.svg +3 -0
- package/src/utils/http.js +162 -13
- package/docs/METHODOLOGY.md +0 -375
- package/docs/commands/README.md +0 -24
- package/docs/commands/action.md +0 -21
- package/docs/commands/agents.md +0 -38
- package/docs/commands/deps.md +0 -115
- package/docs/commands/fix.md +0 -53
- package/docs/commands/hook.md +0 -69
- package/docs/commands/init.md +0 -114
- package/docs/commands/install.md +0 -53
- package/docs/commands/scan.md +0 -100
- package/docs/commands/skill.md +0 -58
- package/docs/compare/README.md +0 -25
- package/docs/compare/github.md +0 -60
- package/docs/compare/semgrep.md +0 -54
- package/docs/compare/snyk.md +0 -62
- package/docs/compare/socket.md +0 -78
- package/docs/compare/zap.md +0 -59
- package/docs/compliance-cra.md +0 -141
- package/docs/compliance-nis2.md +0 -168
- package/docs/dashboard.md +0 -187
- package/docs/evidence.md +0 -162
- package/docs/funding/due-diligence-checklist.md +0 -79
- package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
- package/docs/funding/partner-one-pager.md +0 -84
- package/docs/funding/sponsor-tiers.md +0 -81
- package/docs/index.md +0 -81
- package/docs/internal-audit.md +0 -83
- package/docs/local-loop.md +0 -276
- package/docs/marketing.md +0 -151
- package/docs/owasp-wstg-coverage.md +0 -79
- package/docs/plan.md +0 -102
- package/docs/privacy.md +0 -38
- package/docs/references.bib +0 -559
- package/docs/references.md +0 -119
- package/docs/release-plan.md +0 -228
- package/docs/research/socket-positioning-notes.md +0 -183
- package/docs/use-cases/README.md +0 -14
- package/docs/use-cases/agencies.md +0 -71
- package/docs/use-cases/ai-coding-teams.md +0 -81
- package/docs/use-cases/eu-sme-compliance.md +0 -68
- package/docs/use-cases/open-source-maintainers.md +0 -75
- package/docs/use-cases/solo-founder.md +0 -65
- package/lib/deps/popular-packages.json +0 -1
- package/lib/deps/popular-pypi.json +0 -1
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule-pack verification: how new detection knowledge reaches Penthera without a runtime
|
|
3
|
+
* fetch or an update channel. See docs/rule-packs.md for the trust model.
|
|
4
|
+
*
|
|
5
|
+
* A pack is a directory:
|
|
6
|
+
* pack.json name, version, created_at, min_penthera, rules[] index
|
|
7
|
+
* ({ file, kind, sha256 } per rule file)
|
|
8
|
+
* pack.json.sig `ssh-keygen -Y sign -n penthera-rules` over pack.json
|
|
9
|
+
* *.json rule files, one kind each. Data only: regexes, string
|
|
10
|
+
* lists, IOC lists. Never code.
|
|
11
|
+
*
|
|
12
|
+
* Loading verifies, in order: the signature over pack.json against
|
|
13
|
+
* allowed_signers, every rule file's SHA-256 against pack.json, the schema of
|
|
14
|
+
* every rule, and every regex against the ReDoS guard. Anything that fails is
|
|
15
|
+
* refused whole; there is no partial load.
|
|
16
|
+
*
|
|
17
|
+
* Packs arrive two ways only: bundled in lib/rules/ (they ship inside the npm
|
|
18
|
+
* release, staged and approved with 2FA), or by path with `--rules <dir>`.
|
|
19
|
+
* Nothing in this module touches the network.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync, readdirSync, lstatSync, existsSync } from "node:fs";
|
|
22
|
+
import { join, dirname, resolve } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { checkRegex } from "./regex-guard.js";
|
|
26
|
+
import { PRODUCTS, PRODUCT_IDS } from "../advisories/products.js";
|
|
27
|
+
import { parseVersion, compareVersions } from "../advisories/versions.js";
|
|
28
|
+
import { parseAllowedSigners, verifyWithSigners } from "./sshsig.js";
|
|
29
|
+
|
|
30
|
+
export const NAMESPACE = "penthera-rules";
|
|
31
|
+
export const RULES_DIR = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
export const BUILTIN_SIGNERS = join(RULES_DIR, "allowed_signers");
|
|
33
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(RULES_DIR, "..", "..", "package.json"), "utf-8")).version;
|
|
34
|
+
|
|
35
|
+
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
|
36
|
+
const MAX_RULE_FILE_BYTES = 4 * 1024 * 1024;
|
|
37
|
+
const MAX_RULES_PER_FILE = 5000;
|
|
38
|
+
// Pack regexes run on at most this much input per call. Bundled packs are
|
|
39
|
+
// part of the release and keep the engines' own behaviour (no cap).
|
|
40
|
+
export const PACK_INPUT_CAP = 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
// ── Schema ────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
45
|
+
const PACK_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
46
|
+
const SEMVER = /^\d+\.\d+\.\d+$/;
|
|
47
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
48
|
+
const FILE_NAME = /^[a-z0-9][a-z0-9._-]{0,63}\.json$/;
|
|
49
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
50
|
+
const REF_KEY = /^[A-Za-z0-9_:.-]{1,64}$/;
|
|
51
|
+
// Control characters, DEL, C1 and bidi overrides: a pack's strings reach the
|
|
52
|
+
// terminal, Markdown and SARIF, so none of them may carry escapes.
|
|
53
|
+
const UNSAFE_TEXT = /[\u0000-\u001f\u007f-\u009f--]/;
|
|
54
|
+
const CONFIDENCE = new Set(["confirmed", "likely", "potential"]);
|
|
55
|
+
const SEVERITY = new Set(["critical", "high", "medium", "low", "info"]);
|
|
56
|
+
const NPM_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
|
|
57
|
+
const VERSION_OR_STAR = /^(?:\*|\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/;
|
|
58
|
+
const HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
59
|
+
const REL_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._@/-]{1,200}$/;
|
|
60
|
+
const HTTP_METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]);
|
|
61
|
+
|
|
62
|
+
const CVE_ID = /^CVE-\d{4}-\d{4,7}$/;
|
|
63
|
+
const GHSA_ID = /^GHSA(?:-[23456789cfghjmpqrvwx]{4}){3}$/;
|
|
64
|
+
const CVSS_VERSIONS = new Set(["3.0", "3.1", "4.0"]);
|
|
65
|
+
|
|
66
|
+
class PackError extends Error {}
|
|
67
|
+
const fail = (msg) => { throw new PackError(msg); };
|
|
68
|
+
|
|
69
|
+
function text(v, where, { max = 300, optional = false } = {}) {
|
|
70
|
+
if (v === undefined && optional) return undefined;
|
|
71
|
+
if (typeof v !== "string" || v.length === 0) fail(`${where}: expected a non-empty string`);
|
|
72
|
+
if (v.length > max) fail(`${where}: longer than ${max} characters`);
|
|
73
|
+
if (UNSAFE_TEXT.test(v)) fail(`${where}: contains control or bidi characters`);
|
|
74
|
+
return v;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function list(v, where, { max = 5000, item }) {
|
|
78
|
+
if (!Array.isArray(v) || v.length === 0) fail(`${where}: expected a non-empty array`);
|
|
79
|
+
if (v.length > max) fail(`${where}: more than ${max} entries`);
|
|
80
|
+
return v.map((x, i) => item(x, `${where}[${i}]`));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function regex(pattern, flags, where) {
|
|
84
|
+
text(pattern, `${where}.pattern`, { max: 512 });
|
|
85
|
+
if (flags !== undefined && typeof flags !== "string") fail(`${where}.flags: expected a string`);
|
|
86
|
+
const r = checkRegex(pattern, flags || "");
|
|
87
|
+
if (!r.ok) fail(`${where}.pattern rejected by the ReDoS guard: ${r.reason}`);
|
|
88
|
+
return new RegExp(pattern, flags || "");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Fields every rule carries: where the knowledge came from, and how sure it is. */
|
|
92
|
+
function provenance(r, where) {
|
|
93
|
+
text(r.id, `${where}.id`);
|
|
94
|
+
if (!ID.test(r.id)) fail(`${where}.id: must match ${ID}`);
|
|
95
|
+
text(r.source, `${where}.source`, { max: 500 });
|
|
96
|
+
let url;
|
|
97
|
+
try { url = new URL(r.source); } catch { fail(`${where}.source: not a URL`); }
|
|
98
|
+
if (url.protocol !== "https:") fail(`${where}.source: must be an https URL`);
|
|
99
|
+
if (typeof r.date !== "string" || !DATE.test(r.date) || Number.isNaN(Date.parse(r.date))) fail(`${where}.date: expected YYYY-MM-DD`);
|
|
100
|
+
if (!CONFIDENCE.has(r.confidence)) fail(`${where}.confidence: one of ${[...CONFIDENCE].join(", ")}`);
|
|
101
|
+
const references = r.references === undefined ? [] : list(r.references, `${where}.references`, {
|
|
102
|
+
max: 20,
|
|
103
|
+
item: (k, w) => { if (typeof k !== "string" || !REF_KEY.test(k)) fail(`${w}: not a references.bib key`); return k; },
|
|
104
|
+
});
|
|
105
|
+
return {
|
|
106
|
+
id: r.id,
|
|
107
|
+
source: r.source,
|
|
108
|
+
date: r.date,
|
|
109
|
+
confidence: r.confidence,
|
|
110
|
+
references,
|
|
111
|
+
description: text(r.description, `${where}.description`, { max: 1000, optional: true }),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function severity(v, where, dflt) {
|
|
116
|
+
if (v === undefined && dflt) return dflt;
|
|
117
|
+
if (!SEVERITY.has(v)) fail(`${where}: one of ${[...SEVERITY].join(", ")}`);
|
|
118
|
+
return v;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Nuclei-compatible template, the subset lib/templates.js executes. */
|
|
122
|
+
function template(t, where) {
|
|
123
|
+
if (!t || typeof t !== "object" || Array.isArray(t)) fail(`${where}: expected an object`);
|
|
124
|
+
text(t.id, `${where}.id`, { max: 100 });
|
|
125
|
+
const info = t.info || fail(`${where}.info: required`);
|
|
126
|
+
const out = {
|
|
127
|
+
id: t.id,
|
|
128
|
+
info: {
|
|
129
|
+
name: text(info.name, `${where}.info.name`),
|
|
130
|
+
severity: severity(info.severity, `${where}.info.severity`),
|
|
131
|
+
tags: info.tags === undefined ? [] : list(info.tags, `${where}.info.tags`, { max: 20, item: (x, w) => text(x, w, { max: 40 }) }),
|
|
132
|
+
...(info.description !== undefined ? { description: text(info.description, `${where}.info.description`, { max: 1000 }) } : {}),
|
|
133
|
+
},
|
|
134
|
+
http: list(t.http, `${where}.http`, {
|
|
135
|
+
max: 10,
|
|
136
|
+
item: (req, w) => {
|
|
137
|
+
const method = req.method === undefined ? "GET" : req.method;
|
|
138
|
+
if (!HTTP_METHODS.has(method)) fail(`${w}.method: not an allowed HTTP method`);
|
|
139
|
+
text(req.path, `${w}.path`, { max: 500 });
|
|
140
|
+
// Only paths on the scan target: a pack can never point the scanner at another host.
|
|
141
|
+
const p = req.path.replace(/^\{\{BaseURL\}\}/, "");
|
|
142
|
+
if (!p.startsWith("/") || p.startsWith("//") || /[\s\\]/.test(p)) fail(`${w}.path: must be a path on the target ("/x" or "{{BaseURL}}/x")`);
|
|
143
|
+
const matchers = list(req.matchers, `${w}.matchers`, {
|
|
144
|
+
max: 10,
|
|
145
|
+
item: (m, mw) => {
|
|
146
|
+
const type = m.type;
|
|
147
|
+
if (type === "status") return { type, status: list(m.status, `${mw}.status`, { max: 20, item: (s, sw) => { if (!Number.isInteger(s) || s < 100 || s > 599) fail(`${sw}: HTTP status`); return s; } }) };
|
|
148
|
+
if (type === "word" || type === "negative-word") {
|
|
149
|
+
return { type, words: list(m.words, `${mw}.words`, { max: 50, item: (x, xw) => text(x, xw, { max: 200 }) }), ...(m.condition ? { condition: m.condition === "and" ? "and" : "or" } : {}), ...(m.part === "header" ? { part: "header" } : {}) };
|
|
150
|
+
}
|
|
151
|
+
if (type === "regex" || type === "negative-regex") {
|
|
152
|
+
const pats = list(m.regex, `${mw}.regex`, { max: 10, item: (x, xw) => { regex(x, m.flags, xw); return x; } });
|
|
153
|
+
return { type, regex: pats, ...(m.flags ? { flags: m.flags } : {}), ...(m.condition ? { condition: m.condition === "and" ? "and" : "or" } : {}), ...(m.part === "header" ? { part: "header" } : {}) };
|
|
154
|
+
}
|
|
155
|
+
return fail(`${mw}.type: one of status, word, negative-word, regex, negative-regex`);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
const cond = req["matchers-condition"];
|
|
159
|
+
if (cond !== undefined && cond !== "and" && cond !== "or") fail(`${w}.matchers-condition: "and" or "or"`);
|
|
160
|
+
return { method, path: req.path, matchers, ...(cond ? { "matchers-condition": cond } : {}) };
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
};
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function product(v, where) {
|
|
168
|
+
if (!PRODUCT_IDS.includes(v)) fail(`${where}: one of ${PRODUCT_IDS.join(", ")}`);
|
|
169
|
+
return v;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A release line of `prod` ("8.3", "20", "1.1.1"), checked against how that product numbers them. */
|
|
173
|
+
function branchName(prod, v, where) {
|
|
174
|
+
text(v, where, { max: 20 });
|
|
175
|
+
const parsed = parseVersion(v);
|
|
176
|
+
if (!parsed || parsed.suffix || PRODUCTS[prod].branchOf(parsed) !== v) fail(`${where}: not a ${PRODUCTS[prod].name} release line`);
|
|
177
|
+
return v;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** An exact upstream version of `prod` on release line `branch`. */
|
|
181
|
+
function exactVersion(prod, branch, v, where) {
|
|
182
|
+
text(v, where, { max: 30 });
|
|
183
|
+
const parsed = parseVersion(v);
|
|
184
|
+
if (!parsed || parsed.suffix || parsed.patch === null) fail(`${where}: expected an exact upstream version like 8.3.27`);
|
|
185
|
+
if (PRODUCTS[prod].branchOf(parsed) !== branch) fail(`${where}: ${v} is not on release line ${branch}`);
|
|
186
|
+
return v;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Rule kinds and the engine each one plugs into. `compile` validates one rule
|
|
191
|
+
* and returns what the engine consumes.
|
|
192
|
+
*/
|
|
193
|
+
export const KINDS = {
|
|
194
|
+
// lib/whitebox/secrets.js
|
|
195
|
+
secret: (r, w) => ({
|
|
196
|
+
...provenance(r, w),
|
|
197
|
+
name: text(r.name, `${w}.name`, { max: 100 }),
|
|
198
|
+
pattern: regex(r.pattern, r.flags, w),
|
|
199
|
+
severity: severity(r.severity, `${w}.severity`),
|
|
200
|
+
skipPlaceholders: r.skip_placeholders === true,
|
|
201
|
+
}),
|
|
202
|
+
// lib/agents/hidden-content.js: instructions hidden in comments, decoded blobs
|
|
203
|
+
"agent-instruction": (r, w) => ({ ...provenance(r, w), pattern: regex(r.pattern, r.flags, w) }),
|
|
204
|
+
// lib/agents/hidden-content.js + rules.js: remote-code-execution shapes in skills, hooks, MCP commands
|
|
205
|
+
"agent-exec": (r, w) => ({ ...provenance(r, w), label: text(r.label, `${w}.label`, { max: 100 }), pattern: regex(r.pattern, r.flags, w) }),
|
|
206
|
+
// lib/deps: known-malicious package versions (lockfile names)
|
|
207
|
+
"ioc-package": (r, w) => ({
|
|
208
|
+
...provenance(r, w),
|
|
209
|
+
campaign: text(r.campaign, `${w}.campaign`, { max: 100 }),
|
|
210
|
+
severity: severity(r.severity, `${w}.severity`, "critical"),
|
|
211
|
+
packages: list(r.packages, `${w}.packages`, {
|
|
212
|
+
item: (p, pw) => {
|
|
213
|
+
if (!p || typeof p.name !== "string" || p.name.length > 214 || !NPM_NAME.test(p.name)) fail(`${pw}.name: not an npm package name`);
|
|
214
|
+
const versions = list(p.versions, `${pw}.versions`, { max: 200, item: (v, vw) => { if (typeof v !== "string" || !VERSION_OR_STAR.test(v)) fail(`${vw}: exact version or "*"`); return v; } });
|
|
215
|
+
return { name: p.name, versions };
|
|
216
|
+
},
|
|
217
|
+
}),
|
|
218
|
+
}),
|
|
219
|
+
// lib/deps: exfiltration endpoints found in package code. `hosts` are
|
|
220
|
+
// attacker-owned domains; `urls` pin an endpoint on a shared service
|
|
221
|
+
// (webhook.site) where the bare host would be a false positive.
|
|
222
|
+
"ioc-host": (r, w) => {
|
|
223
|
+
if (r.hosts === undefined && r.urls === undefined) fail(`${w}: needs hosts or urls`);
|
|
224
|
+
return {
|
|
225
|
+
...provenance(r, w),
|
|
226
|
+
campaign: text(r.campaign, `${w}.campaign`, { max: 100 }),
|
|
227
|
+
severity: severity(r.severity, `${w}.severity`, "critical"),
|
|
228
|
+
hosts: r.hosts === undefined ? [] : list(r.hosts, `${w}.hosts`, { max: 500, item: (h, hw) => { if (typeof h !== "string" || !HOSTNAME.test(h)) fail(`${hw}: lowercase hostname`); return h; } }),
|
|
229
|
+
urls: r.urls === undefined ? [] : list(r.urls, `${w}.urls`, {
|
|
230
|
+
max: 500,
|
|
231
|
+
item: (u, uw) => {
|
|
232
|
+
text(u, uw, { max: 300 });
|
|
233
|
+
let url;
|
|
234
|
+
try { url = new URL(u); } catch { fail(`${uw}: not a URL`); }
|
|
235
|
+
if (!/^https?:$/.test(url.protocol) || url.pathname.length < 2) fail(`${uw}: http(s) URL with a path`);
|
|
236
|
+
return u;
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
// lib/deps: files a campaign drops into packages (by path, optionally pinned by hash)
|
|
242
|
+
"ioc-file": (r, w) => ({
|
|
243
|
+
...provenance(r, w),
|
|
244
|
+
campaign: text(r.campaign, `${w}.campaign`, { max: 100 }),
|
|
245
|
+
severity: severity(r.severity, `${w}.severity`, "critical"),
|
|
246
|
+
files: list(r.files, `${w}.files`, {
|
|
247
|
+
max: 500,
|
|
248
|
+
item: (f, fw) => {
|
|
249
|
+
if (!f || typeof f.path !== "string" || !REL_PATH.test(f.path)) fail(`${fw}.path: relative path inside a package`);
|
|
250
|
+
if (f.sha256 !== undefined && (typeof f.sha256 !== "string" || !SHA256.test(f.sha256))) fail(`${fw}.sha256: hex SHA-256`);
|
|
251
|
+
return { path: f.path, ...(f.sha256 ? { sha256: f.sha256 } : {}) };
|
|
252
|
+
},
|
|
253
|
+
}),
|
|
254
|
+
}),
|
|
255
|
+
// lib/deps/typosquat.js: extra names worth protecting from look-alikes
|
|
256
|
+
"typosquat-target": (r, w) => ({
|
|
257
|
+
...provenance(r, w),
|
|
258
|
+
names: list(r.names, `${w}.names`, { max: 5000, item: (n, nw) => { if (typeof n !== "string" || n.length > 214 || !NPM_NAME.test(n)) fail(`${nw}: npm package name`); return n; } }),
|
|
259
|
+
}),
|
|
260
|
+
// lib/advisories: affected and fixed versions of a runtime or server, per release line
|
|
261
|
+
"runtime-advisory": (r, w) => {
|
|
262
|
+
const prod = product(r.product, `${w}.product`);
|
|
263
|
+
const seen = new Set();
|
|
264
|
+
const out = {
|
|
265
|
+
...provenance(r, w),
|
|
266
|
+
product: prod,
|
|
267
|
+
title: text(r.title, `${w}.title`, { max: 200 }),
|
|
268
|
+
severity: severity(r.severity, `${w}.severity`, "critical"),
|
|
269
|
+
cves: r.cves === undefined ? [] : list(r.cves, `${w}.cves`, { max: 50, item: (c, cw) => { if (typeof c !== "string" || !CVE_ID.test(c)) fail(`${cw}: CVE-YYYY-NNNN`); return c; } }),
|
|
270
|
+
ghsa: r.ghsa === undefined ? [] : list(r.ghsa, `${w}.ghsa`, { max: 50, item: (g, gw) => { if (typeof g !== "string" || !GHSA_ID.test(g)) fail(`${gw}: GHSA-xxxx-xxxx-xxxx`); return g; } }),
|
|
271
|
+
// What has to be true besides the version (an OS, a SAPI, a module).
|
|
272
|
+
// A rule with a condition never yields more than `potential`.
|
|
273
|
+
condition: text(r.condition, `${w}.condition`, { max: 300, optional: true }),
|
|
274
|
+
fixed: list(r.fixed, `${w}.fixed`, {
|
|
275
|
+
max: 50,
|
|
276
|
+
item: (f, fw) => {
|
|
277
|
+
if (!f || typeof f !== "object") fail(`${fw}: expected { branch, version }`);
|
|
278
|
+
const branch = branchName(prod, f.branch, `${fw}.branch`);
|
|
279
|
+
if (seen.has(branch)) fail(`${fw}.branch: ${branch} listed twice`);
|
|
280
|
+
seen.add(branch);
|
|
281
|
+
const version = exactVersion(prod, branch, f.version, `${fw}.version`);
|
|
282
|
+
const introduced = f.introduced === undefined ? undefined : exactVersion(prod, branch, f.introduced, `${fw}.introduced`);
|
|
283
|
+
if (introduced && compareVersions(introduced, version) >= 0) fail(`${fw}.introduced: must be below the fixed version`);
|
|
284
|
+
return { branch, version, ...(introduced ? { introduced } : {}) };
|
|
285
|
+
},
|
|
286
|
+
}),
|
|
287
|
+
};
|
|
288
|
+
// Date the CVE was added to CISA's Known Exploited Vulnerabilities catalog (CC0).
|
|
289
|
+
if (r.kev !== undefined) {
|
|
290
|
+
if (typeof r.kev !== "string" || !DATE.test(r.kev) || Number.isNaN(Date.parse(r.kev))) fail(`${w}.kev: expected YYYY-MM-DD`);
|
|
291
|
+
out.kev = r.kev;
|
|
292
|
+
}
|
|
293
|
+
if (r.cvss !== undefined) {
|
|
294
|
+
const c = r.cvss;
|
|
295
|
+
if (!c || !CVSS_VERSIONS.has(c.version) || typeof c.score !== "number" || c.score < 0 || c.score > 10) fail(`${w}.cvss: { version: "3.1" | "4.0", score: 0-10 }`);
|
|
296
|
+
out.cvss = { version: c.version, score: c.score };
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
},
|
|
300
|
+
// lib/advisories: release lines past their vendor's end of support. A line
|
|
301
|
+
// counts as end of life when its `eol` date is on or before the rule's
|
|
302
|
+
// `date`, so the result depends on the pack, never on the scan's clock.
|
|
303
|
+
"runtime-eol": (r, w) => {
|
|
304
|
+
const prod = product(r.product, `${w}.product`);
|
|
305
|
+
const seen = new Set();
|
|
306
|
+
return {
|
|
307
|
+
...provenance(r, w),
|
|
308
|
+
product: prod,
|
|
309
|
+
branches: list(r.branches, `${w}.branches`, {
|
|
310
|
+
max: 100,
|
|
311
|
+
item: (b, bw) => {
|
|
312
|
+
if (!b || typeof b !== "object") fail(`${bw}: expected { branch, eol }`);
|
|
313
|
+
const branch = branchName(prod, b.branch, `${bw}.branch`);
|
|
314
|
+
if (seen.has(branch)) fail(`${bw}.branch: ${branch} listed twice`);
|
|
315
|
+
seen.add(branch);
|
|
316
|
+
if (typeof b.eol !== "string" || !DATE.test(b.eol) || Number.isNaN(Date.parse(b.eol))) fail(`${bw}.eol: expected YYYY-MM-DD`);
|
|
317
|
+
return { branch, eol: b.eol };
|
|
318
|
+
},
|
|
319
|
+
}),
|
|
320
|
+
};
|
|
321
|
+
},
|
|
322
|
+
// lib/templates.js
|
|
323
|
+
"nuclei-template": (r, w) => ({ ...provenance(r, w), template: template(r.template, `${w}.template`) }),
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// ── Reading and verifying ─────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
function cmpSemver(a, b) {
|
|
329
|
+
const pa = a.split(".").map(Number);
|
|
330
|
+
const pb = b.split(".").map(Number);
|
|
331
|
+
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function readRegular(path, max, what) {
|
|
336
|
+
let st;
|
|
337
|
+
try { st = lstatSync(path); } catch { fail(`${what} not found`); }
|
|
338
|
+
if (!st.isFile()) fail(`${what} is not a regular file (symlinks are refused)`);
|
|
339
|
+
if (st.size > max) fail(`${what} is larger than ${max} bytes`);
|
|
340
|
+
return readFileSync(path);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function validateManifest(m) {
|
|
344
|
+
if (!m || typeof m !== "object" || Array.isArray(m)) fail("pack.json: expected an object");
|
|
345
|
+
if (m.schema !== 1) fail("pack.json: schema must be 1");
|
|
346
|
+
if (typeof m.name !== "string" || !PACK_NAME.test(m.name)) fail(`pack.json: name must match ${PACK_NAME}`);
|
|
347
|
+
if (typeof m.version !== "string" || !SEMVER.test(m.version)) fail("pack.json: version must be x.y.z");
|
|
348
|
+
if (typeof m.min_penthera !== "string" || !SEMVER.test(m.min_penthera)) fail("pack.json: min_penthera must be x.y.z");
|
|
349
|
+
if (typeof m.created_at !== "string" || Number.isNaN(Date.parse(m.created_at))) fail("pack.json: created_at must be an ISO 8601 timestamp");
|
|
350
|
+
text(m.description, "pack.json: description", { max: 1000, optional: true });
|
|
351
|
+
const seen = new Set();
|
|
352
|
+
list(m.rules, "pack.json: rules", {
|
|
353
|
+
max: 100,
|
|
354
|
+
item: (e, w) => {
|
|
355
|
+
if (!e || typeof e.file !== "string" || !FILE_NAME.test(e.file) || e.file === "pack.json") fail(`${w}.file: a plain file name like secrets.json`);
|
|
356
|
+
if (seen.has(e.file)) fail(`${w}.file: listed twice`);
|
|
357
|
+
seen.add(e.file);
|
|
358
|
+
if (!Object.hasOwn(KINDS, e.kind)) fail(`${w}.kind: one of ${Object.keys(KINDS).join(", ")}`);
|
|
359
|
+
if (typeof e.sha256 !== "string" || !SHA256.test(e.sha256)) fail(`${w}.sha256: hex SHA-256`);
|
|
360
|
+
return e;
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
return m;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Load allowed_signers entries from the built-in file plus any extra files. */
|
|
367
|
+
export function loadSigners(extra = []) {
|
|
368
|
+
const out = [];
|
|
369
|
+
for (const f of [BUILTIN_SIGNERS, ...[].concat(extra)]) {
|
|
370
|
+
if (!f) continue;
|
|
371
|
+
if (!existsSync(f)) {
|
|
372
|
+
if (f === BUILTIN_SIGNERS) continue;
|
|
373
|
+
throw new Error(`allowed_signers file not found: ${f}`);
|
|
374
|
+
}
|
|
375
|
+
out.push(...parseAllowedSigners(readFileSync(f, "utf-8")));
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Verify a pack directory without activating it.
|
|
382
|
+
* @param {string} dir
|
|
383
|
+
* @param {{ signers?: object[], now?: number, version?: string }} [opts]
|
|
384
|
+
* @returns {{ ok: boolean, dir: string, name?: string, version?: string,
|
|
385
|
+
* signature: "valid"|"invalid"|"missing", signer?: string, signatureError?: string,
|
|
386
|
+
* errors: string[], warnings: string[], manifest?: object, rules?: Record<string, object[]>, count?: number }}
|
|
387
|
+
*/
|
|
388
|
+
export function verifyPack(dir, opts = {}) {
|
|
389
|
+
const abs = resolve(dir);
|
|
390
|
+
const res = { ok: false, dir: abs, signature: "missing", errors: [], warnings: [] };
|
|
391
|
+
try {
|
|
392
|
+
let st;
|
|
393
|
+
try { st = lstatSync(abs); } catch { fail(`not found: ${dir}`); }
|
|
394
|
+
if (!st.isDirectory()) fail(`not a directory: ${dir}`);
|
|
395
|
+
|
|
396
|
+
const raw = readRegular(join(abs, "pack.json"), MAX_MANIFEST_BYTES, "pack.json");
|
|
397
|
+
let manifest;
|
|
398
|
+
try { manifest = JSON.parse(raw.toString("utf-8")); } catch (e) { fail(`pack.json: invalid JSON (${e.message})`); }
|
|
399
|
+
validateManifest(manifest);
|
|
400
|
+
res.name = manifest.name;
|
|
401
|
+
res.version = manifest.version;
|
|
402
|
+
res.manifest = manifest;
|
|
403
|
+
|
|
404
|
+
// 1. Signature over the exact bytes of pack.json.
|
|
405
|
+
const sigPath = join(abs, "pack.json.sig");
|
|
406
|
+
if (existsSync(sigPath)) {
|
|
407
|
+
const sig = readRegular(sigPath, 64 * 1024, "pack.json.sig").toString("utf-8");
|
|
408
|
+
const v = verifyWithSigners(raw, sig, NAMESPACE, opts.signers || loadSigners(), { now: opts.now });
|
|
409
|
+
if (v.ok) { res.signature = "valid"; res.signer = v.principal; }
|
|
410
|
+
else { res.signature = "invalid"; res.signatureError = v.reason; }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (cmpSemver(opts.version || PKG_VERSION, manifest.min_penthera) < 0) {
|
|
414
|
+
fail(`needs Penthera ${manifest.min_penthera} or later (this is ${opts.version || PKG_VERSION})`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// 2. Every rule file against its hash in pack.json, then 3. its contents.
|
|
418
|
+
const rules = Object.fromEntries(Object.keys(KINDS).map((k) => [k, []]));
|
|
419
|
+
const ids = new Set();
|
|
420
|
+
let count = 0;
|
|
421
|
+
for (const entry of manifest.rules) {
|
|
422
|
+
const buf = readRegular(join(abs, entry.file), MAX_RULE_FILE_BYTES, entry.file);
|
|
423
|
+
const digest = createHash("sha256").update(buf).digest("hex");
|
|
424
|
+
if (digest !== entry.sha256) fail(`${entry.file}: SHA-256 does not match pack.json (file changed after the pack was built)`);
|
|
425
|
+
let data;
|
|
426
|
+
try { data = JSON.parse(buf.toString("utf-8")); } catch (e) { fail(`${entry.file}: invalid JSON (${e.message})`); }
|
|
427
|
+
if (!data || data.kind !== entry.kind) fail(`${entry.file}: kind is "${data?.kind}", pack.json says "${entry.kind}"`);
|
|
428
|
+
list(data.rules, `${entry.file}: rules`, { max: MAX_RULES_PER_FILE, item: (r) => r });
|
|
429
|
+
data.rules.forEach((r, i) => {
|
|
430
|
+
const where = `${entry.file}: rules[${i}]`;
|
|
431
|
+
if (!r || typeof r !== "object" || Array.isArray(r)) fail(`${where}: expected an object`);
|
|
432
|
+
const compiled = KINDS[entry.kind](r, where);
|
|
433
|
+
if (ids.has(compiled.id)) fail(`${where}.id: "${compiled.id}" used twice in this pack`);
|
|
434
|
+
ids.add(compiled.id);
|
|
435
|
+
rules[entry.kind].push({ ...compiled, kind: entry.kind, pack: manifest.name, packVersion: manifest.version });
|
|
436
|
+
count++;
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
const listed = new Set(manifest.rules.map((e) => e.file));
|
|
440
|
+
for (const f of readdirSync(abs)) {
|
|
441
|
+
if (f === "pack.json" || f === "pack.json.sig" || listed.has(f)) continue;
|
|
442
|
+
res.warnings.push(`${f} is not listed in pack.json and is ignored`);
|
|
443
|
+
}
|
|
444
|
+
res.rules = rules;
|
|
445
|
+
res.count = count;
|
|
446
|
+
res.ok = true;
|
|
447
|
+
} catch (e) {
|
|
448
|
+
res.errors.push(e instanceof PackError ? e.message : `unexpected error: ${e.message}`);
|
|
449
|
+
}
|
|
450
|
+
return res;
|
|
451
|
+
}
|
|
452
|
+
|
package/lib/rules/regex-guard.js
CHANGED
|
@@ -14,12 +14,17 @@
|
|
|
14
14
|
* 3. Star height 1: a quantified group may not contain a quantifier
|
|
15
15
|
* (`(a+)+`, `(\w*\s?)*`). The exponential case.
|
|
16
16
|
* 4. A group under an unbounded quantifier may not contain alternation
|
|
17
|
-
* (`(a|aa)+`, `(a|a)*`). The other exponential case.
|
|
17
|
+
* (`(a|aa)+`, `(a|a)*`). The other exponential case. Under any repeat
|
|
18
|
+
* above one (`(?:a|a){1,40}`), every choice inside the group must be
|
|
19
|
+
* decided by the next character: alternatives start with disjoint
|
|
20
|
+
* characters, an optional item cannot start like what follows it, and
|
|
21
|
+
* an iteration cannot match the empty string.
|
|
18
22
|
* 5. Bounded repeats are capped at MAX_REPEAT.
|
|
19
23
|
* 6. An unbounded quantifier that is followed by something mandatory may
|
|
20
24
|
* not be reachable from every start position (`\s*x`, `[a-z]+@`): each
|
|
21
25
|
* start would rescan the rest of the input. Something mandatory before
|
|
22
|
-
* it that cannot occur inside its run (`x\s*:`) breaks the chain.
|
|
26
|
+
* it that cannot occur inside its run (`x\s*:`) breaks the chain. `$`
|
|
27
|
+
* counts as mandatory (`\s+$`, `x[a-z]+$`).
|
|
23
28
|
* 7. Two quantifiers that can match the same characters, with nothing
|
|
24
29
|
* mandatory between them that the first cannot also match (`.*b.*`,
|
|
25
30
|
* `\s*\s*`), multiply their cost. A chain with an unbounded member is
|
|
@@ -36,10 +41,10 @@ export const MAX_REPEAT = 256;
|
|
|
36
41
|
export const CHAIN_BUDGET = 1024;
|
|
37
42
|
export const ALLOWED_FLAGS = /^[imsu]*$/;
|
|
38
43
|
|
|
39
|
-
// The narrower check
|
|
40
|
-
// v1.1.1 as a regex): refuse a quantifier applied to a
|
|
41
|
-
// quantifier, at any depth, or a quantifier stacked on
|
|
42
|
-
//
|
|
44
|
+
// The narrower check community templates used before they moved to
|
|
45
|
+
// checkRegex (added in v1.1.1 as a regex): refuse a quantifier applied to a
|
|
46
|
+
// group that contains a quantifier, at any depth, or a quantifier stacked on
|
|
47
|
+
// another (`a+*`). Kept as an export for library users.
|
|
43
48
|
// A linear scan rather than a regex, because a regex inspecting a pattern
|
|
44
49
|
// string is itself open to backtracking on input like "((((((".
|
|
45
50
|
const QUANT_START = (src, i) => src[i] === "*" || src[i] === "+" || (src[i] === "{" && /^\{\d+(,\d*)?\}/.test(src.slice(i, i + 24)));
|
|
@@ -218,6 +223,7 @@ function charSet(src, flags) {
|
|
|
218
223
|
}
|
|
219
224
|
const overlaps = (a, b) => a.some((v, k) => v && b[k]);
|
|
220
225
|
const subset = (a, b) => a.every((v, k) => !v || b[k]);
|
|
226
|
+
const EMPTY_SET = new Uint8Array(SAMPLE.length);
|
|
221
227
|
const union = (sets) => sets.reduce((acc, s) => acc.map((v, k) => v | s[k]), new Uint8Array(SAMPLE.length));
|
|
222
228
|
|
|
223
229
|
/** Characters an item can consume (any one position), or null for anchors. */
|
|
@@ -246,13 +252,72 @@ function innerMax(alt) {
|
|
|
246
252
|
|
|
247
253
|
// ── Rules ─────────────────────────────────────────────────────────────────
|
|
248
254
|
|
|
249
|
-
function checkStructure(alt) {
|
|
255
|
+
function checkStructure(alt, flags) {
|
|
250
256
|
for (const br of alt.branches) for (const it of br) {
|
|
251
257
|
if (it.atom.type !== "group") continue;
|
|
252
258
|
const g = it.atom.alt;
|
|
253
259
|
if (it.max > 1 && hasQuantifier(g)) refuse(`nested quantifier in \`${it.src}\``);
|
|
254
260
|
if (it.max === Infinity && g.branches.length > 1) refuse(`alternation under an unbounded quantifier in \`${it.src}\``);
|
|
255
|
-
|
|
261
|
+
if (it.max > 1) checkDeterministic(g, flags, it.src);
|
|
262
|
+
checkStructure(g, flags);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* First set of the items from `from` on: every character the sequence can
|
|
268
|
+
* start with, and whether it can match the empty string. Anchors are
|
|
269
|
+
* zero-width and skipped.
|
|
270
|
+
*/
|
|
271
|
+
function firstOf(items, flags, from = 0) {
|
|
272
|
+
const sets = [];
|
|
273
|
+
for (let k = from; k < items.length; k++) {
|
|
274
|
+
const it = items[k];
|
|
275
|
+
if (it.atom.type === "anchor") continue;
|
|
276
|
+
const f = it.atom.type === "group" ? altFirst(it.atom.alt, flags) : { set: charSet(it.atom.src, flags), nullable: false };
|
|
277
|
+
sets.push(f.set);
|
|
278
|
+
if (it.min > 0 && !f.nullable) return { set: union(sets), nullable: false };
|
|
279
|
+
}
|
|
280
|
+
return { set: union(sets), nullable: true };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function altFirst(alt, flags) {
|
|
284
|
+
const fs = alt.branches.map((br) => firstOf(br, flags));
|
|
285
|
+
return { set: union(fs.map((f) => f.set)), nullable: fs.some((f) => f.nullable) };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Rule 4b. Inside a group repeated more than once (`(?:a|a){1,40}`), every
|
|
290
|
+
* choice must be decided by the next character: the alternatives of each
|
|
291
|
+
* alternation start with disjoint characters, an optional item cannot start
|
|
292
|
+
* like what follows it, and no iteration can match the empty string. Each
|
|
293
|
+
* ambiguous choice otherwise doubles the backtracking per iteration.
|
|
294
|
+
*/
|
|
295
|
+
function checkDeterministic(alt, flags, src, follow = null) {
|
|
296
|
+
const body = altFirst(alt, flags);
|
|
297
|
+
if (body.nullable) refuse(`repeated group can match the empty string in \`${src}\``);
|
|
298
|
+
walkChoices(alt, flags, src, follow || body.set);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function walkChoices(alt, flags, src, follow) {
|
|
302
|
+
if (alt.branches.length > 1) {
|
|
303
|
+
const firsts = alt.branches.map((br) => firstOf(br, flags));
|
|
304
|
+
if (firsts.some((f) => f.nullable)) refuse(`ambiguous alternation inside the repeated group \`${src}\``);
|
|
305
|
+
for (let a = 0; a < firsts.length; a++) for (let b = a + 1; b < firsts.length; b++) {
|
|
306
|
+
if (overlaps(firsts[a].set, firsts[b].set)) refuse(`alternatives that start alike inside the repeated group \`${src}\``);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
for (const br of alt.branches) {
|
|
310
|
+
for (let k = 0; k < br.length; k++) {
|
|
311
|
+
const it = br[k];
|
|
312
|
+
if (it.atom.type === "anchor") continue;
|
|
313
|
+
const rest = firstOf(br, flags, k + 1);
|
|
314
|
+
const after = rest.nullable ? union([rest.set, follow]) : rest.set;
|
|
315
|
+
const own = it.atom.type === "group" ? altFirst(it.atom.alt, flags) : { set: charSet(it.atom.src, flags), nullable: false };
|
|
316
|
+
if ((it.min === 0 || own.nullable) && overlaps(own.set, after)) {
|
|
317
|
+
refuse(`optional \`${it.src}\` can start like what follows it inside the repeated group \`${src}\``);
|
|
318
|
+
}
|
|
319
|
+
if (it.atom.type === "group") walkChoices(it.atom.alt, flags, src, after);
|
|
320
|
+
}
|
|
256
321
|
}
|
|
257
322
|
}
|
|
258
323
|
|
|
@@ -270,6 +335,13 @@ function expand(branch, flags) {
|
|
|
270
335
|
let seqs = [[]];
|
|
271
336
|
for (const it of branch) {
|
|
272
337
|
const a = it.atom;
|
|
338
|
+
// `$` is mandatory: `\s+$` fails at every start of a long run of spaces
|
|
339
|
+
// that does not end the input, after scanning the rest of the run. It
|
|
340
|
+
// consumes nothing, so its set is empty (it never extends a run).
|
|
341
|
+
if (a.type === "anchor" && a.src === "$") {
|
|
342
|
+
seqs = seqs.map((s) => s.concat([{ set: EMPTY_SET, min: 1, max: 1, src: "$" }]));
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
273
345
|
if (a.type === "anchor") continue;
|
|
274
346
|
if (a.type === "group" && it.max <= 1) {
|
|
275
347
|
const alts = a.alt.branches.flatMap((b) => expand(b, flags));
|
|
@@ -351,7 +423,7 @@ export function checkRegex(source, flags = "") {
|
|
|
351
423
|
if (source.length > MAX_LENGTH) refuse(`pattern longer than ${MAX_LENGTH} characters`);
|
|
352
424
|
try { new RegExp(source, flags); } catch (e) { refuse(`invalid regex: ${e.message}`); }
|
|
353
425
|
const ast = parse(source, flags.includes("u"));
|
|
354
|
-
checkStructure(ast);
|
|
426
|
+
checkStructure(ast, flags);
|
|
355
427
|
checkChains(ast, flags);
|
|
356
428
|
return { ok: true };
|
|
357
429
|
} catch (e) {
|