penthera 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/README.md +139 -76
  3. package/SECURITY.md +8 -6
  4. package/docs/examples/pr-dependency-review.yml +1 -1
  5. package/lib/adaptive-probes.js +3 -3
  6. package/lib/advisories/evidence.js +248 -0
  7. package/lib/advisories/index.js +228 -0
  8. package/lib/advisories/products.js +83 -0
  9. package/lib/advisories/versions.js +65 -0
  10. package/lib/agents/hidden-content.js +122 -11
  11. package/lib/agents/index.js +26 -6
  12. package/lib/agents/lock.js +33 -5
  13. package/lib/agents/rules.js +33 -4
  14. package/lib/compliance/index.js +4 -4
  15. package/lib/crawler.js +12 -15
  16. package/lib/deps/capabilities.js +21 -5
  17. package/lib/deps/findings.js +309 -0
  18. package/lib/deps/index.js +13 -272
  19. package/lib/deps/iocs.js +74 -0
  20. package/lib/deps/lockfile.js +3 -8
  21. package/lib/deps/online.js +8 -3
  22. package/lib/deps/popular-packages.json.gz +0 -0
  23. package/lib/deps/popular-pypi.json.gz +0 -0
  24. package/lib/deps/python/capabilities.js +16 -3
  25. package/lib/deps/python/index.js +5 -1
  26. package/lib/deps/python/lockfiles.js +11 -5
  27. package/lib/deps/python/source.js +75 -26
  28. package/lib/deps/python/toml.js +15 -3
  29. package/lib/deps/source.js +28 -9
  30. package/lib/deps/typosquat.js +30 -5
  31. package/lib/fix/apply.js +253 -34
  32. package/lib/fix/index.js +7 -4
  33. package/lib/fix/model.js +25 -5
  34. package/lib/fix/util.js +6 -2
  35. package/lib/fuzzer.js +3 -3
  36. package/lib/git.js +65 -0
  37. package/lib/hook/index.js +157 -73
  38. package/lib/hook/policy.js +10 -4
  39. package/lib/install/index.js +4 -7
  40. package/lib/machine.js +0 -13
  41. package/lib/memory/store.js +11 -4
  42. package/lib/nuclei-loader.js +0 -25
  43. package/lib/owasp-wstg.js +4 -0
  44. package/lib/plugins.js +1 -1
  45. package/lib/provenance.js +1 -0
  46. package/lib/recon.js +12 -31
  47. package/lib/references.json +224 -0
  48. package/lib/retirejs.js +4 -4
  49. package/lib/rules/advisories/advisories.json +186 -0
  50. package/lib/rules/advisories/eol.json +413 -0
  51. package/lib/rules/advisories/pack.json +20 -0
  52. package/lib/rules/advisories/pack.json.sig +6 -0
  53. package/lib/rules/allowed_signers +13 -0
  54. package/lib/rules/core/agent-exec.json +29 -0
  55. package/lib/rules/core/agent-instructions.json +17 -0
  56. package/lib/rules/core/pack.json +30 -0
  57. package/lib/rules/core/pack.json.sig +6 -0
  58. package/lib/rules/core/secrets.json +161 -0
  59. package/lib/rules/core/templates.json +52 -0
  60. package/lib/rules/index.js +107 -0
  61. package/lib/rules/ioc-2025-2026/files.json +43 -0
  62. package/lib/rules/ioc-2025-2026/hosts.json +35 -0
  63. package/lib/rules/ioc-2025-2026/pack.json +25 -0
  64. package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
  65. package/lib/rules/ioc-2025-2026/packages.json +1048 -0
  66. package/lib/rules/pack.js +452 -0
  67. package/lib/rules/regex-guard.js +81 -9
  68. package/lib/rules/sshsig.js +205 -0
  69. package/lib/templates.js +24 -20
  70. package/lib/tmp.js +21 -0
  71. package/lib/whitebox/frameworks.js +1 -1
  72. package/lib/whitebox/secrets.js +117 -41
  73. package/package.json +12 -4
  74. package/scripts/rules/build-pack.mjs +80 -0
  75. package/scripts/rules/check-bundled.mjs +30 -0
  76. package/scripts/rules/sign-pack.sh +32 -0
  77. package/skills/penthera/SKILL.md +16 -15
  78. package/src/agent/git.js +147 -14
  79. package/src/agent/loop.js +165 -46
  80. package/src/cli/agent.js +11 -5
  81. package/src/cli/deps.js +0 -2
  82. package/src/cli/detect.js +14 -7
  83. package/src/cli/fix.js +26 -8
  84. package/src/cli/hook.js +2 -1
  85. package/src/cli/onboarding.js +14 -6
  86. package/src/cli/report-outputs.js +1 -0
  87. package/src/cli/rules.js +144 -0
  88. package/src/cli/ui.js +43 -10
  89. package/src/cli.js +24 -1
  90. package/src/report/evidence.js +105 -21
  91. package/src/report/footer.js +1 -1
  92. package/src/report/md-escape.js +8 -0
  93. package/src/scan-repo.js +6 -0
  94. package/src/scan-url.js +11 -3
  95. package/src/ui/api/data.js +39 -6
  96. package/src/ui/api/routes.js +44 -11
  97. package/src/ui/api/runs.js +32 -7
  98. package/src/ui/security.js +20 -23
  99. package/src/ui/server.js +52 -27
  100. package/src/ui/static/app.css +583 -213
  101. package/src/ui/static/favicon.svg +1 -1
  102. package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
  103. package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
  104. package/src/ui/static/index.html +5 -4
  105. package/src/ui/static/js/api.js +114 -6
  106. package/src/ui/static/js/app.js +81 -42
  107. package/src/ui/static/js/center.js +246 -133
  108. package/src/ui/static/js/charts.js +99 -18
  109. package/src/ui/static/js/copy.js +68 -9
  110. package/src/ui/static/js/dialog.js +152 -0
  111. package/src/ui/static/js/left.js +23 -60
  112. package/src/ui/static/js/menubar.js +170 -0
  113. package/src/ui/static/js/onboarding.js +104 -0
  114. package/src/ui/static/js/panels.js +186 -0
  115. package/src/ui/static/js/right.js +69 -188
  116. package/src/ui/static/js/state.js +36 -5
  117. package/src/ui/static/logo.svg +10 -0
  118. package/src/ui/static/mark.svg +3 -0
  119. package/src/utils/http.js +162 -13
  120. package/docs/METHODOLOGY.md +0 -375
  121. package/docs/commands/README.md +0 -24
  122. package/docs/commands/action.md +0 -21
  123. package/docs/commands/agents.md +0 -38
  124. package/docs/commands/deps.md +0 -115
  125. package/docs/commands/fix.md +0 -53
  126. package/docs/commands/hook.md +0 -69
  127. package/docs/commands/init.md +0 -114
  128. package/docs/commands/install.md +0 -53
  129. package/docs/commands/scan.md +0 -100
  130. package/docs/commands/skill.md +0 -58
  131. package/docs/compare/README.md +0 -25
  132. package/docs/compare/github.md +0 -60
  133. package/docs/compare/semgrep.md +0 -54
  134. package/docs/compare/snyk.md +0 -62
  135. package/docs/compare/socket.md +0 -78
  136. package/docs/compare/zap.md +0 -59
  137. package/docs/compliance-cra.md +0 -141
  138. package/docs/compliance-nis2.md +0 -168
  139. package/docs/dashboard.md +0 -187
  140. package/docs/evidence.md +0 -162
  141. package/docs/funding/due-diligence-checklist.md +0 -79
  142. package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
  143. package/docs/funding/partner-one-pager.md +0 -84
  144. package/docs/funding/sponsor-tiers.md +0 -81
  145. package/docs/index.md +0 -81
  146. package/docs/internal-audit.md +0 -83
  147. package/docs/local-loop.md +0 -276
  148. package/docs/marketing.md +0 -151
  149. package/docs/owasp-wstg-coverage.md +0 -79
  150. package/docs/plan.md +0 -102
  151. package/docs/privacy.md +0 -38
  152. package/docs/references.bib +0 -559
  153. package/docs/references.md +0 -119
  154. package/docs/release-plan.md +0 -228
  155. package/docs/research/socket-positioning-notes.md +0 -183
  156. package/docs/use-cases/README.md +0 -14
  157. package/docs/use-cases/agencies.md +0 -71
  158. package/docs/use-cases/ai-coding-teams.md +0 -81
  159. package/docs/use-cases/eu-sme-compliance.md +0 -68
  160. package/docs/use-cases/open-source-maintainers.md +0 -75
  161. package/docs/use-cases/solo-founder.md +0 -65
  162. package/lib/deps/popular-packages.json +0 -1
  163. package/lib/deps/popular-pypi.json +0 -1
@@ -0,0 +1,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 { hasNestedQuantifier } from "./rules/regex-guard.js";
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.text().catch(() => "");
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
- // hasNestedQuantifier lives in lib/rules/regex-guard.js. Templates from rule
93
- // packs pass the stricter checkRegex there when the pack loads.
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 (hasNestedQuantifier(pattern)) return false; // refuse the shape that blows up
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 (hasNestedQuantifier(pattern)) continue;
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
- id: "env-file-exposure",
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
- for (const template of BUILT_IN_TEMPLATES) {
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, statSync, lstatSync, 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 = new Set(["node_modules", ".git", "dist", "build", ".next", "coverage", "vendor"]);
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Scan repository source for hardcoded secrets.
3
3
  */
4
- import { readdirSync, readFileSync, lstatSync, existsSync } from "node:fs";
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
- { name: "Anthropic API key", pattern: /sk-ant-(?:api|admin)\d{2}-[A-Za-z0-9_-]{32,}/, severity: "critical" },
30
- { name: "OpenRouter API key", pattern: /sk-or-v1-[a-f0-9]{64}/, severity: "critical" },
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 one hit per rule: `{ name, severity, match }`, with the raw match so
115
- * callers decide how to mask it. At most one rule fires per provider key: once a
116
- * specific AI-provider rule matches a span, broader rules skip that span.
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 SECRET_PATTERNS) {
122
- const match = content.match(rule.pattern);
123
- if (!match) continue;
124
- // For assignment-style rules, test the placeholder/dummy filter against
125
- // the assigned value only (not the whole `key = "value"` match), so a
126
- // variable named e.g. `testToken` can't mask a real secret.
127
- const value = (match[0].match(/['"]([^'"]+)['"]/) || [, match[0]])[1];
128
- if (rule.skipIf && rule.skipIf.test(value)) continue;
129
- const start = match.index;
130
- const end = start + match[0].length;
131
- if (claimed.some(([a, b]) => start < b && end > a)) continue;
132
- claimed.push([start, end]);
133
- hits.push({ name: rule.name, severity: rule.severity, match: match[0] });
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
- export function scanSecrets(repoPath) {
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
- let content;
143
- try {
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
- for (const hit of findSecretsInText(content)) {
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.7.0",
3
+ "version": "1.8.0",
4
4
  "type": "module",
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.",
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": {