penthera 1.8.0 → 1.8.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 +69 -0
- package/README.md +4 -2
- package/lib/advisories/evidence.js +16 -4
- package/lib/advisories/index.js +35 -7
- package/lib/advisories/products.js +17 -3
- package/lib/advisories/versions.js +35 -3
- package/lib/rules/pack.js +2 -2
- package/package.json +2 -2
- package/skills/penthera/SKILL.md +1 -1
- package/src/cli/ui.js +13 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,75 @@ All notable changes to Penthera are documented here. Versioning follows [SemVer]
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.8.1] - 2026-10-22
|
|
8
|
+
|
|
9
|
+
A fixes-only release. Runtime advisories are more careful: pre-release builds
|
|
10
|
+
are never reported as having the fix, a banner without a distribution token is
|
|
11
|
+
at most `potential`, release lines older than a flaw are not reported as
|
|
12
|
+
affected, and two more banner formats are read. `penthera ui` now stops
|
|
13
|
+
cleanly under `npx`.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Runtime advisories no longer claim `likely` for a banner that may be a
|
|
18
|
+
patched distribution package. Debian 11 to 13, Ubuntu 24.04 and later, and
|
|
19
|
+
RHEL print plain upstream PHP versions, so a patched Ubuntu 24.04
|
|
20
|
+
`X-Powered-By: PHP/8.3.6` behind nginx or a CDN was reported as critical
|
|
21
|
+
and likely affected. For PHP, Apache httpd, nginx, Python and OpenSSL,
|
|
22
|
+
"affected" and "end of life" findings from a banner with no distribution
|
|
23
|
+
token are now `potential`, with a "check on server" note and the check
|
|
24
|
+
command. Severity is unchanged. Official images and version files are
|
|
25
|
+
upstream builds and stay `likely`. Found by the runtime-strings test set
|
|
26
|
+
(L1 in [#97](https://github.com/danoszz/penthera/pull/97)).
|
|
27
|
+
|
|
28
|
+
- Runtime advisories resolve Node.js LTS codenames to their release line:
|
|
29
|
+
nvm's `lts/iron` in `.nvmrc` and the node image's `iron`, `jod-alpine`
|
|
30
|
+
and `hydrogen` tags. They read as no version before, so `lts/iron` (Node.js
|
|
31
|
+
20, end of life 30 April 2026) went unreported. Found by the
|
|
32
|
+
runtime-strings test set (B4 in
|
|
33
|
+
[#97](https://github.com/danoszz/penthera/pull/97)).
|
|
34
|
+
- Runtime advisories read aiohttp's `Server: Python/3.9 aiohttp/3.8.6`.
|
|
35
|
+
aiohttp prints only the Python release line, and the banner pattern needed
|
|
36
|
+
x.y.z, so an end-of-life Python behind aiohttp went unreported. Found by
|
|
37
|
+
the runtime-strings test set (B3 in
|
|
38
|
+
[#97](https://github.com/danoszz/penthera/pull/97)).
|
|
39
|
+
- Runtime advisories honour `introduced` on older release lines. CVE-2021-41773
|
|
40
|
+
and CVE-2021-42013 exist only in httpd 2.4.49 and 2.4.50. Yet
|
|
41
|
+
`Apache/2.2.34` and `httpd:2.2` were reported critical, "end of life, no
|
|
42
|
+
fix", and CentOS 6's `Apache/2.2.15 (CentOS)` was "check on server". A line
|
|
43
|
+
older than the one the flaw was introduced on now gets no verdict for it.
|
|
44
|
+
The end-of-life finding stays. Found by the runtime-strings test set (B1 in
|
|
45
|
+
[#97](https://github.com/danoszz/penthera/pull/97)).
|
|
46
|
+
- Runtime advisories read pre-releases correctly. `PHP/8.4.27-dev`, a PHP-8.4
|
|
47
|
+
branch build from before 8.4.27, was taken for a distribution build of
|
|
48
|
+
8.4.27 and reported as having the 21 October fix. Now `-dev`, alpha, beta
|
|
49
|
+
and RC builds (`8.5.0RC1`, `3.15.0rc1`, `4.1.0-beta2`, `php:8.6.0RC4-fpm`)
|
|
50
|
+
sort below their release, so a pre-release of the fixed version is
|
|
51
|
+
affected. A pre-release of the version after the fix (`8.4.28-dev`) is
|
|
52
|
+
"check on server", never "has the fix". Found by the runtime-strings test set
|
|
53
|
+
(B2 in [#97](https://github.com/danoszz/penthera/pull/97)).
|
|
54
|
+
- **`npx penthera ui` stops cleanly on Ctrl-C and SIGTERM.** npm forwards
|
|
55
|
+
the signal to penthera right after the terminal delivered it, and the
|
|
56
|
+
second copy could kill the dashboard before it printed "Stopped." and
|
|
57
|
+
closed its server. Repeated signals are now ignored while it stops. This
|
|
58
|
+
was the intermittent macOS failure in the install-matrix smoke check.
|
|
59
|
+
|
|
60
|
+
### Site
|
|
61
|
+
|
|
62
|
+
- penthera.org counts anonymous page views and load times with Vercel Web
|
|
63
|
+
Analytics and Speed Insights (cookieless, served from the same origin). The
|
|
64
|
+
CSP now allows connections only to those two paths on penthera.org; the
|
|
65
|
+
browser check still sends nothing, and the page says so. The CLI, the
|
|
66
|
+
dashboard and the npm package have no telemetry, unchanged.
|
|
67
|
+
|
|
68
|
+
### Docs
|
|
69
|
+
|
|
70
|
+
- The PHP case study and `docs/runtime-advisories.md` cite OpenAI's
|
|
71
|
+
php2shell-checker as an independent cross-check. A test pins that Penthera
|
|
72
|
+
agrees with its table on all 14 PHP branches.
|
|
73
|
+
- The website is live at [penthera.org](https://penthera.org): the README links
|
|
74
|
+
it, and the npm package's homepage points to it from the next release.
|
|
75
|
+
|
|
7
76
|
## [1.8.0] - 2026-10-21
|
|
8
77
|
|
|
9
78
|
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
|
[](https://www.npmjs.com/package/penthera)
|
|
@@ -265,8 +267,8 @@ What changed, what we are measuring, and what comes next, in one place.
|
|
|
265
267
|
|
|
266
268
|
| | Where it stands | Read more |
|
|
267
269
|
|---|---|---|
|
|
268
|
-
| **Latest release** | 1.8.
|
|
269
|
-
| **On main, unreleased** |
|
|
270
|
+
| **Latest release** | 1.8.1 (2026-10-22): more careful runtime advisories (pre-releases, distribution builds, older lines) on top of 1.8.0's runtime advisories, security-review fixes and redesigned dashboard | [CHANGELOG](CHANGELOG.md) |
|
|
271
|
+
| **On main, unreleased** | Toward 1.9: AI mode (opt-in, local model, rules verify), new detection rules, the E10 benchmark | [Unreleased](CHANGELOG.md#unreleased) |
|
|
270
272
|
| **Now** | Toward 1.9: accuracy on real code (E1) and an MCP server, so coding agents can call Penthera | [Roadmap](ROADMAP.md#next) |
|
|
271
273
|
| **Research** | E1, accuracy on real code, is pre-registered; the benchmark tooling is in review. After the 2026 agent incidents: a checklist for running agents safely and a draft experiment (E8) | [E1 pre-registration](docs/research/prereg/E1-real-world-accuracy.md), [agents safely](docs/agents-safely.md), [what the incidents mean](docs/research/agent-incidents-2026.md) |
|
|
272
274
|
| **Next** | Real-world precision and recall with intervals, published whatever the result | [Roadmap: next](ROADMAP.md#next), [research plan](docs/vision-2.0.md) |
|
|
@@ -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
|
}
|
|
@@ -135,6 +138,12 @@ function walk(dir, depth, files) {
|
|
|
135
138
|
}
|
|
136
139
|
}
|
|
137
140
|
|
|
141
|
+
/** The release line a codename stands for (`lts/iron`, `iron` → "20"), or null. */
|
|
142
|
+
function aliasLine(product, value) {
|
|
143
|
+
const name = value.toLowerCase().replace(/^lts\//, "");
|
|
144
|
+
return Object.hasOwn(PRODUCTS[product].lineAliases || {}, name) ? PRODUCTS[product].lineAliases[name] : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
138
147
|
const imageProduct = new Map(Object.entries(PRODUCTS).flatMap(([id, p]) => p.dockerImages.map((img) => [img, id])));
|
|
139
148
|
|
|
140
149
|
/**
|
|
@@ -151,8 +160,11 @@ export function parseImageRef(ref) {
|
|
|
151
160
|
const path = repo.toLowerCase().replace(/^(?:docker\.io\/)?(?:library\/)?/, "");
|
|
152
161
|
const product = imageProduct.get(path);
|
|
153
162
|
if (!product) return null;
|
|
154
|
-
|
|
155
|
-
|
|
163
|
+
// `8.6.0RC4-fpm` and `3.15.0rc1-slim` are pre-release images; `iron` and
|
|
164
|
+
// `jod-alpine` are Node.js LTS codenames.
|
|
165
|
+
const m = /^(\d+(?:\.\d+){0,2}(?:(?:RC|rc|a|b)\d+)?)(?:$|-)/.exec(tag);
|
|
166
|
+
const alias = m ? null : aliasLine(product, tag.split("-")[0]);
|
|
167
|
+
const v = m ? parseVersion(m[1]) : alias ? parseVersion(alias) : null;
|
|
156
168
|
return { product, version: v, kind: kindOf(product, v, false), tag: tag || "latest" };
|
|
157
169
|
}
|
|
158
170
|
|
|
@@ -179,7 +191,7 @@ function fromCompose(text, where) {
|
|
|
179
191
|
}
|
|
180
192
|
|
|
181
193
|
function versionLine(product, value, where, raw) {
|
|
182
|
-
const v = parseVersion(value);
|
|
194
|
+
const v = parseVersion(aliasLine(product, value) ?? value);
|
|
183
195
|
return { product, version: v, kind: kindOf(product, v, false), where, raw: clip(raw) };
|
|
184
196
|
}
|
|
185
197
|
|
package/lib/advisories/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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,19 +51,26 @@ 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
|
-
|
|
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"],
|
|
@@ -66,6 +79,7 @@ export const PRODUCTS = Object.freeze({
|
|
|
66
79
|
openssl: {
|
|
67
80
|
branchOf: (v) => (v.major >= 3 ? minorLine(v) : v.patch === null ? null : `${v.major}.${v.minor}.${v.patch}`),
|
|
68
81
|
name: "OpenSSL",
|
|
82
|
+
backports: true,
|
|
69
83
|
checkCommand: "openssl version",
|
|
70
84
|
banner: /\bOpenSSL\/(\d+\.\d+\.\d+[a-z]{0,2}[^\s,;()]*)/,
|
|
71
85
|
present: null,
|
|
@@ -7,20 +7,35 @@
|
|
|
7
7
|
* means a distribution build, whose security fixes are backported without the
|
|
8
8
|
* upstream number changing, so the number alone cannot say whether the fix is
|
|
9
9
|
* in. That case is never reported as affected; see lib/advisories/index.js.
|
|
10
|
+
*
|
|
11
|
+
* A pre-release (`8.4.27-dev`, `8.5.0RC1`, `3.15.0rc1`, `3.16.0a1`,
|
|
12
|
+
* `4.1.0-beta2`) is not a suffix: it is a build from before that version was
|
|
13
|
+
* released, so it sorts below it. `pre` holds the marker, upstream the number.
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
16
|
const VERSION = /^(\d{1,4})(?:\.(\d{1,4})(?:\.(\d{1,6}))?)?([a-z]{1,2})?(.*)$/;
|
|
17
|
+
// -dev, alpha, beta, RC and pre in any case, with or without a separator; a
|
|
18
|
+
// bare a/b only with a number (Python's 3.16.0a1), since OpenSSL's 1.1.1a is
|
|
19
|
+
// a release. The number may follow a dot (Node.js's 23.0.0-rc.1).
|
|
20
|
+
const PRE = /^(\d{1,4}\.\d{1,4}(?:\.\d{1,6})?)[-._~]?((?:dev|alpha|beta|rc|pre)(?:\.?\d+)?|[ab]\.?\d+)(?=$|[-+~_ ])(.*)$/i;
|
|
21
|
+
const PRE_RANK = { dev: 0, a: 1, alpha: 1, b: 2, beta: 2, pre: 3, rc: 3 };
|
|
13
22
|
|
|
14
23
|
/**
|
|
15
24
|
* Parse a version string.
|
|
16
25
|
* @param {string} s
|
|
17
26
|
* `20`, `v20` and `8.3` parse too, with the missing parts null.
|
|
18
27
|
* @returns {{ major: number, minor: number|null, patch: number|null, letters: string,
|
|
19
|
-
* suffix: string, upstream: string, raw: string } | null}
|
|
28
|
+
* pre: string, suffix: string, upstream: string, raw: string } | null}
|
|
20
29
|
*/
|
|
21
30
|
export function parseVersion(s) {
|
|
22
31
|
if (typeof s !== "string") return null;
|
|
23
|
-
const
|
|
32
|
+
const t = s.trim().replace(/^v(?=\d)/, "");
|
|
33
|
+
const p = PRE.exec(t);
|
|
34
|
+
if (p) {
|
|
35
|
+
const base = parseVersion(p[1]);
|
|
36
|
+
return { ...base, pre: p[2].toLowerCase().replace(/^\./, ""), suffix: p[3], raw: s.trim() };
|
|
37
|
+
}
|
|
38
|
+
const m = VERSION.exec(t);
|
|
24
39
|
if (!m) return null;
|
|
25
40
|
const [, major, minor, patch, letters = "", suffix = ""] = m;
|
|
26
41
|
// "8.3.12abc" is not a version; letters only follow a full x.y.z.
|
|
@@ -33,13 +48,23 @@ export function parseVersion(s) {
|
|
|
33
48
|
minor: minor === undefined ? null : Number(minor),
|
|
34
49
|
patch: patch === undefined ? null : Number(patch),
|
|
35
50
|
letters,
|
|
51
|
+
pre: "",
|
|
36
52
|
suffix,
|
|
37
53
|
upstream: parts.join(".") + letters,
|
|
38
54
|
raw: s.trim(),
|
|
39
55
|
};
|
|
40
56
|
}
|
|
41
57
|
|
|
42
|
-
/**
|
|
58
|
+
/** Kind and number of a pre-release marker: "rc1" → [3, 1], "dev" → [0, 0]. */
|
|
59
|
+
function preKey(pre) {
|
|
60
|
+
const m = /^([a-z]+)\.?(\d*)$/.exec(pre);
|
|
61
|
+
return [PRE_RANK[m[1]], Number(m[2] || 0)];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Compare two parsed versions (or strings). A missing patch sorts as 0, and a
|
|
66
|
+
* pre-release sorts below its release: 8.4.27-dev < 8.4.27RC1 < 8.4.27.
|
|
67
|
+
*/
|
|
43
68
|
export function compareVersions(a, b) {
|
|
44
69
|
const x = typeof a === "string" ? parseVersion(a) : a;
|
|
45
70
|
const y = typeof b === "string" ? parseVersion(b) : b;
|
|
@@ -56,6 +81,13 @@ export function compareVersions(a, b) {
|
|
|
56
81
|
if (x.letters.length !== y.letters.length) return x.letters.length < y.letters.length ? -1 : 1;
|
|
57
82
|
return x.letters < y.letters ? -1 : 1;
|
|
58
83
|
}
|
|
84
|
+
const ex = x.pre || "", ey = y.pre || "";
|
|
85
|
+
if (ex !== ey) {
|
|
86
|
+
if (!ex || !ey) return ex ? -1 : 1;
|
|
87
|
+
const [kx, nx] = preKey(ex), [ky, ny] = preKey(ey);
|
|
88
|
+
if (kx !== ky) return kx < ky ? -1 : 1;
|
|
89
|
+
if (nx !== ny) return nx < ny ? -1 : 1;
|
|
90
|
+
}
|
|
59
91
|
return 0;
|
|
60
92
|
}
|
|
61
93
|
|
package/lib/rules/pack.js
CHANGED
|
@@ -173,7 +173,7 @@ function product(v, where) {
|
|
|
173
173
|
function branchName(prod, v, where) {
|
|
174
174
|
text(v, where, { max: 20 });
|
|
175
175
|
const parsed = parseVersion(v);
|
|
176
|
-
if (!parsed || parsed.suffix || PRODUCTS[prod].branchOf(parsed) !== v) fail(`${where}: not a ${PRODUCTS[prod].name} release line`);
|
|
176
|
+
if (!parsed || parsed.suffix || parsed.pre || PRODUCTS[prod].branchOf(parsed) !== v) fail(`${where}: not a ${PRODUCTS[prod].name} release line`);
|
|
177
177
|
return v;
|
|
178
178
|
}
|
|
179
179
|
|
|
@@ -181,7 +181,7 @@ function branchName(prod, v, where) {
|
|
|
181
181
|
function exactVersion(prod, branch, v, where) {
|
|
182
182
|
text(v, where, { max: 30 });
|
|
183
183
|
const parsed = parseVersion(v);
|
|
184
|
-
if (!parsed || parsed.suffix || parsed.patch === null) fail(`${where}: expected an exact upstream version like 8.3.27`);
|
|
184
|
+
if (!parsed || parsed.suffix || parsed.pre || parsed.patch === null) fail(`${where}: expected an exact upstream version like 8.3.27`);
|
|
185
185
|
if (PRODUCTS[prod].branchOf(parsed) !== branch) fail(`${where}: ${v} is not on release line ${branch}`);
|
|
186
186
|
return v;
|
|
187
187
|
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "penthera",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Offline cyber security agent for your apps and AI coding tools. Open R&D: measured in public, improved every release.",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/danoszz/penthera.git"
|
|
9
9
|
},
|
|
10
|
-
"homepage": "https://
|
|
10
|
+
"homepage": "https://penthera.org",
|
|
11
11
|
"bugs": {
|
|
12
12
|
"url": "https://github.com/danoszz/penthera/issues"
|
|
13
13
|
},
|
package/skills/penthera/SKILL.md
CHANGED
package/src/cli/ui.js
CHANGED
|
@@ -114,9 +114,19 @@ export async function runUi(argv, { version }) {
|
|
|
114
114
|
if (opts.open) openBrowser(ui.url, ui.session);
|
|
115
115
|
|
|
116
116
|
await new Promise((resolve) => {
|
|
117
|
-
|
|
118
|
-
process.
|
|
119
|
-
|
|
117
|
+
// The signal can arrive twice: npm (npx) forwards it to us right after the
|
|
118
|
+
// terminal or a process-group kill delivered it. Keep listening and ignore
|
|
119
|
+
// repeats, so the second copy cannot fall through to the default action
|
|
120
|
+
// and kill the process before it stops. The caller exits as soon as this
|
|
121
|
+
// resolves, so close only after "Stopped." is written.
|
|
122
|
+
let stopping = false;
|
|
123
|
+
const stop = () => {
|
|
124
|
+
if (stopping) return;
|
|
125
|
+
stopping = true;
|
|
126
|
+
process.stdout.write("\n Stopped.\n", () => ui.close().then(resolve));
|
|
127
|
+
};
|
|
128
|
+
process.on("SIGINT", stop);
|
|
129
|
+
process.on("SIGTERM", stop);
|
|
120
130
|
});
|
|
121
131
|
return 0;
|
|
122
132
|
}
|