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,144 @@
1
+ /**
2
+ * `penthera rules list|verify <dir>`, and loading `--rules <dir>` packs for a scan.
3
+ * Rule packs are data, verified before use; see docs/rule-packs.md.
4
+ */
5
+ import { parseArgs } from "node:util";
6
+ import { resolve } from "node:path";
7
+ import { bold, dim, green, red, yellow } from "./ansi.js";
8
+
9
+ const HELP = `
10
+ ${bold("penthera rules")}: signed, versioned detection rule packs (offline)
11
+
12
+ penthera rules list [--rules <dir>]... Packs in effect: bundled, plus any given
13
+ penthera rules verify <dir> [--rules-signers <file>] [--json]
14
+ Check a pack: signature, file hashes,
15
+ schema, ReDoS guard. Exit 0 only if it
16
+ would load without --allow-unsigned-rules.
17
+
18
+ Add packs to a scan with --rules <dir> (repeatable). There is no download
19
+ command: packs arrive by path or by upgrading Penthera.
20
+ `;
21
+
22
+ const kindsSummary = (rules) => Object.entries(rules || {}).filter(([, v]) => v.length).map(([k, v]) => `${k} ${v.length}`).join(", ");
23
+
24
+ /**
25
+ * Verify and activate --rules packs. Throws with a user-facing message on refusal.
26
+ * @returns {string[]} warnings to print
27
+ */
28
+ export async function activateRules(opts) {
29
+ const dirs = [].concat(opts.rules || []);
30
+ if (dirs.length === 0) return [];
31
+ const { addRulePack, loadSigners } = await import("../../lib/rules/index.js");
32
+ const signers = loadSigners([].concat(opts["rules-signers"] || []).map((f) => resolve(f)));
33
+ const warnings = [];
34
+ for (const d of dirs) {
35
+ const r = addRulePack(resolve(d), { signers, allowUnsigned: !!opts["allow-unsigned-rules"] });
36
+ warnings.push(...r.warnings);
37
+ }
38
+ return warnings;
39
+ }
40
+
41
+ /** Print warnings from activateRules to stderr; unsigned-pack warnings are loud on purpose. */
42
+ export function printRuleWarnings(warnings) {
43
+ for (const w of warnings) {
44
+ if (w.startsWith("LOADING AN UNTRUSTED")) {
45
+ process.stderr.write(`\n ${red(bold("!! WARNING !!"))} ${bold(w)}\n\n`);
46
+ } else {
47
+ process.stderr.write(` ${yellow("warn")} rules: ${w}\n`);
48
+ }
49
+ }
50
+ }
51
+
52
+ export async function runRules(argv) {
53
+ let parsed;
54
+ try {
55
+ parsed = parseArgs({
56
+ args: argv,
57
+ allowPositionals: true,
58
+ options: {
59
+ rules: { type: "string", multiple: true },
60
+ "rules-signers": { type: "string", multiple: true },
61
+ "allow-unsigned-rules": { type: "boolean", default: false },
62
+ json: { type: "boolean", default: false },
63
+ help: { type: "boolean", short: "h", default: false },
64
+ },
65
+ });
66
+ } catch (e) {
67
+ console.error(` ${red("error")} ${e.message}`);
68
+ return 2;
69
+ }
70
+ const { values: opts, positionals } = parsed;
71
+ const [cmd, ...rest] = positionals;
72
+ if (opts.help || !cmd) {
73
+ console.log(HELP);
74
+ return opts.help ? 0 : 2;
75
+ }
76
+
77
+ const rules = await import("../../lib/rules/index.js");
78
+ let signers;
79
+ try {
80
+ signers = rules.loadSigners([].concat(opts["rules-signers"] || []).map((f) => resolve(f)));
81
+ } catch (e) {
82
+ console.error(` ${red("error")} ${e.message}`);
83
+ return 2;
84
+ }
85
+
86
+ if (cmd === "verify") {
87
+ if (rest.length !== 1) {
88
+ console.error(` ${red("error")} usage: penthera rules verify <dir>`);
89
+ return 2;
90
+ }
91
+ const v = rules.verifyPack(rest[0], { signers });
92
+ const trusted = v.ok && v.signature === "valid";
93
+ if (opts.json) {
94
+ const { rules: compiled, manifest, ...out } = v;
95
+ console.log(JSON.stringify({ ...out, trusted, kinds: Object.fromEntries(Object.entries(compiled || {}).map(([k, x]) => [k, x.length])) }, null, 2));
96
+ return trusted ? 0 : 1;
97
+ }
98
+ console.log(`\n ${bold("Rule pack")} ${v.name ? `${v.name}@${v.version}` : rest[0]} ${dim(v.dir)}`);
99
+ const sigLine = v.signature === "valid" ? green(`valid, signed by ${v.signer}`)
100
+ : v.signature === "invalid" ? red(`INVALID: ${v.signatureError}`)
101
+ : yellow("missing (no pack.json.sig)");
102
+ console.log(` signature ${sigLine}`);
103
+ if (v.ok) {
104
+ console.log(` files ${green("all SHA-256 hashes match pack.json")}`);
105
+ console.log(` rules ${green(`${v.count} valid`)} ${dim(`(${kindsSummary(v.rules)})`)}`);
106
+ console.log(` regexes ${green("all pass the ReDoS guard")}`);
107
+ }
108
+ for (const e of v.errors) console.log(` ${red("error")} ${e}`);
109
+ for (const w of v.warnings) console.log(` ${yellow("warn")} ${w}`);
110
+ console.log(trusted
111
+ ? `\n ${green("OK")}: this pack loads with --rules ${rest[0]}\n`
112
+ : v.ok
113
+ ? `\n ${yellow("NOT TRUSTED")}: well-formed, but it only loads with --allow-unsigned-rules.${rules.bundledPackDirs().includes(v.dir) ? " (Bundled packs load as part of the release; npm run rules:check refuses to publish them unsigned.)" : ""}\n`
114
+ : `\n ${red("REFUSED")}: this pack does not load, with or without --allow-unsigned-rules.\n`);
115
+ return trusted ? 0 : 1;
116
+ }
117
+
118
+ if (cmd === "list") {
119
+ try {
120
+ printRuleWarnings(await activateRules({ ...opts, rules: opts.rules }));
121
+ } catch (e) {
122
+ console.error(` ${red("error")} ${e.message}`);
123
+ return 1;
124
+ }
125
+ const packs = rules.activePacks();
126
+ if (opts.json) {
127
+ console.log(JSON.stringify(packs, null, 2));
128
+ return 0;
129
+ }
130
+ console.log(`\n ${bold("Rule packs in effect")}\n`);
131
+ for (const p of packs) {
132
+ const sig = p.signature === "valid" ? green(`signed by ${p.signer}`)
133
+ : p.origin === "bundled" ? yellow(`${p.signature === "invalid" ? "signature invalid" : "unsigned"} (bundled; release check requires a signature)`)
134
+ : red("UNSIGNED (--allow-unsigned-rules)");
135
+ console.log(` ${bold(`${p.name}@${p.version}`)} ${dim(p.origin)} ${p.count} rules ${sig}`);
136
+ console.log(` ${dim(p.dir)}`);
137
+ }
138
+ console.log("");
139
+ return 0;
140
+ }
141
+
142
+ console.error(` ${red("error")} unknown rules command "${cmd}" (list, verify)`);
143
+ return 2;
144
+ }
package/src/cli/ui.js CHANGED
@@ -6,6 +6,9 @@
6
6
  */
7
7
  import { parseArgs } from "node:util";
8
8
  import { spawn } from "node:child_process";
9
+ import { mkdtempSync, openSync, writeSync, closeSync, rmSync, constants as FS } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
9
12
  import { bold, dim, red, green } from "./ansi.js";
10
13
 
11
14
  const HELP = `
@@ -17,25 +20,55 @@ const HELP = `
17
20
 
18
21
  ${dim("Options")}
19
22
  --port <n> Port to listen on (default: 0, a random free port)
20
- --open Open the one-time URL in your browser
23
+ --open Open the one-time URL in your browser (through a
24
+ private launcher file, so the token is not on a
25
+ command line)
21
26
  --project <dir> Folder the dashboard may read and run the agent loop in
22
27
  (repeatable; default: the current folder)
23
28
  --reports <dir> Report JSON folder (default: <first project>/reports)
24
29
  --demo Seed fake data; nothing is read from disk
25
30
  -h, --help Show this help
26
31
 
27
- The URL holds a one-time token. Opening it sets a session cookie; the token
28
- then stops working. Nothing outside the project folders and ~/.penthera is
29
- read, and the server never contacts the network. Ctrl-C stops it.
32
+ The URL holds a one-time token after the #, which the browser never sends
33
+ over the network. The page swaps it for a session that lives in that tab
34
+ only; the token then stops working. Nothing outside the project folders and
35
+ ~/.penthera is read, and the server never contacts the network. Ctrl-C
36
+ stops it.
30
37
  `;
31
38
 
32
- function openBrowser(url) {
33
- const [cmd, args] = process.platform === "darwin" ? ["open", [url]]
34
- : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
35
- : ["xdg-open", [url]];
39
+ /**
40
+ * Write a small page that forwards to `url`, in a folder only this user can
41
+ * read, and return its path. --open hands the browser this path instead of
42
+ * the URL, so the token never appears in a command line (`ps` shows every
43
+ * process's arguments to every local user).
44
+ */
45
+ export function writeLauncher(url, dir = mkdtempSync(join(tmpdir(), "penthera-ui-"))) {
46
+ const file = join(dir, "open.html");
47
+ const safe = url.replace(/[^A-Za-z0-9:/#=._-]/g, "");
48
+ const html = `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer">`
49
+ + `<meta http-equiv="refresh" content="0;url=${safe}"><title>penthera ui</title>`
50
+ + `<p>Opening penthera ui. If nothing happens, use the link printed in the terminal.</p>\n`;
51
+ const fd = openSync(file, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL | (FS.O_NOFOLLOW || 0), 0o600);
52
+ try { writeSync(fd, html); } finally { closeSync(fd); }
53
+ return { file, dir };
54
+ }
55
+
56
+ function openBrowser(url, session) {
57
+ let launcher;
58
+ try { launcher = writeLauncher(url); } catch { return; /* the URL is printed anyway */ }
59
+ const target = launcher.file;
60
+ const [cmd, args] = process.platform === "darwin" ? ["open", [target]]
61
+ : process.platform === "win32" ? ["cmd", ["/c", "start", "", target]]
62
+ : ["xdg-open", [target]];
36
63
  try {
37
64
  spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
38
65
  } catch { /* the URL is printed anyway */ }
66
+ // The launcher is useless once the link is redeemed; remove it then, or after two minutes.
67
+ const cleanup = () => { clearInterval(poll); rmSync(launcher.dir, { recursive: true, force: true }); };
68
+ const poll = setInterval(() => { if (session.redeemed) cleanup(); }, 500);
69
+ poll.unref();
70
+ setTimeout(cleanup, 120_000).unref();
71
+ process.once("exit", cleanup);
39
72
  }
40
73
 
41
74
  export async function runUi(argv, { version }) {
@@ -76,9 +109,9 @@ export async function runUi(argv, { version }) {
76
109
 
77
110
  process.stdout.write(`\n ${bold("penthera ui")}${opts.demo ? dim(" (demo data)") : ""}\n`);
78
111
  process.stdout.write(` ${green(ui.url)}\n`);
79
- process.stdout.write(dim(` one-time link: it signs this browser in once, then stops working\n`));
112
+ process.stdout.write(dim(` one-time link: it signs one browser tab in, then stops working\n`));
80
113
  process.stdout.write(dim(` listening on 127.0.0.1:${ui.port} only. Ctrl-C to stop\n\n`));
81
- if (opts.open) openBrowser(ui.url);
114
+ if (opts.open) openBrowser(ui.url, ui.session);
82
115
 
83
116
  await new Promise((resolve) => {
84
117
  const stop = () => { process.stdout.write("\n Stopped.\n"); ui.close().then(resolve); };
package/src/cli.js CHANGED
@@ -51,7 +51,7 @@ function shouldRunOnboarding(positionals, opts) {
51
51
  }
52
52
 
53
53
  const HELP = `
54
- ${bold("penthera")} v${pkg.version}: Cyber Security Agent. Open-source security for the apps and AI systems you ship
54
+ ${bold("penthera")} v${pkg.version}: your open-source cyber security agent. The security review you never had time for
55
55
 
56
56
  ${dim("Usage")}
57
57
  $ penthera Setup wizard: detect, check, set up (TTY, no args)
@@ -66,6 +66,7 @@ const HELP = `
66
66
  $ penthera memory show|verify|forget Local learning store (never leaves the machine)
67
67
  $ penthera fix --plan Plan local fix recipes (penthera fix --help)
68
68
  $ penthera evidence verify <dir> Check an evidence bundle's hashes and signature
69
+ $ penthera rules list|verify <dir> Signed detection rule packs (penthera rules --help)
69
70
  $ penthera agent --loop Scan, fix, verify, learn; local commits only (--help)
70
71
  $ penthera ui [--open] [--demo] Local dashboard on 127.0.0.1 (penthera ui --help)
71
72
 
@@ -78,6 +79,9 @@ const HELP = `
78
79
  --fuzz + Property-based API fuzzing
79
80
  --nuclei <path> + Community Nuclei YAML templates (alias for --templates)
80
81
  --templates <paths> Comma-separated dirs of Nuclei-compatible YAML templates
82
+ --rules <dir> + Signed rule pack (repeatable; scans, agents, deps)
83
+ --rules-signers <file> Also trust packs signed by these keys (allowed_signers)
84
+ --allow-unsigned-rules Load a pack whose signature does not verify (warns)
81
85
  --adaptive Knowledge-graph adaptive probes on discovered routes
82
86
  --all Enable all modes (recon + deep + fuzz)
83
87
  --machine macOS machine audit (keyloggers, trojans, rootkits)
@@ -210,6 +214,11 @@ export async function run() {
210
214
  return runFix(process.argv.slice(3));
211
215
  }
212
216
 
217
+ if (process.argv[2] === "rules") {
218
+ const { runRules } = await import("./cli/rules.js");
219
+ process.exit(await runRules(process.argv.slice(3)));
220
+ }
221
+
213
222
  if (process.argv[2] === "ui") {
214
223
  const { runUi } = await import("./cli/ui.js");
215
224
  process.exit(await runUi(process.argv.slice(3), { version: pkg.version }));
@@ -268,6 +277,9 @@ export async function run() {
268
277
  fuzz: { type: "boolean", default: false },
269
278
  nuclei: { type: "string" },
270
279
  templates: { type: "string" },
280
+ rules: { type: "string", multiple: true },
281
+ "rules-signers": { type: "string", multiple: true },
282
+ "allow-unsigned-rules": { type: "boolean", default: false },
271
283
  adaptive: { type: "boolean", default: false },
272
284
  all: { type: "boolean", default: false },
273
285
  machine: { type: "boolean", default: false },
@@ -299,6 +311,17 @@ export async function run() {
299
311
  process.exit(0);
300
312
  }
301
313
 
314
+ // --rules <dir>: verified rule packs, added before any engine runs.
315
+ if (opts.rules) {
316
+ const { activateRules, printRuleWarnings } = await import("./cli/rules.js");
317
+ try {
318
+ printRuleWarnings(await activateRules(opts));
319
+ } catch (e) {
320
+ printError(e.message);
321
+ process.exit(2);
322
+ }
323
+ }
324
+
302
325
  if (opts["assessment-init"]) {
303
326
  const path = resolve(opts["assessment-init"]);
304
327
  writeFileSync(path, JSON.stringify(blankAssessment(), null, 2) + "\n");
@@ -11,9 +11,10 @@
11
11
  import { createHash } from "node:crypto";
12
12
  import { spawnSync } from "node:child_process";
13
13
  import {
14
- readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, lstatSync,
14
+ readFileSync, mkdirSync, existsSync, readdirSync, lstatSync, mkdtempSync, realpathSync, renameSync, rmdirSync,
15
+ rmSync, chmodSync, openSync, writeSync, closeSync, fstatSync, readSync, constants as FS,
15
16
  } from "node:fs";
16
- import { join, resolve, relative, sep } from "node:path";
17
+ import { join, resolve, relative, sep, dirname, basename } from "node:path";
17
18
  import { createGit } from "../agent/git.js";
18
19
 
19
20
  export const SCHEMA = "penthera-evidence/1";
@@ -21,6 +22,9 @@ export const NAMESPACE = "penthera-evidence";
21
22
  export const MANIFEST = "manifest.json";
22
23
  export const SIGNATURE = "manifest.json.sig";
23
24
 
25
+ /** Largest artifact or manifest verify will read; a bundle holds reports, not data dumps. */
26
+ export const MAX_VERIFY_BYTES = 64 * 1024 * 1024;
27
+
24
28
  const SAFE_PATH = /^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/;
25
29
  const SECRET_FLAG = /^--?(?:auth-cookie|auth-bearer|cookie|[\w-]*(?:token|secret|password|passwd|api-key|apikey)[\w-]*)$/i;
26
30
  const SECRET_PARAM = /^(?:[\w-]*(?:token|secret|password|passwd|key|sig|signature|auth|session|code)[\w-]*)$/i;
@@ -106,26 +110,66 @@ export function assertEmptyDir(dir) {
106
110
  if (readdirSync(dir).length) throw new Error(`--evidence ${dir} is not empty; use a new directory per run`);
107
111
  }
108
112
 
113
+ /** Create `file` and write `buf`: never over an existing file, never through a symlink. */
114
+ function writeExclusive(file, buf) {
115
+ const fd = openSync(file, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL | (FS.O_NOFOLLOW || 0), 0o644);
116
+ try {
117
+ let off = 0;
118
+ while (off < buf.length) off += writeSync(fd, buf, off, buf.length - off);
119
+ } finally {
120
+ closeSync(fd);
121
+ }
122
+ }
123
+
109
124
  /**
110
- * Write a bundle.
125
+ * Write a bundle. It is built in a private temp folder next to `dir` and
126
+ * renamed into place at the end, so a half-written bundle is never visible
127
+ * and no write lands outside that folder.
111
128
  * @param {string} dir
112
129
  * @param {{ path: string, content: string|Buffer, kind?: string }[]} artifacts
113
130
  * @param {{ version: string, argv: string[], target: string, generatedAt: string,
114
- * scan?: object, repo?: string|null, signKey?: string|null }} meta
131
+ * scan?: object, repo?: string|null, signKey?: string|null,
132
+ * parentReal?: string|null }} meta parentReal: the real path the
133
+ * parent folder must still resolve to (the dashboard's reports/evidence)
115
134
  * @returns {{ manifest: object, signed: boolean, dir: string }}
116
135
  */
117
136
  export function writeEvidenceBundle(dir, artifacts, meta) {
118
137
  const root = resolve(dir);
119
138
  assertEmptyDir(root);
120
- mkdirSync(root, { recursive: true });
139
+ for (const a of artifacts) {
140
+ if (!SAFE_PATH.test(a.path) || a.path === MANIFEST || a.path === SIGNATURE) throw new Error(`Bad artifact path ${a.path}`);
141
+ }
142
+ const parent = dirname(root);
143
+ mkdirSync(parent, { recursive: true });
144
+ const parentReal = realpathSync(parent);
145
+ const checkParent = () => {
146
+ const now = realpathSync(parent);
147
+ if (now !== parentReal || (meta.parentReal && now !== meta.parentReal)) {
148
+ throw new Error(`Refusing to write the bundle: ${parent} now resolves to ${now}`);
149
+ }
150
+ };
151
+ checkParent();
152
+ const tmp = mkdtempSync(join(parentReal, `.${basename(root)}.tmp-`));
153
+ try {
154
+ const out = buildBundle(tmp, artifacts, meta);
155
+ chmodSync(tmp, 0o755);
156
+ checkParent();
157
+ if (existsSync(root)) rmdirSync(root); // assertEmptyDir allowed an existing empty folder
158
+ renameSync(tmp, join(parentReal, basename(root)));
159
+ return { ...out, dir: root };
160
+ } catch (e) {
161
+ rmSync(tmp, { recursive: true, force: true });
162
+ throw e;
163
+ }
164
+ }
121
165
 
166
+ function buildBundle(root, artifacts, meta) {
122
167
  const entries = [];
123
168
  for (const a of artifacts) {
124
- if (!SAFE_PATH.test(a.path) || a.path === MANIFEST || a.path === SIGNATURE) throw new Error(`Bad artifact path ${a.path}`);
125
169
  const buf = Buffer.isBuffer(a.content) ? a.content : Buffer.from(String(a.content), "utf-8");
126
170
  const file = join(root, ...a.path.split("/"));
127
- mkdirSync(resolve(file, ".."), { recursive: true });
128
- writeFileSync(file, buf);
171
+ mkdirSync(dirname(file), { recursive: true });
172
+ writeExclusive(file, buf);
129
173
  entries.push({ path: a.path, kind: a.kind || null, bytes: buf.length, sha256: sha256(buf) });
130
174
  }
131
175
  entries.sort((x, y) => x.path.localeCompare(y.path));
@@ -145,10 +189,10 @@ export function writeEvidenceBundle(dir, artifacts, meta) {
145
189
  signature: signKey ? { namespace: NAMESPACE, file: SIGNATURE, public_key: publicKeyFor(signKey) } : null,
146
190
  };
147
191
  const manifestPath = join(root, MANIFEST);
148
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
192
+ writeExclusive(manifestPath, Buffer.from(JSON.stringify(manifest, null, 2) + "\n", "utf-8"));
149
193
 
150
194
  if (signKey) signManifest(manifestPath, signKey);
151
- return { manifest, signed: Boolean(signKey), dir: root };
195
+ return { manifest, signed: Boolean(signKey) };
152
196
  }
153
197
 
154
198
  /** ssh-keygen -Y sign; writes <manifest>.sig. A passphrase prompt goes to the terminal. */
@@ -161,6 +205,42 @@ export function signManifest(manifestPath, keyPath) {
161
205
  if (r.status !== 0) throw new Error(`ssh-keygen -Y sign failed: ${(r.stderr || "").trim()}`);
162
206
  }
163
207
 
208
+ /**
209
+ * Read `rel` (a SAFE_PATH below `root`) only when every component is a real
210
+ * folder or, for the last, a regular file no larger than MAX_VERIFY_BYTES.
211
+ * Returns { buf } or { reason }.
212
+ */
213
+ function readInside(root, rel, maxBytes = MAX_VERIFY_BYTES) {
214
+ let cur = root;
215
+ const parts = rel.split("/");
216
+ for (let i = 0; i < parts.length; i++) {
217
+ cur = join(cur, parts[i]);
218
+ let st;
219
+ try { st = lstatSync(cur); } catch { return { reason: "missing" }; }
220
+ const last = i === parts.length - 1;
221
+ if (st.isSymbolicLink()) return { reason: last ? "not a regular file" : "inside a symlinked folder" };
222
+ if (!last && !st.isDirectory()) return { reason: "missing" };
223
+ if (last && !st.isFile()) return { reason: "not a regular file" };
224
+ if (last && st.size > maxBytes) return { reason: `larger than ${maxBytes} bytes` };
225
+ }
226
+ let fd;
227
+ try { fd = openSync(cur, FS.O_RDONLY | (FS.O_NOFOLLOW || 0)); } catch { return { reason: "unreadable" }; }
228
+ try {
229
+ const size = fstatSync(fd).size;
230
+ if (size > maxBytes) return { reason: `larger than ${maxBytes} bytes` };
231
+ const buf = Buffer.alloc(size);
232
+ let off = 0;
233
+ while (off < size) {
234
+ const n = readSync(fd, buf, off, size - off, off);
235
+ if (!n) break;
236
+ off += n;
237
+ }
238
+ return { buf: buf.subarray(0, off) };
239
+ } finally {
240
+ closeSync(fd);
241
+ }
242
+ }
243
+
164
244
  function listFiles(root, dir = root, out = []) {
165
245
  for (const name of readdirSync(dir)) {
166
246
  const p = join(dir, name);
@@ -218,17 +298,20 @@ function checkSignature(root, manifestBuf, manifest, allowedSigners) {
218
298
  * `warnings` carries the UNSIGNED notice for a bundle that passes without a
219
299
  * signature; `requireSignature` turns that into a failure.
220
300
  * @param {string} dir
221
- * @param {{ allowedSigners?: string, requireSignature?: boolean }} opts
301
+ * @param {{ allowedSigners?: string, requireSignature?: boolean, maxBytes?: number }} opts
302
+ * maxBytes: largest file read (default MAX_VERIFY_BYTES); every path
303
+ * component is lstat'ed, so a symlinked folder is never followed
222
304
  */
223
- export function verifyEvidence(dir, { allowedSigners = null, requireSignature = false } = {}) {
305
+ export function verifyEvidence(dir, { allowedSigners = null, requireSignature = false, maxBytes = MAX_VERIFY_BYTES } = {}) {
224
306
  const root = resolve(dir);
225
307
  const problems = [];
226
308
  const files = [];
227
309
  const fail = (msg) => ({ ok: false, problems: [msg], warnings: [], files, unlisted: [], signature: null, manifest: null });
228
310
 
229
- const manifestPath = join(root, MANIFEST);
230
- if (!existsSync(manifestPath)) return fail(`${MANIFEST} not found in ${dir}`);
231
- const manifestBuf = readFileSync(manifestPath);
311
+ const read = readInside(root, MANIFEST, maxBytes);
312
+ if (read.reason === "missing") return fail(`${MANIFEST} not found in ${dir}`);
313
+ if (!read.buf) return fail(`${MANIFEST}: ${read.reason}`);
314
+ const manifestBuf = read.buf;
232
315
  let manifest;
233
316
  try {
234
317
  manifest = JSON.parse(manifestBuf.toString("utf-8"));
@@ -238,18 +321,19 @@ export function verifyEvidence(dir, { allowedSigners = null, requireSignature =
238
321
  if (manifest?.schema !== SCHEMA || !Array.isArray(manifest.artifacts)) return fail(`${MANIFEST} is not a ${SCHEMA} manifest`);
239
322
 
240
323
  const listed = new Set();
324
+ const folded = new Set(); // case-insensitive file systems: REPORT.json is report.json
241
325
  for (const a of manifest.artifacts) {
242
326
  const path = a?.path;
243
- if (typeof path !== "string" || !SAFE_PATH.test(path) || path === MANIFEST || path === SIGNATURE || listed.has(path)) {
327
+ const lower = typeof path === "string" ? path.toLowerCase() : null;
328
+ if (typeof path !== "string" || !SAFE_PATH.test(path) || lower === MANIFEST || lower === SIGNATURE || folded.has(lower)) {
244
329
  problems.push(`unsafe or duplicate artifact path ${JSON.stringify(path)}`);
245
330
  continue;
246
331
  }
247
332
  listed.add(path);
248
- const file = join(root, ...path.split("/"));
249
- const st = existsSync(file) ? lstatSync(file) : null;
250
- if (!st) { files.push({ path, ok: false, reason: "missing" }); continue; }
251
- if (!st.isFile()) { files.push({ path, ok: false, reason: "not a regular file" }); continue; }
252
- const actual = sha256(readFileSync(file));
333
+ folded.add(lower);
334
+ const got = readInside(root, path, maxBytes);
335
+ if (!got.buf) { files.push({ path, ok: false, reason: got.reason }); continue; }
336
+ const actual = sha256(got.buf);
253
337
  if (typeof a.sha256 !== "string" || actual !== a.sha256.toLowerCase()) {
254
338
  files.push({ path, ok: false, reason: "hash mismatch", expected: a.sha256, actual });
255
339
  } else {
@@ -3,7 +3,7 @@
3
3
  * report, audience reports, the PR comment and the evidence bundle README.
4
4
  * Never added to SARIF or JSON. `--no-footer` turns it off.
5
5
  */
6
- export const FOOTER = "Checked by Penthera, open-source security for the apps and AI systems you ship · https://github.com/danoszz/penthera";
6
+ export const FOOTER = "Checked by Penthera, an open-source cyber security agent · https://github.com/danoszz/penthera";
7
7
 
8
8
  /** Lines to push at the very end of a Markdown document, or none when opted out. */
9
9
  export function footerLines({ footer = true } = {}) {
@@ -14,3 +14,11 @@ export function mdInline(s) {
14
14
  .replace(/>/g, "&gt;")
15
15
  .replace(/\|/g, "\\|");
16
16
  }
17
+
18
+ /**
19
+ * A Markdown table cell for our own CI reports (text we wrote, not scanned
20
+ * input): backslashes first, then pipes, newlines folded to a space.
21
+ */
22
+ export function mdCell(s) {
23
+ return String(s).replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n|\r/g, " ");
24
+ }
package/src/scan-repo.js CHANGED
@@ -12,6 +12,7 @@ import { join } from "node:path";
12
12
  import { discoverAttackSurface, groupByTrustBoundary, findRiskyRoutes } from "../lib/attack-surface.js";
13
13
  import { scanSecrets } from "../lib/whitebox/secrets.js";
14
14
  import { discoverFrameworkRoutes } from "../lib/whitebox/frameworks.js";
15
+ import { runtimeAdvisoriesForRepo } from "../lib/advisories/index.js";
15
16
 
16
17
  /** Auto-detect API routes directory (Next.js, Express-style, etc.). */
17
18
  function findApiRoot(repoPath) {
@@ -62,6 +63,11 @@ export async function scanRepo(repoPath, opts = {}) {
62
63
  result.findings.push(...scanSecrets(repoPath));
63
64
  result.executedProbes.push("secret-scanning");
64
65
 
66
+ // ── Phase 1b: Runtime pins (Dockerfile, .tool-versions, ...) vs advisories
67
+ progress("Checking pinned runtime versions against advisories...");
68
+ result.findings.push(...runtimeAdvisoriesForRepo(repoPath));
69
+ result.executedProbes.push("runtime-advisories");
70
+
65
71
  // ── Phase 2: Locate API root ───────────────────────────────────────────
66
72
  progress("Locating API routes directory...");
67
73
  const apiRoot = opts.apiRoot || findApiRoot(repoPath);
package/src/scan-url.js CHANGED
@@ -51,12 +51,13 @@ import { scanOpenApi, probeAuthEndpoints } from "../lib/blackbox/openapi.js";
51
51
  import { probeIdorBola } from "../lib/blackbox/idor.js";
52
52
  import { probeOAuthMisconfig } from "../lib/blackbox/oauth.js";
53
53
  import { auditSecurityHeaders } from "../lib/blackbox/headers.js";
54
+ import { runtimeAdvisoriesForResponse } from "../lib/advisories/index.js";
54
55
  import { probeClientSideAuth } from "../lib/blackbox/client-auth.js";
55
56
  import { probeJwt } from "../lib/blackbox/jwt.js";
56
57
  import { probeEmailDns } from "../lib/blackbox/email-dns.js";
57
58
  import { resolveAuth } from "./utils/auth.js";
58
59
  import { normalizeBaseUrl, isPrivateHost, joinUrl } from "./utils/url.js";
59
- import { safeFetch, createEgressContext, runWithEgress, egressSummary, probeErrors } from "./utils/http.js";
60
+ import { safeFetch, readBody, createEgressContext, runWithEgress, egressSummary, probeErrors } from "./utils/http.js";
60
61
  import { RECON_HOSTS } from "../lib/recon.js";
61
62
  import { dedupeFindings } from "./cli/merge-results.js";
62
63
 
@@ -181,6 +182,13 @@ async function scanUrlInContext(target, opts) {
181
182
  result.findings.push(...auditSecurityHeaders(result.fingerprint.headers, target, { local }));
182
183
  }
183
184
 
185
+ // ── Phase 4b2: Runtime advisories (PHP, nginx, Apache, ... versions) ───
186
+ progress("Checking runtime and server versions against advisories...");
187
+ if (result.fingerprint?.headers) {
188
+ const phpPaths = filtered.filter((ep) => ep.status >= 200 && ep.status < 400 && /\.php(?:$|[?#])/i.test(ep.path)).map((ep) => ep.path);
189
+ result.findings.push(...runtimeAdvisoriesForResponse(result.fingerprint, { phpPaths, url: target }));
190
+ }
191
+
184
192
  // ── Phase 4c: OpenAPI / auth probes ────────────────────────────────────
185
193
  progress("Scanning OpenAPI spec and auth endpoints...");
186
194
  const openApiResult = await scanOpenApi(target, { timeout });
@@ -261,7 +269,7 @@ async function scanUrlInContext(target, opts) {
261
269
  unchecked.map(async (file) => {
262
270
  const res = await safeFetch(joinUrl(target, file.path), { timeout: 5_000, probe: "sensitive-files" });
263
271
  if (!res || res.status !== 200) return;
264
- const text = await res.text().catch(() => "");
272
+ const text = await readBody(res).catch(() => "");
265
273
 
266
274
  if (file.severity === "info") {
267
275
  result.findings.push({
@@ -594,7 +602,7 @@ async function scanUrlInContext(target, opts) {
594
602
  "client-auth", "idor", "oauth", "sensitive-files", "cors", "cookie",
595
603
  ...(!local && !isIpHost ? ["email-dns"] : []),
596
604
  ...(result.tls ? ["tls"] : []),
597
- ...(result.fingerprint?.headers ? ["security-headers"] : []),
605
+ ...(result.fingerprint?.headers ? ["security-headers", "runtime-advisories"] : []),
598
606
  ...(auth.bearer ? ["jwt"] : []),
599
607
  ...(!opts.skipRetireJs && retireJsRan ? ["retirejs"] : []),
600
608
  ...(!opts.skipParamDiscovery && hadEndpoints ? ["param-discovery"] : []),