penthera 1.8.0 → 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 (79) hide show
  1. package/CHANGELOG.md +272 -0
  2. package/README.md +21 -4
  3. package/lib/advisories/evidence.js +22 -8
  4. package/lib/advisories/index.js +35 -7
  5. package/lib/advisories/products.js +30 -3
  6. package/lib/advisories/versions.js +35 -3
  7. package/lib/ai/consent.js +213 -0
  8. package/lib/ai/hunt.js +233 -0
  9. package/lib/ai/index.js +86 -0
  10. package/lib/ai/ollama.js +102 -0
  11. package/lib/ai/units.js +139 -0
  12. package/lib/blackbox/challenge.js +33 -0
  13. package/lib/blackbox/headers.js +24 -0
  14. package/lib/blackbox/openapi.js +17 -3
  15. package/lib/coverage.js +215 -0
  16. package/lib/deps/capabilities.js +1 -1
  17. package/lib/deps/findings.js +11 -6
  18. package/lib/deps/index.js +2 -0
  19. package/lib/deps/iocs.js +15 -10
  20. package/lib/deps/python/capabilities.js +45 -7
  21. package/lib/deps/python/index.js +4 -1
  22. package/lib/hook/index.js +12 -2
  23. package/lib/install/policy.js +5 -3
  24. package/lib/llm-audit/CLOUDFLARE-LICENSE +25 -0
  25. package/lib/llm-audit/classes.js +93 -0
  26. package/lib/llm-audit/cloudflare-report-schema.json +461 -0
  27. package/lib/llm-audit/cloudflare.js +167 -0
  28. package/lib/llm-audit/export.js +145 -0
  29. package/lib/llm-audit/verify.js +205 -0
  30. package/lib/owasp-wstg.js +6 -3
  31. package/lib/references.json +22 -0
  32. package/lib/rules/advisories/advisories.json +62 -0
  33. package/lib/rules/advisories/eol.json +98 -0
  34. package/lib/rules/advisories/pack.json +4 -4
  35. package/lib/rules/advisories/pack.json.sig +2 -2
  36. package/lib/rules/ioc-pypi-2026/files.json +19 -0
  37. package/lib/rules/ioc-pypi-2026/hosts.json +19 -0
  38. package/lib/rules/ioc-pypi-2026/pack.json +25 -0
  39. package/lib/rules/ioc-pypi-2026/pack.json.sig +6 -0
  40. package/lib/rules/ioc-pypi-2026/packages.json +17 -0
  41. package/lib/rules/pack.js +18 -2
  42. package/lib/templates.js +112 -1
  43. package/lib/tls.js +61 -0
  44. package/lib/whitebox/code-sinks.js +267 -0
  45. package/lib/whitebox/secrets.js +20 -0
  46. package/package.json +5 -2
  47. package/skills/penthera/SKILL.md +8 -7
  48. package/skills/penthera/references/profiles-and-flags.md +3 -3
  49. package/skills/penthera/references/troubleshooting.md +2 -2
  50. package/skills/penthera/scripts/preflight.sh +27 -10
  51. package/src/agent/loop.js +4 -1
  52. package/src/cli/ai.js +177 -0
  53. package/src/cli/install.js +3 -2
  54. package/src/cli/mcp.js +85 -0
  55. package/src/cli/merge-results.js +1 -0
  56. package/src/cli/profiles.js +3 -3
  57. package/src/cli/prompt.js +31 -6
  58. package/src/cli/ui.js +20 -5
  59. package/src/cli/verify.js +197 -0
  60. package/src/cli.js +115 -4
  61. package/src/mcp/results.js +153 -0
  62. package/src/mcp/server.js +271 -0
  63. package/src/mcp/tools.js +285 -0
  64. package/src/mcp/validate.js +57 -0
  65. package/src/mcp/workspace.js +112 -0
  66. package/src/report/markdown.js +68 -1
  67. package/src/reporter.js +68 -6
  68. package/src/scan-repo.js +58 -38
  69. package/src/scan-url.js +19 -2
  70. package/src/ui/api/routes.js +2 -0
  71. package/src/ui/api/runs.js +107 -23
  72. package/src/ui/server.js +6 -1
  73. package/src/ui/static/app.css +18 -1
  74. package/src/ui/static/js/center.js +34 -3
  75. package/src/ui/static/js/copy.js +42 -0
  76. package/src/ui/static/js/dialog.js +108 -3
  77. package/src/ui/static/js/format.js +2 -0
  78. package/src/ui/static/js/right.js +10 -3
  79. package/src/utils/url.js +30 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,278 @@ 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
+
188
+ ## [1.8.1] - 2026-10-22
189
+
190
+ A fixes-only release. Runtime advisories are more careful: pre-release builds
191
+ are never reported as having the fix, a banner without a distribution token is
192
+ at most `potential`, release lines older than a flaw are not reported as
193
+ affected, and two more banner formats are read. `penthera ui` now stops
194
+ cleanly under `npx`.
195
+
196
+ ### Fixed
197
+
198
+ - Runtime advisories no longer claim `likely` for a banner that may be a
199
+ patched distribution package. Debian 11 to 13, Ubuntu 24.04 and later, and
200
+ RHEL print plain upstream PHP versions, so a patched Ubuntu 24.04
201
+ `X-Powered-By: PHP/8.3.6` behind nginx or a CDN was reported as critical
202
+ and likely affected. For PHP, Apache httpd, nginx, Python and OpenSSL,
203
+ "affected" and "end of life" findings from a banner with no distribution
204
+ token are now `potential`, with a "check on server" note and the check
205
+ command. Severity is unchanged. Official images and version files are
206
+ upstream builds and stay `likely`. Found by the runtime-strings test set
207
+ (L1 in [#97](https://github.com/danoszz/penthera/pull/97)).
208
+
209
+ - Runtime advisories resolve Node.js LTS codenames to their release line:
210
+ nvm's `lts/iron` in `.nvmrc` and the node image's `iron`, `jod-alpine`
211
+ and `hydrogen` tags. They read as no version before, so `lts/iron` (Node.js
212
+ 20, end of life 30 April 2026) went unreported. Found by the
213
+ runtime-strings test set (B4 in
214
+ [#97](https://github.com/danoszz/penthera/pull/97)).
215
+ - Runtime advisories read aiohttp's `Server: Python/3.9 aiohttp/3.8.6`.
216
+ aiohttp prints only the Python release line, and the banner pattern needed
217
+ x.y.z, so an end-of-life Python behind aiohttp went unreported. Found by
218
+ the runtime-strings test set (B3 in
219
+ [#97](https://github.com/danoszz/penthera/pull/97)).
220
+ - Runtime advisories honour `introduced` on older release lines. CVE-2021-41773
221
+ and CVE-2021-42013 exist only in httpd 2.4.49 and 2.4.50. Yet
222
+ `Apache/2.2.34` and `httpd:2.2` were reported critical, "end of life, no
223
+ fix", and CentOS 6's `Apache/2.2.15 (CentOS)` was "check on server". A line
224
+ older than the one the flaw was introduced on now gets no verdict for it.
225
+ The end-of-life finding stays. Found by the runtime-strings test set (B1 in
226
+ [#97](https://github.com/danoszz/penthera/pull/97)).
227
+ - Runtime advisories read pre-releases correctly. `PHP/8.4.27-dev`, a PHP-8.4
228
+ branch build from before 8.4.27, was taken for a distribution build of
229
+ 8.4.27 and reported as having the 21 October fix. Now `-dev`, alpha, beta
230
+ and RC builds (`8.5.0RC1`, `3.15.0rc1`, `4.1.0-beta2`, `php:8.6.0RC4-fpm`)
231
+ sort below their release, so a pre-release of the fixed version is
232
+ affected. A pre-release of the version after the fix (`8.4.28-dev`) is
233
+ "check on server", never "has the fix". Found by the runtime-strings test set
234
+ (B2 in [#97](https://github.com/danoszz/penthera/pull/97)).
235
+ - **`npx penthera ui` stops cleanly on Ctrl-C and SIGTERM.** npm forwards
236
+ the signal to penthera right after the terminal delivered it, and the
237
+ second copy could kill the dashboard before it printed "Stopped." and
238
+ closed its server. Repeated signals are now ignored while it stops. This
239
+ was the intermittent macOS failure in the install-matrix smoke check.
240
+
241
+ ### Site
242
+
243
+ - penthera.org counts anonymous page views and load times with Vercel Web
244
+ Analytics and Speed Insights (cookieless, served from the same origin). The
245
+ CSP now allows connections only to those two paths on penthera.org; the
246
+ browser check still sends nothing, and the page says so. The CLI, the
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>`.
270
+
271
+ ### Docs
272
+
273
+ - The PHP case study and `docs/runtime-advisories.md` cite OpenAI's
274
+ php2shell-checker as an independent cross-check. A test pins that Penthera
275
+ agrees with its table on all 14 PHP branches.
276
+ - The website is live at [penthera.org](https://penthera.org): the README links
277
+ it, and the npm package's homepage points to it from the next release.
278
+
7
279
  ## [1.8.0] - 2026-10-21
8
280
 
9
281
  1.8.0 is 1.8.0-rc.1 plus the changes below; there was no separate rc.2
package/README.md CHANGED
@@ -8,6 +8,8 @@
8
8
 
9
9
  The security review you never had time for. Offline, rule-based, and you can verify every finding yourself.
10
10
 
11
+ **[penthera.org](https://penthera.org)**: try the check in your browser. Nothing you paste leaves the page.
12
+
11
13
  <br/>
12
14
 
13
15
  [![npm](https://img.shields.io/npm/v/penthera?color=0000ed)](https://www.npmjs.com/package/penthera)
@@ -85,6 +87,21 @@ npx skills add danoszz/penthera
85
87
  The skill tells your agent to refuse targets you do not own or are not
86
88
  authorized to test. Other install routes (the one-line installer, the Claude
87
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).
88
105
  Penthera needs Node.js 22 or later. To run it in a container:
89
106
  `docker build -t penthera . && docker run --rm penthera https://your-app.example`.
90
107
 
@@ -265,10 +282,10 @@ What changed, what we are measuring, and what comes next, in one place.
265
282
 
266
283
  | | Where it stands | Read more |
267
284
  |---|---|---|
268
- | **Latest release** | 1.8.0 (2026-10-21): runtime advisories starting with PHP's critical release, fixes from a second security review, a fix loop that cannot edit its own tests, the redesigned dashboard | [CHANGELOG](CHANGELOG.md) |
269
- | **On main, unreleased** | Whatever lands after 1.8.0 | [Unreleased](CHANGELOG.md#unreleased) |
270
- | **Now** | Toward 1.9: accuracy on real code (E1) and an MCP server, so coding agents can call Penthera | [Roadmap](ROADMAP.md#next) |
271
- | **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) |
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) |
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) |
272
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) |
273
290
 
274
291
  Releases are deliberately slow: a feature freeze, an independent review, the
@@ -15,6 +15,8 @@
15
15
  * "present" the product is in use, the version is not visible
16
16
  * where the header, cookie or file it came from
17
17
  * raw the text it was read from (a header value, a line), for the report
18
+ * banner true for black-box evidence (a header), which may come from a
19
+ * distribution package even without a distribution token
18
20
  *
19
21
  * Nothing here decides whether anything is vulnerable; that is index.js.
20
22
  */
@@ -27,6 +29,7 @@ const clip = (s, n = 200) => (s.length > n ? `${s.slice(0, n)}…` : s);
27
29
 
28
30
  function kindOf(product, v, distro) {
29
31
  if (!v) return "present";
32
+ // A pre-release is an upstream build, not a distribution one.
30
33
  if (v.patch !== null) return distro || v.suffix ? "distro" : "exact";
31
34
  // "php:8" spans release lines; "php:8.3" and "node:20" name one.
32
35
  return PRODUCTS[product].branchOf(v) ? "branch" : "present";
@@ -55,7 +58,7 @@ export function evidenceFromResponse(fp = {}, more = {}) {
55
58
  if (m) {
56
59
  const v = parseVersion(m[1]);
57
60
  if (v) {
58
- out.push({ product: id, version: v, kind: kindOf(id, v, distro), where: `${name} header`, raw: clip(`${name}: ${value}`) });
61
+ out.push({ product: id, version: v, kind: kindOf(id, v, distro), where: `${name} header`, raw: clip(`${name}: ${value}`), banner: true });
59
62
  found = true;
60
63
  continue;
61
64
  }
@@ -81,7 +84,7 @@ export function evidenceFromResponse(fp = {}, more = {}) {
81
84
 
82
85
  // ── White-box ─────────────────────────────────────────────────────────────
83
86
 
84
- const SKIP_DIRS = new Set([
87
+ export const RUNTIME_PIN_SKIP_DIRS = new Set([
85
88
  "node_modules", ".git", "dist", "build", ".next", "coverage", "reports", "vendor",
86
89
  "venv", ".venv", "site-packages", "__pycache__", ".tox", ".nox", ".obsidian",
87
90
  ]);
@@ -93,7 +96,9 @@ const isCompose = (n) => /^(?:docker-)?compose(?:\.[\w-]+)?\.ya?ml$/i.test(n);
93
96
  const VERSION_FILES = new Set(Object.values(PRODUCTS).flatMap((p) => p.versionFiles));
94
97
  const ROOT_ONLY = new Set([".tool-versions", "runtime.txt", "composer.json"]);
95
98
 
96
- 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;
97
102
  if (isDockerfile(name) || isCompose(name) || VERSION_FILES.has(name)) return true;
98
103
  return depth === 0 && ROOT_ONLY.has(name);
99
104
  }
@@ -119,10 +124,10 @@ function walk(dir, depth, files) {
119
124
  let entries;
120
125
  try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
121
126
  for (const e of entries) {
122
- if (SKIP_DIRS.has(e.name)) continue;
127
+ if (RUNTIME_PIN_SKIP_DIRS.has(e.name)) continue;
123
128
  const full = join(dir, e.name);
124
129
  if (e.isFile()) {
125
- if (wanted(e.name, depth)) files.push({ full, depth });
130
+ if (isRuntimePinFile(e.name, depth)) files.push({ full, depth });
126
131
  continue;
127
132
  }
128
133
  if (!e.isDirectory()) continue;
@@ -135,6 +140,12 @@ function walk(dir, depth, files) {
135
140
  }
136
141
  }
137
142
 
143
+ /** The release line a codename stands for (`lts/iron`, `iron` → "20"), or null. */
144
+ function aliasLine(product, value) {
145
+ const name = value.toLowerCase().replace(/^lts\//, "");
146
+ return Object.hasOwn(PRODUCTS[product].lineAliases || {}, name) ? PRODUCTS[product].lineAliases[name] : null;
147
+ }
148
+
138
149
  const imageProduct = new Map(Object.entries(PRODUCTS).flatMap(([id, p]) => p.dockerImages.map((img) => [img, id])));
139
150
 
140
151
  /**
@@ -151,8 +162,11 @@ export function parseImageRef(ref) {
151
162
  const path = repo.toLowerCase().replace(/^(?:docker\.io\/)?(?:library\/)?/, "");
152
163
  const product = imageProduct.get(path);
153
164
  if (!product) return null;
154
- const m = /^(\d+(?:\.\d+){0,2})(?:$|-)/.exec(tag);
155
- const v = m ? parseVersion(m[1]) : null;
165
+ // `8.6.0RC4-fpm` and `3.15.0rc1-slim` are pre-release images; `iron` and
166
+ // `jod-alpine` are Node.js LTS codenames.
167
+ const m = /^(\d+(?:\.\d+){0,2}(?:(?:RC|rc|a|b)\d+)?)(?:$|-)/.exec(tag);
168
+ const alias = m ? null : aliasLine(product, tag.split("-")[0]);
169
+ const v = m ? parseVersion(m[1]) : alias ? parseVersion(alias) : null;
156
170
  return { product, version: v, kind: kindOf(product, v, false), tag: tag || "latest" };
157
171
  }
158
172
 
@@ -179,7 +193,7 @@ function fromCompose(text, where) {
179
193
  }
180
194
 
181
195
  function versionLine(product, value, where, raw) {
182
- const v = parseVersion(value);
196
+ const v = parseVersion(aliasLine(product, value) ?? value);
183
197
  return { product, version: v, kind: kindOf(product, v, false), where, raw: clip(raw) };
184
198
  }
185
199
 
@@ -17,14 +17,17 @@
17
17
  *
18
18
  * A distro build (8.1.2-1ubuntu2.19, or any version behind a Server header
19
19
  * that names a distribution) is never "affected": distributions backport fixes
20
- * without changing the upstream number. An advisory with a `condition` (an OS,
20
+ * without changing the upstream number. A banner with no distribution token
21
+ * for a product distributions backport (PHP/8.3.6 from Ubuntu 24.04 looks
22
+ * exactly like upstream 8.3.6) gives at most `potential`, with a note to check
23
+ * on the server; a pinned image or version file is upstream and stays `likely`. An advisory with a `condition` (an OS,
21
24
  * a SAPI, a module) gives at most `potential`, since neither a banner nor a pin
22
25
  * shows the condition. Matching uses only the pack's data and dates, never the
23
26
  * scan's clock, so the same input and packs give the same findings.
24
27
  */
25
28
  import { rulesOf } from "../rules/index.js";
26
29
  import { PRODUCTS } from "./products.js";
27
- import { compareVersions, compareBranches } from "./versions.js";
30
+ import { parseVersion, compareVersions, compareBranches } from "./versions.js";
28
31
  import { evidenceFromResponse, evidenceFromRepo } from "./evidence.js";
29
32
 
30
33
  export { evidenceFromResponse, evidenceFromRepo };
@@ -76,9 +79,13 @@ export function verdictFor(adv, ev, eol) {
76
79
  const fix = branch ? adv.fixed.find((f) => f.branch === branch) : null;
77
80
  const newest = adv.fixed.map((f) => f.branch).sort(compareBranches).at(-1);
78
81
  const newerLine = branch && !fix && newest && compareBranches(branch, newest) > 0;
82
+ // The flaw came in on the oldest listed line (`introduced`), so a line older
83
+ // than that one never had it: httpd 2.2 and CVE-2021-41773 (2.4.49 only).
84
+ const oldest = adv.fixed.reduce((a, f) => (!a || compareBranches(f.branch, a.branch) < 0 ? f : a), null);
85
+ const olderLine = branch && !fix && oldest?.introduced && compareBranches(branch, oldest.branch) < 0;
79
86
 
80
87
  if (ev.kind === "present") return { verdict: "check", why: "the version is not visible" };
81
- if (newerLine) return { verdict: "none" };
88
+ if (newerLine || olderLine) return { verdict: "none" };
82
89
 
83
90
  if (ev.kind === "branch") {
84
91
  if (!fix && eol) return { verdict: "unsupported", why: `release line ${branch} is end of life` };
@@ -88,6 +95,15 @@ export function verdictFor(adv, ev, eol) {
88
95
  // exact or distro: the upstream version is known.
89
96
  if (fix) {
90
97
  if (fix.introduced && compareVersions(ev.version, fix.introduced) < 0) return { verdict: "none" };
98
+ // A pre-release of the version after the fix may predate the fix: PHP-8.4
99
+ // says 8.4.28-dev from 8.4.27's RC until well after its release, while the
100
+ // fix is merged only at release. Two patches on, it is in. (A pre-release
101
+ // of the fixed version sorts below it and is affected.)
102
+ if (ev.version.pre && compareVersions(ev.version, fix.version) >= 0) {
103
+ const f = parseVersion(fix.version);
104
+ const safe = !f.letters && compareVersions(ev.version.upstream, `${f.major}.${f.minor}.${f.patch + 2}`) >= 0;
105
+ if (!safe) return { verdict: "check", fix, why: `it is a pre-release build (${ev.version.raw}), which may predate the fix` };
106
+ }
91
107
  if (compareVersions(ev.version, fix.version) >= 0) return { verdict: "patched", fix };
92
108
  if (ev.kind === "distro") return { verdict: "check", fix, why: "it is a distribution build, which may carry the fix as a backport under the same upstream version" };
93
109
  return { verdict: "affected", fix };
@@ -97,6 +113,14 @@ export function verdictFor(adv, ev, eol) {
97
113
  return { verdict: "check", why: `the advisory lists no fix for release line ${branch}` };
98
114
  }
99
115
 
116
+ // An exact version from a banner with no distribution token, for a product
117
+ // distributions backport: it may be a patched package under the same number.
118
+ const unmarked = (ev, p) => ev.kind === "exact" && ev.banner === true && p.backports;
119
+
120
+ function unmarkedNote(p, label) {
121
+ return `Check on server: your distribution may have patched this under the same version number. The banner names no distribution, and Debian 11 to 13, Ubuntu 24.04 and later, and RHEL print plain upstream version numbers, so ${label} may be a distribution package. Run \`${p.checkCommand}\` on the server and ask the package manager which package provides it (\`dpkg -S\` or \`rpm -qf\` on the binary). For a package, look the CVE up in your distribution's security tracker.`;
122
+ }
123
+
100
124
  function evidenceText(ev) {
101
125
  return ev.raws.map((r) => `- ${r}`).join("\n");
102
126
  }
@@ -122,6 +146,7 @@ export function matchRuntimeAdvisories(evidence, opts = {}) {
122
146
  const checks = [];
123
147
  const patched = [];
124
148
  const start = findings.length;
149
+ const note = unmarked(ev, p) ? unmarkedNote(p, label) : "";
125
150
 
126
151
  for (const adv of advisories.filter((a) => a.product === ev.product).sort(bySeverity)) {
127
152
  const v = verdictFor(adv, ev, eol);
@@ -129,12 +154,13 @@ export function matchRuntimeAdvisories(evidence, opts = {}) {
129
154
  findings.push({
130
155
  ...base,
131
156
  severity: adv.severity,
132
- confidence: adv.condition ? "potential" : "likely",
157
+ confidence: adv.condition || note ? "potential" : "likely",
133
158
  title: `${label} is affected by ${adv.title}`,
134
159
  description: [
135
160
  `${label} (seen in ${ev.wheres.join(", ")}) is below ${v.fix.version}, the first fixed version on the ${v.fix.branch} line, for ${advLine(adv)}.`,
136
161
  adv.kev ? `CISA lists it as exploited in the wild (Known Exploited Vulnerabilities catalog, added ${adv.kev}).` : "",
137
162
  adv.condition ? `It only applies when: ${adv.condition}. A version alone cannot show that, so confirm it on the server.` : "",
163
+ note,
138
164
  adv.description || "",
139
165
  `Evidence:\n${evidenceText(ev)}`,
140
166
  ].filter(Boolean).join("\n\n"),
@@ -150,8 +176,9 @@ export function matchRuntimeAdvisories(evidence, opts = {}) {
150
176
  title: `${label} is on an end-of-life line with no fix for ${adv.title}`,
151
177
  description: [
152
178
  `${label} (seen in ${ev.wheres.join(", ")}) is on release line ${branch}, which ${p.name} stopped supporting on ${eol.eol} (${eol.rule.source}). The advisory ${advLine(adv)} lists fixes only for ${adv.fixed.map((f) => f.branch).join(", ")}, so this line gets none. Whether the flaw exists on ${branch} is not stated; treat it as affected.`,
179
+ note,
153
180
  `Evidence:\n${evidenceText(ev)}`,
154
- ].join("\n\n"),
181
+ ].filter(Boolean).join("\n\n"),
155
182
  remediation: `Move to a supported ${p.name} line (${adv.fixed.map((f) => `${f.branch}: ${f.version}+`).join(", ")}).`,
156
183
  cves: adv.cves,
157
184
  rule: adv.id,
@@ -186,13 +213,14 @@ export function matchRuntimeAdvisories(evidence, opts = {}) {
186
213
  findings.push({
187
214
  ...base,
188
215
  severity: distro ? "medium" : "high",
189
- confidence: distro ? "needs-human-review" : "likely",
216
+ confidence: distro ? "needs-human-review" : note ? "potential" : "likely",
190
217
  title: distro
191
218
  ? `${label} is a distribution build of an end-of-life ${p.name} line`
192
219
  : `${label} is end of life: no more security fixes`,
193
220
  description: [
194
221
  `${p.name} ${branch} reached end of support on ${eol.eol} (${eol.rule.source}). Upstream publishes no security fixes for it, including for future advisories.`,
195
222
  distro ? "This is a distribution build, and some distributions keep patching a line after upstream stops. Check your distribution's support period for this package." : "",
223
+ note,
196
224
  `Evidence:\n${evidenceText(ev)}`,
197
225
  ].filter(Boolean).join("\n\n"),
198
226
  remediation: `Upgrade to a supported ${p.name} release line.`,
@@ -205,7 +233,7 @@ export function matchRuntimeAdvisories(evidence, opts = {}) {
205
233
  findings.push({
206
234
  ...base,
207
235
  severity: "info",
208
- confidence: ev.kind === "exact" ? "likely" : "potential",
236
+ confidence: ev.kind === "exact" && !ev.version.pre ? "likely" : "potential",
209
237
  title: `${label} has the fix for ${patched.length === 1 ? patched[0].title : `${patched.length} known advisories`} (as advertised)`,
210
238
  description: [
211
239
  `The version ${ev.kind === "distro" ? "string" : "banner or pin"} is at or above the fixed version for: ${patched.map((a) => ids(a)).join(", ")}. This is what ${ev.wheres.join(", ")} says; a proxy or a stale banner can differ from what runs.`,
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * The runtimes and servers runtime advisories can name, and how to recognize
3
- * each one. Detection is code; which versions are affected is data, in signed
3
+ * each one. `backports`: distributions commonly ship it with security fixes
4
+ * backported under the upstream number, so a banner with no distribution
5
+ * token may still be a patched package (Debian 11-13, Ubuntu 24.04+ and RHEL
6
+ * print plain PHP versions; Debian's and RHEL's nginx add no OS token). Detection is code; which versions are affected is data, in signed
4
7
  * rule packs (kind `runtime-advisory`, see lib/rules/pack.js).
5
8
  */
6
9
 
@@ -12,6 +15,7 @@ export const PRODUCTS = Object.freeze({
12
15
  php: {
13
16
  branchOf: minorLine,
14
17
  name: "PHP",
18
+ backports: true,
15
19
  // On the server itself, the version and any distro build string.
16
20
  checkCommand: "php -v",
17
21
  banner: /\bPHP\/(\d+\.\d+(?:\.\d+)?[^\s,;()]*)/i,
@@ -24,6 +28,7 @@ export const PRODUCTS = Object.freeze({
24
28
  "apache-httpd": {
25
29
  branchOf: minorLine,
26
30
  name: "Apache HTTP Server",
31
+ backports: true,
27
32
  checkCommand: "httpd -v (or apache2 -v)",
28
33
  banner: /\bApache\/(\d+\.\d+\.\d+[^\s,;()]*)/,
29
34
  // not Apache-Coyote (Tomcat) or Apache-HttpClient
@@ -35,6 +40,7 @@ export const PRODUCTS = Object.freeze({
35
40
  nginx: {
36
41
  branchOf: minorLine,
37
42
  name: "nginx",
43
+ backports: true,
38
44
  checkCommand: "nginx -v",
39
45
  banner: /\bnginx\/(\d+\.\d+\.\d+[^\s,;()]*)/i,
40
46
  present: /^nginx\b/i,
@@ -45,27 +51,48 @@ export const PRODUCTS = Object.freeze({
45
51
  nodejs: {
46
52
  branchOf: (v) => String(v.major),
47
53
  name: "Node.js",
54
+ // Node.js has no version banner, and official images and binaries are upstream.
55
+ backports: false,
48
56
  checkCommand: "node -v",
49
57
  banner: null,
50
58
  present: null,
51
59
  dockerImages: ["node"],
52
60
  toolVersions: ["nodejs", "node"],
53
61
  versionFiles: [".nvmrc", ".node-version"],
62
+ // LTS codenames, as in nvm's `lts/iron` and the node image's `iron` tag
63
+ // (the `lts` field of https://nodejs.org/dist/index.json).
64
+ lineAliases: { argon: "4", boron: "6", carbon: "8", dubnium: "10", erbium: "12", fermium: "14", gallium: "16", hydrogen: "18", iron: "20", jod: "22", krypton: "24" },
54
65
  },
55
66
  python: {
56
67
  branchOf: minorLine,
57
68
  name: "Python",
69
+ backports: true,
58
70
  checkCommand: "python3 --version",
59
- // Werkzeug and http.server print "Python/3.12.1" in Server
60
- banner: /\bPython\/(\d+\.\d+\.\d+[^\s,;()]*)/,
71
+ // Werkzeug and http.server print "Python/3.12.1" in Server; aiohttp only
72
+ // "Python/3.12 aiohttp/3.9.5", which still names the release line
73
+ banner: /\bPython\/(\d+\.\d+(?:\.\d+)?[^\s,;()]*)/,
61
74
  present: null,
62
75
  dockerImages: ["python"],
63
76
  toolVersions: ["python"],
64
77
  versionFiles: [".python-version"],
65
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
+ },
66
92
  openssl: {
67
93
  branchOf: (v) => (v.major >= 3 ? minorLine(v) : v.patch === null ? null : `${v.major}.${v.minor}.${v.patch}`),
68
94
  name: "OpenSSL",
95
+ backports: true,
69
96
  checkCommand: "openssl version",
70
97
  banner: /\bOpenSSL\/(\d+\.\d+\.\d+[a-z]{0,2}[^\s,;()]*)/,
71
98
  present: null,