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.
- package/CHANGELOG.md +170 -0
- package/README.md +128 -663
- package/bin/scan.js +19 -0
- package/docs/commands/README.md +23 -0
- package/docs/commands/action.md +21 -0
- package/docs/commands/agents.md +38 -0
- package/docs/commands/deps.md +115 -0
- package/docs/commands/fix.md +53 -0
- package/docs/commands/hook.md +69 -0
- package/docs/commands/install.md +53 -0
- package/docs/commands/scan.md +100 -0
- package/docs/commands/skill.md +58 -0
- package/docs/compare/README.md +25 -0
- package/docs/compare/github.md +60 -0
- package/docs/compare/semgrep.md +54 -0
- package/docs/compare/snyk.md +62 -0
- package/docs/compare/socket.md +78 -0
- package/docs/compare/zap.md +59 -0
- package/docs/dashboard.md +244 -0
- package/docs/evidence.md +8 -4
- package/docs/examples/github-actions-deps.yml +4 -4
- package/docs/examples/github-actions-scan.yml +4 -4
- package/docs/index.md +9 -0
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +4 -3
- package/docs/privacy.md +38 -0
- package/docs/references.bib +10 -0
- package/docs/references.md +1 -0
- package/docs/research/socket-positioning-notes.md +183 -0
- package/docs/use-cases/README.md +14 -0
- package/docs/use-cases/agencies.md +71 -0
- package/docs/use-cases/ai-coding-teams.md +81 -0
- package/docs/use-cases/eu-sme-compliance.md +68 -0
- package/docs/use-cases/open-source-maintainers.md +75 -0
- package/docs/use-cases/solo-founder.md +65 -0
- package/lib/blackbox/email-dns.js +5 -0
- package/lib/compliance/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +290 -30
- package/lib/deps/lockfile.js +25 -2
- package/lib/deps/popular-pypi.json +1 -0
- package/lib/deps/python/capabilities.js +447 -0
- package/lib/deps/python/index.js +234 -0
- package/lib/deps/python/lockfiles.js +359 -0
- package/lib/deps/python/source.js +396 -0
- package/lib/deps/python/toml.js +219 -0
- package/lib/deps/report.js +37 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +41 -8
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +17 -5
- package/lib/memory/repo-suppress.js +11 -3
- package/lib/references.json +77 -0
- package/lib/remediation/index.js +9 -0
- package/lib/retirejs.js +69 -17
- package/lib/rules/regex-guard.js +368 -0
- package/lib/templates.js +13 -6
- package/lib/tls.js +7 -0
- package/lib/whitebox/frameworks.js +8 -5
- package/lib/whitebox/secrets.js +20 -7
- package/package.json +7 -3
- package/skills/penthera/SKILL.md +23 -5
- package/src/cli/deps.js +60 -5
- package/src/cli/evidence.js +14 -4
- package/src/cli/install.js +14 -5
- package/src/cli/report-outputs.js +25 -2
- package/src/cli/ui.js +89 -0
- package/src/cli.js +19 -7
- package/src/report/audience.js +10 -10
- package/src/report/evidence.js +33 -14
- package/src/report/footer.js +11 -0
- package/src/report/markdown.js +6 -3
- package/src/report/md-escape.js +16 -0
- package/src/reporter.js +1 -0
- package/src/scan-url.js +5 -1
- package/src/ui/api/data.js +309 -0
- package/src/ui/api/demo.js +464 -0
- package/src/ui/api/routes.js +134 -0
- package/src/ui/api/runs.js +243 -0
- package/src/ui/api/summarize.js +451 -0
- package/src/ui/security.js +153 -0
- package/src/ui/server.js +186 -0
- package/src/ui/static/app.css +723 -0
- package/src/ui/static/favicon.svg +1 -0
- package/src/ui/static/index.html +57 -0
- package/src/ui/static/js/api.js +29 -0
- package/src/ui/static/js/app.js +138 -0
- package/src/ui/static/js/charts.js +207 -0
- package/src/ui/static/js/copy.js +79 -0
- package/src/ui/static/js/dom.js +120 -0
- package/src/ui/static/js/format.js +42 -0
- package/src/ui/static/js/theme.js +10 -0
- package/src/ui/static/js/typer.js +99 -0
- package/src/ui/static/js/views/ai.js +68 -0
- package/src/ui/static/js/views/deps.js +96 -0
- package/src/ui/static/js/views/evidence.js +115 -0
- package/src/ui/static/js/views/findings.js +165 -0
- package/src/ui/static/js/views/history.js +92 -0
- package/src/ui/static/js/views/overview.js +184 -0
- package/src/ui/static/js/views/run.js +349 -0
- package/src/ui/static/js/views/settings.js +67 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static ReDoS guard for regexes that come from data: rule packs, community
|
|
3
|
+
* Nuclei templates. Rule packs scan hostile input (dependency source, agent
|
|
4
|
+
* configs, response bodies), so a pack must not be able to hang the scanner
|
|
5
|
+
* by shipping a pattern that backtracks catastrophically.
|
|
6
|
+
*
|
|
7
|
+
* V8's regex engine backtracks, and its linear-time engine (the `l` flag)
|
|
8
|
+
* refuses ordinary bounded repeats like `{64}`, so the guard is structural:
|
|
9
|
+
* it parses the pattern and refuses the shapes that blow up.
|
|
10
|
+
*
|
|
11
|
+
* 1. Length cap (MAX_LENGTH).
|
|
12
|
+
* 2. No backreferences and no lookaround. Detection rules do not need them,
|
|
13
|
+
* and both defeat simple reasoning about cost.
|
|
14
|
+
* 3. Star height 1: a quantified group may not contain a quantifier
|
|
15
|
+
* (`(a+)+`, `(\w*\s?)*`). The exponential case.
|
|
16
|
+
* 4. A group under an unbounded quantifier may not contain alternation
|
|
17
|
+
* (`(a|aa)+`, `(a|a)*`). The other exponential case.
|
|
18
|
+
* 5. Bounded repeats are capped at MAX_REPEAT.
|
|
19
|
+
* 6. An unbounded quantifier that is followed by something mandatory may
|
|
20
|
+
* not be reachable from every start position (`\s*x`, `[a-z]+@`): each
|
|
21
|
+
* start would rescan the rest of the input. Something mandatory before
|
|
22
|
+
* it that cannot occur inside its run (`x\s*:`) breaks the chain.
|
|
23
|
+
* 7. Two quantifiers that can match the same characters, with nothing
|
|
24
|
+
* mandatory between them that the first cannot also match (`.*b.*`,
|
|
25
|
+
* `\s*\s*`), multiply their cost. A chain with an unbounded member is
|
|
26
|
+
* refused; a bounded chain must stay under CHAIN_BUDGET.
|
|
27
|
+
*
|
|
28
|
+
* The guard is conservative: it refuses some safe patterns. That is the right
|
|
29
|
+
* trade for data that arrives from outside the codebase. tests/rule-packs.test.js
|
|
30
|
+
* fuzzes it: every pattern it accepts must run in bounded time on adversarial
|
|
31
|
+
* input.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const MAX_LENGTH = 512;
|
|
35
|
+
export const MAX_REPEAT = 256;
|
|
36
|
+
export const CHAIN_BUDGET = 1024;
|
|
37
|
+
export const ALLOWED_FLAGS = /^[imsu]*$/;
|
|
38
|
+
|
|
39
|
+
// The narrower check for community templates (lib/templates.js, added in
|
|
40
|
+
// v1.1.1 as a regex): refuse a quantifier applied to a group that contains a
|
|
41
|
+
// quantifier, at any depth, or a quantifier stacked on another (`a+*`). It
|
|
42
|
+
// keeps user templates working that the strict checkRegex below would refuse.
|
|
43
|
+
// A linear scan rather than a regex, because a regex inspecting a pattern
|
|
44
|
+
// string is itself open to backtracking on input like "((((((".
|
|
45
|
+
const QUANT_START = (src, i) => src[i] === "*" || src[i] === "+" || (src[i] === "{" && /^\{\d+(,\d*)?\}/.test(src.slice(i, i + 24)));
|
|
46
|
+
|
|
47
|
+
export function hasNestedQuantifier(pattern) {
|
|
48
|
+
const src = String(pattern);
|
|
49
|
+
const groups = []; // per open group: does it contain a quantifier?
|
|
50
|
+
let prevQuant = false;
|
|
51
|
+
for (let i = 0; i < src.length; i++) {
|
|
52
|
+
const c = src[i];
|
|
53
|
+
if (c === "\\") { i++; prevQuant = false; continue; }
|
|
54
|
+
if (c === "[") {
|
|
55
|
+
for (i++; i < src.length && src[i] !== "]"; i++) if (src[i] === "\\") i++;
|
|
56
|
+
prevQuant = false;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (c === "(") { groups.push(false); prevQuant = false; continue; }
|
|
60
|
+
if (c === ")") {
|
|
61
|
+
const inner = groups.pop() || false;
|
|
62
|
+
if (inner && QUANT_START(src, i + 1)) return true;
|
|
63
|
+
if (inner && groups.length) groups[groups.length - 1] = true;
|
|
64
|
+
prevQuant = false;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (QUANT_START(src, i) || c === "?") {
|
|
68
|
+
const isQ = c !== "?" || (i > 0 && src[i - 1] !== "(" && !prevQuant);
|
|
69
|
+
if (c !== "?" && prevQuant) return true; // a+* / a{2}+
|
|
70
|
+
if (isQ && c !== "?" && groups.length) groups[groups.length - 1] = true;
|
|
71
|
+
if (c === "{") i = src.indexOf("}", i);
|
|
72
|
+
prevQuant = c !== "?" ? true : prevQuant;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
prevQuant = false;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class GuardError extends Error {}
|
|
81
|
+
const refuse = (msg) => { throw new GuardError(msg); };
|
|
82
|
+
|
|
83
|
+
// ── Parser ────────────────────────────────────────────────────────────────
|
|
84
|
+
// A small parser for the subset of JS regex syntax a detection rule needs.
|
|
85
|
+
// Nodes: { type: "alt", branches: Item[][] }, items are { atom, min, max, src }
|
|
86
|
+
// where atom is { type: "char"|"class"|"any"|"anchor"|"group", src, alt? }.
|
|
87
|
+
|
|
88
|
+
function parse(src, unicode) {
|
|
89
|
+
let i = 0;
|
|
90
|
+
const peek = () => src[i];
|
|
91
|
+
|
|
92
|
+
function parseAlt(depth) {
|
|
93
|
+
if (depth > 8) refuse("groups nested deeper than 8");
|
|
94
|
+
const branches = [[]];
|
|
95
|
+
while (i < src.length) {
|
|
96
|
+
const c = peek();
|
|
97
|
+
if (c === ")") break;
|
|
98
|
+
if (c === "|") { i++; branches.push([]); continue; }
|
|
99
|
+
const start = i;
|
|
100
|
+
const atom = parseAtom(depth);
|
|
101
|
+
const q = parseQuantifier(atom);
|
|
102
|
+
branches[branches.length - 1].push({ atom, ...q, src: src.slice(start, i) });
|
|
103
|
+
}
|
|
104
|
+
return { type: "alt", branches };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseAtom(depth) {
|
|
108
|
+
const c = src[i];
|
|
109
|
+
if (c === "(") {
|
|
110
|
+
i++;
|
|
111
|
+
if (src.startsWith("?:", i)) i += 2;
|
|
112
|
+
else if (src.startsWith("?=", i) || src.startsWith("?!", i) || src.startsWith("?<=", i) || src.startsWith("?<!", i)) refuse("lookaround is not allowed");
|
|
113
|
+
else if (src.startsWith("?<", i)) {
|
|
114
|
+
const end = src.indexOf(">", i);
|
|
115
|
+
if (end < 0) refuse("unterminated group name");
|
|
116
|
+
i = end + 1;
|
|
117
|
+
} else if (c === "(" && src[i] === "?") refuse("unsupported group syntax");
|
|
118
|
+
const alt = parseAlt(depth + 1);
|
|
119
|
+
if (src[i] !== ")") refuse("unterminated group");
|
|
120
|
+
i++;
|
|
121
|
+
return { type: "group", alt };
|
|
122
|
+
}
|
|
123
|
+
if (c === "[") return parseClass();
|
|
124
|
+
if (c === "\\") return parseEscape();
|
|
125
|
+
if (c === ".") { i++; return { type: "any", src: "." }; }
|
|
126
|
+
if (c === "^" || c === "$") { i++; return { type: "anchor", src: c }; }
|
|
127
|
+
if (c === "*" || c === "+" || c === "?" || c === "{" && /^\{\d/.test(src.slice(i))) refuse("quantifier with nothing to repeat");
|
|
128
|
+
if (c === "]" || c === "}") { if (unicode) refuse(`lone ${c}`); }
|
|
129
|
+
i++;
|
|
130
|
+
return { type: "char", src: c };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseEscape() {
|
|
134
|
+
const start = i;
|
|
135
|
+
i++;
|
|
136
|
+
const c = src[i];
|
|
137
|
+
if (c === undefined) refuse("trailing backslash");
|
|
138
|
+
if (/[1-9]/.test(c)) refuse("backreferences are not allowed");
|
|
139
|
+
if (c === "k" && src[i + 1] === "<") refuse("backreferences are not allowed");
|
|
140
|
+
if (c === "b" || c === "B") { i++; return { type: "anchor", src: src.slice(start, i) }; }
|
|
141
|
+
if (c === "x") i += 3;
|
|
142
|
+
else if (c === "u" && src[i + 1] === "{") i = src.indexOf("}", i) + 1 || refuse("bad \\u{} escape");
|
|
143
|
+
else if (c === "u") i += 5;
|
|
144
|
+
else if ((c === "p" || c === "P") && src[i + 1] === "{") i = src.indexOf("}", i) + 1 || refuse("bad \\p{} escape");
|
|
145
|
+
else if (c === "c") i += 2;
|
|
146
|
+
else i++;
|
|
147
|
+
const s = src.slice(start, i);
|
|
148
|
+
return { type: /^\\[dDwWsSpP]/.test(s) ? "class" : "char", src: s };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseClass() {
|
|
152
|
+
const start = i;
|
|
153
|
+
i++;
|
|
154
|
+
if (src[i] === "^") i++;
|
|
155
|
+
if (src[i] === "]") i++;
|
|
156
|
+
while (i < src.length && src[i] !== "]") {
|
|
157
|
+
if (src[i] === "\\") {
|
|
158
|
+
if (/[1-9]/.test(src[i + 1] || "")) refuse("backreferences are not allowed");
|
|
159
|
+
i += 2;
|
|
160
|
+
} else if (src[i] === "[" && unicode) refuse("nested classes are not allowed");
|
|
161
|
+
else i++;
|
|
162
|
+
}
|
|
163
|
+
if (src[i] !== "]") refuse("unterminated character class");
|
|
164
|
+
i++;
|
|
165
|
+
return { type: "class", src: src.slice(start, i) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseQuantifier(atom) {
|
|
169
|
+
let min = 1;
|
|
170
|
+
let max = 1;
|
|
171
|
+
const c = src[i];
|
|
172
|
+
if (c === "*") { min = 0; max = Infinity; i++; }
|
|
173
|
+
else if (c === "+") { min = 1; max = Infinity; i++; }
|
|
174
|
+
else if (c === "?") { min = 0; max = 1; i++; }
|
|
175
|
+
else if (c === "{") {
|
|
176
|
+
const m = /^\{(\d+)(,(\d*))?\}/.exec(src.slice(i));
|
|
177
|
+
if (!m) return { min, max };
|
|
178
|
+
min = Number(m[1]);
|
|
179
|
+
max = m[2] ? (m[3] === "" ? Infinity : Number(m[3])) : min;
|
|
180
|
+
if (max < min) refuse("quantifier range out of order");
|
|
181
|
+
if (min > MAX_REPEAT || (max !== Infinity && max > MAX_REPEAT)) refuse(`repeat count above ${MAX_REPEAT}`);
|
|
182
|
+
i += m[0].length;
|
|
183
|
+
} else return { min, max };
|
|
184
|
+
if (atom.type === "anchor") refuse("quantified anchor");
|
|
185
|
+
if (src[i] === "?") i++; // lazy: same cost bound as greedy
|
|
186
|
+
if (src[i] === "*" || src[i] === "+" || src[i] === "?" || (src[i] === "{" && /^\{\d+(,\d*)?\}/.test(src.slice(i)))) refuse("stacked quantifiers");
|
|
187
|
+
return { min, max };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const ast = parseAlt(0);
|
|
191
|
+
if (i < src.length) refuse("unbalanced parenthesis");
|
|
192
|
+
return ast;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Character sets ────────────────────────────────────────────────────────
|
|
196
|
+
// Approximated by membership over a sample alphabet: all of ASCII plus
|
|
197
|
+
// representatives of the Unicode classes rules tend to touch. Two atoms
|
|
198
|
+
// "overlap" if some sample character matches both.
|
|
199
|
+
|
|
200
|
+
const SAMPLE = (() => {
|
|
201
|
+
const out = [];
|
|
202
|
+
for (let c = 0; c < 128; c++) out.push(String.fromCharCode(c));
|
|
203
|
+
out.push(" ", "é", "ß", "İ", "
", "", "中", "а", "", "\u{1F600}");
|
|
204
|
+
return out;
|
|
205
|
+
})();
|
|
206
|
+
|
|
207
|
+
const setCache = new Map();
|
|
208
|
+
function charSet(src, flags) {
|
|
209
|
+
const key = `${flags}\u0000${src}`;
|
|
210
|
+
let set = setCache.get(key);
|
|
211
|
+
if (set) return set;
|
|
212
|
+
const re = new RegExp(`^(?:${src})$`, flags.replace(/[gy]/g, ""));
|
|
213
|
+
set = new Uint8Array(SAMPLE.length);
|
|
214
|
+
for (let k = 0; k < SAMPLE.length; k++) set[k] = re.test(SAMPLE[k]) ? 1 : 0;
|
|
215
|
+
if (setCache.size > 5000) setCache.clear();
|
|
216
|
+
setCache.set(key, set);
|
|
217
|
+
return set;
|
|
218
|
+
}
|
|
219
|
+
const overlaps = (a, b) => a.some((v, k) => v && b[k]);
|
|
220
|
+
const subset = (a, b) => a.every((v, k) => !v || b[k]);
|
|
221
|
+
const union = (sets) => sets.reduce((acc, s) => acc.map((v, k) => v | s[k]), new Uint8Array(SAMPLE.length));
|
|
222
|
+
|
|
223
|
+
/** Characters an item can consume (any one position), or null for anchors. */
|
|
224
|
+
function itemSet(item, flags) {
|
|
225
|
+
const a = item.atom;
|
|
226
|
+
if (a.type === "anchor") return null;
|
|
227
|
+
if (a.type !== "group") return charSet(a.src, flags);
|
|
228
|
+
const sets = [];
|
|
229
|
+
for (const br of a.alt.branches) for (const it of br) { const s = itemSet(it, flags); if (s) sets.push(s); }
|
|
230
|
+
return union(sets);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function hasQuantifier(alt) {
|
|
234
|
+
return alt.branches.some((br) => br.some((it) => it.max > 1 || (it.atom.type === "group" && hasQuantifier(it.atom.alt))));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Largest max among quantifiers inside a group (Infinity if any unbounded). */
|
|
238
|
+
function innerMax(alt) {
|
|
239
|
+
let m = 1;
|
|
240
|
+
for (const br of alt.branches) for (const it of br) {
|
|
241
|
+
m = Math.max(m, it.max);
|
|
242
|
+
if (it.atom.type === "group") m = Math.max(m, innerMax(it.atom.alt));
|
|
243
|
+
}
|
|
244
|
+
return m;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Rules ─────────────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
function checkStructure(alt) {
|
|
250
|
+
for (const br of alt.branches) for (const it of br) {
|
|
251
|
+
if (it.atom.type !== "group") continue;
|
|
252
|
+
const g = it.atom.alt;
|
|
253
|
+
if (it.max > 1 && hasQuantifier(g)) refuse(`nested quantifier in \`${it.src}\``);
|
|
254
|
+
if (it.max === Infinity && g.branches.length > 1) refuse(`alternation under an unbounded quantifier in \`${it.src}\``);
|
|
255
|
+
checkStructure(g);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const MAX_PATHS = 256;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Expand a branch into the linear sequences of leaf items it can match.
|
|
263
|
+
* A group that is not repeated (`(?:a|b)`, `(?:sudo\s+)?`) is inlined once
|
|
264
|
+
* per alternative, plus a "skipped" path when it is optional, so quantifiers
|
|
265
|
+
* on either side of it are compared along each real path. A repeated group
|
|
266
|
+
* becomes one leaf (rule 3 guarantees it holds no quantifier). Past MAX_PATHS
|
|
267
|
+
* the remaining groups collapse to one conservative leaf each.
|
|
268
|
+
*/
|
|
269
|
+
function expand(branch, flags) {
|
|
270
|
+
let seqs = [[]];
|
|
271
|
+
for (const it of branch) {
|
|
272
|
+
const a = it.atom;
|
|
273
|
+
if (a.type === "anchor") continue;
|
|
274
|
+
if (a.type === "group" && it.max <= 1) {
|
|
275
|
+
const alts = a.alt.branches.flatMap((b) => expand(b, flags));
|
|
276
|
+
if (it.min === 0) alts.push([]);
|
|
277
|
+
if (seqs.length * alts.length <= MAX_PATHS) {
|
|
278
|
+
seqs = seqs.flatMap((s) => alts.map((x) => s.concat(x)));
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const leaf = { set: itemSet(it, flags), min: it.min, max: innerMax(a.alt), src: it.src };
|
|
282
|
+
seqs = seqs.map((s) => s.concat([leaf]));
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const leaf = { set: itemSet(it, flags), min: it.min, max: it.max, src: it.src };
|
|
286
|
+
seqs = seqs.map((s) => s.concat([leaf]));
|
|
287
|
+
}
|
|
288
|
+
return seqs;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Rule 6. An unanchored search tries every start position. An unbounded
|
|
293
|
+
* quantifier followed by something mandatory rescans its run from each start
|
|
294
|
+
* that can reach it, which is quadratic when everything mandatory before it
|
|
295
|
+
* can also sit inside that run (`\s*x`, `[^a]{1,40}\S[ab]* ` on "bbbb").
|
|
296
|
+
* A greedy quantifier with nothing mandatory after it succeeds at once.
|
|
297
|
+
*/
|
|
298
|
+
function checkRescan(seq, anchored) {
|
|
299
|
+
if (anchored) return;
|
|
300
|
+
for (let q = 0; q < seq.length; q++) {
|
|
301
|
+
const it = seq[q];
|
|
302
|
+
if (it.max !== Infinity) continue;
|
|
303
|
+
if (!seq.slice(q + 1).some((x) => x.min > 0)) continue;
|
|
304
|
+
if (seq.slice(0, q).every((x) => x.min === 0 || overlaps(x.set, it.set))) {
|
|
305
|
+
refuse(q === 0 || seq.slice(0, q).every((x) => x.min === 0)
|
|
306
|
+
? `pattern starts with an unbounded quantifier (\`${it.src}\`)`
|
|
307
|
+
: `unbounded quantifier \`${it.src}\` can be rescanned from every start position`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function checkChains(alt, flags) {
|
|
313
|
+
for (const br of alt.branches) for (const seq of expand(br, flags)) {
|
|
314
|
+
checkRescan(seq, br[0]?.atom.type === "anchor" && br[0].atom.src === "^" && !flags.includes("m"));
|
|
315
|
+
for (let a = 0; a < seq.length; a++) {
|
|
316
|
+
const head = seq[a];
|
|
317
|
+
if (head.max <= 1) continue;
|
|
318
|
+
let cost = head.max;
|
|
319
|
+
const mids = []; // mandatory atoms between head and the current item
|
|
320
|
+
for (let b = a + 1; b < seq.length; b++) {
|
|
321
|
+
const it = seq[b];
|
|
322
|
+
// The costs multiply when the head can stop inside the item's run:
|
|
323
|
+
// they share characters, and every mandatory atom in between can sit
|
|
324
|
+
// in that run too (`.*b.*` on "bbbb"). `[^\n]*\|\s*` stays linear,
|
|
325
|
+
// because `|` cannot occur inside a run of spaces.
|
|
326
|
+
if (it.max > 1 && overlaps(head.set, it.set) && mids.every((m) => overlaps(m, it.set))) {
|
|
327
|
+
cost = cost * it.max;
|
|
328
|
+
if (cost === Infinity) refuse(`overlapping quantifiers \`${head.src}\` … \`${it.src}\` (polynomial backtracking)`);
|
|
329
|
+
if (cost > CHAIN_BUDGET) refuse(`overlapping quantifiers \`${head.src}\` … \`${it.src}\` exceed the backtracking budget`);
|
|
330
|
+
}
|
|
331
|
+
// A mandatory atom the head cannot match ends the head's reach.
|
|
332
|
+
if (it.min > 0) {
|
|
333
|
+
if (!subset(it.set, head.set)) break;
|
|
334
|
+
mids.push(it.set);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Check a regex from data. Returns { ok: true } or { ok: false, reason }.
|
|
343
|
+
* Never throws.
|
|
344
|
+
* @param {string} source pattern source (no slashes)
|
|
345
|
+
* @param {string} [flags] only i, m, s, u are accepted
|
|
346
|
+
*/
|
|
347
|
+
export function checkRegex(source, flags = "") {
|
|
348
|
+
try {
|
|
349
|
+
if (typeof source !== "string" || source.length === 0) refuse("pattern must be a non-empty string");
|
|
350
|
+
if (typeof flags !== "string" || !ALLOWED_FLAGS.test(flags)) refuse(`flags must be a subset of "imsu" (got "${flags}")`);
|
|
351
|
+
if (source.length > MAX_LENGTH) refuse(`pattern longer than ${MAX_LENGTH} characters`);
|
|
352
|
+
try { new RegExp(source, flags); } catch (e) { refuse(`invalid regex: ${e.message}`); }
|
|
353
|
+
const ast = parse(source, flags.includes("u"));
|
|
354
|
+
checkStructure(ast);
|
|
355
|
+
checkChains(ast, flags);
|
|
356
|
+
return { ok: true };
|
|
357
|
+
} catch (e) {
|
|
358
|
+
if (e instanceof GuardError) return { ok: false, reason: e.message };
|
|
359
|
+
return { ok: false, reason: `guard error: ${e.message}` };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Compile a regex from data, or throw with the guard's reason. */
|
|
364
|
+
export function compileSafe(source, flags = "") {
|
|
365
|
+
const r = checkRegex(source, flags);
|
|
366
|
+
if (!r.ok) throw new Error(`unsafe regex /${String(source).slice(0, 80)}/: ${r.reason}`);
|
|
367
|
+
return new RegExp(source, flags);
|
|
368
|
+
}
|
package/lib/templates.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { joinUrl } from "../src/utils/url.js";
|
|
8
8
|
import { request } from "../src/utils/http.js";
|
|
9
|
+
import { hasNestedQuantifier } from "./rules/regex-guard.js";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Execute a single template check against a target.
|
|
@@ -88,10 +89,11 @@ export async function executeTemplate(baseUrl, template) {
|
|
|
88
89
|
// the input and the pattern. This is a mitigation, not an immunity proof: real
|
|
89
90
|
// guarantees need a non-backtracking engine or a killable worker.
|
|
90
91
|
const REGEX_INPUT_CAP = 256 * 1024; // 256 KB is far past any real matcher's needs
|
|
91
|
-
|
|
92
|
+
// hasNestedQuantifier lives in lib/rules/regex-guard.js. Templates from rule
|
|
93
|
+
// packs pass the stricter checkRegex there when the pack loads.
|
|
92
94
|
|
|
93
95
|
function safeRegexTest(pattern, target, flags = "") {
|
|
94
|
-
if (
|
|
96
|
+
if (hasNestedQuantifier(pattern)) return false; // refuse the shape that blows up
|
|
95
97
|
try {
|
|
96
98
|
const input = target.length > REGEX_INPUT_CAP ? target.slice(0, REGEX_INPUT_CAP) : target;
|
|
97
99
|
return new RegExp(pattern, flags).test(input);
|
|
@@ -100,7 +102,7 @@ function safeRegexTest(pattern, target, flags = "") {
|
|
|
100
102
|
}
|
|
101
103
|
}
|
|
102
104
|
|
|
103
|
-
function runMatcher(matcher, ctx) {
|
|
105
|
+
export function runMatcher(matcher, ctx) {
|
|
104
106
|
const part = matcher.part || "body";
|
|
105
107
|
const target = part === "header" ? ctx.headers : ctx.body;
|
|
106
108
|
|
|
@@ -142,16 +144,21 @@ function runMatcher(matcher, ctx) {
|
|
|
142
144
|
/**
|
|
143
145
|
* Run a single extractor against response data.
|
|
144
146
|
*/
|
|
145
|
-
function runExtractor(extractor, ctx) {
|
|
147
|
+
export function runExtractor(extractor, ctx) {
|
|
146
148
|
const part = extractor.part || "body";
|
|
147
149
|
const target = part === "header" ? ctx.headers : ctx.body;
|
|
148
150
|
|
|
149
151
|
switch (extractor.type) {
|
|
150
152
|
case "regex": {
|
|
151
153
|
for (const pattern of extractor.regex || []) {
|
|
152
|
-
if (
|
|
154
|
+
if (hasNestedQuantifier(pattern)) continue;
|
|
153
155
|
const capped = target.length > REGEX_INPUT_CAP ? target.slice(0, REGEX_INPUT_CAP) : target;
|
|
154
|
-
|
|
156
|
+
let match;
|
|
157
|
+
try {
|
|
158
|
+
match = capped.match(new RegExp(pattern));
|
|
159
|
+
} catch {
|
|
160
|
+
continue; // invalid pattern in a template must not abort the scan
|
|
161
|
+
}
|
|
155
162
|
if (match) return match[extractor.group || 0];
|
|
156
163
|
}
|
|
157
164
|
return null;
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
}
|
package/lib/whitebox/secrets.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Scan repository source for hardcoded secrets.
|
|
3
3
|
*/
|
|
4
|
-
import { readdirSync, readFileSync,
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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.
|
|
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
|
-
"
|
|
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.
|
|
84
|
+
"ajv": "^8.20.0",
|
|
85
|
+
"fast-check": "^4.10.2",
|
|
82
86
|
"vitest": "^5.0.5"
|
|
83
87
|
},
|
|
84
88
|
"dependencies": {
|
package/skills/penthera/SKILL.md
CHANGED
|
@@ -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.
|
|
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**
|