penthera 1.8.1 → 1.9.0-rc.1

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 (77) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -3
  3. package/lib/advisories/evidence.js +6 -4
  4. package/lib/advisories/products.js +13 -0
  5. package/lib/ai/consent.js +213 -0
  6. package/lib/ai/hunt.js +233 -0
  7. package/lib/ai/index.js +86 -0
  8. package/lib/ai/ollama.js +102 -0
  9. package/lib/ai/units.js +139 -0
  10. package/lib/blackbox/challenge.js +33 -0
  11. package/lib/blackbox/headers.js +24 -0
  12. package/lib/blackbox/openapi.js +17 -3
  13. package/lib/coverage.js +215 -0
  14. package/lib/deps/capabilities.js +1 -1
  15. package/lib/deps/findings.js +11 -6
  16. package/lib/deps/index.js +2 -0
  17. package/lib/deps/iocs.js +15 -10
  18. package/lib/deps/python/capabilities.js +45 -7
  19. package/lib/deps/python/index.js +4 -1
  20. package/lib/hook/index.js +12 -2
  21. package/lib/install/policy.js +5 -3
  22. package/lib/llm-audit/CLOUDFLARE-LICENSE +25 -0
  23. package/lib/llm-audit/classes.js +93 -0
  24. package/lib/llm-audit/cloudflare-report-schema.json +461 -0
  25. package/lib/llm-audit/cloudflare.js +167 -0
  26. package/lib/llm-audit/export.js +145 -0
  27. package/lib/llm-audit/verify.js +205 -0
  28. package/lib/owasp-wstg.js +6 -3
  29. package/lib/references.json +22 -0
  30. package/lib/rules/advisories/advisories.json +62 -0
  31. package/lib/rules/advisories/eol.json +98 -0
  32. package/lib/rules/advisories/pack.json +4 -4
  33. package/lib/rules/advisories/pack.json.sig +2 -2
  34. package/lib/rules/ioc-pypi-2026/files.json +19 -0
  35. package/lib/rules/ioc-pypi-2026/hosts.json +19 -0
  36. package/lib/rules/ioc-pypi-2026/pack.json +25 -0
  37. package/lib/rules/ioc-pypi-2026/pack.json.sig +6 -0
  38. package/lib/rules/ioc-pypi-2026/packages.json +17 -0
  39. package/lib/rules/pack.js +16 -0
  40. package/lib/templates.js +112 -1
  41. package/lib/tls.js +61 -0
  42. package/lib/whitebox/code-sinks.js +267 -0
  43. package/lib/whitebox/secrets.js +20 -0
  44. package/package.json +4 -1
  45. package/skills/penthera/SKILL.md +8 -7
  46. package/skills/penthera/references/profiles-and-flags.md +3 -3
  47. package/skills/penthera/references/troubleshooting.md +2 -2
  48. package/skills/penthera/scripts/preflight.sh +27 -10
  49. package/src/agent/loop.js +4 -1
  50. package/src/cli/ai.js +177 -0
  51. package/src/cli/install.js +3 -2
  52. package/src/cli/mcp.js +85 -0
  53. package/src/cli/merge-results.js +1 -0
  54. package/src/cli/profiles.js +3 -3
  55. package/src/cli/prompt.js +31 -6
  56. package/src/cli/ui.js +17 -12
  57. package/src/cli/verify.js +197 -0
  58. package/src/cli.js +115 -4
  59. package/src/mcp/results.js +153 -0
  60. package/src/mcp/server.js +271 -0
  61. package/src/mcp/tools.js +285 -0
  62. package/src/mcp/validate.js +57 -0
  63. package/src/mcp/workspace.js +112 -0
  64. package/src/report/markdown.js +68 -1
  65. package/src/reporter.js +68 -6
  66. package/src/scan-repo.js +58 -38
  67. package/src/scan-url.js +19 -2
  68. package/src/ui/api/routes.js +2 -0
  69. package/src/ui/api/runs.js +107 -23
  70. package/src/ui/server.js +6 -1
  71. package/src/ui/static/app.css +18 -1
  72. package/src/ui/static/js/center.js +34 -3
  73. package/src/ui/static/js/copy.js +42 -0
  74. package/src/ui/static/js/dialog.js +108 -3
  75. package/src/ui/static/js/format.js +2 -0
  76. package/src/ui/static/js/right.js +10 -3
  77. package/src/utils/url.js +30 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,187 @@ All notable changes to Penthera are documented here. Versioning follows [SemVer]
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.9.0-rc.1] - 2026-10-22
8
+
9
+ Release candidate for 1.9.0, on npm's `next` dist-tag (`npm install penthera@next`).
10
+ New: `penthera mcp` (an MCP server for coding agents, local and read-only by
11
+ default), opt-in AI mode where a local model proposes and Penthera's rules
12
+ verify, new detection rules, and default scans that send no attack payloads.
13
+
14
+ ### Added
15
+
16
+ - **Coverage ledger** in every `--repo` report (terminal, Markdown, JSON
17
+ `coverage`): which files each check read, which it did not and why, and the
18
+ source files no code analysis looked at. It reads only file names and sizes,
19
+ and is the first step toward an opt-in AI mode.
20
+ - **AI mode, opt-in and experimental: `penthera --repo . --ai`.** A model in
21
+ your local Ollama reads your code part by part and proposes problems no rule
22
+ describes; Penthera checks each proposal with its rules. Confirmed ones mark
23
+ the rule finding "also proposed by AI"; the rest are shown as
24
+ "model-proposed, unverified", apart from rule findings, and never change the
25
+ exit code. Every run asks first (default no), on stderr; runs that cannot ask
26
+ need `--ai-consent local` or `"ai": { "allow": ["local"] }` in
27
+ `penthera.policy.json`, or stop with exit 2 before scanning. Ollama cloud
28
+ models (`-cloud`) are recognised and need a second screen where you type
29
+ `ollama.com`. Secrets are masked before any code reaches the model; budgets
30
+ (`--ai-budget`) are hard stops; consents are logged locally
31
+ (`penthera ai consent`). `penthera ai models` lists models and where they
32
+ run. See [docs/ai-mode.md](docs/ai-mode.md).
33
+ - **AI mode in the dashboard.** New scan has an AI mode switch, off; turning
34
+ it on opens the consent sheet on every scan (a picture of where the code
35
+ goes, the model picker, the terminal's exact consent lines, two equal
36
+ buttons; cloud models need `ollama.com` typed). The server checks the
37
+ model's route with Ollama itself and logs the consent. Runs show an
38
+ "AI · local" tag, unverified proposals are folded under their own heading,
39
+ and Settings lists the consent log.
40
+ - **`penthera verify <findings.json>`** checks findings from an AI security
41
+ audit against Penthera's rules on your checkout. It reads Cloudflare's
42
+ security-audit skill `findings.json` (schema vendored from commit `c1c8a8c`,
43
+ MIT). A finding a rule agrees with is shown as Penthera's own finding, marked
44
+ "also proposed by" the model; the rest are labelled "model-proposed,
45
+ unverified" and never fail CI. Penthera runs no model and sends nothing
46
+ anywhere. See [docs/commands/verify.md](docs/commands/verify.md).
47
+ - **`--audit-seeds <file>`** writes repo findings in the security-audit
48
+ skill's `findings.json` schema, as seeds for an AI audit: each entry is
49
+ `needs_validation`, names its class and the line the rule matched, and is
50
+ checked against the skill's schema before it is written.
51
+ - Risky-pattern findings from `--repo` now carry the repo-relative `file` and
52
+ the `pattern` name.
53
+ - **`penthera mcp`: Penthera as an MCP server for your coding agent.** Claude
54
+ Code, Cursor, Codex, VS Code and Windsurf can call it over stdio. Add it
55
+ with a pinned version: `claude mcp add penthera -- npx -y penthera@1.9.0 mcp`.
56
+ Six read-only tools: `scan_repo`, `scan_url` (passive only),
57
+ `check_dependencies`, `audit_ai_tooling`, `explain_finding` and
58
+ `runtime_advisories`. Results are structured JSON plus a summary, with
59
+ evidence at `file:line`, confidence labels and each tool's limits.
60
+ - Tools read only inside the workspace roots, and symlinks cannot lead
61
+ out. `scan_url` reaches loopback, or hosts you list with
62
+ `--allow-host`; active modes are not reachable over MCP.
63
+ - Text from scanned files has invisible characters removed and is marked
64
+ as data, so a file cannot steer the agent through the results.
65
+ - Speaks MCP 2026-07-28 and the earlier `initialize` versions. No new
66
+ dependency, no port, no telemetry.
67
+ - `server.json` and `mcpName` for the MCP Registry
68
+ (`io.github.danoszz/penthera`). See `docs/commands/mcp.md`.
69
+ - **Secret findings carry the line number**, so reports and SARIF point at
70
+ the line.
71
+
72
+ ### Detection
73
+
74
+ - Three exposure checks in the default URL scan, each a documented miss in
75
+ the E10 pilot (Juice Shop). All send plain `GET`s, no payloads:
76
+ - a public Prometheus `/metrics` endpoint (medium), matched on the
77
+ exposition format, so an SPA's index page at `/metrics` does not count;
78
+ - browsable directory listings (medium) in folders that `robots.txt`
79
+ disallows and in common upload and backup folders, matched on the
80
+ listing's page title;
81
+ - a stack trace in the error page for a path that does not exist (low),
82
+ matched on Node.js, Python, Java, PHP and .NET frame shapes.
83
+ - The header audit reports an `X-Powered-By` header (low, `confirmed`), such
84
+ as Express's default `X-Powered-By: Express`. It was a documented miss in
85
+ the E10 pilot (DVNA). The finding names the value and how to turn the
86
+ header off.
87
+ - The secret scan reads Terraform and HCL files (`.tf`, `.tfvars`, `.hcl`).
88
+ A private key inline in a Terraform file was a documented miss in the E10
89
+ pilot (Juice Shop).
90
+ - New secret rule: a session signing secret written into
91
+ `session({ secret: '...' })` or `cookieSession(...)`, including a hardcoded
92
+ fallback after `process.env.X ||` (high). Express's documented example,
93
+ `'keyboard cat'`, was a documented miss in the E10 pilot (DVNA).
94
+ - The repo scan looks for code-execution sinks in all server code, not just
95
+ route handlers: `eval`/`new Function` with a non-constant argument, `vm`
96
+ calls and `child_process` `exec` with request data in their arguments.
97
+ A taint-lite pass (assignments and destructuring within one file) sets the
98
+ confidence: `likely` when request data reaches the sink, `potential` for an
99
+ `eval` of a value it cannot trace. Before, a repo with an Express `routes/`
100
+ folder stopped before any code-pattern check; three E10 pilot misses (DVNA's
101
+ `exec` in `core/appHandler.js`, Juice Shop's B2B `vm` and profile `eval`)
102
+ came from this.
103
+ - The advisories pack knows MongoDB: end-of-life lines from MongoDB's
104
+ lifecycle schedule (4.4 ended 2024-02-29, 5.0 on 2024-10-31, 6.0 on
105
+ 2025-07-31, and the interim releases), matched against the official `mongo`
106
+ image in Dockerfiles and compose files and `mongodb` in `.tool-versions`.
107
+ `mongo:4.4.29` in Juice Shop's compose file was a documented miss in the
108
+ E10 pilot. **The pack is unsigned in this change**: the maintainer signs it
109
+ (`scripts/rules/sign-pack.sh`) before release.
110
+
111
+ ### Changed
112
+
113
+ - **Faster merges for contributors.** Pull requests get a quicker install check
114
+ (Linux and macOS, Node 22), and docs-, site- or benchmark-only changes skip it.
115
+ The merge queue and `main` still run the full matrix (Linux, macOS and
116
+ Windows on Node 22 and 24, plus the upgrade test). Release notes are written
117
+ as fragments in `changelog.d/`, so parallel pull requests no longer conflict
118
+ on `CHANGELOG.md`.
119
+ - **The default scan sends no attack payloads to your login.** A URL scan
120
+ used to POST nine logins and an `' OR '1'='1` string to `/api/login`,
121
+ which a CDN firewall (Vercel, Cloudflare) can read as an attack and start
122
+ challenging you. Now `quick` sends no login request, `standard` sends one
123
+ login with invalid credentials (to catch a login that accepts anything),
124
+ and the rate-limit burst and the SQL error check run only with `--deep`.
125
+ The MCP `scan_url` tool follows the same profiles.
126
+
127
+ ### Fixed
128
+
129
+ - `penthera ui` listens for SIGINT and SIGTERM before it prints that it is
130
+ listening. A signal that arrived in between got the default action and
131
+ killed the process without "Stopped." (seen as a hung CI test; about one
132
+ run in a hundred locally). The test now fails within seconds with the
133
+ child's output instead of hanging for three minutes.
134
+ - `--repo` scans of Express, Hono and Fastify apps with a `routes/`, `api/` or
135
+ `server/routes/` folder now get route discovery, trust-boundary and
136
+ risky-pattern checks. That folder was taken for a Next.js API root, no
137
+ `route.js` files were found there, and route analysis stopped. An explicit
138
+ `--api-root` is still used as given.
139
+ - **TLS keys anyone can compute.** URL scans report, as critical, any
140
+ certificate in the served chain whose EC public key (P-256, P-384, P-521) is
141
+ k·G for a private scalar k below 1024. The private key then follows from
142
+ the certificate alone. This is the flaw in eMudhra's P-384 intermediates
143
+ ([Mozilla bug 2083449](https://bugzilla.mozilla.org/show_bug.cgi?id=2083449),
144
+ private keys below 256). The check is arithmetic, not a list, so it has no
145
+ false positives and no data to maintain. Research note:
146
+ [docs/research/emudhra-weak-keys-2026-10.md](docs/research/emudhra-weak-keys-2026-10.md).
147
+ - **Node.js security release of 21 October 2026** in the `advisories` pack:
148
+ CVE-2026-111497 (Brotli decoder out-of-bounds write, remote code execution)
149
+ and the TLS certificate hostname bypasses CVE-2026-106069, CVE-2026-105483
150
+ and CVE-2026-106072, fixed in 22.23.4, 24.22.1 and 26.12.1, from
151
+ [nodejs.org](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases).
152
+ Node.js has no server banner, so repo scans find it (`.nvmrc`,
153
+ `.node-version`, `.tool-versions`, `node:` images); 20.x and 25.x are
154
+ reported as end-of-life lines. The pack's signature was removed with the
155
+ change; `npm run rules:check` fails until the maintainer re-signs it.
156
+ - **Python start-up code in wheels.** `penthera deps` now finds `.pth` files
157
+ and a shipped `sitecustomize.py` / `usercustomize.py` wherever a wheel puts
158
+ them at the site-packages root, including `<name>-<version>.data/purelib/`
159
+ and `platlib/`, the layout the malicious litellm 1.107.0 used
160
+ ([OSV MAL-2026-2144](https://osv.dev/vulnerability/MAL-2026-2144)). Before,
161
+ only top-level `.pth` files were read, so that wheel's hook was missed until
162
+ it was installed. A `.pth` import of clean code is now medium (was low);
163
+ risky code behind it, or any shipped `sitecustomize.py`, is high. The
164
+ `penthera install` policy holds `startup-hook` for approval by default.
165
+ - **PyPI indicators.** A new rule kind, `ioc-pypi-package` (PEP 503 names,
166
+ PEP 440 versions, kept apart from npm names), and a new bundled pack,
167
+ `ioc-pypi-2026`, with the litellm 1.82.7, 1.82.8 and 1.107.0 versions, the
168
+ 1.107.0 payload files by SHA-256 and its endpoints. Python scans now run
169
+ `ioc-file` and `ioc-host` rules too. The pack is unsigned until the
170
+ maintainer signs it, so `npm run rules:check` fails until then.
171
+ - **The pre-commit hook keeps working when the npx cache is cleaned.** A hook
172
+ installed through `npx penthera` pointed at a copy in npm's cache; once
173
+ that was gone, every commit skipped the check silently. It now falls back
174
+ to the same version through npx (`npx -y penthera@<version> hook run`,
175
+ pinned), and only skips, with a clear warning, when that cannot run either.
176
+
177
+ ### Site
178
+
179
+ - The FAQ says how to scan your own site behind Vercel's or Cloudflare's
180
+ firewall: allow your IP first, or scan a preview URL.
181
+
182
+ ### Docs
183
+
184
+ - README, ROADMAP and the research plan describe what is on main for 1.9
185
+ (`penthera mcp`, AI mode, new detection, no attack payloads by default) and
186
+ list the E10 benchmark.
187
+
7
188
  ## [1.8.1] - 2026-10-22
8
189
 
9
190
  A fixes-only release. Runtime advisories are more careful: pre-release builds
@@ -64,6 +245,28 @@ cleanly under `npx`.
64
245
  CSP now allows connections only to those two paths on penthera.org; the
65
246
  browser check still sends nothing, and the page says so. The CLI, the
66
247
  dashboard and the npm package have no telemetry, unchanged.
248
+ - A shared penthera.org link now plays a 14-second muted reel in iMessage
249
+ and other apps that support `og:video`: setup, an offline scan, the
250
+ verdict, the PHP advisory of 21 October 2026, and the share card.
251
+ `og.png` stays the poster and the fallback; X keeps the image card.
252
+ Rendered by `site/tools/reel.mjs`.
253
+
254
+ ### Research
255
+
256
+ - Draft protocol for experiment E10: Penthera against six deliberately
257
+ vulnerable apps with public answer keys (Juice Shop, DVNA, NodeGoat, DVWA,
258
+ crAPI, WebGoat), each pinned by commit and image digest. Answer keys in
259
+ `bench/known-vuln/`, frozen before any scan; an opt-in runner
260
+ (`npm run bench:known`, not in `npm test` or the package) starts the apps
261
+ isolated in Docker on 127.0.0.1. A pilot on Juice Shop and DVNA is in
262
+ `bench/known-vuln/RESULTS.md`, marked as a pilot.
263
+ - Each of the pilot's 13 standard-run misses is verified in
264
+ `bench/known-vuln/MISSES.md` (all real gaps; none a scoring artefact).
265
+ A re-run after the fixes that followed (#107, #108, #110, #111) is in
266
+ `bench/known-vuln/RESULTS-after-fixes.md`, next to the unchanged pilot:
267
+ pooled standard recall 1/14 to 10/14 at the same precision, flagged as not
268
+ independent, with WebGoat as a held-out app that did not change. The
269
+ runner gains `--out <file>`.
67
270
 
68
271
  ### Docs
69
272
 
package/README.md CHANGED
@@ -87,6 +87,21 @@ npx skills add danoszz/penthera
87
87
  The skill tells your agent to refuse targets you do not own or are not
88
88
  authorized to test. Other install routes (the one-line installer, the Claude
89
89
  Code plugin, a per-project copy) are in [docs/commands/skill.md](docs/commands/skill.md).
90
+
91
+ ### Use it from your agent
92
+
93
+ Penthera is also an MCP server, so Claude Code, Cursor, Codex, VS Code and
94
+ Windsurf can call it as a tool. Pin the version:
95
+
96
+ ```bash
97
+ claude mcp add penthera -- npx -y penthera@1.9.0 mcp
98
+ ```
99
+
100
+ It offers six read-only tools: `scan_repo`, `check_dependencies`,
101
+ `audit_ai_tooling`, `runtime_advisories`, `explain_finding`, and `scan_url`
102
+ (passive, loopback or hosts you list). Tools read only inside your project,
103
+ never apply fixes, and run no shell. Configs for the other clients and the
104
+ safety rules are in [docs/commands/mcp.md](docs/commands/mcp.md).
90
105
  Penthera needs Node.js 22 or later. To run it in a container:
91
106
  `docker build -t penthera . && docker run --rm penthera https://your-app.example`.
92
107
 
@@ -268,9 +283,9 @@ What changed, what we are measuring, and what comes next, in one place.
268
283
  | | Where it stands | Read more |
269
284
  |---|---|---|
270
285
  | **Latest release** | 1.8.1 (2026-10-22): more careful runtime advisories (pre-releases, distribution builds, older lines) on top of 1.8.0's runtime advisories, security-review fixes and redesigned dashboard | [CHANGELOG](CHANGELOG.md) |
271
- | **On main, unreleased** | Toward 1.9: AI mode (opt-in, local model, rules verify), new detection rules, the E10 benchmark | [Unreleased](CHANGELOG.md#unreleased) |
272
- | **Now** | Toward 1.9: accuracy on real code (E1) and an MCP server, so coding agents can call Penthera | [Roadmap](ROADMAP.md#next) |
273
- | **Research** | E1, accuracy on real code, is pre-registered; the benchmark tooling is in review. After the 2026 agent incidents: a checklist for running agents safely and a draft experiment (E8) | [E1 pre-registration](docs/research/prereg/E1-real-world-accuracy.md), [agents safely](docs/agents-safely.md), [what the incidents mean](docs/research/agent-incidents-2026.md) |
286
+ | **On main, unreleased** | For 1.9: `penthera mcp` for coding agents, opt-in AI mode (a local model proposes, rules verify), new detection rules, no attack payloads in default scans | [Unreleased](CHANGELOG.md#unreleased) |
287
+ | **Now** | 1.9.0-rc.1 on npm `next`, then accuracy on real code (E1) | [Roadmap](ROADMAP.md#on-main-for-19) |
288
+ | **Research** | E10 pilot published: precision 0.88, recall 0.07 → 0.71 on two known-vulnerable apps after fixes (one labeller, not independent; a re-run is pending because default scans no longer send login checks). E1 on real code is pre-registered; E8 (agent guardrails) is a draft | [E10 results](bench/known-vuln/RESULTS.md), [E1 pre-registration](docs/research/prereg/E1-real-world-accuracy.md), [agents safely](docs/agents-safely.md) |
274
289
  | **Next** | Real-world precision and recall with intervals, published whatever the result | [Roadmap: next](ROADMAP.md#next), [research plan](docs/vision-2.0.md) |
275
290
 
276
291
  Releases are deliberately slow: a feature freeze, an independent review, the
@@ -84,7 +84,7 @@ export function evidenceFromResponse(fp = {}, more = {}) {
84
84
 
85
85
  // ── White-box ─────────────────────────────────────────────────────────────
86
86
 
87
- const SKIP_DIRS = new Set([
87
+ export const RUNTIME_PIN_SKIP_DIRS = new Set([
88
88
  "node_modules", ".git", "dist", "build", ".next", "coverage", "reports", "vendor",
89
89
  "venv", ".venv", "site-packages", "__pycache__", ".tox", ".nox", ".obsidian",
90
90
  ]);
@@ -96,7 +96,9 @@ const isCompose = (n) => /^(?:docker-)?compose(?:\.[\w-]+)?\.ya?ml$/i.test(n);
96
96
  const VERSION_FILES = new Set(Object.values(PRODUCTS).flatMap((p) => p.versionFiles));
97
97
  const ROOT_ONLY = new Set([".tool-versions", "runtime.txt", "composer.json"]);
98
98
 
99
- function wanted(name, depth) {
99
+ /** Would the runtime-pin check read a file with this name at this folder depth (0 = repo root)? */
100
+ export function isRuntimePinFile(name, depth) {
101
+ if (depth > MAX_DEPTH) return false;
100
102
  if (isDockerfile(name) || isCompose(name) || VERSION_FILES.has(name)) return true;
101
103
  return depth === 0 && ROOT_ONLY.has(name);
102
104
  }
@@ -122,10 +124,10 @@ function walk(dir, depth, files) {
122
124
  let entries;
123
125
  try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
124
126
  for (const e of entries) {
125
- if (SKIP_DIRS.has(e.name)) continue;
127
+ if (RUNTIME_PIN_SKIP_DIRS.has(e.name)) continue;
126
128
  const full = join(dir, e.name);
127
129
  if (e.isFile()) {
128
- if (wanted(e.name, depth)) files.push({ full, depth });
130
+ if (isRuntimePinFile(e.name, depth)) files.push({ full, depth });
129
131
  continue;
130
132
  }
131
133
  if (!e.isDirectory()) continue;
@@ -76,6 +76,19 @@ export const PRODUCTS = Object.freeze({
76
76
  toolVersions: ["python"],
77
77
  versionFiles: [".python-version"],
78
78
  },
79
+ mongodb: {
80
+ branchOf: minorLine,
81
+ name: "MongoDB",
82
+ // Read only from the official image and .tool-versions, which are upstream builds.
83
+ backports: false,
84
+ checkCommand: "mongod --version",
85
+ // A database: no HTTP banner. Seen only in what the repository pins.
86
+ banner: null,
87
+ present: null,
88
+ dockerImages: ["mongo"],
89
+ toolVersions: ["mongodb"],
90
+ versionFiles: [],
91
+ },
79
92
  openssl: {
80
93
  branchOf: (v) => (v.major >= 3 ? minorLine(v) : v.patch === null ? null : `${v.major}.${v.minor}.${v.patch}`),
81
94
  name: "OpenSSL",
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Consent for AI mode (docs/ai-mode.md, spec-ai-mode.md "Consent").
3
+ *
4
+ * Every `--ai` run asks. Nothing is remembered as permission: the log in
5
+ * ~/.penthera/consent.jsonl records what was agreed, for the user and for an
6
+ * auditor, and is never read back to skip a question.
7
+ *
8
+ * Two routes exist, and the text always states the one that applies:
9
+ * - local: Ollama on 127.0.0.1 runs the model on this computer.
10
+ * - ollama-cloud: Ollama on 127.0.0.1 forwards the code to ollama.com. Needs a
11
+ * second screen where the user types the host.
12
+ *
13
+ * Interactive runs ask on stderr (stdout stays clean for --json). A run that
14
+ * cannot ask (CI, piped, --json) needs --ai-consent <route> or the policy
15
+ * file's "ai": { "allow": ... }, and a local consent never covers the cloud.
16
+ * Declining is never an error: the scan runs with rules only.
17
+ */
18
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join, resolve } from "node:path";
21
+
22
+ /** Bumped whenever the meaning of the consent text changes; a log entry records the version shown. */
23
+ export const CONSENT_TEXT_VERSION = 1;
24
+ export const ROUTES = ["local", "ollama-cloud"];
25
+ export const CLOUD_HOST = "ollama.com";
26
+ export const CONSENT_LOG = "consent.jsonl";
27
+
28
+ export function consentLogPath(env = process.env) {
29
+ const base = env.PENTHERA_HOME ? resolve(env.PENTHERA_HOME) : join(homedir(), ".penthera");
30
+ return join(base, CONSENT_LOG);
31
+ }
32
+
33
+ /**
34
+ * The five (or six) lines of the consent screen, plus the question.
35
+ * @param {{ route: string, model: string, project: string, maxUnits: number, repro?: boolean }} p
36
+ */
37
+ export function consentScreen({ route, model, project, maxUnits, repro = false }) {
38
+ const cloud = route === "ollama-cloud";
39
+ const lines = [
40
+ "",
41
+ " AI mode: a model reads your code to look for problems the rules can't see.",
42
+ "",
43
+ cloud
44
+ ? ` Model ${model}, through your local Ollama, run on ${CLOUD_HOST}`
45
+ : ` Model ${model}, through Ollama on this computer (127.0.0.1)`,
46
+ ` Reads up to ${maxUnits} parts of ${project}, with secrets masked`,
47
+ cloud
48
+ ? ` Sends those parts to ${CLOUD_HOST} (you confirm the host next)`
49
+ : " Sends nothing off this computer",
50
+ " Decides nothing. Penthera checks every model finding with its own rules.",
51
+ " Unchecked ones are labelled and never fail CI.",
52
+ ];
53
+ if (repro) lines.push(` Runs parts of ${project}'s own code in a sandbox with no network, to check findings`);
54
+ lines.push("");
55
+ return { text: lines.join("\n") + "\n", question: ` Use this model for ${project}? [y/N/details]: ` };
56
+ }
57
+
58
+ /** The second screen for a cloud model: the host is typed back. */
59
+ export function cloudScreen({ project, maxUnits }) {
60
+ return {
61
+ text: [
62
+ "",
63
+ ` This model runs on ${CLOUD_HOST}, not on this computer.`,
64
+ "",
65
+ ` Sends up to ${maxUnits} parts of ${project}, secrets masked, to ${CLOUD_HOST}`,
66
+ " through your local Ollama, under Ollama's terms",
67
+ "",
68
+ ].join("\n") + "\n",
69
+ question: ` Type ${CLOUD_HOST} to send your code there, or press Enter to stop: `,
70
+ };
71
+ }
72
+
73
+ /** What `details` prints: the route in full, the model, and where consent is kept. */
74
+ export function consentDetails({ route, model, digest, size, logPath }) {
75
+ const cloud = route === "ollama-cloud";
76
+ return [
77
+ "",
78
+ " What happens",
79
+ cloud
80
+ ? ` Penthera sends code units, secrets masked, to Ollama on 127.0.0.1, which forwards them to ${CLOUD_HOST}.`
81
+ : " Penthera sends code units, secrets masked, to Ollama on 127.0.0.1. Nothing else.",
82
+ cloud
83
+ ? ` Your code leaves this computer and is processed by ${CLOUD_HOST} under Ollama's terms.`
84
+ : " The model runs on this computer. The report lists every host Penthera contacted.",
85
+ " Penthera checks the address and the model. It cannot see whether something on",
86
+ " this computer forwards port 11434 elsewhere.",
87
+ " The model gets no tools, files, network or shell. Its findings are candidates:",
88
+ " a Penthera rule confirms them, or they are shown as model-proposed, unverified.",
89
+ "",
90
+ " Model",
91
+ ` ${model}${size ? ` (${formatBytes(size)})` : ""}${digest ? `, digest ${String(digest).slice(0, 19)}` : ""}`,
92
+ "",
93
+ " Consent",
94
+ " Asked on every run; nothing is remembered as permission.",
95
+ ` Each answer is logged on this computer only: ${logPath}`,
96
+ " `penthera ai consent` shows the log.",
97
+ "",
98
+ ].join("\n") + "\n";
99
+ }
100
+
101
+ function formatBytes(n) {
102
+ return n >= 1e9 ? `${(n / 1e9).toFixed(1)} GB` : `${Math.round(n / 1e6)} MB`;
103
+ }
104
+
105
+ /**
106
+ * Decide consent without asking: the --ai-consent flag or the policy file.
107
+ * @returns {{ ok: true, via: "flag"|"policy" } | { ok: false, reason: string } | null} null when nothing was given
108
+ */
109
+ export function consentWithoutAsking({ route, flag = null, policyAllow = null }) {
110
+ if (flag != null) {
111
+ if (!ROUTES.includes(flag)) return { ok: false, reason: `--ai-consent must be one of ${ROUTES.join(", ")} (got "${flag}").` };
112
+ if (flag === route) return { ok: true, via: "flag" };
113
+ return { ok: false, reason: route === "ollama-cloud"
114
+ ? `This model runs on ${CLOUD_HOST}. --ai-consent local does not cover it; use --ai-consent ollama-cloud to send code there.`
115
+ : `--ai-consent ${flag} does not match this model's route (${route}).` };
116
+ }
117
+ if (policyAllow) {
118
+ if (policyAllow.includes(route)) return { ok: true, via: "policy" };
119
+ return { ok: false, reason: `penthera.policy.json allows AI mode only for ${policyAllow.join(", ") || "nothing"}; this model's route is ${route}.` };
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Ask for consent. Interactive: the screens above. Non-interactive: the flag
126
+ * or the policy, else a refusal that names the flag.
127
+ *
128
+ * @param {object} p
129
+ * @param {"local"|"ollama-cloud"} p.route
130
+ * @param {string} p.model
131
+ * @param {string} p.project - display name of the repo
132
+ * @param {number} p.maxUnits
133
+ * @param {boolean} [p.repro]
134
+ * @param {string|null} [p.flag] - --ai-consent value
135
+ * @param {string[]|null} [p.policyAllow] - policy ai.allow
136
+ * @param {boolean} p.interactive
137
+ * @param {(q: string) => Promise<string>} p.ask
138
+ * @param {(t: string) => void} p.write
139
+ * @param {object} [p.modelInfo] - { digest, size } for details
140
+ * @returns {Promise<{ ok: boolean, via?: string, reason?: string, declined?: boolean }>}
141
+ */
142
+ export async function askConsent(p) {
143
+ if (!ROUTES.includes(p.route)) return { ok: false, reason: `Unknown AI route ${p.route}` };
144
+ if (p.policyAllow && p.policyAllow.length === 0) {
145
+ return { ok: false, reason: "penthera.policy.json turns AI mode off for this repository (\"ai\": { \"allow\": [] })." };
146
+ }
147
+ if (!p.interactive) {
148
+ const decided = consentWithoutAsking(p);
149
+ if (decided) return decided;
150
+ return {
151
+ ok: false,
152
+ reason: `AI mode needs consent, and this run cannot ask. Add --ai-consent ${p.route}, or "ai": { "allow": ["${p.route}"] } in penthera.policy.json.`,
153
+ };
154
+ }
155
+ if (p.flag != null && !ROUTES.includes(p.flag)) return consentWithoutAsking(p);
156
+
157
+ const screen = consentScreen(p);
158
+ for (;;) {
159
+ p.write(screen.text);
160
+ const answer = String(await p.ask(screen.question)).trim().toLowerCase();
161
+ if (answer === "details" || answer === "d") {
162
+ p.write(consentDetails({ route: p.route, model: p.model, digest: p.modelInfo?.digest, size: p.modelInfo?.size, logPath: consentLogPath(p.env) }));
163
+ continue;
164
+ }
165
+ if (answer !== "y" && answer !== "yes") return { ok: false, declined: true, reason: "AI mode not confirmed." };
166
+ break;
167
+ }
168
+ if (p.route === "ollama-cloud") {
169
+ const cs = cloudScreen(p);
170
+ p.write(cs.text);
171
+ const typed = String(await p.ask(cs.question)).trim().toLowerCase();
172
+ if (typed !== CLOUD_HOST) return { ok: false, declined: true, reason: `${CLOUD_HOST} was not typed; nothing was sent.` };
173
+ }
174
+ return { ok: true, via: "terminal" };
175
+ }
176
+
177
+ /**
178
+ * Append one consent to the local log (mode 0600). Never read back to skip a question.
179
+ * @param {{ project: string, route: string, model: string, digest?: string, via: string, interface?: string }} entry
180
+ */
181
+ export function logConsent(entry, env = process.env) {
182
+ const path = consentLogPath(env);
183
+ mkdirSync(resolve(path, ".."), { recursive: true });
184
+ const fresh = !existsSync(path);
185
+ const line = { at: new Date().toISOString(), textVersion: CONSENT_TEXT_VERSION, ...entry };
186
+ appendFileSync(path, JSON.stringify(line) + "\n", { mode: 0o600 });
187
+ if (fresh) {
188
+ try { chmodSync(path, 0o600); } catch { /* best effort on file systems without modes */ }
189
+ }
190
+ return line;
191
+ }
192
+
193
+ /** The consent log, newest last. Lines that do not parse are skipped. */
194
+ export function readConsentLog(env = process.env) {
195
+ const path = consentLogPath(env);
196
+ if (!existsSync(path)) return [];
197
+ return readFileSync(path, "utf-8").split("\n").filter(Boolean).flatMap((l) => {
198
+ try { return [JSON.parse(l)]; } catch { return []; }
199
+ });
200
+ }
201
+
202
+ /** Validate the policy file's "ai" block: { allow: ["local"] | ["local", "ollama-cloud"] | [] }. */
203
+ export function normalizeAiPolicy(raw, label) {
204
+ if (raw == null) return null;
205
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${label}: expected an object like { "allow": ["local"] }`);
206
+ const extra = Object.keys(raw).filter((k) => k !== "allow");
207
+ if (extra.length) throw new Error(`${label}: unknown field(s) ${extra.join(", ")} (expected allow)`);
208
+ const allow = typeof raw.allow === "string" ? [raw.allow] : raw.allow;
209
+ if (!Array.isArray(allow) || allow.some((r) => !ROUTES.includes(r))) {
210
+ throw new Error(`${label}: "allow" must list routes from ${ROUTES.join(", ")} (an empty list turns AI mode off)`);
211
+ }
212
+ return { allow: [...new Set(allow)] };
213
+ }