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.
Files changed (163) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/README.md +139 -76
  3. package/SECURITY.md +8 -6
  4. package/docs/examples/pr-dependency-review.yml +1 -1
  5. package/lib/adaptive-probes.js +3 -3
  6. package/lib/advisories/evidence.js +248 -0
  7. package/lib/advisories/index.js +228 -0
  8. package/lib/advisories/products.js +83 -0
  9. package/lib/advisories/versions.js +65 -0
  10. package/lib/agents/hidden-content.js +122 -11
  11. package/lib/agents/index.js +26 -6
  12. package/lib/agents/lock.js +33 -5
  13. package/lib/agents/rules.js +33 -4
  14. package/lib/compliance/index.js +4 -4
  15. package/lib/crawler.js +12 -15
  16. package/lib/deps/capabilities.js +21 -5
  17. package/lib/deps/findings.js +309 -0
  18. package/lib/deps/index.js +13 -272
  19. package/lib/deps/iocs.js +74 -0
  20. package/lib/deps/lockfile.js +3 -8
  21. package/lib/deps/online.js +8 -3
  22. package/lib/deps/popular-packages.json.gz +0 -0
  23. package/lib/deps/popular-pypi.json.gz +0 -0
  24. package/lib/deps/python/capabilities.js +16 -3
  25. package/lib/deps/python/index.js +5 -1
  26. package/lib/deps/python/lockfiles.js +11 -5
  27. package/lib/deps/python/source.js +75 -26
  28. package/lib/deps/python/toml.js +15 -3
  29. package/lib/deps/source.js +28 -9
  30. package/lib/deps/typosquat.js +30 -5
  31. package/lib/fix/apply.js +253 -34
  32. package/lib/fix/index.js +7 -4
  33. package/lib/fix/model.js +25 -5
  34. package/lib/fix/util.js +6 -2
  35. package/lib/fuzzer.js +3 -3
  36. package/lib/git.js +65 -0
  37. package/lib/hook/index.js +157 -73
  38. package/lib/hook/policy.js +10 -4
  39. package/lib/install/index.js +4 -7
  40. package/lib/machine.js +0 -13
  41. package/lib/memory/store.js +11 -4
  42. package/lib/nuclei-loader.js +0 -25
  43. package/lib/owasp-wstg.js +4 -0
  44. package/lib/plugins.js +1 -1
  45. package/lib/provenance.js +1 -0
  46. package/lib/recon.js +12 -31
  47. package/lib/references.json +224 -0
  48. package/lib/retirejs.js +4 -4
  49. package/lib/rules/advisories/advisories.json +186 -0
  50. package/lib/rules/advisories/eol.json +413 -0
  51. package/lib/rules/advisories/pack.json +20 -0
  52. package/lib/rules/advisories/pack.json.sig +6 -0
  53. package/lib/rules/allowed_signers +13 -0
  54. package/lib/rules/core/agent-exec.json +29 -0
  55. package/lib/rules/core/agent-instructions.json +17 -0
  56. package/lib/rules/core/pack.json +30 -0
  57. package/lib/rules/core/pack.json.sig +6 -0
  58. package/lib/rules/core/secrets.json +161 -0
  59. package/lib/rules/core/templates.json +52 -0
  60. package/lib/rules/index.js +107 -0
  61. package/lib/rules/ioc-2025-2026/files.json +43 -0
  62. package/lib/rules/ioc-2025-2026/hosts.json +35 -0
  63. package/lib/rules/ioc-2025-2026/pack.json +25 -0
  64. package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
  65. package/lib/rules/ioc-2025-2026/packages.json +1048 -0
  66. package/lib/rules/pack.js +452 -0
  67. package/lib/rules/regex-guard.js +81 -9
  68. package/lib/rules/sshsig.js +205 -0
  69. package/lib/templates.js +24 -20
  70. package/lib/tmp.js +21 -0
  71. package/lib/whitebox/frameworks.js +1 -1
  72. package/lib/whitebox/secrets.js +117 -41
  73. package/package.json +12 -4
  74. package/scripts/rules/build-pack.mjs +80 -0
  75. package/scripts/rules/check-bundled.mjs +30 -0
  76. package/scripts/rules/sign-pack.sh +32 -0
  77. package/skills/penthera/SKILL.md +16 -15
  78. package/src/agent/git.js +147 -14
  79. package/src/agent/loop.js +165 -46
  80. package/src/cli/agent.js +11 -5
  81. package/src/cli/deps.js +0 -2
  82. package/src/cli/detect.js +14 -7
  83. package/src/cli/fix.js +26 -8
  84. package/src/cli/hook.js +2 -1
  85. package/src/cli/onboarding.js +14 -6
  86. package/src/cli/report-outputs.js +1 -0
  87. package/src/cli/rules.js +144 -0
  88. package/src/cli/ui.js +43 -10
  89. package/src/cli.js +24 -1
  90. package/src/report/evidence.js +105 -21
  91. package/src/report/footer.js +1 -1
  92. package/src/report/md-escape.js +8 -0
  93. package/src/scan-repo.js +6 -0
  94. package/src/scan-url.js +11 -3
  95. package/src/ui/api/data.js +39 -6
  96. package/src/ui/api/routes.js +44 -11
  97. package/src/ui/api/runs.js +32 -7
  98. package/src/ui/security.js +20 -23
  99. package/src/ui/server.js +52 -27
  100. package/src/ui/static/app.css +583 -213
  101. package/src/ui/static/favicon.svg +1 -1
  102. package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
  103. package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
  104. package/src/ui/static/index.html +5 -4
  105. package/src/ui/static/js/api.js +114 -6
  106. package/src/ui/static/js/app.js +81 -42
  107. package/src/ui/static/js/center.js +246 -133
  108. package/src/ui/static/js/charts.js +99 -18
  109. package/src/ui/static/js/copy.js +68 -9
  110. package/src/ui/static/js/dialog.js +152 -0
  111. package/src/ui/static/js/left.js +23 -60
  112. package/src/ui/static/js/menubar.js +170 -0
  113. package/src/ui/static/js/onboarding.js +104 -0
  114. package/src/ui/static/js/panels.js +186 -0
  115. package/src/ui/static/js/right.js +69 -188
  116. package/src/ui/static/js/state.js +36 -5
  117. package/src/ui/static/logo.svg +10 -0
  118. package/src/ui/static/mark.svg +3 -0
  119. package/src/utils/http.js +162 -13
  120. package/docs/METHODOLOGY.md +0 -375
  121. package/docs/commands/README.md +0 -24
  122. package/docs/commands/action.md +0 -21
  123. package/docs/commands/agents.md +0 -38
  124. package/docs/commands/deps.md +0 -115
  125. package/docs/commands/fix.md +0 -53
  126. package/docs/commands/hook.md +0 -69
  127. package/docs/commands/init.md +0 -114
  128. package/docs/commands/install.md +0 -53
  129. package/docs/commands/scan.md +0 -100
  130. package/docs/commands/skill.md +0 -58
  131. package/docs/compare/README.md +0 -25
  132. package/docs/compare/github.md +0 -60
  133. package/docs/compare/semgrep.md +0 -54
  134. package/docs/compare/snyk.md +0 -62
  135. package/docs/compare/socket.md +0 -78
  136. package/docs/compare/zap.md +0 -59
  137. package/docs/compliance-cra.md +0 -141
  138. package/docs/compliance-nis2.md +0 -168
  139. package/docs/dashboard.md +0 -187
  140. package/docs/evidence.md +0 -162
  141. package/docs/funding/due-diligence-checklist.md +0 -79
  142. package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
  143. package/docs/funding/partner-one-pager.md +0 -84
  144. package/docs/funding/sponsor-tiers.md +0 -81
  145. package/docs/index.md +0 -81
  146. package/docs/internal-audit.md +0 -83
  147. package/docs/local-loop.md +0 -276
  148. package/docs/marketing.md +0 -151
  149. package/docs/owasp-wstg-coverage.md +0 -79
  150. package/docs/plan.md +0 -102
  151. package/docs/privacy.md +0 -38
  152. package/docs/references.bib +0 -559
  153. package/docs/references.md +0 -119
  154. package/docs/release-plan.md +0 -228
  155. package/docs/research/socket-positioning-notes.md +0 -183
  156. package/docs/use-cases/README.md +0 -14
  157. package/docs/use-cases/agencies.md +0 -71
  158. package/docs/use-cases/ai-coding-teams.md +0 -81
  159. package/docs/use-cases/eu-sme-compliance.md +0 -68
  160. package/docs/use-cases/open-source-maintainers.md +0 -75
  161. package/docs/use-cases/solo-founder.md +0 -65
  162. package/lib/deps/popular-packages.json +0 -1
  163. 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
+
@@ -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 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.
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
- checkStructure(g);
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) {