penthera 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +128 -663
  3. package/bin/scan.js +19 -0
  4. package/docs/commands/README.md +23 -0
  5. package/docs/commands/action.md +21 -0
  6. package/docs/commands/agents.md +38 -0
  7. package/docs/commands/deps.md +115 -0
  8. package/docs/commands/fix.md +53 -0
  9. package/docs/commands/hook.md +69 -0
  10. package/docs/commands/install.md +53 -0
  11. package/docs/commands/scan.md +100 -0
  12. package/docs/commands/skill.md +58 -0
  13. package/docs/compare/README.md +25 -0
  14. package/docs/compare/github.md +60 -0
  15. package/docs/compare/semgrep.md +54 -0
  16. package/docs/compare/snyk.md +62 -0
  17. package/docs/compare/socket.md +78 -0
  18. package/docs/compare/zap.md +59 -0
  19. package/docs/dashboard.md +244 -0
  20. package/docs/evidence.md +8 -4
  21. package/docs/examples/github-actions-deps.yml +4 -4
  22. package/docs/examples/github-actions-scan.yml +4 -4
  23. package/docs/index.md +9 -0
  24. package/docs/internal-audit.md +83 -0
  25. package/docs/marketing.md +151 -0
  26. package/docs/plan.md +4 -3
  27. package/docs/privacy.md +38 -0
  28. package/docs/references.bib +10 -0
  29. package/docs/references.md +1 -0
  30. package/docs/research/socket-positioning-notes.md +183 -0
  31. package/docs/use-cases/README.md +14 -0
  32. package/docs/use-cases/agencies.md +71 -0
  33. package/docs/use-cases/ai-coding-teams.md +81 -0
  34. package/docs/use-cases/eu-sme-compliance.md +68 -0
  35. package/docs/use-cases/open-source-maintainers.md +75 -0
  36. package/docs/use-cases/solo-founder.md +65 -0
  37. package/lib/blackbox/email-dns.js +5 -0
  38. package/lib/compliance/questionnaire.js +4 -2
  39. package/lib/deps/capabilities.js +49 -12
  40. package/lib/deps/index.js +290 -30
  41. package/lib/deps/lockfile.js +25 -2
  42. package/lib/deps/popular-pypi.json +1 -0
  43. package/lib/deps/python/capabilities.js +447 -0
  44. package/lib/deps/python/index.js +234 -0
  45. package/lib/deps/python/lockfiles.js +359 -0
  46. package/lib/deps/python/source.js +396 -0
  47. package/lib/deps/python/toml.js +219 -0
  48. package/lib/deps/report.js +37 -6
  49. package/lib/deps/source.js +149 -24
  50. package/lib/deps/typosquat.js +24 -13
  51. package/lib/fix/apply.js +83 -19
  52. package/lib/hook/index.js +41 -8
  53. package/lib/install/index.js +59 -18
  54. package/lib/install/policy.js +17 -5
  55. package/lib/memory/repo-suppress.js +11 -3
  56. package/lib/references.json +77 -0
  57. package/lib/remediation/index.js +9 -0
  58. package/lib/retirejs.js +69 -17
  59. package/lib/rules/regex-guard.js +368 -0
  60. package/lib/templates.js +13 -6
  61. package/lib/tls.js +7 -0
  62. package/lib/whitebox/frameworks.js +8 -5
  63. package/lib/whitebox/secrets.js +20 -7
  64. package/package.json +7 -3
  65. package/skills/penthera/SKILL.md +23 -5
  66. package/src/cli/deps.js +60 -5
  67. package/src/cli/evidence.js +14 -4
  68. package/src/cli/install.js +14 -5
  69. package/src/cli/report-outputs.js +25 -2
  70. package/src/cli/ui.js +89 -0
  71. package/src/cli.js +19 -7
  72. package/src/report/audience.js +10 -10
  73. package/src/report/evidence.js +33 -14
  74. package/src/report/footer.js +11 -0
  75. package/src/report/markdown.js +6 -3
  76. package/src/report/md-escape.js +16 -0
  77. package/src/reporter.js +1 -0
  78. package/src/scan-url.js +5 -1
  79. package/src/ui/api/data.js +309 -0
  80. package/src/ui/api/demo.js +464 -0
  81. package/src/ui/api/routes.js +134 -0
  82. package/src/ui/api/runs.js +243 -0
  83. package/src/ui/api/summarize.js +451 -0
  84. package/src/ui/security.js +153 -0
  85. package/src/ui/server.js +186 -0
  86. package/src/ui/static/app.css +723 -0
  87. package/src/ui/static/favicon.svg +1 -0
  88. package/src/ui/static/index.html +57 -0
  89. package/src/ui/static/js/api.js +29 -0
  90. package/src/ui/static/js/app.js +138 -0
  91. package/src/ui/static/js/charts.js +207 -0
  92. package/src/ui/static/js/copy.js +79 -0
  93. package/src/ui/static/js/dom.js +120 -0
  94. package/src/ui/static/js/format.js +42 -0
  95. package/src/ui/static/js/theme.js +10 -0
  96. package/src/ui/static/js/typer.js +99 -0
  97. package/src/ui/static/js/views/ai.js +68 -0
  98. package/src/ui/static/js/views/deps.js +96 -0
  99. package/src/ui/static/js/views/evidence.js +115 -0
  100. package/src/ui/static/js/views/findings.js +165 -0
  101. package/src/ui/static/js/views/history.js +92 -0
  102. package/src/ui/static/js/views/overview.js +184 -0
  103. package/src/ui/static/js/views/run.js +349 -0
  104. package/src/ui/static/js/views/settings.js +67 -0
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Static ReDoS guard for regexes that come from data: rule packs, community
3
+ * Nuclei templates. Rule packs scan hostile input (dependency source, agent
4
+ * configs, response bodies), so a pack must not be able to hang the scanner
5
+ * by shipping a pattern that backtracks catastrophically.
6
+ *
7
+ * V8's regex engine backtracks, and its linear-time engine (the `l` flag)
8
+ * refuses ordinary bounded repeats like `{64}`, so the guard is structural:
9
+ * it parses the pattern and refuses the shapes that blow up.
10
+ *
11
+ * 1. Length cap (MAX_LENGTH).
12
+ * 2. No backreferences and no lookaround. Detection rules do not need them,
13
+ * and both defeat simple reasoning about cost.
14
+ * 3. Star height 1: a quantified group may not contain a quantifier
15
+ * (`(a+)+`, `(\w*\s?)*`). The exponential case.
16
+ * 4. A group under an unbounded quantifier may not contain alternation
17
+ * (`(a|aa)+`, `(a|a)*`). The other exponential case.
18
+ * 5. Bounded repeats are capped at MAX_REPEAT.
19
+ * 6. An unbounded quantifier that is followed by something mandatory may
20
+ * not be reachable from every start position (`\s*x`, `[a-z]+@`): each
21
+ * start would rescan the rest of the input. Something mandatory before
22
+ * it that cannot occur inside its run (`x\s*:`) breaks the chain.
23
+ * 7. Two quantifiers that can match the same characters, with nothing
24
+ * mandatory between them that the first cannot also match (`.*b.*`,
25
+ * `\s*\s*`), multiply their cost. A chain with an unbounded member is
26
+ * refused; a bounded chain must stay under CHAIN_BUDGET.
27
+ *
28
+ * The guard is conservative: it refuses some safe patterns. That is the right
29
+ * trade for data that arrives from outside the codebase. tests/rule-packs.test.js
30
+ * fuzzes it: every pattern it accepts must run in bounded time on adversarial
31
+ * input.
32
+ */
33
+
34
+ export const MAX_LENGTH = 512;
35
+ export const MAX_REPEAT = 256;
36
+ export const CHAIN_BUDGET = 1024;
37
+ export const ALLOWED_FLAGS = /^[imsu]*$/;
38
+
39
+ // The narrower check for community templates (lib/templates.js, added in
40
+ // v1.1.1 as a regex): refuse a quantifier applied to a group that contains a
41
+ // quantifier, at any depth, or a quantifier stacked on another (`a+*`). It
42
+ // keeps user templates working that the strict checkRegex below would refuse.
43
+ // A linear scan rather than a regex, because a regex inspecting a pattern
44
+ // string is itself open to backtracking on input like "((((((".
45
+ const QUANT_START = (src, i) => src[i] === "*" || src[i] === "+" || (src[i] === "{" && /^\{\d+(,\d*)?\}/.test(src.slice(i, i + 24)));
46
+
47
+ export function hasNestedQuantifier(pattern) {
48
+ const src = String(pattern);
49
+ const groups = []; // per open group: does it contain a quantifier?
50
+ let prevQuant = false;
51
+ for (let i = 0; i < src.length; i++) {
52
+ const c = src[i];
53
+ if (c === "\\") { i++; prevQuant = false; continue; }
54
+ if (c === "[") {
55
+ for (i++; i < src.length && src[i] !== "]"; i++) if (src[i] === "\\") i++;
56
+ prevQuant = false;
57
+ continue;
58
+ }
59
+ if (c === "(") { groups.push(false); prevQuant = false; continue; }
60
+ if (c === ")") {
61
+ const inner = groups.pop() || false;
62
+ if (inner && QUANT_START(src, i + 1)) return true;
63
+ if (inner && groups.length) groups[groups.length - 1] = true;
64
+ prevQuant = false;
65
+ continue;
66
+ }
67
+ if (QUANT_START(src, i) || c === "?") {
68
+ const isQ = c !== "?" || (i > 0 && src[i - 1] !== "(" && !prevQuant);
69
+ if (c !== "?" && prevQuant) return true; // a+* / a{2}+
70
+ if (isQ && c !== "?" && groups.length) groups[groups.length - 1] = true;
71
+ if (c === "{") i = src.indexOf("}", i);
72
+ prevQuant = c !== "?" ? true : prevQuant;
73
+ continue;
74
+ }
75
+ prevQuant = false;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ class GuardError extends Error {}
81
+ const refuse = (msg) => { throw new GuardError(msg); };
82
+
83
+ // ── Parser ────────────────────────────────────────────────────────────────
84
+ // A small parser for the subset of JS regex syntax a detection rule needs.
85
+ // Nodes: { type: "alt", branches: Item[][] }, items are { atom, min, max, src }
86
+ // where atom is { type: "char"|"class"|"any"|"anchor"|"group", src, alt? }.
87
+
88
+ function parse(src, unicode) {
89
+ let i = 0;
90
+ const peek = () => src[i];
91
+
92
+ function parseAlt(depth) {
93
+ if (depth > 8) refuse("groups nested deeper than 8");
94
+ const branches = [[]];
95
+ while (i < src.length) {
96
+ const c = peek();
97
+ if (c === ")") break;
98
+ if (c === "|") { i++; branches.push([]); continue; }
99
+ const start = i;
100
+ const atom = parseAtom(depth);
101
+ const q = parseQuantifier(atom);
102
+ branches[branches.length - 1].push({ atom, ...q, src: src.slice(start, i) });
103
+ }
104
+ return { type: "alt", branches };
105
+ }
106
+
107
+ function parseAtom(depth) {
108
+ const c = src[i];
109
+ if (c === "(") {
110
+ i++;
111
+ if (src.startsWith("?:", i)) i += 2;
112
+ else if (src.startsWith("?=", i) || src.startsWith("?!", i) || src.startsWith("?<=", i) || src.startsWith("?<!", i)) refuse("lookaround is not allowed");
113
+ else if (src.startsWith("?<", i)) {
114
+ const end = src.indexOf(">", i);
115
+ if (end < 0) refuse("unterminated group name");
116
+ i = end + 1;
117
+ } else if (c === "(" && src[i] === "?") refuse("unsupported group syntax");
118
+ const alt = parseAlt(depth + 1);
119
+ if (src[i] !== ")") refuse("unterminated group");
120
+ i++;
121
+ return { type: "group", alt };
122
+ }
123
+ if (c === "[") return parseClass();
124
+ if (c === "\\") return parseEscape();
125
+ if (c === ".") { i++; return { type: "any", src: "." }; }
126
+ if (c === "^" || c === "$") { i++; return { type: "anchor", src: c }; }
127
+ if (c === "*" || c === "+" || c === "?" || c === "{" && /^\{\d/.test(src.slice(i))) refuse("quantifier with nothing to repeat");
128
+ if (c === "]" || c === "}") { if (unicode) refuse(`lone ${c}`); }
129
+ i++;
130
+ return { type: "char", src: c };
131
+ }
132
+
133
+ function parseEscape() {
134
+ const start = i;
135
+ i++;
136
+ const c = src[i];
137
+ if (c === undefined) refuse("trailing backslash");
138
+ if (/[1-9]/.test(c)) refuse("backreferences are not allowed");
139
+ if (c === "k" && src[i + 1] === "<") refuse("backreferences are not allowed");
140
+ if (c === "b" || c === "B") { i++; return { type: "anchor", src: src.slice(start, i) }; }
141
+ if (c === "x") i += 3;
142
+ else if (c === "u" && src[i + 1] === "{") i = src.indexOf("}", i) + 1 || refuse("bad \\u{} escape");
143
+ else if (c === "u") i += 5;
144
+ else if ((c === "p" || c === "P") && src[i + 1] === "{") i = src.indexOf("}", i) + 1 || refuse("bad \\p{} escape");
145
+ else if (c === "c") i += 2;
146
+ else i++;
147
+ const s = src.slice(start, i);
148
+ return { type: /^\\[dDwWsSpP]/.test(s) ? "class" : "char", src: s };
149
+ }
150
+
151
+ function parseClass() {
152
+ const start = i;
153
+ i++;
154
+ if (src[i] === "^") i++;
155
+ if (src[i] === "]") i++;
156
+ while (i < src.length && src[i] !== "]") {
157
+ if (src[i] === "\\") {
158
+ if (/[1-9]/.test(src[i + 1] || "")) refuse("backreferences are not allowed");
159
+ i += 2;
160
+ } else if (src[i] === "[" && unicode) refuse("nested classes are not allowed");
161
+ else i++;
162
+ }
163
+ if (src[i] !== "]") refuse("unterminated character class");
164
+ i++;
165
+ return { type: "class", src: src.slice(start, i) };
166
+ }
167
+
168
+ function parseQuantifier(atom) {
169
+ let min = 1;
170
+ let max = 1;
171
+ const c = src[i];
172
+ if (c === "*") { min = 0; max = Infinity; i++; }
173
+ else if (c === "+") { min = 1; max = Infinity; i++; }
174
+ else if (c === "?") { min = 0; max = 1; i++; }
175
+ else if (c === "{") {
176
+ const m = /^\{(\d+)(,(\d*))?\}/.exec(src.slice(i));
177
+ if (!m) return { min, max };
178
+ min = Number(m[1]);
179
+ max = m[2] ? (m[3] === "" ? Infinity : Number(m[3])) : min;
180
+ if (max < min) refuse("quantifier range out of order");
181
+ if (min > MAX_REPEAT || (max !== Infinity && max > MAX_REPEAT)) refuse(`repeat count above ${MAX_REPEAT}`);
182
+ i += m[0].length;
183
+ } else return { min, max };
184
+ if (atom.type === "anchor") refuse("quantified anchor");
185
+ if (src[i] === "?") i++; // lazy: same cost bound as greedy
186
+ if (src[i] === "*" || src[i] === "+" || src[i] === "?" || (src[i] === "{" && /^\{\d+(,\d*)?\}/.test(src.slice(i)))) refuse("stacked quantifiers");
187
+ return { min, max };
188
+ }
189
+
190
+ const ast = parseAlt(0);
191
+ if (i < src.length) refuse("unbalanced parenthesis");
192
+ return ast;
193
+ }
194
+
195
+ // ── Character sets ────────────────────────────────────────────────────────
196
+ // Approximated by membership over a sample alphabet: all of ASCII plus
197
+ // representatives of the Unicode classes rules tend to touch. Two atoms
198
+ // "overlap" if some sample character matches both.
199
+
200
+ const SAMPLE = (() => {
201
+ const out = [];
202
+ for (let c = 0; c < 128; c++) out.push(String.fromCharCode(c));
203
+ out.push(" ", "é", "ß", "İ", "
", "​", "中", "а", "", "\u{1F600}");
204
+ return out;
205
+ })();
206
+
207
+ const setCache = new Map();
208
+ function charSet(src, flags) {
209
+ const key = `${flags}\u0000${src}`;
210
+ let set = setCache.get(key);
211
+ if (set) return set;
212
+ const re = new RegExp(`^(?:${src})$`, flags.replace(/[gy]/g, ""));
213
+ set = new Uint8Array(SAMPLE.length);
214
+ for (let k = 0; k < SAMPLE.length; k++) set[k] = re.test(SAMPLE[k]) ? 1 : 0;
215
+ if (setCache.size > 5000) setCache.clear();
216
+ setCache.set(key, set);
217
+ return set;
218
+ }
219
+ const overlaps = (a, b) => a.some((v, k) => v && b[k]);
220
+ const subset = (a, b) => a.every((v, k) => !v || b[k]);
221
+ const union = (sets) => sets.reduce((acc, s) => acc.map((v, k) => v | s[k]), new Uint8Array(SAMPLE.length));
222
+
223
+ /** Characters an item can consume (any one position), or null for anchors. */
224
+ function itemSet(item, flags) {
225
+ const a = item.atom;
226
+ if (a.type === "anchor") return null;
227
+ if (a.type !== "group") return charSet(a.src, flags);
228
+ const sets = [];
229
+ for (const br of a.alt.branches) for (const it of br) { const s = itemSet(it, flags); if (s) sets.push(s); }
230
+ return union(sets);
231
+ }
232
+
233
+ function hasQuantifier(alt) {
234
+ return alt.branches.some((br) => br.some((it) => it.max > 1 || (it.atom.type === "group" && hasQuantifier(it.atom.alt))));
235
+ }
236
+
237
+ /** Largest max among quantifiers inside a group (Infinity if any unbounded). */
238
+ function innerMax(alt) {
239
+ let m = 1;
240
+ for (const br of alt.branches) for (const it of br) {
241
+ m = Math.max(m, it.max);
242
+ if (it.atom.type === "group") m = Math.max(m, innerMax(it.atom.alt));
243
+ }
244
+ return m;
245
+ }
246
+
247
+ // ── Rules ─────────────────────────────────────────────────────────────────
248
+
249
+ function checkStructure(alt) {
250
+ for (const br of alt.branches) for (const it of br) {
251
+ if (it.atom.type !== "group") continue;
252
+ const g = it.atom.alt;
253
+ if (it.max > 1 && hasQuantifier(g)) refuse(`nested quantifier in \`${it.src}\``);
254
+ if (it.max === Infinity && g.branches.length > 1) refuse(`alternation under an unbounded quantifier in \`${it.src}\``);
255
+ checkStructure(g);
256
+ }
257
+ }
258
+
259
+ const MAX_PATHS = 256;
260
+
261
+ /**
262
+ * Expand a branch into the linear sequences of leaf items it can match.
263
+ * A group that is not repeated (`(?:a|b)`, `(?:sudo\s+)?`) is inlined once
264
+ * per alternative, plus a "skipped" path when it is optional, so quantifiers
265
+ * on either side of it are compared along each real path. A repeated group
266
+ * becomes one leaf (rule 3 guarantees it holds no quantifier). Past MAX_PATHS
267
+ * the remaining groups collapse to one conservative leaf each.
268
+ */
269
+ function expand(branch, flags) {
270
+ let seqs = [[]];
271
+ for (const it of branch) {
272
+ const a = it.atom;
273
+ if (a.type === "anchor") continue;
274
+ if (a.type === "group" && it.max <= 1) {
275
+ const alts = a.alt.branches.flatMap((b) => expand(b, flags));
276
+ if (it.min === 0) alts.push([]);
277
+ if (seqs.length * alts.length <= MAX_PATHS) {
278
+ seqs = seqs.flatMap((s) => alts.map((x) => s.concat(x)));
279
+ continue;
280
+ }
281
+ const leaf = { set: itemSet(it, flags), min: it.min, max: innerMax(a.alt), src: it.src };
282
+ seqs = seqs.map((s) => s.concat([leaf]));
283
+ continue;
284
+ }
285
+ const leaf = { set: itemSet(it, flags), min: it.min, max: it.max, src: it.src };
286
+ seqs = seqs.map((s) => s.concat([leaf]));
287
+ }
288
+ return seqs;
289
+ }
290
+
291
+ /**
292
+ * Rule 6. An unanchored search tries every start position. An unbounded
293
+ * quantifier followed by something mandatory rescans its run from each start
294
+ * that can reach it, which is quadratic when everything mandatory before it
295
+ * can also sit inside that run (`\s*x`, `[^a]{1,40}\S[ab]* ` on "bbbb").
296
+ * A greedy quantifier with nothing mandatory after it succeeds at once.
297
+ */
298
+ function checkRescan(seq, anchored) {
299
+ if (anchored) return;
300
+ for (let q = 0; q < seq.length; q++) {
301
+ const it = seq[q];
302
+ if (it.max !== Infinity) continue;
303
+ if (!seq.slice(q + 1).some((x) => x.min > 0)) continue;
304
+ if (seq.slice(0, q).every((x) => x.min === 0 || overlaps(x.set, it.set))) {
305
+ refuse(q === 0 || seq.slice(0, q).every((x) => x.min === 0)
306
+ ? `pattern starts with an unbounded quantifier (\`${it.src}\`)`
307
+ : `unbounded quantifier \`${it.src}\` can be rescanned from every start position`);
308
+ }
309
+ }
310
+ }
311
+
312
+ function checkChains(alt, flags) {
313
+ for (const br of alt.branches) for (const seq of expand(br, flags)) {
314
+ checkRescan(seq, br[0]?.atom.type === "anchor" && br[0].atom.src === "^" && !flags.includes("m"));
315
+ for (let a = 0; a < seq.length; a++) {
316
+ const head = seq[a];
317
+ if (head.max <= 1) continue;
318
+ let cost = head.max;
319
+ const mids = []; // mandatory atoms between head and the current item
320
+ for (let b = a + 1; b < seq.length; b++) {
321
+ const it = seq[b];
322
+ // The costs multiply when the head can stop inside the item's run:
323
+ // they share characters, and every mandatory atom in between can sit
324
+ // in that run too (`.*b.*` on "bbbb"). `[^\n]*\|\s*` stays linear,
325
+ // because `|` cannot occur inside a run of spaces.
326
+ if (it.max > 1 && overlaps(head.set, it.set) && mids.every((m) => overlaps(m, it.set))) {
327
+ cost = cost * it.max;
328
+ if (cost === Infinity) refuse(`overlapping quantifiers \`${head.src}\` … \`${it.src}\` (polynomial backtracking)`);
329
+ if (cost > CHAIN_BUDGET) refuse(`overlapping quantifiers \`${head.src}\` … \`${it.src}\` exceed the backtracking budget`);
330
+ }
331
+ // A mandatory atom the head cannot match ends the head's reach.
332
+ if (it.min > 0) {
333
+ if (!subset(it.set, head.set)) break;
334
+ mids.push(it.set);
335
+ }
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Check a regex from data. Returns { ok: true } or { ok: false, reason }.
343
+ * Never throws.
344
+ * @param {string} source pattern source (no slashes)
345
+ * @param {string} [flags] only i, m, s, u are accepted
346
+ */
347
+ export function checkRegex(source, flags = "") {
348
+ try {
349
+ if (typeof source !== "string" || source.length === 0) refuse("pattern must be a non-empty string");
350
+ if (typeof flags !== "string" || !ALLOWED_FLAGS.test(flags)) refuse(`flags must be a subset of "imsu" (got "${flags}")`);
351
+ if (source.length > MAX_LENGTH) refuse(`pattern longer than ${MAX_LENGTH} characters`);
352
+ try { new RegExp(source, flags); } catch (e) { refuse(`invalid regex: ${e.message}`); }
353
+ const ast = parse(source, flags.includes("u"));
354
+ checkStructure(ast);
355
+ checkChains(ast, flags);
356
+ return { ok: true };
357
+ } catch (e) {
358
+ if (e instanceof GuardError) return { ok: false, reason: e.message };
359
+ return { ok: false, reason: `guard error: ${e.message}` };
360
+ }
361
+ }
362
+
363
+ /** Compile a regex from data, or throw with the guard's reason. */
364
+ export function compileSafe(source, flags = "") {
365
+ const r = checkRegex(source, flags);
366
+ if (!r.ok) throw new Error(`unsafe regex /${String(source).slice(0, 80)}/: ${r.reason}`);
367
+ return new RegExp(source, flags);
368
+ }
package/lib/templates.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { joinUrl } from "../src/utils/url.js";
8
8
  import { request } from "../src/utils/http.js";
9
+ import { hasNestedQuantifier } from "./rules/regex-guard.js";
9
10
 
10
11
  /**
11
12
  * Execute a single template check against a target.
@@ -88,10 +89,11 @@ export async function executeTemplate(baseUrl, template) {
88
89
  // the input and the pattern. This is a mitigation, not an immunity proof: real
89
90
  // guarantees need a non-backtracking engine or a killable worker.
90
91
  const REGEX_INPUT_CAP = 256 * 1024; // 256 KB is far past any real matcher's needs
91
- const NESTED_QUANTIFIER = /(\([^)]*[+*][^)]*\)|\[[^\]]*\][^)]*)[+*]\s*[+*]|\((?:\?:)?[^)]*[+*]\)[+*]/;
92
+ // hasNestedQuantifier lives in lib/rules/regex-guard.js. Templates from rule
93
+ // packs pass the stricter checkRegex there when the pack loads.
92
94
 
93
95
  function safeRegexTest(pattern, target, flags = "") {
94
- if (NESTED_QUANTIFIER.test(String(pattern))) return false; // refuse the shape that blows up
96
+ if (hasNestedQuantifier(pattern)) return false; // refuse the shape that blows up
95
97
  try {
96
98
  const input = target.length > REGEX_INPUT_CAP ? target.slice(0, REGEX_INPUT_CAP) : target;
97
99
  return new RegExp(pattern, flags).test(input);
@@ -100,7 +102,7 @@ function safeRegexTest(pattern, target, flags = "") {
100
102
  }
101
103
  }
102
104
 
103
- function runMatcher(matcher, ctx) {
105
+ export function runMatcher(matcher, ctx) {
104
106
  const part = matcher.part || "body";
105
107
  const target = part === "header" ? ctx.headers : ctx.body;
106
108
 
@@ -142,16 +144,21 @@ function runMatcher(matcher, ctx) {
142
144
  /**
143
145
  * Run a single extractor against response data.
144
146
  */
145
- function runExtractor(extractor, ctx) {
147
+ export function runExtractor(extractor, ctx) {
146
148
  const part = extractor.part || "body";
147
149
  const target = part === "header" ? ctx.headers : ctx.body;
148
150
 
149
151
  switch (extractor.type) {
150
152
  case "regex": {
151
153
  for (const pattern of extractor.regex || []) {
152
- if (NESTED_QUANTIFIER.test(String(pattern))) continue;
154
+ if (hasNestedQuantifier(pattern)) continue;
153
155
  const capped = target.length > REGEX_INPUT_CAP ? target.slice(0, REGEX_INPUT_CAP) : target;
154
- const match = capped.match(new RegExp(pattern));
156
+ let match;
157
+ try {
158
+ match = capped.match(new RegExp(pattern));
159
+ } catch {
160
+ continue; // invalid pattern in a template must not abort the scan
161
+ }
155
162
  if (match) return match[extractor.group || 0];
156
163
  }
157
164
  return null;
package/lib/tls.js CHANGED
@@ -26,6 +26,13 @@ const WEAK_PROTOCOLS = new Set(["TLSv1", "TLSv1.1", "SSLv3"]);
26
26
  */
27
27
  export function checkTls(hostname, port = 443, timeout = 10_000) {
28
28
  return new Promise((resolve) => {
29
+ // Validation is off on purpose, and only on this socket: the audit has to
30
+ // complete the handshake with a target whose certificate is expired,
31
+ // self-signed or for the wrong name, so it can report exactly that. The
32
+ // verdict comes from socket.authorized / authorizationError below. The
33
+ // socket only reads the handshake (cert, protocol, cipher) and is closed
34
+ // straight away; no request or credential is ever sent over it. Every
35
+ // other connection Penthera makes keeps Node's default validation.
29
36
  const socket = tls.connect(
30
37
  { host: hostname, port, servername: hostname, rejectUnauthorized: false },
31
38
  () => {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Discover API routes in Express, Hono, and Fastify projects.
3
3
  */
4
- import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
4
+ import { readdirSync, readFileSync, statSync, lstatSync, existsSync } from "node:fs";
5
5
  import { join, relative } from "node:path";
6
6
 
7
7
  const SKIP = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage", "vendor"]);
@@ -18,13 +18,16 @@ const ROUTE_RES = [
18
18
  /method:\s*['"](GET|POST|PUT|PATCH|DELETE)['"]\s*,\s*url:\s*['"`]([^'"`]+)['"`]/gi,
19
19
  ];
20
20
 
21
- function walkCodeFiles(dir, files = []) {
22
- if (!existsSync(dir)) return files;
21
+ function walkCodeFiles(dir, files = [], root = dir) {
22
+ // Nested repos and worktrees are separate projects; symlinks are not followed.
23
+ if (!existsSync(dir) || (dir !== root && existsSync(join(dir, ".git")))) return files;
23
24
  for (const entry of readdirSync(dir)) {
24
25
  if (SKIP.has(entry)) continue;
25
26
  const full = join(dir, entry);
26
- if (statSync(full).isDirectory()) {
27
- walkCodeFiles(full, files);
27
+ const st = lstatSync(full);
28
+ if (st.isSymbolicLink()) continue;
29
+ if (st.isDirectory()) {
30
+ walkCodeFiles(full, files, root);
28
31
  } else if (CODE_EXT.test(entry)) {
29
32
  files.push(full);
30
33
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Scan repository source for hardcoded secrets.
3
3
  */
4
- import { readdirSync, readFileSync, statSync, existsSync } from "node:fs";
4
+ import { readdirSync, readFileSync, lstatSync, existsSync } from "node:fs";
5
5
  import { join, relative } from "node:path";
6
6
 
7
7
  const SKIP_DIRS = new Set([
@@ -15,8 +15,10 @@ const SKIP_FILES = /\.(png|jpg|jpeg|gif|webp|ico|woff|woff2|ttf|eot|zip|tar|gz|p
15
15
  // dummy/probe credentials that security tooling (Penthera included) hardcodes
16
16
  // to test login endpoints, e.g. `password: "invalid-password-12345"`. Matched
17
17
  // against the assigned value only, with word boundaries so a real secret that
18
- // merely contains one of these substrings isn't suppressed.
19
- const TEST_VALUE = /\b(?:placeholder|example|changeme|dummy|sample|redacted|invalid|fake|test|your)\b|xxx|\*{3,}/i;
18
+ // merely contains one of these substrings isn't suppressed. A value that is
19
+ // only a template placeholder (`"${apiKey}"`, `"{{ api_key }}"`) holds no
20
+ // secret either: it is filled in at runtime.
21
+ const TEST_VALUE = /\b(?:placeholder|example|changeme|dummy|sample|redacted|invalid|fake|test|your)\b|xxx|\*{3,}|^\$\{[^}]+\}$|^\{\{[^}]+\}\}$/i;
20
22
 
21
23
  const SECRET_PATTERNS = [
22
24
  { name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/, severity: "critical" },
@@ -77,13 +79,24 @@ export function isSecretScanTarget(relPath) {
77
79
  return SCAN_EXTENSIONS.has(ext) || entry.startsWith(".env");
78
80
  }
79
81
 
80
- function walkFiles(dir, files = []) {
81
- if (!existsSync(dir)) return files;
82
+ // A subfolder with its own `.git` (a nested repo, submodule or worktree, such
83
+ // as the ones coding agents create under .claude/worktrees) is a separate
84
+ // project: scanning it would report its secrets as this repo's. Scan it on its
85
+ // own instead. Symlinked folders are not followed, so a link cannot pull files
86
+ // from outside the repo into the scan or loop forever.
87
+ export function isNestedRepo(dir, root) {
88
+ return dir !== root && existsSync(join(dir, ".git"));
89
+ }
90
+
91
+ function walkFiles(dir, files = [], root = dir) {
92
+ if (!existsSync(dir) || isNestedRepo(dir, root)) return files;
82
93
  for (const entry of readdirSync(dir)) {
83
94
  if (SKIP_DIRS.has(entry)) continue;
84
95
  const full = join(dir, entry);
85
- if (statSync(full).isDirectory()) {
86
- walkFiles(full, files);
96
+ const st = lstatSync(full);
97
+ if (st.isSymbolicLink()) continue;
98
+ if (st.isDirectory()) {
99
+ walkFiles(full, files, root);
87
100
  } else if (!SKIP_FILES.test(entry)) {
88
101
  const ext = entry.includes(".") ? entry.slice(entry.lastIndexOf(".")) : "";
89
102
  if (SCAN_EXTENSIONS.has(ext) || entry.startsWith(".env")) files.push(full);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "penthera",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "description": "Open-source security for the apps and AI systems you ship. Check it before you deploy, vet what you build with (dependencies, MCP servers, skills), keep watching after you ship. Offline, deterministic, free.",
6
6
  "repository": {
@@ -49,10 +49,13 @@
49
49
  "validate:report": "node skills/penthera/scripts/validate-report.mjs",
50
50
  "package:skill": "bash scripts/package-skill.sh",
51
51
  "refresh:popular": "node scripts/refresh-popular-packages.mjs",
52
+ "refresh:popular-pypi": "node scripts/refresh-popular-pypi.mjs",
52
53
  "prepack": "npm test",
53
54
  "mock-server": "node tests/fixtures/mock-server.js",
54
55
  "bench": "node bench/run.js",
55
- "refresh:retire-db": "node scripts/refresh-retire-db.mjs"
56
+ "docs:numbers": "node scripts/docs-numbers.mjs",
57
+ "refresh:retire-db": "node scripts/refresh-retire-db.mjs",
58
+ "audit:internal": "node scripts/audit/run.mjs"
56
59
  },
57
60
  "keywords": [
58
61
  "security",
@@ -78,7 +81,8 @@
78
81
  ],
79
82
  "license": "MIT",
80
83
  "devDependencies": {
81
- "ajv": "^8.17.1",
84
+ "ajv": "^8.20.0",
85
+ "fast-check": "^4.10.2",
82
86
  "vitest": "^5.0.5"
83
87
  },
84
88
  "dependencies": {
@@ -4,7 +4,7 @@ description: >
4
4
  Open-source security for the apps and AI systems the user ships, run locally with
5
5
  the Penthera CLI. Scans the
6
6
  user's own live URL and repo (TLS, headers, auth, OpenAPI, secrets incl. AI keys,
7
- opt-in injection); checks npm dependencies offline ("is this dependency safe?",
7
+ opt-in injection); checks npm and Python dependencies offline ("is this dependency safe?",
8
8
  "what changed in my deps?", "install X safely", "any AGPL in our deps?"); blocks
9
9
  secrets and risky agent config before they are committed ("add a pre-commit hook");
10
10
  audits AI tooling ("audit my MCP servers / skills / hooks", "check this skill
@@ -19,7 +19,7 @@ compatibility: Node.js 22+, network access to target, Penthera CLI (npm link or
19
19
  allowed-tools: "Bash(node:*) Bash(npm:*) Bash(penthera:*) Read Write Edit"
20
20
  metadata:
21
21
  author: danoszz
22
- version: 1.5.0
22
+ version: 1.6.0
23
23
  category: security
24
24
  ---
25
25
 
@@ -80,6 +80,7 @@ All examples below use `penthera`; substitute `node bin/penthera.js` when needed
80
80
  | macOS machine audit | `penthera --machine` |
81
81
  | Audit MCP servers, skills, hooks, agent config | `penthera agents . --home -o reports/agents.json` (see **Workflow 6**) |
82
82
  | Report for my auditor / manager / developers | `--audience auditor\|owner\|developer --markdown reports/<who>.md`, or `--evidence <dir>` (see **Workflow 7**) |
83
+ | Open the penthera dashboard / show me my security posture | `penthera ui --open` (see **Dashboard**) |
83
84
 
84
85
  ## Default safe behavior
85
86
 
@@ -270,19 +271,20 @@ Summarize secret findings; remind user to rotate any exposed credentials.
270
271
 
271
272
  ## Dependency risk scan
272
273
 
273
- **Triggers:** "is this dependency safe?", "what changed in my deps?", "review this lockfile change / dependency upgrade", "does this package have install scripts?", "is this a typosquat?"
274
+ **Triggers:** "is this dependency safe?", "what changed in my deps?", "review this lockfile change / dependency upgrade", "does this package have install scripts?", "is this a typosquat?", "is this pip / PyPI package safe?", "check my requirements.txt / uv.lock / poetry.lock / venv", "does this package have a .pth file or setup.py hook?"
274
275
 
275
- Offline and read-only: it reads `package-lock.json` and `node_modules`, runs no package code, and sends nothing anywhere, so no target authorization is needed. It never installs anything; if `node_modules` is missing, tell the user to run `npm ci --ignore-scripts` first.
276
+ Offline and read-only: it reads `package-lock.json` and `node_modules`, or for Python `uv.lock` / `poetry.lock` / `pdm.lock` / `Pipfile.lock` / `requirements*.txt` and the `.venv` (or `--venv <path>`), runs no package code (not even pip), and sends nothing anywhere, so no target authorization is needed. It never installs anything; if `node_modules` is missing, tell the user to run `npm ci --ignore-scripts` first. For Python without a venv, suggest `uv sync` or `pip install -r requirements.txt` into a `.venv` (wheels only, `--only-binary :all:`, if they are wary of setup.py).
276
277
 
277
278
  ```bash
278
279
  penthera deps # what every installed package can do
279
280
  penthera deps --base origin/main # capability diff vs the lockfile on main
280
281
  penthera deps --diff ../base-checkout # diff vs another installed tree
281
282
  penthera deps --prod -o reports/deps.json # skip devDependencies, write JSON + Markdown
283
+ penthera deps --venv path/to/venv # Python: a virtualenv that is not .venv/venv
282
284
  ```
283
285
 
284
286
  1. Lead with the diff: which packages are **new** or **gained** capabilities (network, shell, install script, whole environment, dynamic code), each with its `file:line` evidence.
285
- 2. Then critical/high risk signals (AI-config writes, AI CLI invocation, GitHub token harvesting, obfuscation, env exfiltration). Open the evidence file and read the lines before calling anything malicious: a capability is what code *can* do, not a verdict.
287
+ 2. Then critical/high risk signals (AI-config writes, AI CLI invocation, GitHub token harvesting, obfuscation, env exfiltration; for Python also `.pth` startup hooks that run risky code, unowned `.pth` files, files changed since install). Open the evidence file and read the lines before calling anything malicious: a capability is what code *can* do, not a verdict.
286
288
  3. If old versions were "not compared", say so; suggest installing the base tree and passing it to `--diff`.
287
289
  4. `--online` is the only network use (npm registry metadata: publish age, publisher change, deprecation). Only add it if the user agrees.
288
290
 
@@ -325,6 +327,22 @@ penthera hook run # check what is staged now (exit 1 = blocked)
325
327
  2. If `core.hooksPath` is set (husky, lefthook), do not pass `--force`; add `npx --no-install penthera hook run` to that tool's pre-commit config instead.
326
328
  3. Never suggest `git commit --no-verify` to get past a finding unless the user asks how to skip it; suggest fixing it or a `penthera.suppress.json` entry with a reason.
327
329
 
330
+ ## Dashboard
331
+
332
+ **Triggers:** "open the penthera dashboard", "show me the dashboard", "show my security posture", "run the agent from the UI".
333
+
334
+ ```bash
335
+ penthera ui --open # this folder, reports in ./reports
336
+ penthera ui --project <dir> --reports <dir> --open # other folders
337
+ penthera ui --demo --open # fake data, to look around first
338
+ ```
339
+
340
+ 1. Start it in the user's terminal (it keeps running until Ctrl-C). It binds 127.0.0.1 on a random port and prints a one-time URL: tell the user to open that link (or pass `--open`). Never paste the token anywhere else.
341
+ 2. It reads only the `--project` folders, the reports folder and `~/.penthera`. If the screens are empty, scan with `-o reports/<name>.json` first (and `penthera deps -o reports/deps.json`, `penthera agents . -o reports/agents.json`).
342
+ 3. The Run screen starts scans, dependency checks, AI tooling audits and the fix loop. The fix loop is `penthera agent --loop` with the same rules: the user approves every diff in the page, nothing is pushed. A URL that is not on this machine needs its hostname typed to confirm; the authorization gate above still applies.
343
+
344
+ See [docs/dashboard.md](../../docs/dashboard.md).
345
+
328
346
  ## Examples
329
347
 
330
348
  **Example 1: Localhost quick check**