penthera 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +315 -0
- package/README.md +139 -76
- package/SECURITY.md +8 -6
- package/docs/examples/pr-dependency-review.yml +1 -1
- package/lib/adaptive-probes.js +3 -3
- package/lib/advisories/evidence.js +248 -0
- package/lib/advisories/index.js +228 -0
- package/lib/advisories/products.js +83 -0
- package/lib/advisories/versions.js +65 -0
- package/lib/agents/hidden-content.js +122 -11
- package/lib/agents/index.js +26 -6
- package/lib/agents/lock.js +33 -5
- package/lib/agents/rules.js +33 -4
- package/lib/compliance/index.js +4 -4
- package/lib/crawler.js +12 -15
- package/lib/deps/capabilities.js +21 -5
- package/lib/deps/findings.js +309 -0
- package/lib/deps/index.js +13 -272
- package/lib/deps/iocs.js +74 -0
- package/lib/deps/lockfile.js +3 -8
- package/lib/deps/online.js +8 -3
- package/lib/deps/popular-packages.json.gz +0 -0
- package/lib/deps/popular-pypi.json.gz +0 -0
- package/lib/deps/python/capabilities.js +16 -3
- package/lib/deps/python/index.js +5 -1
- package/lib/deps/python/lockfiles.js +11 -5
- package/lib/deps/python/source.js +75 -26
- package/lib/deps/python/toml.js +15 -3
- package/lib/deps/source.js +28 -9
- package/lib/deps/typosquat.js +30 -5
- package/lib/fix/apply.js +253 -34
- package/lib/fix/index.js +7 -4
- package/lib/fix/model.js +25 -5
- package/lib/fix/util.js +6 -2
- package/lib/fuzzer.js +3 -3
- package/lib/git.js +65 -0
- package/lib/hook/index.js +157 -73
- package/lib/hook/policy.js +10 -4
- package/lib/install/index.js +4 -7
- package/lib/machine.js +0 -13
- package/lib/memory/store.js +11 -4
- package/lib/nuclei-loader.js +0 -25
- package/lib/owasp-wstg.js +4 -0
- package/lib/plugins.js +1 -1
- package/lib/provenance.js +1 -0
- package/lib/recon.js +12 -31
- package/lib/references.json +224 -0
- package/lib/retirejs.js +4 -4
- package/lib/rules/advisories/advisories.json +186 -0
- package/lib/rules/advisories/eol.json +413 -0
- package/lib/rules/advisories/pack.json +20 -0
- package/lib/rules/advisories/pack.json.sig +6 -0
- package/lib/rules/allowed_signers +13 -0
- package/lib/rules/core/agent-exec.json +29 -0
- package/lib/rules/core/agent-instructions.json +17 -0
- package/lib/rules/core/pack.json +30 -0
- package/lib/rules/core/pack.json.sig +6 -0
- package/lib/rules/core/secrets.json +161 -0
- package/lib/rules/core/templates.json +52 -0
- package/lib/rules/index.js +107 -0
- package/lib/rules/ioc-2025-2026/files.json +43 -0
- package/lib/rules/ioc-2025-2026/hosts.json +35 -0
- package/lib/rules/ioc-2025-2026/pack.json +25 -0
- package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
- package/lib/rules/ioc-2025-2026/packages.json +1048 -0
- package/lib/rules/pack.js +452 -0
- package/lib/rules/regex-guard.js +81 -9
- package/lib/rules/sshsig.js +205 -0
- package/lib/templates.js +24 -20
- package/lib/tmp.js +21 -0
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +117 -41
- package/package.json +12 -4
- package/scripts/rules/build-pack.mjs +80 -0
- package/scripts/rules/check-bundled.mjs +30 -0
- package/scripts/rules/sign-pack.sh +32 -0
- package/skills/penthera/SKILL.md +16 -15
- package/src/agent/git.js +147 -14
- package/src/agent/loop.js +165 -46
- package/src/cli/agent.js +11 -5
- package/src/cli/deps.js +0 -2
- package/src/cli/detect.js +14 -7
- package/src/cli/fix.js +26 -8
- package/src/cli/hook.js +2 -1
- package/src/cli/onboarding.js +14 -6
- package/src/cli/report-outputs.js +1 -0
- package/src/cli/rules.js +144 -0
- package/src/cli/ui.js +43 -10
- package/src/cli.js +24 -1
- package/src/report/evidence.js +105 -21
- package/src/report/footer.js +1 -1
- package/src/report/md-escape.js +8 -0
- package/src/scan-repo.js +6 -0
- package/src/scan-url.js +11 -3
- package/src/ui/api/data.js +39 -6
- package/src/ui/api/routes.js +44 -11
- package/src/ui/api/runs.js +32 -7
- package/src/ui/security.js +20 -23
- package/src/ui/server.js +52 -27
- package/src/ui/static/app.css +583 -213
- package/src/ui/static/favicon.svg +1 -1
- package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
- package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
- package/src/ui/static/index.html +5 -4
- package/src/ui/static/js/api.js +114 -6
- package/src/ui/static/js/app.js +81 -42
- package/src/ui/static/js/center.js +246 -133
- package/src/ui/static/js/charts.js +99 -18
- package/src/ui/static/js/copy.js +68 -9
- package/src/ui/static/js/dialog.js +152 -0
- package/src/ui/static/js/left.js +23 -60
- package/src/ui/static/js/menubar.js +170 -0
- package/src/ui/static/js/onboarding.js +104 -0
- package/src/ui/static/js/panels.js +186 -0
- package/src/ui/static/js/right.js +69 -188
- package/src/ui/static/js/state.js +36 -5
- package/src/ui/static/logo.svg +10 -0
- package/src/ui/static/mark.svg +3 -0
- package/src/utils/http.js +162 -13
- package/docs/METHODOLOGY.md +0 -375
- package/docs/commands/README.md +0 -24
- package/docs/commands/action.md +0 -21
- package/docs/commands/agents.md +0 -38
- package/docs/commands/deps.md +0 -115
- package/docs/commands/fix.md +0 -53
- package/docs/commands/hook.md +0 -69
- package/docs/commands/init.md +0 -114
- package/docs/commands/install.md +0 -53
- package/docs/commands/scan.md +0 -100
- package/docs/commands/skill.md +0 -58
- package/docs/compare/README.md +0 -25
- package/docs/compare/github.md +0 -60
- package/docs/compare/semgrep.md +0 -54
- package/docs/compare/snyk.md +0 -62
- package/docs/compare/socket.md +0 -78
- package/docs/compare/zap.md +0 -59
- package/docs/compliance-cra.md +0 -141
- package/docs/compliance-nis2.md +0 -168
- package/docs/dashboard.md +0 -187
- package/docs/evidence.md +0 -162
- package/docs/funding/due-diligence-checklist.md +0 -79
- package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
- package/docs/funding/partner-one-pager.md +0 -84
- package/docs/funding/sponsor-tiers.md +0 -81
- package/docs/index.md +0 -81
- package/docs/internal-audit.md +0 -83
- package/docs/local-loop.md +0 -276
- package/docs/marketing.md +0 -151
- package/docs/owasp-wstg-coverage.md +0 -79
- package/docs/plan.md +0 -102
- package/docs/privacy.md +0 -38
- package/docs/references.bib +0 -559
- package/docs/references.md +0 -119
- package/docs/release-plan.md +0 -228
- package/docs/research/socket-positioning-notes.md +0 -183
- package/docs/use-cases/README.md +0 -14
- package/docs/use-cases/agencies.md +0 -71
- package/docs/use-cases/ai-coding-teams.md +0 -81
- package/docs/use-cases/eu-sme-compliance.md +0 -68
- package/docs/use-cases/open-source-maintainers.md +0 -75
- package/docs/use-cases/solo-founder.md +0 -65
- package/lib/deps/popular-packages.json +0 -1
- package/lib/deps/popular-pypi.json +0 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify OpenSSH signatures (`ssh-keygen -Y sign`) in pure Node, so checking
|
|
3
|
+
* a rule pack needs no ssh-keygen binary and spawns nothing.
|
|
4
|
+
*
|
|
5
|
+
* Format: PROTOCOL.sshsig in the OpenSSH source tree. A signature is an
|
|
6
|
+
* armored blob:
|
|
7
|
+
* "SSHSIG" | uint32 version | string publickey | string namespace |
|
|
8
|
+
* string reserved | string hash_algorithm | string signature
|
|
9
|
+
* and what the key signs is:
|
|
10
|
+
* "SSHSIG" | string namespace | string reserved | string hash_algorithm |
|
|
11
|
+
* string H(message)
|
|
12
|
+
*
|
|
13
|
+
* Supported keys: ssh-ed25519 and RSA (rsa-sha2-256/512 signatures). Others
|
|
14
|
+
* (ECDSA, security keys, certificates) are refused with a clear reason rather
|
|
15
|
+
* than half-supported.
|
|
16
|
+
*
|
|
17
|
+
* allowed_signers: the format ssh-keygen reads (ALLOWED SIGNERS in
|
|
18
|
+
* ssh-keygen(1)): `principals [options] keytype base64-key [comment]`, with the
|
|
19
|
+
* options namespaces="...", valid-after=, valid-before= honoured and
|
|
20
|
+
* cert-authority refused.
|
|
21
|
+
*/
|
|
22
|
+
import { createHash, createPublicKey, verify } from "node:crypto";
|
|
23
|
+
|
|
24
|
+
const MAGIC = Buffer.from("SSHSIG");
|
|
25
|
+
|
|
26
|
+
class Reader {
|
|
27
|
+
constructor(buf) { this.buf = buf; this.off = 0; }
|
|
28
|
+
u32() {
|
|
29
|
+
if (this.off + 4 > this.buf.length) throw new Error("truncated");
|
|
30
|
+
const v = this.buf.readUInt32BE(this.off);
|
|
31
|
+
this.off += 4;
|
|
32
|
+
return v;
|
|
33
|
+
}
|
|
34
|
+
bytes() {
|
|
35
|
+
const n = this.u32();
|
|
36
|
+
if (n > this.buf.length - this.off) throw new Error("truncated");
|
|
37
|
+
const v = this.buf.subarray(this.off, this.off + n);
|
|
38
|
+
this.off += n;
|
|
39
|
+
return v;
|
|
40
|
+
}
|
|
41
|
+
str() { return this.bytes().toString("latin1"); }
|
|
42
|
+
done() { return this.off === this.buf.length; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sshString = (b) => {
|
|
46
|
+
const buf = Buffer.isBuffer(b) ? b : Buffer.from(b);
|
|
47
|
+
const len = Buffer.alloc(4);
|
|
48
|
+
len.writeUInt32BE(buf.length);
|
|
49
|
+
return Buffer.concat([len, buf]);
|
|
50
|
+
};
|
|
51
|
+
const b64url = (b) => Buffer.from(b).toString("base64url");
|
|
52
|
+
const stripMpint = (b) => (b[0] === 0 ? b.subarray(1) : b);
|
|
53
|
+
|
|
54
|
+
/** A KeyObject from an SSH public key blob, or throw. */
|
|
55
|
+
export function keyFromBlob(blob) {
|
|
56
|
+
const r = new Reader(blob);
|
|
57
|
+
const type = r.str();
|
|
58
|
+
if (type === "ssh-ed25519") {
|
|
59
|
+
const pk = r.bytes();
|
|
60
|
+
if (pk.length !== 32 || !r.done()) throw new Error("malformed ed25519 key");
|
|
61
|
+
return { type, key: createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: b64url(pk) }, format: "jwk" }) };
|
|
62
|
+
}
|
|
63
|
+
if (type === "ssh-rsa") {
|
|
64
|
+
const e = stripMpint(r.bytes());
|
|
65
|
+
const n = stripMpint(r.bytes());
|
|
66
|
+
if (!r.done()) throw new Error("malformed RSA key");
|
|
67
|
+
if (n.length * 8 < 2048) throw new Error("RSA keys under 2048 bits are not accepted");
|
|
68
|
+
return { type, key: createPublicKey({ key: { kty: "RSA", n: b64url(n), e: b64url(e) }, format: "jwk" }) };
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`unsupported key type ${type} (use ssh-ed25519 or ssh-rsa)`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Decode an armored SSH signature into its fields, or throw. */
|
|
74
|
+
export function parseSignature(armored) {
|
|
75
|
+
const text = String(armored);
|
|
76
|
+
const m = /-----BEGIN SSH SIGNATURE-----([\s\S]*?)-----END SSH SIGNATURE-----/.exec(text);
|
|
77
|
+
if (!m) throw new Error("not an SSH signature (missing armor)");
|
|
78
|
+
const raw = Buffer.from(m[1].replace(/\s+/g, ""), "base64");
|
|
79
|
+
if (!raw.subarray(0, 6).equals(MAGIC)) throw new Error("bad signature magic");
|
|
80
|
+
const r = new Reader(raw.subarray(6));
|
|
81
|
+
const version = r.u32();
|
|
82
|
+
if (version !== 1) throw new Error(`unsupported signature version ${version}`);
|
|
83
|
+
const publicKey = Buffer.from(r.bytes());
|
|
84
|
+
const namespace = r.str();
|
|
85
|
+
const reserved = Buffer.from(r.bytes());
|
|
86
|
+
const hashAlg = r.str();
|
|
87
|
+
const sig = new Reader(r.bytes());
|
|
88
|
+
if (!r.done()) throw new Error("trailing bytes after signature");
|
|
89
|
+
const sigType = sig.str();
|
|
90
|
+
const sigBytes = Buffer.from(sig.bytes());
|
|
91
|
+
return { publicKey, namespace, reserved, hashAlg, sigType, sigBytes };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check that `armored` is a valid signature over `message` in `namespace`.
|
|
96
|
+
* Does not decide whether the key is trusted; see verifyWithSigners.
|
|
97
|
+
* @returns {{ ok: true, publicKey: Buffer } | { ok: false, reason: string }}
|
|
98
|
+
*/
|
|
99
|
+
export function verifySignature(message, armored, namespace) {
|
|
100
|
+
let s;
|
|
101
|
+
try { s = parseSignature(armored); } catch (e) { return { ok: false, reason: e.message }; }
|
|
102
|
+
if (s.namespace !== namespace) return { ok: false, reason: `signature namespace is "${s.namespace}", expected "${namespace}"` };
|
|
103
|
+
if (s.hashAlg !== "sha512" && s.hashAlg !== "sha256") return { ok: false, reason: `unsupported hash ${s.hashAlg}` };
|
|
104
|
+
let k;
|
|
105
|
+
try { k = keyFromBlob(s.publicKey); } catch (e) { return { ok: false, reason: e.message }; }
|
|
106
|
+
const digest = createHash(s.hashAlg).update(message).digest();
|
|
107
|
+
const signed = Buffer.concat([MAGIC, sshString(namespace), sshString(s.reserved), sshString(s.hashAlg), sshString(digest)]);
|
|
108
|
+
let good = false;
|
|
109
|
+
try {
|
|
110
|
+
if (k.type === "ssh-ed25519") {
|
|
111
|
+
good = s.sigType === "ssh-ed25519" && verify(null, signed, k.key, s.sigBytes);
|
|
112
|
+
} else if (s.sigType === "rsa-sha2-512" || s.sigType === "rsa-sha2-256") {
|
|
113
|
+
good = verify(s.sigType === "rsa-sha2-512" ? "sha512" : "sha256", signed, k.key, s.sigBytes);
|
|
114
|
+
} else {
|
|
115
|
+
return { ok: false, reason: `unsupported signature type ${s.sigType}` };
|
|
116
|
+
}
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return { ok: false, reason: `signature check failed: ${e.message}` };
|
|
119
|
+
}
|
|
120
|
+
return good ? { ok: true, publicKey: s.publicKey } : { ok: false, reason: "signature does not match (file changed after signing, or wrong key)" };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── allowed_signers ───────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
function tokenize(line) {
|
|
126
|
+
const out = [];
|
|
127
|
+
let cur = "";
|
|
128
|
+
let quoted = false;
|
|
129
|
+
for (const ch of line) {
|
|
130
|
+
if (ch === '"') { quoted = !quoted; cur += ch; continue; }
|
|
131
|
+
if (!quoted && (ch === " " || ch === "\t")) { if (cur) out.push(cur); cur = ""; continue; }
|
|
132
|
+
cur += ch;
|
|
133
|
+
}
|
|
134
|
+
if (cur) out.push(cur);
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** "20260101", "202601011200", "20260101120000Z" -> epoch ms, or NaN. */
|
|
139
|
+
function parseTime(s) {
|
|
140
|
+
const m = /^(\d{4})(\d{2})(\d{2})(?:(\d{2})(\d{2})(\d{2})?)?(Z?)$/.exec(s);
|
|
141
|
+
if (!m) return NaN;
|
|
142
|
+
const [, y, mo, d, h = "00", mi = "00", se = "00", z] = m;
|
|
143
|
+
const iso = `${y}-${mo}-${d}T${h}:${mi}:${se}${z ? "Z" : ""}`;
|
|
144
|
+
return Date.parse(iso);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const KEY_TYPES = new Set(["ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com"]);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Parse an allowed_signers file.
|
|
151
|
+
* @returns {{ principals: string[], namespaces: string[]|null, validAfter?: number, validBefore?: number, keyType: string, key: Buffer, certAuthority: boolean }[]}
|
|
152
|
+
*/
|
|
153
|
+
export function parseAllowedSigners(text) {
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
156
|
+
const line = raw.trim();
|
|
157
|
+
if (!line || line.startsWith("#")) continue;
|
|
158
|
+
const t = tokenize(line);
|
|
159
|
+
if (t.length < 3) continue;
|
|
160
|
+
const entry = { principals: t[0].split(","), namespaces: null, certAuthority: false };
|
|
161
|
+
let k = 1;
|
|
162
|
+
if (!KEY_TYPES.has(t[k])) {
|
|
163
|
+
for (const opt of t[k].split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)) {
|
|
164
|
+
const [name, ...rest] = opt.split("=");
|
|
165
|
+
const value = rest.join("=").replace(/^"|"$/g, "");
|
|
166
|
+
const lname = name.toLowerCase();
|
|
167
|
+
if (lname === "cert-authority") entry.certAuthority = true;
|
|
168
|
+
else if (lname === "namespaces") entry.namespaces = value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
169
|
+
else if (lname === "valid-after") entry.validAfter = parseTime(value);
|
|
170
|
+
else if (lname === "valid-before") entry.validBefore = parseTime(value);
|
|
171
|
+
}
|
|
172
|
+
k++;
|
|
173
|
+
}
|
|
174
|
+
if (!KEY_TYPES.has(t[k]) || !t[k + 1]) continue;
|
|
175
|
+
entry.keyType = t[k];
|
|
176
|
+
entry.key = Buffer.from(t[k + 1], "base64");
|
|
177
|
+
out.push(entry);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Verify a signature and check its key against allowed_signers entries.
|
|
184
|
+
* @param {Buffer|string} message
|
|
185
|
+
* @param {string} armored
|
|
186
|
+
* @param {string} namespace
|
|
187
|
+
* @param {ReturnType<typeof parseAllowedSigners>} signers
|
|
188
|
+
* @param {{ now?: number }} [opts]
|
|
189
|
+
* @returns {{ ok: true, principal: string, keyType: string } | { ok: false, reason: string }}
|
|
190
|
+
*/
|
|
191
|
+
export function verifyWithSigners(message, armored, namespace, signers, opts = {}) {
|
|
192
|
+
const v = verifySignature(message, armored, namespace);
|
|
193
|
+
if (!v.ok) return v;
|
|
194
|
+
const now = opts.now ?? Date.now();
|
|
195
|
+
const matches = signers.filter((e) => e.key.equals(v.publicKey));
|
|
196
|
+
if (matches.length === 0) return { ok: false, reason: "valid signature, but the key is not in allowed_signers" };
|
|
197
|
+
for (const e of matches) {
|
|
198
|
+
if (e.certAuthority) continue;
|
|
199
|
+
if (e.namespaces && !e.namespaces.includes(namespace)) continue;
|
|
200
|
+
if (e.validAfter !== undefined && !(now >= e.validAfter)) continue;
|
|
201
|
+
if (e.validBefore !== undefined && !(now < e.validBefore)) continue;
|
|
202
|
+
return { ok: true, principal: e.principals[0], keyType: e.keyType };
|
|
203
|
+
}
|
|
204
|
+
return { ok: false, reason: `key is in allowed_signers but not valid for namespace "${namespace}" at this time` };
|
|
205
|
+
}
|
package/lib/templates.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* Compatible with a subset of Nuclei's template format.
|
|
6
6
|
*/
|
|
7
7
|
import { joinUrl } from "../src/utils/url.js";
|
|
8
|
-
import { request } from "../src/utils/http.js";
|
|
9
|
-
import {
|
|
8
|
+
import { request, readBody } from "../src/utils/http.js";
|
|
9
|
+
import { checkRegex } from "./rules/regex-guard.js";
|
|
10
|
+
import { rulesOf } from "./rules/index.js";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Execute a single template check against a target.
|
|
@@ -37,7 +38,7 @@ export async function executeTemplate(baseUrl, template) {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
const res = await request(url, fetchOpts);
|
|
40
|
-
const body = await res
|
|
41
|
+
const body = await readBody(res).catch(() => "");
|
|
41
42
|
const headerString = [...res.headers.entries()]
|
|
42
43
|
.map(([k, v]) => `${k}: ${v}`).join("\n");
|
|
43
44
|
|
|
@@ -89,11 +90,22 @@ export async function executeTemplate(baseUrl, template) {
|
|
|
89
90
|
// the input and the pattern. This is a mitigation, not an immunity proof: real
|
|
90
91
|
// guarantees need a non-backtracking engine or a killable worker.
|
|
91
92
|
const REGEX_INPUT_CAP = 256 * 1024; // 256 KB is far past any real matcher's needs
|
|
92
|
-
//
|
|
93
|
-
//
|
|
93
|
+
// Template regexes pass the same structural ReDoS guard as rule packs
|
|
94
|
+
// (checkRegex in lib/rules/regex-guard.js); a refused pattern never runs.
|
|
95
|
+
const guardCache = new Map();
|
|
96
|
+
function regexAllowed(pattern, flags = "") {
|
|
97
|
+
const key = `${flags}\u0000${pattern}`;
|
|
98
|
+
let ok = guardCache.get(key);
|
|
99
|
+
if (ok === undefined) {
|
|
100
|
+
ok = checkRegex(pattern, flags).ok;
|
|
101
|
+
if (guardCache.size > 1000) guardCache.clear();
|
|
102
|
+
guardCache.set(key, ok);
|
|
103
|
+
}
|
|
104
|
+
return ok;
|
|
105
|
+
}
|
|
94
106
|
|
|
95
107
|
function safeRegexTest(pattern, target, flags = "") {
|
|
96
|
-
if (
|
|
108
|
+
if (!regexAllowed(pattern, flags)) return false; // refuse shapes that backtrack
|
|
97
109
|
try {
|
|
98
110
|
const input = target.length > REGEX_INPUT_CAP ? target.slice(0, REGEX_INPUT_CAP) : target;
|
|
99
111
|
return new RegExp(pattern, flags).test(input);
|
|
@@ -151,7 +163,7 @@ export function runExtractor(extractor, ctx) {
|
|
|
151
163
|
switch (extractor.type) {
|
|
152
164
|
case "regex": {
|
|
153
165
|
for (const pattern of extractor.regex || []) {
|
|
154
|
-
if (
|
|
166
|
+
if (!regexAllowed(pattern)) continue;
|
|
155
167
|
const capped = target.length > REGEX_INPUT_CAP ? target.slice(0, REGEX_INPUT_CAP) : target;
|
|
156
168
|
let match;
|
|
157
169
|
try {
|
|
@@ -187,18 +199,8 @@ export function runExtractor(extractor, ctx) {
|
|
|
187
199
|
*/
|
|
188
200
|
export const BUILT_IN_TEMPLATES = [
|
|
189
201
|
// ── Exposed sensitive files ──────────────────────────────────────────
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
info: { name: ".env file exposed", severity: "critical", tags: ["exposure", "config"] },
|
|
193
|
-
http: [{
|
|
194
|
-
method: "GET", path: "/.env",
|
|
195
|
-
matchers: [
|
|
196
|
-
{ type: "status", status: [200] },
|
|
197
|
-
{ type: "word", words: ["DB_", "API_KEY", "SECRET", "PASSWORD", "FIREBASE_", "STRIPE_"], condition: "or" },
|
|
198
|
-
],
|
|
199
|
-
"matchers-condition": "and",
|
|
200
|
-
}],
|
|
201
|
-
},
|
|
202
|
+
// .env: lib/rules/core/templates.json.
|
|
203
|
+
...rulesOf("nuclei-template", { pack: "core" }).map((r) => r.template),
|
|
202
204
|
{
|
|
203
205
|
id: "env-local-exposure",
|
|
204
206
|
info: { name: ".env.local file exposed", severity: "critical", tags: ["exposure", "config"] },
|
|
@@ -387,7 +389,9 @@ export const BUILT_IN_TEMPLATES = [
|
|
|
387
389
|
*/
|
|
388
390
|
export async function runBuiltInTemplates(baseUrl) {
|
|
389
391
|
const allFindings = [];
|
|
390
|
-
|
|
392
|
+
// Built-ins, then templates from other packs (bundled or --rules).
|
|
393
|
+
const packTemplates = rulesOf("nuclei-template", { exceptPack: "core" }).map((r) => r.template);
|
|
394
|
+
for (const template of [...BUILT_IN_TEMPLATES, ...packTemplates]) {
|
|
391
395
|
const findings = await executeTemplate(baseUrl, template);
|
|
392
396
|
allFindings.push(...findings);
|
|
393
397
|
}
|
package/lib/tmp.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Temporary folders that are always removed. */
|
|
2
|
+
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Run `fn` with a fresh private folder (mkdtemp, real path) and remove it
|
|
8
|
+
* afterwards, whether `fn` returns or throws.
|
|
9
|
+
* @template T
|
|
10
|
+
* @param {string} prefix e.g. "penthera-hook-"
|
|
11
|
+
* @param {(dir: string) => Promise<T> | T} fn
|
|
12
|
+
* @returns {Promise<T>}
|
|
13
|
+
*/
|
|
14
|
+
export async function withTempDir(prefix, fn) {
|
|
15
|
+
const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
|
16
|
+
try {
|
|
17
|
+
return await fn(dir);
|
|
18
|
+
} finally {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Discover API routes in Express, Hono, and Fastify projects.
|
|
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 = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage", "vendor"]);
|
package/lib/whitebox/secrets.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Scan repository source for hardcoded secrets.
|
|
3
3
|
*/
|
|
4
|
-
import { readdirSync,
|
|
4
|
+
import { readdirSync, lstatSync, existsSync, openSync, fstatSync, readSync, closeSync, constants } from "node:fs";
|
|
5
5
|
import { join, relative } from "node:path";
|
|
6
|
+
import { rulesOf, rulesGeneration, PACK_INPUT_CAP } from "../rules/index.js";
|
|
6
7
|
|
|
7
8
|
const SKIP_DIRS = new Set([
|
|
8
9
|
"node_modules", ".git", "dist", "build", ".next", "coverage",
|
|
@@ -25,21 +26,9 @@ const SECRET_PATTERNS = [
|
|
|
25
26
|
{ name: "GitHub token", pattern: /ghp_[a-zA-Z0-9]{36,}/, severity: "critical" },
|
|
26
27
|
// AI provider keys. Vibecoded apps leak these more than any other credential,
|
|
27
28
|
// often via a NEXT_PUBLIC_/VITE_ variable or a committed notebook. Specific
|
|
28
|
-
// prefixes come first so a key is reported under its real provider.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
{ name: "OpenAI API key", pattern: /sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}/, severity: "critical" },
|
|
32
|
-
{ name: "OpenAI API key (legacy format)", pattern: /sk-[a-zA-Z0-9]{20,}/, severity: "critical" },
|
|
33
|
-
// AIza keys are also Firebase web keys, which are public by design, so this
|
|
34
|
-
// is high rather than critical: it matters if the key has Gemini/Vertex access.
|
|
35
|
-
{ name: "Google API key (Gemini / Firebase / Maps)", pattern: /AIza[0-9A-Za-z_-]{35}/, severity: "high" },
|
|
36
|
-
{ name: "Groq API key", pattern: /gsk_[A-Za-z0-9]{48,}/, severity: "critical" },
|
|
37
|
-
{ name: "Hugging Face token", pattern: /hf_[A-Za-z0-9]{34,}/, severity: "critical" },
|
|
38
|
-
{ name: "Replicate API token", pattern: /r8_[A-Za-z0-9]{37,}/, severity: "critical" },
|
|
39
|
-
{ name: "xAI API key", pattern: /xai-[A-Za-z0-9]{70,}/, severity: "critical" },
|
|
40
|
-
{ name: "Perplexity API key", pattern: /pplx-[A-Za-z0-9]{40,}/, severity: "critical" },
|
|
41
|
-
{ name: "LangSmith API key", pattern: /lsv2_(?:pt|sk)_[a-f0-9]{32}_[a-f0-9]{10}/, severity: "high" },
|
|
42
|
-
{ name: "Pinecone API key", pattern: /pcsk_[A-Za-z0-9_]{50,}/, severity: "high" },
|
|
29
|
+
// prefixes come first so a key is reported under its real provider. They
|
|
30
|
+
// live in the bundled core rule pack (lib/rules/core/secrets.json).
|
|
31
|
+
...coreSecrets(),
|
|
43
32
|
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{20,}/, severity: "critical" },
|
|
44
33
|
{
|
|
45
34
|
name: "Generic API key assignment",
|
|
@@ -63,6 +52,31 @@ const SECRET_PATTERNS = [
|
|
|
63
52
|
},
|
|
64
53
|
];
|
|
65
54
|
|
|
55
|
+
function fromPack(r) {
|
|
56
|
+
return { name: r.name, pattern: r.pattern, severity: r.severity, ...(r.skipPlaceholders ? { skipIf: TEST_VALUE } : {}), rule: r };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function coreSecrets() {
|
|
60
|
+
return rulesOf("secret", { pack: "core" }).map(fromPack);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Built-ins first, then rules from other packs (bundled or --rules), so a
|
|
64
|
+
// pack can add detections but never shadow or reorder the built-in ones.
|
|
65
|
+
let _patterns = null;
|
|
66
|
+
let _generation = -1;
|
|
67
|
+
function secretPatterns() {
|
|
68
|
+
if (_generation !== rulesGeneration()) {
|
|
69
|
+
_patterns = [...SECRET_PATTERNS, ...rulesOf("secret", { exceptPack: "core" }).map(fromPack)];
|
|
70
|
+
_generation = rulesGeneration();
|
|
71
|
+
}
|
|
72
|
+
return _patterns;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The secret rules in effect, in match order (built-ins, then packs). */
|
|
76
|
+
export function activeSecretPatterns() {
|
|
77
|
+
return secretPatterns().map(({ name, pattern, severity, skipIf, rule }) => ({ name, pattern, severity, skipIf: !!skipIf, pack: rule?.pack || null }));
|
|
78
|
+
}
|
|
79
|
+
|
|
66
80
|
const SCAN_EXTENSIONS = new Set([
|
|
67
81
|
".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".py", ".rb", ".go", ".java",
|
|
68
82
|
".env", ".yaml", ".yml", ".json", ".toml", ".cfg", ".ini", ".sh",
|
|
@@ -88,6 +102,11 @@ export function isNestedRepo(dir, root) {
|
|
|
88
102
|
return dir !== root && existsSync(join(dir, ".git"));
|
|
89
103
|
}
|
|
90
104
|
|
|
105
|
+
// Per-file cap for the repo scan. Larger files are reported, not read.
|
|
106
|
+
export const MAX_SCAN_BYTES = 16 * 1024 * 1024;
|
|
107
|
+
|
|
108
|
+
// Regular files only: a FIFO, socket or device named `.env` would block the
|
|
109
|
+
// read (or never end), and a symlink could point outside the repo.
|
|
91
110
|
function walkFiles(dir, files = [], root = dir) {
|
|
92
111
|
if (!existsSync(dir) || isNestedRepo(dir, root)) return files;
|
|
93
112
|
for (const entry of readdirSync(dir)) {
|
|
@@ -97,7 +116,7 @@ function walkFiles(dir, files = [], root = dir) {
|
|
|
97
116
|
if (st.isSymbolicLink()) continue;
|
|
98
117
|
if (st.isDirectory()) {
|
|
99
118
|
walkFiles(full, files, root);
|
|
100
|
-
} else if (!SKIP_FILES.test(entry)) {
|
|
119
|
+
} else if (st.isFile() && !SKIP_FILES.test(entry)) {
|
|
101
120
|
const ext = entry.includes(".") ? entry.slice(entry.lastIndexOf(".")) : "";
|
|
102
121
|
if (SCAN_EXTENSIONS.has(ext) || entry.startsWith(".env")) files.push(full);
|
|
103
122
|
}
|
|
@@ -109,46 +128,103 @@ export function maskMatch(match) {
|
|
|
109
128
|
return match.length <= 8 ? "***" : match.slice(0, 4) + "…" + match.slice(-4);
|
|
110
129
|
}
|
|
111
130
|
|
|
131
|
+
// A file can repeat one key shape thousands of times; past this many reported
|
|
132
|
+
// hits per rule the file is already flagged, so stop looking for more.
|
|
133
|
+
const MAX_HITS_PER_RULE = 50;
|
|
134
|
+
|
|
135
|
+
/** Every match of a rule. Pack rules added by path see at most PACK_INPUT_CAP chars, as with packMatch(). */
|
|
136
|
+
function* allMatches(rule, content) {
|
|
137
|
+
if (rule.rule) {
|
|
138
|
+
const input = rule.rule.origin === "bundled" || content.length <= PACK_INPUT_CAP ? content : content.slice(0, PACK_INPUT_CAP);
|
|
139
|
+
rule.globalPattern ??= new RegExp(rule.rule.pattern.source, rule.rule.pattern.flags.replace(/[gy]/g, "") + "g");
|
|
140
|
+
yield* input.matchAll(rule.globalPattern);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
rule.globalPattern ??= new RegExp(rule.pattern.source, rule.pattern.flags.replace(/[gy]/g, "") + "g");
|
|
144
|
+
yield* content.matchAll(rule.globalPattern);
|
|
145
|
+
}
|
|
146
|
+
|
|
112
147
|
/**
|
|
113
148
|
* Find secrets in a string of text (a source file, a JS bundle, a config).
|
|
114
|
-
* Returns
|
|
115
|
-
* callers decide how to mask it.
|
|
116
|
-
*
|
|
149
|
+
* Returns every hit: `{ name, severity, match, index }`, with the raw match so
|
|
150
|
+
* callers decide how to mask it. Placeholder values are skipped one match at a
|
|
151
|
+
* time, so a placeholder never hides a real secret of the same rule later in
|
|
152
|
+
* the text. At most one rule fires per span: once a specific AI-provider rule
|
|
153
|
+
* matches a span, broader rules skip that span.
|
|
117
154
|
*/
|
|
118
155
|
export function findSecretsInText(content) {
|
|
119
156
|
const hits = [];
|
|
120
157
|
const claimed = [];
|
|
121
|
-
for (const rule of
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
158
|
+
for (const rule of secretPatterns()) {
|
|
159
|
+
let found = 0;
|
|
160
|
+
for (const match of allMatches(rule, content)) {
|
|
161
|
+
// For assignment-style rules, test the placeholder/dummy filter against
|
|
162
|
+
// the assigned value only (not the whole `key = "value"` match), so a
|
|
163
|
+
// variable named e.g. `testToken` can't mask a real secret.
|
|
164
|
+
const value = (match[0].match(/['"]([^'"]+)['"]/) || [, match[0]])[1];
|
|
165
|
+
if (rule.skipIf && rule.skipIf.test(value)) continue;
|
|
166
|
+
const start = match.index;
|
|
167
|
+
const end = start + match[0].length;
|
|
168
|
+
if (claimed.some(([a, b]) => start < b && end > a)) continue;
|
|
169
|
+
claimed.push([start, end]);
|
|
170
|
+
hits.push({ name: rule.name, severity: rule.severity, match: match[0], index: start });
|
|
171
|
+
if (++found >= MAX_HITS_PER_RULE) break;
|
|
172
|
+
}
|
|
134
173
|
}
|
|
135
174
|
return hits;
|
|
136
175
|
}
|
|
137
176
|
|
|
138
|
-
|
|
177
|
+
/** A regular file's text, or null when it is not one (anymore) or is larger than `max` bytes. */
|
|
178
|
+
function readRegularFile(path, max) {
|
|
179
|
+
let fd;
|
|
180
|
+
try {
|
|
181
|
+
// O_NONBLOCK: if the file was swapped for a FIFO after the walk, open must not wait for a writer.
|
|
182
|
+
fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK || 0) | (constants.O_NOFOLLOW || 0));
|
|
183
|
+
const st = fstatSync(fd);
|
|
184
|
+
if (!st.isFile()) return null;
|
|
185
|
+
if (st.size > max) return { tooLarge: st.size };
|
|
186
|
+
const buf = Buffer.alloc(st.size);
|
|
187
|
+
let off = 0;
|
|
188
|
+
while (off < st.size) {
|
|
189
|
+
const n = readSync(fd, buf, off, st.size - off, off);
|
|
190
|
+
if (n === 0) break;
|
|
191
|
+
off += n;
|
|
192
|
+
}
|
|
193
|
+
return { text: buf.subarray(0, off).toString("utf-8") };
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
} finally {
|
|
197
|
+
if (fd !== undefined) closeSync(fd);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function scanSecrets(repoPath, { maxBytes = MAX_SCAN_BYTES } = {}) {
|
|
139
202
|
const findings = [];
|
|
140
203
|
|
|
141
204
|
for (const filePath of walkFiles(repoPath)) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
content = readFileSync(filePath, "utf-8");
|
|
145
|
-
} catch {
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
205
|
+
const read = readRegularFile(filePath, maxBytes);
|
|
206
|
+
if (!read) continue;
|
|
148
207
|
|
|
149
208
|
const rel = relative(repoPath, filePath);
|
|
150
209
|
|
|
151
|
-
|
|
210
|
+
if (read.tooLarge) {
|
|
211
|
+
findings.push({
|
|
212
|
+
severity: "info",
|
|
213
|
+
title: "File too large to scan for secrets",
|
|
214
|
+
description: `\`${rel}\` is ${read.tooLarge} bytes, over the ${maxBytes}-byte secret-scan limit, so it was not checked.`,
|
|
215
|
+
url: rel,
|
|
216
|
+
category: "secrets",
|
|
217
|
+
source: "secret-scan",
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// One finding per rule per file (the finding id is per rule and file);
|
|
223
|
+
// the first real hit is the one shown.
|
|
224
|
+
const seen = new Set();
|
|
225
|
+
for (const hit of findSecretsInText(read.text)) {
|
|
226
|
+
if (seen.has(hit.name)) continue;
|
|
227
|
+
seen.add(hit.name);
|
|
152
228
|
findings.push({
|
|
153
229
|
severity: hit.severity,
|
|
154
230
|
title: `${hit.name} in source`,
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "penthera",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Offline cyber security agent for your apps and AI coding tools. Open R&D: measured in public, improved every release.",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/danoszz/penthera.git"
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"src",
|
|
21
21
|
"lib",
|
|
22
22
|
"skills",
|
|
23
|
-
"docs",
|
|
23
|
+
"docs/examples",
|
|
24
|
+
"scripts/rules",
|
|
24
25
|
"pentest.config.example.js",
|
|
25
26
|
"CHANGELOG.md",
|
|
26
27
|
"LICENSE",
|
|
@@ -37,6 +38,8 @@
|
|
|
37
38
|
},
|
|
38
39
|
"scripts": {
|
|
39
40
|
"test": "vitest run --reporter=verbose",
|
|
41
|
+
"test:load": "node scripts/test-under-load.mjs",
|
|
42
|
+
"lint": "eslint .",
|
|
40
43
|
"scan": "node bin/penthera.js",
|
|
41
44
|
"scan:full": "node bin/scan.js",
|
|
42
45
|
"pentest": "vitest run --reporter=verbose",
|
|
@@ -50,11 +53,12 @@
|
|
|
50
53
|
"package:skill": "bash scripts/package-skill.sh",
|
|
51
54
|
"refresh:popular": "node scripts/refresh-popular-packages.mjs",
|
|
52
55
|
"refresh:popular-pypi": "node scripts/refresh-popular-pypi.mjs",
|
|
53
|
-
"prepack": "npm test",
|
|
54
56
|
"mock-server": "node tests/fixtures/mock-server.js",
|
|
55
57
|
"bench": "node bench/run.js",
|
|
56
58
|
"docs:numbers": "node scripts/docs-numbers.mjs",
|
|
57
59
|
"refresh:retire-db": "node scripts/refresh-retire-db.mjs",
|
|
60
|
+
"rules:build": "node scripts/rules/build-pack.mjs",
|
|
61
|
+
"rules:check": "node scripts/rules/check-bundled.mjs",
|
|
58
62
|
"audit:internal": "node scripts/audit/run.mjs"
|
|
59
63
|
},
|
|
60
64
|
"keywords": [
|
|
@@ -81,8 +85,12 @@
|
|
|
81
85
|
],
|
|
82
86
|
"license": "MIT",
|
|
83
87
|
"devDependencies": {
|
|
88
|
+
"@eslint/js": "10.0.1",
|
|
84
89
|
"ajv": "^8.20.0",
|
|
90
|
+
"eslint": "10.12.0",
|
|
85
91
|
"fast-check": "^4.10.2",
|
|
92
|
+
"globals": "17.12.0",
|
|
93
|
+
"rolldown": "1.2.14",
|
|
86
94
|
"vitest": "^5.0.5"
|
|
87
95
|
},
|
|
88
96
|
"dependencies": {
|