penthera 1.8.0-rc.1 → 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,152 @@ 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
+
76
+ ## [1.8.0] - 2026-10-21
77
+
78
+ 1.8.0 is 1.8.0-rc.1 plus the changes below; there was no separate rc.2
79
+ release. Highlights across both: fixes from a second independent security
80
+ review, a fix loop that cannot edit the tests that judge it, the redesigned
81
+ dashboard (Simple and Advanced mode), release checks on Linux, macOS and
82
+ Windows, and now runtime advisories, starting with PHP's critical security
83
+ release of 21 October 2026, in a new signed `advisories` rule pack (1.8.0-rc.1
84
+ cannot load it). The benchmark gained two PHP cases: precision 0.88 (95% CI
85
+ 0.69 to 0.96) and recall 0.91 (0.73 to 0.98) on 24 scored findings.
86
+
87
+ ### Added
88
+
89
+ - **Runtime advisories: is the PHP, Apache, nginx, Node.js, Python or OpenSSL
90
+ you run affected?** URL scans read `Server` and `X-Powered-By` banners, the
91
+ `PHPSESSID` cookie name and `.php` pages. Repo scans read Dockerfile and
92
+ compose images, `.tool-versions`, `.php-version`, `.nvmrc`,
93
+ `.python-version`, `runtime.txt` and `composer.json`. Each product and
94
+ version gets one answer: affected, end of life with no fix, check on server,
95
+ or has the fix (as advertised). A hidden version is never reported as safe.
96
+ A distribution build (`8.1.2-1ubuntu2.19`, or any version behind a `Server`
97
+ header that names a distribution) is never reported as affected, because
98
+ distributions backport fixes under the same version number. Advisories that
99
+ need more than a version (Windows, CGI) stay `potential`. See
100
+ [docs/runtime-advisories.md](docs/runtime-advisories.md).
101
+ - **Two rule-pack kinds, `runtime-advisory` and `runtime-eol`**, so a new
102
+ advisory ships as a signed data pack (`--rules`) the same day, without a
103
+ release. End of life is judged by the pack's date, not the scan's clock.
104
+ - **A bundled `advisories` pack** with the PHP security release of 21 October
105
+ 2026 (CVE-2026-109618 and CVE-2026-109619, both Critical, CVSS 4.0 9.2,
106
+ fixed in 8.2.35, 8.3.36, 8.4.27 and 8.5.12, from the php-src advisories),
107
+ vendor-checked worked examples (CVE-2024-4577 and CVE-2024-8926 for PHP-CGI on Windows, CVE-2021-41773 and
108
+ CVE-2021-42013 for Apache HTTP Server 2.4.49 and 2.4.50) and end-of-life
109
+ release lines. Benchmark cases for both URL and repo scans.
110
+
111
+ ### Site
112
+
113
+ - **A landing page in `site/`**, a static site for Vercel: the positioning,
114
+ `npx penthera`, prompts for Claude Code, Cursor and Codex, the hero loop and
115
+ the walkthrough, the limits and the research plan. No third-party scripts,
116
+ analytics or cookies; fonts are self-hosted or system fonts.
117
+ - **A check in the browser.** Drop or paste a `package.json`, `.mcp.json`,
118
+ `.claude/settings.json`, `CLAUDE.md` or `AGENTS.md` and Penthera's own
119
+ `lib/` code runs in the tab: the agent-config rules, the secret patterns,
120
+ install scripts, typosquats against the vendored popular list, and the
121
+ bundled IOC pack. `site/build.mjs` bundles `lib/` at build time, so the site
122
+ and the CLI run the same rules; `tests/site.test.js` holds the page to the
123
+ CLI on the agents and deps fixtures. The page is served with
124
+ `connect-src 'none'`, so it cannot send the files anywhere.
125
+ - **Runtime advisories in the browser check.** Drop a `Dockerfile`,
126
+ `compose.yaml`, `.php-version`, `.tool-versions` or `composer.json`, or
127
+ paste the response headers `curl -sI` printed for your site (the page never
128
+ contacts it), and `lib/advisories` runs on them: the same findings and
129
+ confidence as the CLI, with "check on the server: distributions backport
130
+ fixes" next to every version-only verdict. `tests/site.test.js` compares
131
+ pasted headers with a URL scan of a loopback server sending the same
132
+ headers, and runtime pins with `runtimeAdvisoriesForRepo`.
133
+ - `penthera deps` findings are built in `lib/deps/findings.js` (moved from
134
+ `lib/deps/index.js`, no behaviour change), so the browser check uses the
135
+ same wording.
136
+ - `scripts/docs-commands.mjs` also checks every command the site shows.
137
+
138
+ ### Docs
139
+
140
+ - **New title for the launch:** "Penthera: your open-source cyber security
141
+ agent", in the README, docs index, `penthera --help`, the GitHub Action name
142
+ (which renames its Marketplace listing), the dashboard and the report
143
+ footer.
144
+
145
+ - **README hero is a 12-second loop** of the terminal and the dashboard
146
+ (setup, an offline scan, Simple and Advanced mode, a fix approved), cut from
147
+ the walkthrough video by `scripts/media/hero-gif.sh`, and it loops without a
148
+ jump. The terminal-only scan clip is now `assets/demo-scan.gif`. The Demo
149
+ media workflow re-cuts the loop on every release.
150
+ - **The README diagram is back**, updated: what Penthera checks, where the
151
+ findings go, and that the fix loop asks first and cannot edit its own tests.
152
+
7
153
  ## [1.8.0-rc.1] - 2026-10-20
8
154
 
9
155
  Release candidate for 1.8.0, published on npm's `next` dist-tag
package/README.md CHANGED
@@ -1,10 +1,14 @@
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.
10
+
11
+ **[penthera.org](https://penthera.org)**: try the check in your browser. Nothing you paste leaves the page.
8
12
 
9
13
  <br/>
10
14
 
@@ -16,7 +20,7 @@
16
20
 
17
21
  <br/>
18
22
 
19
- <img src="assets/demo.gif" alt="Penthera running an interactive security scan in the terminal" width="760">
23
+ <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
24
 
21
25
  <br/><br/>
22
26
 
@@ -89,6 +93,29 @@ Penthera needs Node.js 22 or later. To run it in a container:
89
93
 
90
94
  ## What it does
91
95
 
96
+ ```mermaid
97
+ flowchart LR
98
+ U["You or your AI agent"] --> P(("Penthera<br/>on your machine"))
99
+
100
+ P --> APP["Your app<br/>URL scan, repo scan"]
101
+ P --> DEP["Your dependencies<br/>capabilities, install firewall<br/>typosquats, upgrade diff"]
102
+ P --> AI["Your AI tooling<br/>skills, MCP, hooks<br/>plugins, rule files"]
103
+
104
+ APP --> F[["Findings<br/>severity, confidence<br/>evidence at file:line"]]
105
+ DEP --> F
106
+ AI --> F
107
+
108
+ F --> FIX["Fix loop<br/>asks first, local branch<br/>cannot edit its own tests"]
109
+ FIX -. "re-scan proves it" .-> F
110
+ F --> R["Reports and dashboard<br/>terminal, Markdown, SARIF<br/>penthera ui"]
111
+ F --> E["Evidence<br/>signed bundle<br/>NIS2 and CRA drafts"]
112
+
113
+ classDef core fill:#0000ed,stroke:#0000ed,color:#ffffff
114
+ classDef box fill:#ffffff,stroke:#000000,color:#000000
115
+ class P core
116
+ class U,APP,DEP,AI,F,FIX,R,E box
117
+ ```
118
+
92
119
  ### Before you deploy
93
120
 
94
121
  | Job | What Penthera does | Command |
@@ -127,7 +154,7 @@ Each feature goes through the same path: **method → evidence → tool.**
127
154
  - **Method.** The [methodology](docs/METHODOLOGY.md) states the threat model,
128
155
  what black-box (HTTP) and white-box (source) analysis can and cannot see, the
129
156
  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
157
+ choice cites the research or standard it rests on: 92 verified references, in
131
158
  APA style, in [docs/references.md](docs/references.md).
132
159
  - **Evidence.** A benchmark runs on every release, and the release gate fails
133
160
  if precision or recall drops by more than 0.02. Experiments that decide what
@@ -138,17 +165,17 @@ Each feature goes through the same path: **method → evidence → tool.**
138
165
 
139
166
  | Measure | Result today |
140
167
  |---|---|
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)) |
168
+ | Precision on the bundled benchmark | 0.88, 95% CI 0.69 to 0.96 (21 true positives, 3 false positives) |
169
+ | Recall on the bundled benchmark | 0.91, 95% CI 0.73 to 0.98 (2 misses, [each one explained](bench/RESULTS.md)) |
143
170
  | Requests to non-loopback hosts during the benchmark | 0 |
144
171
  | 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
172
 
146
- These numbers come from 22 findings in small labs that we wrote ourselves, so
173
+ These numbers come from 24 findings in small labs that we wrote ourselves, so
147
174
  they catch regressions; they do not measure real-world accuracy. Measuring that
148
175
  on real projects is experiment E1, the first step of the
149
176
  [research plan](docs/vision-2.0.md). Intervals are Wilson. What Penthera cannot
150
177
  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
178
+ Counts as of 2026-10-21; refresh them with `npm run docs:numbers` and
152
179
  `npm run bench`.
153
180
 
154
181
  **Cite this repository** with the button on GitHub or
@@ -240,9 +267,9 @@ What changed, what we are measuring, and what comes next, in one place.
240
267
 
241
268
  | | Where it stands | Read more |
242
269
  |---|---|---|
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) |
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) |
272
+ | **Now** | Toward 1.9: accuracy on real code (E1) and an MCP server, so coding agents can call Penthera | [Roadmap](ROADMAP.md#next) |
246
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) |
247
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) |
248
275
 
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,260 @@
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
+ * banner true for black-box evidence (a header), which may come from a
19
+ * distribution package even without a distribution token
20
+ *
21
+ * Nothing here decides whether anything is vulnerable; that is index.js.
22
+ */
23
+ import { readdirSync, lstatSync, openSync, fstatSync, readSync, closeSync, constants } from "node:fs";
24
+ import { join, relative, basename } from "node:path";
25
+ import { PRODUCTS, DISTRO_HINT } from "./products.js";
26
+ import { parseVersion } from "./versions.js";
27
+
28
+ const clip = (s, n = 200) => (s.length > n ? `${s.slice(0, n)}…` : s);
29
+
30
+ function kindOf(product, v, distro) {
31
+ if (!v) return "present";
32
+ // A pre-release is an upstream build, not a distribution one.
33
+ if (v.patch !== null) return distro || v.suffix ? "distro" : "exact";
34
+ // "php:8" spans release lines; "php:8.3" and "node:20" name one.
35
+ return PRODUCTS[product].branchOf(v) ? "branch" : "present";
36
+ }
37
+
38
+ // ── Black-box ─────────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Evidence from one response's headers, cookies and page links.
42
+ * @param {{ headers?: Record<string,string>, cookieNames?: string[], phpLink?: string|null }} fp
43
+ * what lib/crawler.js fingerprint() collected for the homepage
44
+ * @param {{ phpPaths?: string[] }} [more] .php paths the endpoint discovery found reachable
45
+ */
46
+ export function evidenceFromResponse(fp = {}, more = {}) {
47
+ const headers = fp.headers || {};
48
+ const out = [];
49
+ const server = String(headers.server || "");
50
+ const poweredBy = String(headers["x-powered-by"] || "");
51
+ const distro = DISTRO_HINT.test(server);
52
+
53
+ for (const [id, p] of Object.entries(PRODUCTS)) {
54
+ let found = false;
55
+ for (const [name, value] of [["server", server], ["x-powered-by", poweredBy]]) {
56
+ if (!value) continue;
57
+ const m = p.banner && p.banner.exec(value);
58
+ if (m) {
59
+ const v = parseVersion(m[1]);
60
+ if (v) {
61
+ out.push({ product: id, version: v, kind: kindOf(id, v, distro), where: `${name} header`, raw: clip(`${name}: ${value}`), banner: true });
62
+ found = true;
63
+ continue;
64
+ }
65
+ }
66
+ if (p.present && p.present.test(value)) {
67
+ out.push({ product: id, version: null, kind: "present", where: `${name} header`, raw: clip(`${name}: ${value}`) });
68
+ found = true;
69
+ }
70
+ }
71
+ if (id === "php" && !found) {
72
+ if ((fp.cookieNames || []).some((n) => n.toUpperCase() === "PHPSESSID")) {
73
+ out.push({ product: id, version: null, kind: "present", where: "Set-Cookie", raw: "PHPSESSID cookie (PHP's default session cookie name)" });
74
+ found = true;
75
+ }
76
+ }
77
+ if (id === "php" && !found) {
78
+ const link = fp.phpLink || (more.phpPaths || [])[0];
79
+ if (link) out.push({ product: id, version: null, kind: "present", where: fp.phpLink ? "homepage links" : "endpoint discovery", raw: clip(`.php page: ${link}`) });
80
+ }
81
+ }
82
+ return dedupe(out);
83
+ }
84
+
85
+ // ── White-box ─────────────────────────────────────────────────────────────
86
+
87
+ const SKIP_DIRS = new Set([
88
+ "node_modules", ".git", "dist", "build", ".next", "coverage", "reports", "vendor",
89
+ "venv", ".venv", "site-packages", "__pycache__", ".tox", ".nox", ".obsidian",
90
+ ]);
91
+ const MAX_DEPTH = 4;
92
+ const MAX_FILE_BYTES = 256 * 1024;
93
+
94
+ const isDockerfile = (n) => /^(?:Dockerfile|Containerfile)(?:\..+)?$/i.test(n) || /\.(?:dockerfile|containerfile)$/i.test(n);
95
+ const isCompose = (n) => /^(?:docker-)?compose(?:\.[\w-]+)?\.ya?ml$/i.test(n);
96
+ const VERSION_FILES = new Set(Object.values(PRODUCTS).flatMap((p) => p.versionFiles));
97
+ const ROOT_ONLY = new Set([".tool-versions", "runtime.txt", "composer.json"]);
98
+
99
+ function wanted(name, depth) {
100
+ if (isDockerfile(name) || isCompose(name) || VERSION_FILES.has(name)) return true;
101
+ return depth === 0 && ROOT_ONLY.has(name);
102
+ }
103
+
104
+ function readSmall(path) {
105
+ let fd;
106
+ try {
107
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK || 0) | (constants.O_NOFOLLOW || 0));
108
+ const st = fstatSync(fd);
109
+ if (!st.isFile() || st.size > MAX_FILE_BYTES) return null;
110
+ const buf = Buffer.alloc(st.size);
111
+ const n = st.size ? readSync(fd, buf, 0, st.size, 0) : 0;
112
+ return buf.subarray(0, n).toString("utf-8");
113
+ } catch {
114
+ return null;
115
+ } finally {
116
+ if (fd !== undefined) closeSync(fd);
117
+ }
118
+ }
119
+
120
+ function walk(dir, depth, files) {
121
+ if (depth > MAX_DEPTH) return;
122
+ let entries;
123
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
124
+ for (const e of entries) {
125
+ if (SKIP_DIRS.has(e.name)) continue;
126
+ const full = join(dir, e.name);
127
+ if (e.isFile()) {
128
+ if (wanted(e.name, depth)) files.push({ full, depth });
129
+ continue;
130
+ }
131
+ if (!e.isDirectory()) continue;
132
+ // Not a symlinked folder, and not a nested repo or worktree.
133
+ let st;
134
+ try { st = lstatSync(full); } catch { continue; }
135
+ if (!st.isDirectory()) continue;
136
+ try { lstatSync(join(full, ".git")); continue; } catch { /* no nested .git */ }
137
+ walk(full, depth + 1, files);
138
+ }
139
+ }
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
+
147
+ const imageProduct = new Map(Object.entries(PRODUCTS).flatMap(([id, p]) => p.dockerImages.map((img) => [img, id])));
148
+
149
+ /**
150
+ * `php:8.3.12-fpm-alpine`, `docker.io/library/node:20`, `python@sha256:…`.
151
+ * Official images are upstream builds, so a variant after the version
152
+ * (`-fpm-alpine`, `-slim-bookworm`) is not a distro build string.
153
+ */
154
+ export function parseImageRef(ref) {
155
+ const noDigest = ref.split("@")[0];
156
+ const slash = noDigest.lastIndexOf("/");
157
+ const colon = noDigest.indexOf(":", slash + 1);
158
+ const repo = colon === -1 ? noDigest : noDigest.slice(0, colon);
159
+ const tag = colon === -1 ? "" : noDigest.slice(colon + 1);
160
+ const path = repo.toLowerCase().replace(/^(?:docker\.io\/)?(?:library\/)?/, "");
161
+ const product = imageProduct.get(path);
162
+ if (!product) return 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;
168
+ return { product, version: v, kind: kindOf(product, v, false), tag: tag || "latest" };
169
+ }
170
+
171
+ function fromDockerfile(text, where) {
172
+ const out = [];
173
+ for (const line of text.split(/\r?\n/)) {
174
+ const m = /^\s*FROM\s+(?:--platform=\S+\s+)?(\S+)/i.exec(line);
175
+ if (!m || m[1].includes("$")) continue;
176
+ const img = parseImageRef(m[1]);
177
+ if (img) out.push({ product: img.product, version: img.version, kind: img.kind, where, raw: clip(line.trim()) });
178
+ }
179
+ return out;
180
+ }
181
+
182
+ function fromCompose(text, where) {
183
+ const out = [];
184
+ for (const line of text.split(/\r?\n/)) {
185
+ const m = /^\s*image:\s*["']?([^\s"'#]+)/.exec(line);
186
+ if (!m || m[1].includes("$")) continue;
187
+ const img = parseImageRef(m[1]);
188
+ if (img) out.push({ product: img.product, version: img.version, kind: img.kind, where, raw: clip(line.trim()) });
189
+ }
190
+ return out;
191
+ }
192
+
193
+ function versionLine(product, value, where, raw) {
194
+ const v = parseVersion(aliasLine(product, value) ?? value);
195
+ return { product, version: v, kind: kindOf(product, v, false), where, raw: clip(raw) };
196
+ }
197
+
198
+ function fromToolVersions(text, where) {
199
+ const out = [];
200
+ for (const line of text.split(/\r?\n/)) {
201
+ const [tool, value] = line.trim().split(/\s+/);
202
+ if (!tool || !value || tool.startsWith("#")) continue;
203
+ const id = Object.keys(PRODUCTS).find((k) => PRODUCTS[k].toolVersions.includes(tool));
204
+ if (id) out.push(versionLine(id, value, where, line.trim()));
205
+ }
206
+ return out;
207
+ }
208
+
209
+ function fromVersionFile(name, text, where) {
210
+ const id = Object.keys(PRODUCTS).find((k) => PRODUCTS[k].versionFiles.includes(name));
211
+ const value = text.split(/\r?\n/).map((l) => l.trim()).find((l) => l && !l.startsWith("#"));
212
+ if (!id || !value) return [];
213
+ return [versionLine(id, value, where, `${name}: ${value}`)];
214
+ }
215
+
216
+ function fromComposer(text, where) {
217
+ let j;
218
+ try { j = JSON.parse(text); } catch { return []; }
219
+ if (!j || typeof j !== "object") return [];
220
+ // `require.php` is a constraint and `config.platform.php` a version Composer
221
+ // pretends to have while resolving; neither is the PHP that runs. Both only
222
+ // say PHP is in use.
223
+ const constraint = typeof j.require?.php === "string" ? ` (require.php: ${clip(j.require.php, 60)})` : "";
224
+ return [{ product: "php", version: null, kind: "present", where, raw: `composer.json${constraint}` }];
225
+ }
226
+
227
+ /**
228
+ * Evidence from a repository's pins.
229
+ * @param {string} root
230
+ */
231
+ export function evidenceFromRepo(root) {
232
+ const files = [];
233
+ walk(root, 0, files);
234
+ const out = [];
235
+ for (const { full } of files) {
236
+ const text = readSmall(full);
237
+ if (text === null) continue;
238
+ const name = basename(full);
239
+ const where = relative(root, full).split("\\").join("/");
240
+ if (isDockerfile(name)) out.push(...fromDockerfile(text, where));
241
+ else if (isCompose(name)) out.push(...fromCompose(text, where));
242
+ else if (name === ".tool-versions") out.push(...fromToolVersions(text, where));
243
+ else if (name === "runtime.txt") {
244
+ const m = /^\s*python-(\d+\.\d+(?:\.\d+)?)\s*$/m.exec(text);
245
+ if (m) out.push(versionLine("python", m[1], where, `runtime.txt: python-${m[1]}`));
246
+ } else if (name === "composer.json") out.push(...fromComposer(text, where));
247
+ else out.push(...fromVersionFile(name, text, where));
248
+ }
249
+ return dedupe(out);
250
+ }
251
+
252
+ function dedupe(items) {
253
+ const seen = new Set();
254
+ return items.filter((e) => {
255
+ const k = `${e.product}|${e.version?.raw ?? ""}|${e.kind}|${e.where}`;
256
+ if (seen.has(k)) return false;
257
+ seen.add(k);
258
+ return true;
259
+ });
260
+ }