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
package/lib/hook/index.js CHANGED
@@ -14,11 +14,11 @@
14
14
  * Offline, no telemetry. Findings use the standard shape, so ids match a full
15
15
  * scan and penthera.suppress.json works here too.
16
16
  */
17
- import { execFileSync, spawnSync } from "node:child_process";
18
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
19
- import { tmpdir } from "node:os";
17
+ import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
20
18
  import { join, posix, resolve } from "node:path";
21
19
  import { fileURLToPath } from "node:url";
20
+ import { git, gitSpawn, gitToplevel } from "../git.js";
21
+ import { withTempDir } from "../tmp.js";
22
22
  import { findSecretsInText, isSecretScanTarget, maskMatch } from "../whitebox/secrets.js";
23
23
  import { loadPolicy, normalizePolicy, POLICY_FILE } from "../install/policy.js";
24
24
  import { parseRepoSuppressions, REPO_SUPPRESS_FILE } from "../memory/repo-suppress.js";
@@ -28,73 +28,101 @@ import { globToRegExp, isAtLeast, SEVERITIES } from "./policy.js";
28
28
 
29
29
  export const HOOK_MARKER = "# penthera-hook";
30
30
  export const BACKUP_NAME = "pre-commit.pre-penthera";
31
- const MAX_BLOB_BYTES = 2 * 1024 * 1024;
31
+ // Staged files larger than this are not read. They never pass silently: by
32
+ // default they block the commit (policy "largeFiles": "warn" only warns).
33
+ // Large enough for the package-lock.json of a big monorepo.
34
+ export const MAX_BLOB_BYTES = 16 * 1024 * 1024;
35
+ const REGULAR = new Set(["100644", "100755"]);
32
36
 
33
37
  // ── git plumbing ──────────────────────────────────────────────────────────
34
38
 
35
- function git(cwd, args, opts = {}) {
36
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024, ...opts });
39
+ export function repoRoot(cwd) {
40
+ const root = gitToplevel(cwd);
41
+ if (!root) throw new Error(`Not inside a git repository: ${cwd}`);
42
+ return root;
37
43
  }
38
44
 
39
- export function repoRoot(cwd) {
40
- try {
41
- return git(cwd, ["rev-parse", "--show-toplevel"]).trim();
42
- } catch {
43
- throw new Error(`Not inside a git repository: ${cwd}`);
45
+ /**
46
+ * Index entries this commit adds: added, copied, modified, renamed or
47
+ * type-changed (a symlink that became a file). `{ path, mode, oid }`, read
48
+ * NUL-separated so any byte in a name survives, with the object id of the
49
+ * staged blob, so content is always read by id and never by a path git
50
+ * might parse as revision syntax.
51
+ */
52
+ export function stagedEntries(root) {
53
+ const parts = git(root, ["diff", "--cached", "--raw", "-z", "--no-abbrev", "--no-renames", "--diff-filter=ACMRT"]).split("\0");
54
+ const out = [];
55
+ for (let i = 0; i < parts.length - 1; i += 2) {
56
+ const m = /^:\d{6} (\d{6}) [0-9a-f]+ ([0-9a-f]+) [A-Z]\d*$/.exec(parts[i]);
57
+ if (!m) throw new Error(`unexpected git diff --raw output: ${JSON.stringify(parts[i].slice(0, 200))}`);
58
+ out.push({ path: parts[i + 1], mode: m[1], oid: m[2] });
44
59
  }
60
+ return out;
45
61
  }
46
62
 
47
- /** Paths added, copied, modified or renamed in the index (what this commit adds). */
48
- export function stagedFiles(root) {
49
- return git(root, ["diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR", "--no-renames"]).split("\0").filter(Boolean);
63
+ function catFile(root, mode, oids) {
64
+ const r = gitSpawn(root, ["cat-file", mode], { input: oids.map((o) => `${o}\n`).join("") });
65
+ if (r.status !== 0) throw new Error(`git cat-file failed: ${String(r.stderr).trim()}`);
66
+ return r.stdout;
50
67
  }
51
68
 
52
- /** Read index blobs in one `git cat-file --batch` call. Returns Map(path → Buffer); large files are skipped. */
53
- export function readStaged(root, paths) {
54
- const out = new Map();
55
- const wanted = paths.filter((p) => !p.includes("\n"));
56
- if (!wanted.length) return out;
57
- const r = spawnSync("git", ["cat-file", "--batch"], { cwd: root, input: wanted.map((p) => `:${p}\n`).join(""), maxBuffer: 1024 * 1024 * 1024 });
58
- if (r.status !== 0) throw new Error(`git cat-file failed: ${String(r.stderr).trim()}`);
59
- const buf = r.stdout;
69
+ /**
70
+ * Read staged blobs by object id. Only regular files (mode 100644/100755):
71
+ * a staged symlink or submodule has no content of its own to check.
72
+ * @param {{ path: string, mode: string, oid: string }[]} entries
73
+ * @returns {{ blobs: Map<string, Buffer>, oversized: { path: string, size: number }[] }}
74
+ */
75
+ export function readStaged(root, entries, { maxBytes = MAX_BLOB_BYTES } = {}) {
76
+ const blobs = new Map();
77
+ const oversized = [];
78
+ const files = entries.filter((e) => REGULAR.has(e.mode));
79
+ if (!files.length) return { blobs, oversized };
80
+ const sizes = catFile(root, "--batch-check", files.map((e) => e.oid)).toString().split("\n");
81
+ const wanted = [];
82
+ files.forEach((e, i) => {
83
+ const m = /^([0-9a-f]+) blob (\d+)$/.exec(sizes[i] || "");
84
+ if (!m || m[1] !== e.oid) throw new Error(`git cat-file: staged blob for ${JSON.stringify(e.path)} not found`);
85
+ const size = Number(m[2]);
86
+ if (size > maxBytes) oversized.push({ path: e.path, size });
87
+ else wanted.push(e);
88
+ });
89
+ if (!wanted.length) return { blobs, oversized };
90
+ const buf = catFile(root, "--batch", wanted.map((e) => e.oid));
60
91
  let pos = 0;
61
- for (const p of wanted) {
92
+ for (const e of wanted) {
62
93
  const nl = buf.indexOf(10, pos);
63
- if (nl === -1) break;
64
- const header = buf.subarray(pos, nl).toString();
65
- pos = nl + 1;
66
- const m = /^\S+ (\w+) (\d+)$/.exec(header);
67
- if (!m) continue; // "<name> missing"
94
+ const m = nl === -1 ? null : /^([0-9a-f]+) blob (\d+)$/.exec(buf.subarray(pos, nl).toString());
95
+ if (!m || m[1] !== e.oid) throw new Error(`git cat-file: unexpected output for ${JSON.stringify(e.path)}`);
68
96
  const size = Number(m[2]);
69
- if (m[1] === "blob" && size <= MAX_BLOB_BYTES) out.set(p, buf.subarray(pos, pos + size));
70
- pos += size + 1;
97
+ blobs.set(e.path, buf.subarray(nl + 1, nl + 1 + size));
98
+ pos = nl + 1 + size + 1;
71
99
  }
72
- return out;
100
+ return { blobs, oversized };
73
101
  }
74
102
 
75
103
  // ── Checks ────────────────────────────────────────────────────────────────
76
104
 
77
- function lineOf(text, index) {
78
- let n = 1;
79
- for (let i = 0; i < index; i++) if (text.charCodeAt(i) === 10) n++;
80
- return n;
81
- }
82
-
83
105
  function checkSecrets(files, blobs) {
84
106
  const findings = [];
85
107
  for (const path of files) {
86
108
  if (!isSecretScanTarget(path)) continue;
87
109
  const buf = blobs.get(path);
88
- if (!buf || buf.subarray(0, 8192).includes(0)) continue;
110
+ if (!buf) continue;
111
+ // Read as text even when it holds NUL bytes: a NUL does not stop a key
112
+ // from being a key, and the extension already says this is a text file.
89
113
  const text = buf.toString("utf-8");
90
- for (const hit of findSecretsInText(text)) {
114
+ const hits = findSecretsInText(text).sort((a, b) => a.index - b.index);
115
+ let line = 1;
116
+ let at = 0;
117
+ for (const hit of hits) {
118
+ for (; at < hit.index; at++) if (text.charCodeAt(at) === 10) line++;
91
119
  // Same title/url/category/source as the repo scan, so finding ids match.
92
120
  findings.push({
93
121
  severity: hit.severity,
94
122
  title: `${hit.name} in source`,
95
123
  description: `Possible secret in \`${path}\`: \`${maskMatch(hit.match)}\``,
96
124
  url: path,
97
- line: lineOf(text, text.indexOf(hit.match)),
125
+ line,
98
126
  evidence: maskMatch(hit.match),
99
127
  category: "secrets",
100
128
  source: "secret-scan",
@@ -126,9 +154,8 @@ async function checkAgents(root, staged) {
126
154
  .map((l) => /^(\d{6}) [0-9a-f]+ \d\t(.*)$/s.exec(l))
127
155
  .filter((m) => m && (m[1] === "100644" || m[1] === "100755") && isAgentConfig(m[2]))
128
156
  .map((m) => m[2]);
129
- const tmp = realpathSync(mkdtempSync(join(tmpdir(), "penthera-hook-")));
130
- try {
131
- const r = spawnSync("git", ["checkout-index", "-z", "--stdin", `--prefix=${tmp}/`], { cwd: root, input: all.join("\0") });
157
+ return withTempDir("penthera-hook-", async (tmp) => {
158
+ const r = gitSpawn(root, ["checkout-index", "-z", "--stdin", `--prefix=${tmp}/`], { input: all.join("\0") });
132
159
  if (r.status !== 0) throw new Error(`git checkout-index failed: ${String(r.stderr).trim()}`);
133
160
  const { scanAgents } = await import("../agents/index.js");
134
161
  const result = await scanAgents(tmp, { lockPath: join(tmp, ".penthera-hook-no-lock") });
@@ -140,9 +167,7 @@ async function checkAgents(root, staged) {
140
167
  return m ? { ...f, line: Number(m[1]) } : f;
141
168
  });
142
169
  return { findings, files: touched.length };
143
- } finally {
144
- rmSync(tmp, { recursive: true, force: true });
145
- }
170
+ });
146
171
  }
147
172
 
148
173
  const EMPTY_LOCK = { lockfileVersion: 3, packages: {} };
@@ -182,15 +207,15 @@ function suppressedBy(entries, f) {
182
207
 
183
208
  const bySeverity = (a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity) || String(a.url).localeCompare(String(b.url)) || (a.line || 0) - (b.line || 0);
184
209
 
185
- /**
186
- * Check what is staged. Never writes to the repo.
187
- * @param {string} cwd anywhere inside the repo
188
- * @param {{ policyPath?: string, now?: string }} opts
189
- * @returns {Promise<{ root, staged, checked, blocking, below, suppressed, blockOn, policyPath, warnings, duration }>}
190
- */
191
- /** One file as staged, or null when it is not in the index. */
210
+ /** One regular file as staged (stage 0), or null when it is not in the index. */
192
211
  function stagedBlob(root, path) {
193
- return readStaged(root, [path]).get(path) || null;
212
+ const line = git(root, ["--literal-pathspecs", "ls-files", "-s", "-z", "--", path]).split("\0")
213
+ .map((l) => /^(\d{6}) ([0-9a-f]+) 0\t(.*)$/s.exec(l))
214
+ .find((m) => m && m[3] === path);
215
+ if (!line) return null;
216
+ const { blobs, oversized } = readStaged(root, [{ path, mode: line[1], oid: line[2] }]);
217
+ if (oversized.length) throw new Error(`${path} (staged) is too large to read`);
218
+ return blobs.get(path) || null;
194
219
  }
195
220
 
196
221
  /** The policy: an explicit --policy file, else penthera.policy.json as staged, else defaults. */
@@ -207,18 +232,42 @@ function stagedPolicy(root, explicitPath) {
207
232
  return { policy: normalizePolicy(raw, `${POLICY_FILE} (staged)`), path: join(root, POLICY_FILE) };
208
233
  }
209
234
 
235
+ /**
236
+ * Check what is staged. Never writes to the repo.
237
+ * @param {string} cwd anywhere inside the repo
238
+ * @param {{ policyPath?: string, now?: string, maxBlobBytes?: number }} opts
239
+ * @returns {Promise<{ root, staged, checked, blocking, below, suppressed, blockOn, policyPath, warnings, duration }>}
240
+ */
210
241
  export async function runHook(cwd, opts = {}) {
211
242
  const started = Date.now();
212
243
  const root = repoRoot(cwd);
213
244
  const { policy, path: policyPath } = stagedPolicy(root, opts.policyPath || null);
214
- const { blockOn, checks, ignore } = policy.hook;
245
+ const { blockOn, checks, ignore, largeFiles } = policy.hook;
215
246
  const ignored = ignore.map(globToRegExp);
216
- const staged = stagedFiles(root).filter((p) => !ignored.some((re) => re.test(p)));
217
-
218
- const wantBlobs = staged.filter((p) => (checks.secrets && isSecretScanTarget(p)) || /(^|\/)(package-lock|npm-shrinkwrap)\.json$/.test(p));
219
- const blobs = readStaged(root, wantBlobs);
247
+ const entries = stagedEntries(root).filter((e) => !ignored.some((re) => re.test(e.path)));
248
+ const staged = entries.map((e) => e.path);
249
+
250
+ const isLock = (p) => /(^|\/)(package-lock|npm-shrinkwrap)\.json$/.test(p);
251
+ const wantBlobs = entries.filter((e) => (checks.secrets && isSecretScanTarget(e.path)) || (checks.deps && isLock(e.path)));
252
+ const { blobs, oversized } = readStaged(root, wantBlobs, { maxBytes: opts.maxBlobBytes ?? MAX_BLOB_BYTES });
253
+ const warnings = [];
254
+ const unchecked = [];
255
+ for (const o of oversized) {
256
+ const limit = `${Math.round((opts.maxBlobBytes ?? MAX_BLOB_BYTES) / 1024 / 1024 * 10) / 10} MB`;
257
+ warnings.push(`${o.path} is ${o.size} bytes, over the ${limit} limit, so it was not checked${largeFiles === "block" ? " (this blocks the commit)" : ""}`);
258
+ if (largeFiles === "block") {
259
+ unchecked.push({
260
+ severity: "critical",
261
+ title: "Staged file too large to check",
262
+ description: `\`${o.path}\` is ${o.size} bytes, over the ${limit} the hook reads, so it could hold a secret nobody checked.`,
263
+ url: o.path,
264
+ category: "unchecked",
265
+ source: "hook",
266
+ });
267
+ }
268
+ }
220
269
 
221
- const secrets = checks.secrets ? checkSecrets(staged, blobs) : [];
270
+ const secrets = checks.secrets ? [...checkSecrets(staged, blobs), ...unchecked] : unchecked;
222
271
  const agents = checks.agents ? await checkAgents(root, staged) : { findings: [], files: 0 };
223
272
  const deps = checks.deps ? await checkLockfiles(root, staged, blobs, { licensePolicy: checks.licenses ? policy.licenses : null }) : { findings: [], files: 0 };
224
273
 
@@ -248,7 +297,7 @@ export async function runHook(cwd, opts = {}) {
248
297
  blocking: kept.filter((f) => isAtLeast(f.severity, blockOn)),
249
298
  below: kept.filter((f) => !isAtLeast(f.severity, blockOn)),
250
299
  suppressed,
251
- warnings: sup.warnings,
300
+ warnings: [...warnings, ...sup.warnings],
252
301
  duration: Date.now() - started,
253
302
  };
254
303
  }
@@ -258,8 +307,27 @@ export function fixHint(f) {
258
307
  if (f.category === "secrets") return "Load it from an env var or secret store and unstage the file; rotate it if it was ever pushed or shared.";
259
308
  if (f.category === "license") return `Replace ${f.package}, or add it to "allowPackages" in the licenses section of penthera.policy.json.`;
260
309
  if (f.category === "dependency-risk") return `Review it before committing the lockfile: \`penthera deps --base HEAD -v\` shows the evidence.`;
261
- const first = String(f.description || "").replace(/\s*\[[^\]]*\]$/, "").replace(/\s*\(line \d+\)/, "").split(/(?<=\.)\s/)[0];
262
- return first || "See `penthera agents` for details.";
310
+ if (f.category === "unchecked") return `If it holds no secrets, add it to "ignore" (or set "largeFiles": "warn") in the hook section of penthera.policy.json.`;
311
+ return firstSentence(f.description) || "See `penthera agents` for details.";
312
+ }
313
+
314
+ /**
315
+ * The description's first sentence, without a trailing `[...]` tag or a
316
+ * `(line N)` note. Only the first line, capped, is looked at, and nothing
317
+ * here backtracks: descriptions quote agent config, which anyone can write.
318
+ */
319
+ function firstSentence(description) {
320
+ let s = String(description || "");
321
+ const nl = s.indexOf("\n");
322
+ s = (nl === -1 ? s : s.slice(0, nl)).slice(0, 2000);
323
+ if (s.endsWith("]")) {
324
+ const open = s.lastIndexOf("[");
325
+ if (open !== -1 && !s.slice(open, -1).includes("]")) s = s.slice(0, open).trimEnd();
326
+ }
327
+ const line = /\(line \d+\)/.exec(s);
328
+ if (line) s = s.slice(0, line.index).trimEnd() + s.slice(line.index + line[0].length);
329
+ const stop = /\.\s/.exec(s);
330
+ return stop ? s.slice(0, stop.index + 1) : s;
263
331
  }
264
332
 
265
333
  // ── Install / uninstall ───────────────────────────────────────────────────
@@ -288,14 +356,22 @@ exit 0
288
356
 
289
357
  /** Where git looks for hooks (honours core.hooksPath and worktrees). */
290
358
  export function hooksDir(root) {
291
- return resolve(root, git(root, ["rev-parse", "--git-path", "hooks"]).trim());
359
+ // raw: the repo's real core.hooksPath, which the safe config overrides.
360
+ return resolve(root, git(root, ["rev-parse", "--git-path", "hooks"], { raw: true }).trim());
292
361
  }
293
362
 
294
363
  function hooksPathSetting(root) {
295
- try { return git(root, ["config", "--get", "core.hooksPath"]).trim() || null; } catch { return null; }
364
+ try { return git(root, ["config", "--get", "core.hooksPath"], { raw: true }).trim() || null; } catch { return null; }
365
+ }
366
+
367
+ /** lstat, or null when nothing is there (a dangling symlink is something). */
368
+ function lstatOrNull(path) {
369
+ try { return lstatSync(path); } catch (e) { if (e.code === "ENOENT") return null; throw e; }
296
370
  }
297
371
 
298
- const isOurs = (path) => existsSync(path) && readFileSync(path, "utf-8").includes(HOOK_MARKER);
372
+ // Ours only when it is a regular file with our marker: a symlink is never
373
+ // ours, even when it points at a file that carries the marker.
374
+ const isOurs = (path) => !!lstatOrNull(path)?.isFile() && readFileSync(path, "utf-8").includes(HOOK_MARKER);
299
375
 
300
376
  /**
301
377
  * Write .git/hooks/pre-commit. An existing hook that is not ours is kept as
@@ -314,15 +390,23 @@ export function installHook(cwd, { force = false, script = hookScript() } = {})
314
390
  const path = join(dir, "pre-commit");
315
391
  const backup = join(dir, BACKUP_NAME);
316
392
  let updated = false;
317
- if (existsSync(path)) {
318
- if (isOurs(path)) updated = true;
319
- else if (existsSync(backup)) throw new Error(`Both ${path} and ${backup} exist. Merge them by hand, then run \`penthera hook install\` again.`);
393
+ const st = lstatOrNull(path);
394
+ if (st) {
395
+ // Anything that is not our own regular file (another hook, a symlink to a
396
+ // shared or not-yet-created hook) is moved aside as it is, never written through.
397
+ if (isOurs(path)) {
398
+ updated = true;
399
+ unlinkSync(path);
400
+ } else if (st.isDirectory()) throw new Error(`${path} is a directory. Move it away, then run \`penthera hook install\` again.`);
401
+ else if (lstatOrNull(backup)) throw new Error(`Both ${path} and ${backup} exist. Merge them by hand, then run \`penthera hook install\` again.`);
320
402
  else renameSync(path, backup);
321
403
  }
322
404
  mkdirSync(dir, { recursive: true });
323
- writeFileSync(path, script);
405
+ // "wx": create a new file, and fail rather than follow anything that
406
+ // appeared at this path in the meantime.
407
+ writeFileSync(path, script, { flag: "wx", mode: 0o755 });
324
408
  chmodSync(path, 0o755);
325
- return { path, chained: existsSync(backup) ? backup : null, updated };
409
+ return { path, chained: lstatOrNull(backup) ? backup : null, updated };
326
410
  }
327
411
 
328
412
  /**
@@ -334,10 +418,10 @@ export function uninstallHook(cwd) {
334
418
  const dir = hooksDir(root);
335
419
  const path = join(dir, "pre-commit");
336
420
  const backup = join(dir, BACKUP_NAME);
337
- if (!existsSync(path)) return { path, removed: false, restored: null };
421
+ if (!lstatOrNull(path)) return { path, removed: false, restored: null };
338
422
  if (!isOurs(path)) throw new Error(`${path} was not installed by penthera; left untouched.`);
339
423
  unlinkSync(path);
340
- if (existsSync(backup)) {
424
+ if (lstatOrNull(backup)) {
341
425
  renameSync(backup, path);
342
426
  return { path, removed: true, restored: path };
343
427
  }
@@ -5,7 +5,8 @@
5
5
  * "hook": {
6
6
  * "blockOn": "high", // lowest severity that blocks: critical | high | medium | low
7
7
  * "checks": { "secrets": true, "agents": true, "deps": true, "licenses": true },
8
- * "ignore": ["tests/fixtures/**"] // staged paths the hook skips (globs)
8
+ * "ignore": ["tests/fixtures/**"], // staged paths the hook skips (globs)
9
+ * "largeFiles": "block" // a staged file too large to check: block (default) | warn
9
10
  * }
10
11
  */
11
12
  export const SEVERITIES = ["critical", "high", "medium", "low", "info"];
@@ -15,13 +16,16 @@ export const DEFAULT_HOOK_POLICY = Object.freeze({
15
16
  blockOn: "high",
16
17
  checks: Object.freeze(Object.fromEntries(CHECKS.map((c) => [c, true]))),
17
18
  ignore: Object.freeze([]),
19
+ largeFiles: "block",
18
20
  });
21
+ const LARGE_FILES = ["block", "warn"];
19
22
 
20
23
  export function normalizeHookPolicy(raw, label = "hook") {
21
24
  if (raw == null) return { ...DEFAULT_HOOK_POLICY, checks: { ...DEFAULT_HOOK_POLICY.checks }, ignore: [] };
25
+ const FIELDS = ["blockOn", "checks", "ignore", "largeFiles"];
22
26
  if (typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${label}: expected an object`);
23
- const extra = Object.keys(raw).filter((k) => !["blockOn", "checks", "ignore"].includes(k));
24
- if (extra.length) throw new Error(`${label}: unknown field(s) ${extra.join(", ")} (expected blockOn, checks, ignore)`);
27
+ const extra = Object.keys(raw).filter((k) => !FIELDS.includes(k));
28
+ if (extra.length) throw new Error(`${label}: unknown field(s) ${extra.join(", ")} (expected ${FIELDS.join(", ")})`);
25
29
  const blockOn = raw.blockOn ?? "high";
26
30
  if (!SEVERITIES.slice(0, 4).includes(blockOn)) throw new Error(`${label}: "blockOn" must be one of critical, high, medium, low`);
27
31
  const checks = { ...DEFAULT_HOOK_POLICY.checks };
@@ -35,7 +39,9 @@ export function normalizeHookPolicy(raw, label = "hook") {
35
39
  }
36
40
  const ignore = raw.ignore ?? [];
37
41
  if (!Array.isArray(ignore) || ignore.some((x) => typeof x !== "string" || !x.trim())) throw new Error(`${label}: "ignore" must be an array of path globs`);
38
- return { blockOn, checks, ignore: ignore.map((x) => x.trim()) };
42
+ const largeFiles = raw.largeFiles ?? "block";
43
+ if (!LARGE_FILES.includes(largeFiles)) throw new Error(`${label}: "largeFiles" must be "block" or "warn"`);
44
+ return { blockOn, checks, ignore: ignore.map((x) => x.trim()), largeFiles };
39
45
  }
40
46
 
41
47
  /** Git-style path glob: `**` crosses folders, `*` and `?` stay inside one; a bare name matches at any depth. */
@@ -24,9 +24,9 @@
24
24
  */
25
25
  import { execFile } from "node:child_process";
26
26
  import { promisify } from "node:util";
27
- import { existsSync, mkdtempSync, readFileSync, writeFileSync, copyFileSync, rmSync, mkdirSync } from "node:fs";
27
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync, mkdirSync } from "node:fs";
28
28
  import { join, resolve } from "node:path";
29
- import { tmpdir } from "node:os";
29
+ import { withTempDir } from "../tmp.js";
30
30
  import { scanDeps } from "../deps/index.js";
31
31
  import { parseLockfile } from "../deps/lockfile.js";
32
32
  import { sourceFromNpmCache, npmCacheDir } from "../deps/source.js";
@@ -183,8 +183,7 @@ export async function planInstall(root, specs, opts = {}) {
183
183
  const original = Object.fromEntries(COPY.map((f) => [f, readIfExists(join(root, f))]));
184
184
  const lockName = LOCKFILES.find((f) => original[f]) || "package-lock.json";
185
185
 
186
- const tmp = mkdtempSync(join(tmpdir(), "penthera-install-"));
187
- try {
186
+ return withTempDir("penthera-install-", async (tmp) => {
188
187
  const work = join(tmp, "project");
189
188
  mkdirSync(work);
190
189
  for (const f of COPY) if (original[f]) copyFileSync(join(root, f), join(work, f));
@@ -246,9 +245,7 @@ export async function planInstall(root, specs, opts = {}) {
246
245
  typosquats: result.deps.typosquats,
247
246
  evidence: result.deps.diff.evidence,
248
247
  };
249
- } finally {
250
- rmSync(tmp, { recursive: true, force: true });
251
- }
248
+ });
252
249
  }
253
250
 
254
251
  /** Packages whose new or changed install scripts would run. */
package/lib/machine.js CHANGED
@@ -56,19 +56,6 @@ function verifySignature(program) {
56
56
  }
57
57
  }
58
58
 
59
- function runFile(file, args = [], opts = {}) {
60
- try {
61
- return execFileSync(file, args, {
62
- encoding: "utf-8",
63
- timeout: opts.timeout || 30_000,
64
- maxBuffer: 10 * 1024 * 1024,
65
- stdio: ["pipe", "pipe", "pipe"],
66
- }).trim();
67
- } catch (e) {
68
- return e.stdout?.trim?.() || "";
69
- }
70
- }
71
-
72
59
  function which(bin) {
73
60
  try {
74
61
  const p = execSync(`which ${bin}`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
@@ -28,7 +28,7 @@ import { homedir } from "node:os";
28
28
 
29
29
  export const EVENT_VERSION = 1;
30
30
  export const EVENT_TYPES = Object.freeze([
31
- "scan", "fix_applied", "fix_verified", "fix_failed", "fp_suppressed", "probe_yield", "loop_run",
31
+ "scan", "fix_applied", "fix_verified", "fix_failed", "fp_suppressed", "probe_yield", "loop_run", "gave_up",
32
32
  ]);
33
33
  const MAX_LINE_BYTES = 256 * 1024;
34
34
  const LOCK_STALE_MS = 30_000;
@@ -152,7 +152,11 @@ function sleepMs(ms) {
152
152
 
153
153
  function withLock(dir, fn) {
154
154
  const lock = join(dir, "events.lock");
155
- const started = Date.now();
155
+ // The timeout is per holder: while other writers keep taking and releasing
156
+ // the lock we are queued behind progress, not stuck, however long that takes
157
+ // on a loaded machine. Only one holder keeping it for LOCK_TIMEOUT_MS is busy.
158
+ let holder = null;
159
+ let waitingSince = Date.now();
156
160
  let fd;
157
161
  for (;;) {
158
162
  try {
@@ -161,9 +165,12 @@ function withLock(dir, fn) {
161
165
  } catch (e) {
162
166
  if (e.code !== "EEXIST") throw e;
163
167
  try {
164
- if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) { unlinkSync(lock); continue; }
168
+ const st = statSync(lock);
169
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) { unlinkSync(lock); continue; }
170
+ const id = `${st.ino}:${st.mtimeMs}`;
171
+ if (id !== holder) { holder = id; waitingSince = Date.now(); }
165
172
  } catch { continue; } // lock vanished between calls: retry
166
- if (Date.now() - started > LOCK_TIMEOUT_MS) throw new Error(`memory: lock busy (${lock})`);
173
+ if (Date.now() - waitingSince > LOCK_TIMEOUT_MS) throw new Error(`memory: lock busy (${lock})`);
167
174
  sleepMs(5 + Math.floor(Math.random() * 20));
168
175
  }
169
176
  }
@@ -307,28 +307,3 @@ export async function executeTemplates(baseUrl, templates, opts = {}) {
307
307
  return findings;
308
308
  }
309
309
 
310
- /**
311
- * Get stats about a template directory.
312
- */
313
- export async function getTemplateStats(dir) {
314
- const templates = await loadTemplatesFromDir(dir);
315
- const bySeverity = {};
316
- const byTag = {};
317
-
318
- for (const t of templates) {
319
- const sev = t.info.severity || "unknown";
320
- bySeverity[sev] = (bySeverity[sev] || 0) + 1;
321
- for (const tag of t.info.tags) {
322
- byTag[tag] = (byTag[tag] || 0) + 1;
323
- }
324
- }
325
-
326
- return {
327
- total: templates.length,
328
- bySeverity,
329
- topTags: Object.entries(byTag)
330
- .sort(([, a], [, b]) => b - a)
331
- .slice(0, 20)
332
- .map(([tag, count]) => ({ tag, count })),
333
- };
334
- }
package/lib/owasp-wstg.js CHANGED
@@ -34,6 +34,7 @@ export const PROBE_WSTG = [
34
34
  { id: "secret-scanning", probe: "Secret scanning", wstg: ["WSTG-CONF-02", "WSTG-CONF-05"], profiles: ["quick", "standard", "deep"], modes: ["repo"] },
35
35
  { id: "trust-boundary", probe: "Route / trust-boundary analysis", wstg: ["WSTG-ATHZ-02", "WSTG-CONF-02"], profiles: ["quick", "standard", "deep"], modes: ["repo"] },
36
36
  { id: "risky-patterns", probe: "Risky code patterns", wstg: ["WSTG-INPV-01", "WSTG-INPV-12"], profiles: ["standard", "deep"], modes: ["repo"] },
37
+ { id: "runtime-advisories", probe: "Runtime & server advisories (PHP, nginx, Apache, Node.js, Python, OpenSSL)", wstg: ["WSTG-INFO-02", "WSTG-INFO-08", "WSTG-CONF-01"], profiles: ["quick", "standard", "deep"], modes: ["url", "repo"] },
37
38
  ];
38
39
 
39
40
  /** Finding category/source → WSTG tags for individual findings. */
@@ -54,6 +55,7 @@ export const FINDING_WSTG = {
54
55
  fuzzing: ["WSTG-INPV-11"],
55
56
  recon: ["WSTG-INFO-01"],
56
57
  cve: ["WSTG-CONF-04"],
58
+ "runtime-advisory": ["WSTG-INFO-02", "WSTG-CONF-01"],
57
59
  "rate-limiting": ["WSTG-ATHN-10"],
58
60
  "code-pattern": ["WSTG-INPV-01", "WSTG-INPV-12"],
59
61
  config: ["WSTG-CONF-02"],
@@ -79,7 +81,9 @@ const WSTG_NAMES = {
79
81
  "WSTG-INFO-04": "Enumerate Applications on Webserver",
80
82
  "WSTG-INFO-06": "Identify Application Entry Points",
81
83
  "WSTG-INFO-07": "Map Execution Paths Through Application",
84
+ "WSTG-INFO-08": "Fingerprint Web Application Framework",
82
85
  "WSTG-INFO-09": "Fingerprint Web Application",
86
+ "WSTG-CONF-01": "Test Network Infrastructure Configuration",
83
87
  "WSTG-CONF-02": "Test Application Platform Configuration",
84
88
  "WSTG-CONF-04": "Review Old Backup and Unreferenced Files",
85
89
  "WSTG-CONF-05": "Enumerate Infrastructure and Application Admin Interfaces",
package/lib/plugins.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * const findings = await runTemplateScan("https://app.example", templates);
8
8
  */
9
9
  import { existsSync, statSync } from "node:fs";
10
- import { extname, resolve } from "node:path";
10
+ import { resolve } from "node:path";
11
11
  import {
12
12
  loadTemplatesFromDir,
13
13
  executeTemplates,
package/lib/provenance.js CHANGED
@@ -27,6 +27,7 @@ const SOURCE_CONFIDENCE = {
27
27
  "email-dns-probe": "confirmed",
28
28
  "framework-check": "likely",
29
29
  "deps-scan": "likely",
30
+ "runtime-advisory": "likely",
30
31
  "static-analysis": "likely",
31
32
  "trust-boundary": "likely",
32
33
  "auth-probe": "likely",
package/lib/recon.js CHANGED
@@ -12,10 +12,12 @@
12
12
  * Inspired by: subfinder, gau, waybackurls, ParamSpider
13
13
  */
14
14
 
15
- import { safeFetch } from "../src/utils/http.js";
15
+ import { fetchJson } from "../src/utils/http.js";
16
16
  import { registrableDomain } from "../src/utils/url.js";
17
17
 
18
18
  const RECON_TIMEOUT = 15_000;
19
+ // Certificate-transparency answers for large domains run to megabytes.
20
+ const RECON_MAX_BODY = 20 * 1024 * 1024;
19
21
 
20
22
  /**
21
23
  * Third-party hosts this module queries. Under --offline the scan skips recon
@@ -33,18 +35,11 @@ export const RECON_HOSTS = ["crt.sh", "web.archive.org", "otx.alienvault.com"];
33
35
  * @returns {string[]} Discovered subdomains
34
36
  */
35
37
  export async function discoverSubdomains(domain) {
36
- const res = await safeFetch(
38
+ const data = await fetchJson(
37
39
  `https://crt.sh/?q=%25.${encodeURIComponent(domain)}&output=json`,
38
- { timeout: 20_000, probe: "recon", redirect: "follow" },
40
+ { timeout: 20_000, probe: "recon", redirect: "follow", maxBytes: RECON_MAX_BODY },
39
41
  );
40
- if (!res || !res.ok) return [];
41
-
42
- let data;
43
- try {
44
- data = await res.json();
45
- } catch {
46
- return [];
47
- }
42
+ if (!data) return [];
48
43
 
49
44
  const subdomains = new Set();
50
45
  for (const entry of data) {
@@ -72,18 +67,11 @@ export async function discoverSubdomains(domain) {
72
67
  */
73
68
  export async function fetchWaybackUrls(domain, opts = {}) {
74
69
  const limit = opts.limit || 1000;
75
- const res = await safeFetch(
70
+ const rows = await fetchJson(
76
71
  `https://web.archive.org/cdx/search/cdx?url=*.${encodeURIComponent(domain)}/*&output=json&fl=original&collapse=urlkey&limit=${limit}`,
77
- { timeout: 20_000, probe: "recon", redirect: "follow" },
72
+ { timeout: 20_000, probe: "recon", redirect: "follow", maxBytes: RECON_MAX_BODY },
78
73
  );
79
- if (!res || !res.ok) return [];
80
-
81
- let rows;
82
- try {
83
- rows = await res.json();
84
- } catch {
85
- return [];
86
- }
74
+ if (!rows) return [];
87
75
 
88
76
  // First row is the header ["original"]
89
77
  return rows.slice(1).map((r) => r[0]).filter(Boolean);
@@ -98,18 +86,11 @@ export async function fetchWaybackUrls(domain, opts = {}) {
98
86
  */
99
87
  export async function fetchOtxUrls(domain, opts = {}) {
100
88
  const limit = opts.limit || 200;
101
- const res = await safeFetch(
89
+ const data = await fetchJson(
102
90
  `https://otx.alienvault.com/api/v1/indicators/domain/${encodeURIComponent(domain)}/url_list?limit=${limit}`,
103
- { timeout: RECON_TIMEOUT, probe: "recon", redirect: "follow" },
91
+ { timeout: RECON_TIMEOUT, probe: "recon", redirect: "follow", maxBytes: RECON_MAX_BODY },
104
92
  );
105
- if (!res || !res.ok) return [];
106
-
107
- let data;
108
- try {
109
- data = await res.json();
110
- } catch {
111
- return [];
112
- }
93
+ if (!data) return [];
113
94
 
114
95
  return (data.url_list || []).map((e) => e.url).filter(Boolean);
115
96
  }