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 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
  [![npm](https://img.shields.io/npm/v/penthera?color=0000ed)](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.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
+ | **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
- const m = /^(\d+(?:\.\d+){0,2})(?:$|-)/.exec(tag);
155
- const v = m ? parseVersion(m[1]) : null;
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
 
@@ -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,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
- 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"],
@@ -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 m = VERSION.exec(s.trim().replace(/^v(?=\d)/, ""));
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
- /** Compare two parsed versions (or strings). A missing patch sorts as 0. */
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.0",
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://github.com/danoszz/penthera#readme",
10
+ "homepage": "https://penthera.org",
11
11
  "bugs": {
12
12
  "url": "https://github.com/danoszz/penthera/issues"
13
13
  },
@@ -19,7 +19,7 @@ compatibility: Node.js 22+, network access to target, Penthera CLI (npm link or
19
19
  allowed-tools: "Bash(node:*) Bash(npm:*) Bash(penthera:*) Read Write Edit"
20
20
  metadata:
21
21
  author: danoszz
22
- version: 1.8.0
22
+ version: 1.8.1
23
23
  category: security
24
24
  ---
25
25
 
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
- const stop = () => { process.stdout.write("\n Stopped.\n"); ui.close().then(resolve); };
118
- process.once("SIGINT", stop);
119
- process.once("SIGTERM", stop);
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
  }