penthera 1.8.0-rc.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,83 @@ All notable changes to Penthera are documented here. Versioning follows [SemVer]
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.8.0] - 2026-10-21
8
+
9
+ 1.8.0 is 1.8.0-rc.1 plus the changes below; there was no separate rc.2
10
+ release. Highlights across both: fixes from a second independent security
11
+ review, a fix loop that cannot edit the tests that judge it, the redesigned
12
+ dashboard (Simple and Advanced mode), release checks on Linux, macOS and
13
+ Windows, and now runtime advisories, starting with PHP's critical security
14
+ release of 21 October 2026, in a new signed `advisories` rule pack (1.8.0-rc.1
15
+ cannot load it). The benchmark gained two PHP cases: precision 0.88 (95% CI
16
+ 0.69 to 0.96) and recall 0.91 (0.73 to 0.98) on 24 scored findings.
17
+
18
+ ### Added
19
+
20
+ - **Runtime advisories: is the PHP, Apache, nginx, Node.js, Python or OpenSSL
21
+ you run affected?** URL scans read `Server` and `X-Powered-By` banners, the
22
+ `PHPSESSID` cookie name and `.php` pages. Repo scans read Dockerfile and
23
+ compose images, `.tool-versions`, `.php-version`, `.nvmrc`,
24
+ `.python-version`, `runtime.txt` and `composer.json`. Each product and
25
+ version gets one answer: affected, end of life with no fix, check on server,
26
+ or has the fix (as advertised). A hidden version is never reported as safe.
27
+ A distribution build (`8.1.2-1ubuntu2.19`, or any version behind a `Server`
28
+ header that names a distribution) is never reported as affected, because
29
+ distributions backport fixes under the same version number. Advisories that
30
+ need more than a version (Windows, CGI) stay `potential`. See
31
+ [docs/runtime-advisories.md](docs/runtime-advisories.md).
32
+ - **Two rule-pack kinds, `runtime-advisory` and `runtime-eol`**, so a new
33
+ advisory ships as a signed data pack (`--rules`) the same day, without a
34
+ release. End of life is judged by the pack's date, not the scan's clock.
35
+ - **A bundled `advisories` pack** with the PHP security release of 21 October
36
+ 2026 (CVE-2026-109618 and CVE-2026-109619, both Critical, CVSS 4.0 9.2,
37
+ fixed in 8.2.35, 8.3.36, 8.4.27 and 8.5.12, from the php-src advisories),
38
+ vendor-checked worked examples (CVE-2024-4577 and CVE-2024-8926 for PHP-CGI on Windows, CVE-2021-41773 and
39
+ CVE-2021-42013 for Apache HTTP Server 2.4.49 and 2.4.50) and end-of-life
40
+ release lines. Benchmark cases for both URL and repo scans.
41
+
42
+ ### Site
43
+
44
+ - **A landing page in `site/`**, a static site for Vercel: the positioning,
45
+ `npx penthera`, prompts for Claude Code, Cursor and Codex, the hero loop and
46
+ the walkthrough, the limits and the research plan. No third-party scripts,
47
+ analytics or cookies; fonts are self-hosted or system fonts.
48
+ - **A check in the browser.** Drop or paste a `package.json`, `.mcp.json`,
49
+ `.claude/settings.json`, `CLAUDE.md` or `AGENTS.md` and Penthera's own
50
+ `lib/` code runs in the tab: the agent-config rules, the secret patterns,
51
+ install scripts, typosquats against the vendored popular list, and the
52
+ bundled IOC pack. `site/build.mjs` bundles `lib/` at build time, so the site
53
+ and the CLI run the same rules; `tests/site.test.js` holds the page to the
54
+ CLI on the agents and deps fixtures. The page is served with
55
+ `connect-src 'none'`, so it cannot send the files anywhere.
56
+ - **Runtime advisories in the browser check.** Drop a `Dockerfile`,
57
+ `compose.yaml`, `.php-version`, `.tool-versions` or `composer.json`, or
58
+ paste the response headers `curl -sI` printed for your site (the page never
59
+ contacts it), and `lib/advisories` runs on them: the same findings and
60
+ confidence as the CLI, with "check on the server: distributions backport
61
+ fixes" next to every version-only verdict. `tests/site.test.js` compares
62
+ pasted headers with a URL scan of a loopback server sending the same
63
+ headers, and runtime pins with `runtimeAdvisoriesForRepo`.
64
+ - `penthera deps` findings are built in `lib/deps/findings.js` (moved from
65
+ `lib/deps/index.js`, no behaviour change), so the browser check uses the
66
+ same wording.
67
+ - `scripts/docs-commands.mjs` also checks every command the site shows.
68
+
69
+ ### Docs
70
+
71
+ - **New title for the launch:** "Penthera: your open-source cyber security
72
+ agent", in the README, docs index, `penthera --help`, the GitHub Action name
73
+ (which renames its Marketplace listing), the dashboard and the report
74
+ footer.
75
+
76
+ - **README hero is a 12-second loop** of the terminal and the dashboard
77
+ (setup, an offline scan, Simple and Advanced mode, a fix approved), cut from
78
+ the walkthrough video by `scripts/media/hero-gif.sh`, and it loops without a
79
+ jump. The terminal-only scan clip is now `assets/demo-scan.gif`. The Demo
80
+ media workflow re-cuts the loop on every release.
81
+ - **The README diagram is back**, updated: what Penthera checks, where the
82
+ findings go, and that the fix loop asks first and cannot edit its own tests.
83
+
7
84
  ## [1.8.0-rc.1] - 2026-10-20
8
85
 
9
86
  Release candidate for 1.8.0, published on npm's `next` dist-tag
package/README.md CHANGED
@@ -1,10 +1,12 @@
1
1
  <div align="center">
2
2
 
3
- <img src="assets/penthera-social.jpg" alt="Penthera: Cyber Security Agent" width="840">
3
+ <img src="assets/penthera-social.jpg" alt="Penthera: your open-source cyber security agent" width="840">
4
4
 
5
5
  <br/><br/>
6
6
 
7
- **Penthera: an open-source cyber security agent you can use today, and verify yourself.**
7
+ **Penthera: your open-source cyber security agent**
8
+
9
+ The security review you never had time for. Offline, rule-based, and you can verify every finding yourself.
8
10
 
9
11
  <br/>
10
12
 
@@ -16,7 +18,7 @@
16
18
 
17
19
  <br/>
18
20
 
19
- <img src="assets/demo.gif" alt="Penthera running an interactive security scan in the terminal" width="760">
21
+ <img src="assets/demo.gif" alt="A 12-second loop: npx penthera sets up a project, an offline scan runs in the terminal, the dashboard shows the verdict in Simple mode and the triage matrix in Advanced mode, and a fix is approved" width="760">
20
22
 
21
23
  <br/><br/>
22
24
 
@@ -89,6 +91,29 @@ Penthera needs Node.js 22 or later. To run it in a container:
89
91
 
90
92
  ## What it does
91
93
 
94
+ ```mermaid
95
+ flowchart LR
96
+ U["You or your AI agent"] --> P(("Penthera<br/>on your machine"))
97
+
98
+ P --> APP["Your app<br/>URL scan, repo scan"]
99
+ P --> DEP["Your dependencies<br/>capabilities, install firewall<br/>typosquats, upgrade diff"]
100
+ P --> AI["Your AI tooling<br/>skills, MCP, hooks<br/>plugins, rule files"]
101
+
102
+ APP --> F[["Findings<br/>severity, confidence<br/>evidence at file:line"]]
103
+ DEP --> F
104
+ AI --> F
105
+
106
+ F --> FIX["Fix loop<br/>asks first, local branch<br/>cannot edit its own tests"]
107
+ FIX -. "re-scan proves it" .-> F
108
+ F --> R["Reports and dashboard<br/>terminal, Markdown, SARIF<br/>penthera ui"]
109
+ F --> E["Evidence<br/>signed bundle<br/>NIS2 and CRA drafts"]
110
+
111
+ classDef core fill:#0000ed,stroke:#0000ed,color:#ffffff
112
+ classDef box fill:#ffffff,stroke:#000000,color:#000000
113
+ class P core
114
+ class U,APP,DEP,AI,F,FIX,R,E box
115
+ ```
116
+
92
117
  ### Before you deploy
93
118
 
94
119
  | Job | What Penthera does | Command |
@@ -127,7 +152,7 @@ Each feature goes through the same path: **method → evidence → tool.**
127
152
  - **Method.** The [methodology](docs/METHODOLOGY.md) states the threat model,
128
153
  what black-box (HTTP) and white-box (source) analysis can and cannot see, the
129
154
  confidence model, and the false-positive controls on each probe. Each design
130
- choice cites the research or standard it rests on: 57 verified references, in
155
+ choice cites the research or standard it rests on: 92 verified references, in
131
156
  APA style, in [docs/references.md](docs/references.md).
132
157
  - **Evidence.** A benchmark runs on every release, and the release gate fails
133
158
  if precision or recall drops by more than 0.02. Experiments that decide what
@@ -138,17 +163,17 @@ Each feature goes through the same path: **method → evidence → tool.**
138
163
 
139
164
  | Measure | Result today |
140
165
  |---|---|
141
- | Precision on the bundled benchmark | 0.86, 95% CI 0.67 to 0.95 (19 true positives, 3 false positives) |
142
- | Recall on the bundled benchmark | 0.90, 95% CI 0.71 to 0.97 (2 misses, [each one explained](bench/RESULTS.md)) |
166
+ | Precision on the bundled benchmark | 0.88, 95% CI 0.69 to 0.96 (21 true positives, 3 false positives) |
167
+ | Recall on the bundled benchmark | 0.91, 95% CI 0.73 to 0.98 (2 misses, [each one explained](bench/RESULTS.md)) |
143
168
  | Requests to non-loopback hosts during the benchmark | 0 |
144
169
  | Detection rules, all readable | 14 URL scan templates, 20 secret and AI-key patterns, 15 dependency capabilities, 39 AI-tooling rules, 5 fix recipes |
145
170
 
146
- These numbers come from 22 findings in small labs that we wrote ourselves, so
171
+ These numbers come from 24 findings in small labs that we wrote ourselves, so
147
172
  they catch regressions; they do not measure real-world accuracy. Measuring that
148
173
  on real projects is experiment E1, the first step of the
149
174
  [research plan](docs/vision-2.0.md). Intervals are Wilson. What Penthera cannot
150
175
  see is listed under [Known limitations](docs/METHODOLOGY.md#known-limitations).
151
- Counts as of 2026-10-20; refresh them with `npm run docs:numbers` and
176
+ Counts as of 2026-10-21; refresh them with `npm run docs:numbers` and
152
177
  `npm run bench`.
153
178
 
154
179
  **Cite this repository** with the button on GitHub or
@@ -240,9 +265,9 @@ What changed, what we are measuring, and what comes next, in one place.
240
265
 
241
266
  | | Where it stands | Read more |
242
267
  |---|---|---|
243
- | **Latest release** | 1.7.0 (2026-10-19): one-command setup (`npx penthera`), the dashboard, signed rule packs | [CHANGELOG](CHANGELOG.md) |
244
- | **On main, unreleased** | Fixes from a second independent security review, a fix loop that cannot edit the tests that judge it, release checks on Linux, macOS and Windows, docs tested as code | [Unreleased](CHANGELOG.md#unreleased) |
245
- | **Now** | 1.8.0 feature freeze: fixes only, a release candidate on npm's `next` tag first | [Roadmap: now](ROADMAP.md#now-180-feature-freeze) |
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) |
246
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) |
247
272
  | **Next** | Real-world precision and recall with intervals, published whatever the result | [Roadmap: next](ROADMAP.md#next), [research plan](docs/vision-2.0.md) |
248
273
 
package/SECURITY.md CHANGED
@@ -9,8 +9,8 @@ Security fixes land on the latest minor release only. Upgrade to get them.
9
9
 
10
10
  | Version | Supported |
11
11
  | ------- | --------- |
12
- | 1.7.x | Yes |
13
- | < 1.7 | No |
12
+ | 1.8.x | Yes |
13
+ | < 1.8 | No |
14
14
 
15
15
  ## Reporting a vulnerability
16
16
 
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Evidence that a runtime or server is in use, and at which version.
3
+ *
4
+ * Black-box: what one HTTP response says about itself (Server, X-Powered-By,
5
+ * a PHPSESSID cookie, links to .php pages). White-box: what the repository
6
+ * pins (Dockerfile and compose images, .tool-versions, .php-version, .nvmrc,
7
+ * .python-version, runtime.txt, composer.json).
8
+ *
9
+ * Every item is `{ product, version, kind, where, raw, distro? }`:
10
+ * version the parsed version (lib/advisories/versions.js) or null
11
+ * kind "exact" an upstream x.y.z
12
+ * "branch" only x.y (a floating Docker tag, `.python-version: 3.12`)
13
+ * "distro" a version with a distribution build string, or on a
14
+ * response whose Server header names a distribution
15
+ * "present" the product is in use, the version is not visible
16
+ * where the header, cookie or file it came from
17
+ * raw the text it was read from (a header value, a line), for the report
18
+ *
19
+ * Nothing here decides whether anything is vulnerable; that is index.js.
20
+ */
21
+ import { readdirSync, lstatSync, openSync, fstatSync, readSync, closeSync, constants } from "node:fs";
22
+ import { join, relative, basename } from "node:path";
23
+ import { PRODUCTS, DISTRO_HINT } from "./products.js";
24
+ import { parseVersion } from "./versions.js";
25
+
26
+ const clip = (s, n = 200) => (s.length > n ? `${s.slice(0, n)}…` : s);
27
+
28
+ function kindOf(product, v, distro) {
29
+ if (!v) return "present";
30
+ if (v.patch !== null) return distro || v.suffix ? "distro" : "exact";
31
+ // "php:8" spans release lines; "php:8.3" and "node:20" name one.
32
+ return PRODUCTS[product].branchOf(v) ? "branch" : "present";
33
+ }
34
+
35
+ // ── Black-box ─────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Evidence from one response's headers, cookies and page links.
39
+ * @param {{ headers?: Record<string,string>, cookieNames?: string[], phpLink?: string|null }} fp
40
+ * what lib/crawler.js fingerprint() collected for the homepage
41
+ * @param {{ phpPaths?: string[] }} [more] .php paths the endpoint discovery found reachable
42
+ */
43
+ export function evidenceFromResponse(fp = {}, more = {}) {
44
+ const headers = fp.headers || {};
45
+ const out = [];
46
+ const server = String(headers.server || "");
47
+ const poweredBy = String(headers["x-powered-by"] || "");
48
+ const distro = DISTRO_HINT.test(server);
49
+
50
+ for (const [id, p] of Object.entries(PRODUCTS)) {
51
+ let found = false;
52
+ for (const [name, value] of [["server", server], ["x-powered-by", poweredBy]]) {
53
+ if (!value) continue;
54
+ const m = p.banner && p.banner.exec(value);
55
+ if (m) {
56
+ const v = parseVersion(m[1]);
57
+ if (v) {
58
+ out.push({ product: id, version: v, kind: kindOf(id, v, distro), where: `${name} header`, raw: clip(`${name}: ${value}`) });
59
+ found = true;
60
+ continue;
61
+ }
62
+ }
63
+ if (p.present && p.present.test(value)) {
64
+ out.push({ product: id, version: null, kind: "present", where: `${name} header`, raw: clip(`${name}: ${value}`) });
65
+ found = true;
66
+ }
67
+ }
68
+ if (id === "php" && !found) {
69
+ if ((fp.cookieNames || []).some((n) => n.toUpperCase() === "PHPSESSID")) {
70
+ out.push({ product: id, version: null, kind: "present", where: "Set-Cookie", raw: "PHPSESSID cookie (PHP's default session cookie name)" });
71
+ found = true;
72
+ }
73
+ }
74
+ if (id === "php" && !found) {
75
+ const link = fp.phpLink || (more.phpPaths || [])[0];
76
+ if (link) out.push({ product: id, version: null, kind: "present", where: fp.phpLink ? "homepage links" : "endpoint discovery", raw: clip(`.php page: ${link}`) });
77
+ }
78
+ }
79
+ return dedupe(out);
80
+ }
81
+
82
+ // ── White-box ─────────────────────────────────────────────────────────────
83
+
84
+ const SKIP_DIRS = new Set([
85
+ "node_modules", ".git", "dist", "build", ".next", "coverage", "reports", "vendor",
86
+ "venv", ".venv", "site-packages", "__pycache__", ".tox", ".nox", ".obsidian",
87
+ ]);
88
+ const MAX_DEPTH = 4;
89
+ const MAX_FILE_BYTES = 256 * 1024;
90
+
91
+ const isDockerfile = (n) => /^(?:Dockerfile|Containerfile)(?:\..+)?$/i.test(n) || /\.(?:dockerfile|containerfile)$/i.test(n);
92
+ const isCompose = (n) => /^(?:docker-)?compose(?:\.[\w-]+)?\.ya?ml$/i.test(n);
93
+ const VERSION_FILES = new Set(Object.values(PRODUCTS).flatMap((p) => p.versionFiles));
94
+ const ROOT_ONLY = new Set([".tool-versions", "runtime.txt", "composer.json"]);
95
+
96
+ function wanted(name, depth) {
97
+ if (isDockerfile(name) || isCompose(name) || VERSION_FILES.has(name)) return true;
98
+ return depth === 0 && ROOT_ONLY.has(name);
99
+ }
100
+
101
+ function readSmall(path) {
102
+ let fd;
103
+ try {
104
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK || 0) | (constants.O_NOFOLLOW || 0));
105
+ const st = fstatSync(fd);
106
+ if (!st.isFile() || st.size > MAX_FILE_BYTES) return null;
107
+ const buf = Buffer.alloc(st.size);
108
+ const n = st.size ? readSync(fd, buf, 0, st.size, 0) : 0;
109
+ return buf.subarray(0, n).toString("utf-8");
110
+ } catch {
111
+ return null;
112
+ } finally {
113
+ if (fd !== undefined) closeSync(fd);
114
+ }
115
+ }
116
+
117
+ function walk(dir, depth, files) {
118
+ if (depth > MAX_DEPTH) return;
119
+ let entries;
120
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
121
+ for (const e of entries) {
122
+ if (SKIP_DIRS.has(e.name)) continue;
123
+ const full = join(dir, e.name);
124
+ if (e.isFile()) {
125
+ if (wanted(e.name, depth)) files.push({ full, depth });
126
+ continue;
127
+ }
128
+ if (!e.isDirectory()) continue;
129
+ // Not a symlinked folder, and not a nested repo or worktree.
130
+ let st;
131
+ try { st = lstatSync(full); } catch { continue; }
132
+ if (!st.isDirectory()) continue;
133
+ try { lstatSync(join(full, ".git")); continue; } catch { /* no nested .git */ }
134
+ walk(full, depth + 1, files);
135
+ }
136
+ }
137
+
138
+ const imageProduct = new Map(Object.entries(PRODUCTS).flatMap(([id, p]) => p.dockerImages.map((img) => [img, id])));
139
+
140
+ /**
141
+ * `php:8.3.12-fpm-alpine`, `docker.io/library/node:20`, `python@sha256:…`.
142
+ * Official images are upstream builds, so a variant after the version
143
+ * (`-fpm-alpine`, `-slim-bookworm`) is not a distro build string.
144
+ */
145
+ export function parseImageRef(ref) {
146
+ const noDigest = ref.split("@")[0];
147
+ const slash = noDigest.lastIndexOf("/");
148
+ const colon = noDigest.indexOf(":", slash + 1);
149
+ const repo = colon === -1 ? noDigest : noDigest.slice(0, colon);
150
+ const tag = colon === -1 ? "" : noDigest.slice(colon + 1);
151
+ const path = repo.toLowerCase().replace(/^(?:docker\.io\/)?(?:library\/)?/, "");
152
+ const product = imageProduct.get(path);
153
+ if (!product) return null;
154
+ const m = /^(\d+(?:\.\d+){0,2})(?:$|-)/.exec(tag);
155
+ const v = m ? parseVersion(m[1]) : null;
156
+ return { product, version: v, kind: kindOf(product, v, false), tag: tag || "latest" };
157
+ }
158
+
159
+ function fromDockerfile(text, where) {
160
+ const out = [];
161
+ for (const line of text.split(/\r?\n/)) {
162
+ const m = /^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)/i.exec(line);
163
+ if (!m || m[1].includes("$")) continue;
164
+ const img = parseImageRef(m[1]);
165
+ if (img) out.push({ product: img.product, version: img.version, kind: img.kind, where, raw: clip(line.trim()) });
166
+ }
167
+ return out;
168
+ }
169
+
170
+ function fromCompose(text, where) {
171
+ const out = [];
172
+ for (const line of text.split(/\r?\n/)) {
173
+ const m = /^\s*image:\s*["']?([^\s"'#]+)/.exec(line);
174
+ if (!m || m[1].includes("$")) continue;
175
+ const img = parseImageRef(m[1]);
176
+ if (img) out.push({ product: img.product, version: img.version, kind: img.kind, where, raw: clip(line.trim()) });
177
+ }
178
+ return out;
179
+ }
180
+
181
+ function versionLine(product, value, where, raw) {
182
+ const v = parseVersion(value);
183
+ return { product, version: v, kind: kindOf(product, v, false), where, raw: clip(raw) };
184
+ }
185
+
186
+ function fromToolVersions(text, where) {
187
+ const out = [];
188
+ for (const line of text.split(/\r?\n/)) {
189
+ const [tool, value] = line.trim().split(/\s+/);
190
+ if (!tool || !value || tool.startsWith("#")) continue;
191
+ const id = Object.keys(PRODUCTS).find((k) => PRODUCTS[k].toolVersions.includes(tool));
192
+ if (id) out.push(versionLine(id, value, where, line.trim()));
193
+ }
194
+ return out;
195
+ }
196
+
197
+ function fromVersionFile(name, text, where) {
198
+ const id = Object.keys(PRODUCTS).find((k) => PRODUCTS[k].versionFiles.includes(name));
199
+ const value = text.split(/\r?\n/).map((l) => l.trim()).find((l) => l && !l.startsWith("#"));
200
+ if (!id || !value) return [];
201
+ return [versionLine(id, value, where, `${name}: ${value}`)];
202
+ }
203
+
204
+ function fromComposer(text, where) {
205
+ let j;
206
+ try { j = JSON.parse(text); } catch { return []; }
207
+ if (!j || typeof j !== "object") return [];
208
+ // `require.php` is a constraint and `config.platform.php` a version Composer
209
+ // pretends to have while resolving; neither is the PHP that runs. Both only
210
+ // say PHP is in use.
211
+ const constraint = typeof j.require?.php === "string" ? ` (require.php: ${clip(j.require.php, 60)})` : "";
212
+ return [{ product: "php", version: null, kind: "present", where, raw: `composer.json${constraint}` }];
213
+ }
214
+
215
+ /**
216
+ * Evidence from a repository's pins.
217
+ * @param {string} root
218
+ */
219
+ export function evidenceFromRepo(root) {
220
+ const files = [];
221
+ walk(root, 0, files);
222
+ const out = [];
223
+ for (const { full } of files) {
224
+ const text = readSmall(full);
225
+ if (text === null) continue;
226
+ const name = basename(full);
227
+ const where = relative(root, full).split("\\").join("/");
228
+ if (isDockerfile(name)) out.push(...fromDockerfile(text, where));
229
+ else if (isCompose(name)) out.push(...fromCompose(text, where));
230
+ else if (name === ".tool-versions") out.push(...fromToolVersions(text, where));
231
+ else if (name === "runtime.txt") {
232
+ const m = /^\s*python-(\d+\.\d+(?:\.\d+)?)\s*$/m.exec(text);
233
+ if (m) out.push(versionLine("python", m[1], where, `runtime.txt: python-${m[1]}`));
234
+ } else if (name === "composer.json") out.push(...fromComposer(text, where));
235
+ else out.push(...fromVersionFile(name, text, where));
236
+ }
237
+ return dedupe(out);
238
+ }
239
+
240
+ function dedupe(items) {
241
+ const seen = new Set();
242
+ return items.filter((e) => {
243
+ const k = `${e.product}|${e.version?.raw ?? ""}|${e.kind}|${e.where}`;
244
+ if (seen.has(k)) return false;
245
+ seen.add(k);
246
+ return true;
247
+ });
248
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Runtime advisories: is the PHP, nginx, Apache, Node.js, Python or OpenSSL
3
+ * this app runs affected by a published security advisory?
4
+ *
5
+ * Evidence (./evidence.js) says which product and, if visible, which version.
6
+ * Rule packs say which versions each advisory affects (`runtime-advisory`) and
7
+ * which release lines are past end of support (`runtime-eol`). This module
8
+ * compares the two and never claims more than the evidence carries:
9
+ *
10
+ * exact upstream version, below the fix affected adv. severity, likely
11
+ * exact version on an end-of-life line unsupported adv. severity, potential
12
+ * version hidden, a floating tag, a distro
13
+ * build below the fix, a line the advisory
14
+ * does not list check on server medium, needs-human-review
15
+ * exact version at or above every fix patched info
16
+ * no evidence of the product nothing (absence is not "safe")
17
+ *
18
+ * A distro build (8.1.2-1ubuntu2.19, or any version behind a Server header
19
+ * that names a distribution) is never "affected": distributions backport fixes
20
+ * without changing the upstream number. An advisory with a `condition` (an OS,
21
+ * a SAPI, a module) gives at most `potential`, since neither a banner nor a pin
22
+ * shows the condition. Matching uses only the pack's data and dates, never the
23
+ * scan's clock, so the same input and packs give the same findings.
24
+ */
25
+ import { rulesOf } from "../rules/index.js";
26
+ import { PRODUCTS } from "./products.js";
27
+ import { compareVersions, compareBranches } from "./versions.js";
28
+ import { evidenceFromResponse, evidenceFromRepo } from "./evidence.js";
29
+
30
+ export { evidenceFromResponse, evidenceFromRepo };
31
+
32
+ const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
33
+ const bySeverity = (a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity] || a.id.localeCompare(b.id);
34
+
35
+ function ids(adv) {
36
+ const list = [...adv.cves, ...adv.ghsa];
37
+ return list.length ? list.join(", ") : adv.id;
38
+ }
39
+
40
+ function advLine(adv) {
41
+ const cvss = adv.cvss ? `, CVSS ${adv.cvss.version} ${adv.cvss.score}` : "";
42
+ return `${adv.title} (${ids(adv)}${cvss}; ${adv.source})`;
43
+ }
44
+
45
+ /** End-of-life record for `branch`, if the pack says it had ended by the rule's own date. */
46
+ function eolOf(eolRules, product, branch) {
47
+ if (!branch) return null;
48
+ for (const r of eolRules) {
49
+ if (r.product !== product) continue;
50
+ const b = r.branches.find((x) => x.branch === branch);
51
+ if (b && b.eol <= r.date) return { ...b, rule: r };
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** Merge evidence of the same product, version and kind seen in several places. */
57
+ function group(evidence) {
58
+ const groups = new Map();
59
+ for (const e of evidence) {
60
+ const key = `${e.product}|${e.version?.raw ?? ""}|${e.kind}`;
61
+ if (!groups.has(key)) groups.set(key, { ...e, wheres: [], raws: [] });
62
+ const g = groups.get(key);
63
+ if (!g.wheres.includes(e.where)) g.wheres.push(e.where);
64
+ if (!g.raws.includes(e.raw)) g.raws.push(e.raw);
65
+ }
66
+ return [...groups.values()];
67
+ }
68
+
69
+ /**
70
+ * Verdict of one advisory for one evidence group.
71
+ * @returns {{ verdict: "affected"|"unsupported"|"check"|"patched"|"none", fix?: object, why?: string }}
72
+ */
73
+ export function verdictFor(adv, ev, eol) {
74
+ const p = PRODUCTS[ev.product];
75
+ const branch = ev.version ? p.branchOf(ev.version) : null;
76
+ const fix = branch ? adv.fixed.find((f) => f.branch === branch) : null;
77
+ const newest = adv.fixed.map((f) => f.branch).sort(compareBranches).at(-1);
78
+ const newerLine = branch && !fix && newest && compareBranches(branch, newest) > 0;
79
+
80
+ if (ev.kind === "present") return { verdict: "check", why: "the version is not visible" };
81
+ if (newerLine) return { verdict: "none" };
82
+
83
+ if (ev.kind === "branch") {
84
+ if (!fix && eol) return { verdict: "unsupported", why: `release line ${branch} is end of life` };
85
+ return { verdict: "check", fix, why: fix ? `only the release line (${branch}) is pinned, not the exact version` : `the advisory lists no fix for release line ${branch}` };
86
+ }
87
+
88
+ // exact or distro: the upstream version is known.
89
+ if (fix) {
90
+ if (fix.introduced && compareVersions(ev.version, fix.introduced) < 0) return { verdict: "none" };
91
+ if (compareVersions(ev.version, fix.version) >= 0) return { verdict: "patched", fix };
92
+ 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
+ return { verdict: "affected", fix };
94
+ }
95
+ if (ev.kind === "distro") return { verdict: "check", why: `release line ${branch} has no upstream fix, but distributions support some lines longer` };
96
+ if (eol) return { verdict: "unsupported", why: `release line ${branch} is end of life` };
97
+ return { verdict: "check", why: `the advisory lists no fix for release line ${branch}` };
98
+ }
99
+
100
+ function evidenceText(ev) {
101
+ return ev.raws.map((r) => `- ${r}`).join("\n");
102
+ }
103
+
104
+ /**
105
+ * Findings for a set of evidence.
106
+ * @param {object[]} evidence from evidenceFromResponse() or evidenceFromRepo()
107
+ * @param {{ advisories?: object[], eol?: object[], url?: string }} [opts] rules (default: the
108
+ * active packs); `url` is the scanned page, used as the finding location for black-box evidence
109
+ */
110
+ export function matchRuntimeAdvisories(evidence, opts = {}) {
111
+ const advisories = opts.advisories || rulesOf("runtime-advisory");
112
+ const eolRules = opts.eol || rulesOf("runtime-eol");
113
+ const findings = [];
114
+
115
+ for (const ev of group(evidence)) {
116
+ const p = PRODUCTS[ev.product];
117
+ const label = ev.version ? `${p.name} ${ev.version.raw}` : p.name;
118
+ const branch = ev.version ? p.branchOf(ev.version) : null;
119
+ const eol = ev.kind === "present" ? null : eolOf(eolRules, ev.product, branch);
120
+ const url = opts.url || ev.wheres[0];
121
+ const base = { url, category: "runtime-advisory", source: "runtime-advisory", evidence: ev.raws.join("\n") };
122
+ const checks = [];
123
+ const patched = [];
124
+ const start = findings.length;
125
+
126
+ for (const adv of advisories.filter((a) => a.product === ev.product).sort(bySeverity)) {
127
+ const v = verdictFor(adv, ev, eol);
128
+ if (v.verdict === "affected") {
129
+ findings.push({
130
+ ...base,
131
+ severity: adv.severity,
132
+ confidence: adv.condition ? "potential" : "likely",
133
+ title: `${label} is affected by ${adv.title}`,
134
+ description: [
135
+ `${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
+ adv.kev ? `CISA lists it as exploited in the wild (Known Exploited Vulnerabilities catalog, added ${adv.kev}).` : "",
137
+ adv.condition ? `It only applies when: ${adv.condition}. A version alone cannot show that, so confirm it on the server.` : "",
138
+ adv.description || "",
139
+ `Evidence:\n${evidenceText(ev)}`,
140
+ ].filter(Boolean).join("\n\n"),
141
+ remediation: `Upgrade ${p.name} to ${v.fix.version} or later on the ${v.fix.branch} line, or to a newer supported line. Then confirm with \`${p.checkCommand}\` on the server.`,
142
+ cves: adv.cves,
143
+ rule: adv.id,
144
+ });
145
+ } else if (v.verdict === "unsupported") {
146
+ findings.push({
147
+ ...base,
148
+ severity: adv.severity,
149
+ confidence: "potential",
150
+ title: `${label} is on an end-of-life line with no fix for ${adv.title}`,
151
+ description: [
152
+ `${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.`,
153
+ `Evidence:\n${evidenceText(ev)}`,
154
+ ].join("\n\n"),
155
+ remediation: `Move to a supported ${p.name} line (${adv.fixed.map((f) => `${f.branch}: ${f.version}+`).join(", ")}).`,
156
+ cves: adv.cves,
157
+ rule: adv.id,
158
+ });
159
+ } else if (v.verdict === "check") {
160
+ checks.push({ adv, ...v });
161
+ } else if (v.verdict === "patched") {
162
+ patched.push(adv);
163
+ }
164
+ }
165
+
166
+ if (checks.length) {
167
+ const why = [...new Set(checks.map((c) => c.why))].join("; ");
168
+ findings.push({
169
+ ...base,
170
+ severity: "medium",
171
+ confidence: "needs-human-review",
172
+ title: `${label} in use: check whether it is affected by ${checks.length === 1 ? checks[0].adv.title : `${checks.length} advisories`}`,
173
+ description: [
174
+ `Penthera cannot tell from outside whether ${label} has the fix: ${why}. That is not a sign it is safe.`,
175
+ `Advisories to check:\n${checks.map((c) => `- ${advLine(c.adv)}${c.fix ? `: fixed in ${c.fix.version} on ${c.fix.branch}` : ""}`).join("\n")}`,
176
+ `Evidence:\n${evidenceText(ev)}`,
177
+ ].join("\n\n"),
178
+ remediation: `Run \`${p.checkCommand}\` on the server (or in the container) and compare with the fixed versions above. For a distribution package, check the distribution's security tracker for the CVE instead of the upstream number.`,
179
+ cves: [...new Set(checks.flatMap((c) => c.adv.cves))],
180
+ rule: checks.map((c) => c.adv.id).join(","),
181
+ });
182
+ }
183
+
184
+ if (eol) {
185
+ const distro = ev.kind === "distro";
186
+ findings.push({
187
+ ...base,
188
+ severity: distro ? "medium" : "high",
189
+ confidence: distro ? "needs-human-review" : "likely",
190
+ title: distro
191
+ ? `${label} is a distribution build of an end-of-life ${p.name} line`
192
+ : `${label} is end of life: no more security fixes`,
193
+ description: [
194
+ `${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
+ 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." : "",
196
+ `Evidence:\n${evidenceText(ev)}`,
197
+ ].filter(Boolean).join("\n\n"),
198
+ remediation: `Upgrade to a supported ${p.name} release line.`,
199
+ rule: eol.rule.id,
200
+ });
201
+ }
202
+
203
+ // "Has the fix" only when nothing else about this version needs attention.
204
+ if (patched.length && findings.length === start) {
205
+ findings.push({
206
+ ...base,
207
+ severity: "info",
208
+ confidence: ev.kind === "exact" ? "likely" : "potential",
209
+ title: `${label} has the fix for ${patched.length === 1 ? patched[0].title : `${patched.length} known advisories`} (as advertised)`,
210
+ description: [
211
+ `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.`,
212
+ `Evidence:\n${evidenceText(ev)}`,
213
+ ].join("\n\n"),
214
+ });
215
+ }
216
+ }
217
+ return findings;
218
+ }
219
+
220
+ /** Black-box: runtime-advisory findings for a URL scan's homepage fingerprint. */
221
+ export function runtimeAdvisoriesForResponse(fingerprint, opts = {}) {
222
+ return matchRuntimeAdvisories(evidenceFromResponse(fingerprint || {}, { phpPaths: opts.phpPaths }), opts);
223
+ }
224
+
225
+ /** White-box: runtime-advisory findings for a repository's runtime pins. */
226
+ export function runtimeAdvisoriesForRepo(root, opts) {
227
+ return matchRuntimeAdvisories(evidenceFromRepo(root), opts);
228
+ }