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.
- package/CHANGELOG.md +203 -0
- package/README.md +18 -3
- package/lib/advisories/evidence.js +6 -4
- package/lib/advisories/products.js +13 -0
- package/lib/ai/consent.js +213 -0
- package/lib/ai/hunt.js +233 -0
- package/lib/ai/index.js +86 -0
- package/lib/ai/ollama.js +102 -0
- package/lib/ai/units.js +139 -0
- package/lib/blackbox/challenge.js +33 -0
- package/lib/blackbox/headers.js +24 -0
- package/lib/blackbox/openapi.js +17 -3
- package/lib/coverage.js +215 -0
- package/lib/deps/capabilities.js +1 -1
- package/lib/deps/findings.js +11 -6
- package/lib/deps/index.js +2 -0
- package/lib/deps/iocs.js +15 -10
- package/lib/deps/python/capabilities.js +45 -7
- package/lib/deps/python/index.js +4 -1
- package/lib/hook/index.js +12 -2
- package/lib/install/policy.js +5 -3
- package/lib/llm-audit/CLOUDFLARE-LICENSE +25 -0
- package/lib/llm-audit/classes.js +93 -0
- package/lib/llm-audit/cloudflare-report-schema.json +461 -0
- package/lib/llm-audit/cloudflare.js +167 -0
- package/lib/llm-audit/export.js +145 -0
- package/lib/llm-audit/verify.js +205 -0
- package/lib/owasp-wstg.js +6 -3
- package/lib/references.json +22 -0
- package/lib/rules/advisories/advisories.json +62 -0
- package/lib/rules/advisories/eol.json +98 -0
- package/lib/rules/advisories/pack.json +4 -4
- package/lib/rules/advisories/pack.json.sig +2 -2
- package/lib/rules/ioc-pypi-2026/files.json +19 -0
- package/lib/rules/ioc-pypi-2026/hosts.json +19 -0
- package/lib/rules/ioc-pypi-2026/pack.json +25 -0
- package/lib/rules/ioc-pypi-2026/pack.json.sig +6 -0
- package/lib/rules/ioc-pypi-2026/packages.json +17 -0
- package/lib/rules/pack.js +16 -0
- package/lib/templates.js +112 -1
- package/lib/tls.js +61 -0
- package/lib/whitebox/code-sinks.js +267 -0
- package/lib/whitebox/secrets.js +20 -0
- package/package.json +4 -1
- package/skills/penthera/SKILL.md +8 -7
- package/skills/penthera/references/profiles-and-flags.md +3 -3
- package/skills/penthera/references/troubleshooting.md +2 -2
- package/skills/penthera/scripts/preflight.sh +27 -10
- package/src/agent/loop.js +4 -1
- package/src/cli/ai.js +177 -0
- package/src/cli/install.js +3 -2
- package/src/cli/mcp.js +85 -0
- package/src/cli/merge-results.js +1 -0
- package/src/cli/profiles.js +3 -3
- package/src/cli/prompt.js +31 -6
- package/src/cli/ui.js +17 -12
- package/src/cli/verify.js +197 -0
- package/src/cli.js +115 -4
- package/src/mcp/results.js +153 -0
- package/src/mcp/server.js +271 -0
- package/src/mcp/tools.js +285 -0
- package/src/mcp/validate.js +57 -0
- package/src/mcp/workspace.js +112 -0
- package/src/report/markdown.js +68 -1
- package/src/reporter.js +68 -6
- package/src/scan-repo.js +58 -38
- package/src/scan-url.js +19 -2
- package/src/ui/api/routes.js +2 -0
- package/src/ui/api/runs.js +107 -23
- package/src/ui/server.js +6 -1
- package/src/ui/static/app.css +18 -1
- package/src/ui/static/js/center.js +34 -3
- package/src/ui/static/js/copy.js +42 -0
- package/src/ui/static/js/dialog.js +108 -3
- package/src/ui/static/js/format.js +2 -0
- package/src/ui/static/js/right.js +10 -3
- 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** |
|
|
272
|
-
| **Now** |
|
|
273
|
-
| **Research** |
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
+
}
|