penthera 1.7.0 → 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.
Files changed (163) hide show
  1. package/CHANGELOG.md +315 -0
  2. package/README.md +139 -76
  3. package/SECURITY.md +8 -6
  4. package/docs/examples/pr-dependency-review.yml +1 -1
  5. package/lib/adaptive-probes.js +3 -3
  6. package/lib/advisories/evidence.js +248 -0
  7. package/lib/advisories/index.js +228 -0
  8. package/lib/advisories/products.js +83 -0
  9. package/lib/advisories/versions.js +65 -0
  10. package/lib/agents/hidden-content.js +122 -11
  11. package/lib/agents/index.js +26 -6
  12. package/lib/agents/lock.js +33 -5
  13. package/lib/agents/rules.js +33 -4
  14. package/lib/compliance/index.js +4 -4
  15. package/lib/crawler.js +12 -15
  16. package/lib/deps/capabilities.js +21 -5
  17. package/lib/deps/findings.js +309 -0
  18. package/lib/deps/index.js +13 -272
  19. package/lib/deps/iocs.js +74 -0
  20. package/lib/deps/lockfile.js +3 -8
  21. package/lib/deps/online.js +8 -3
  22. package/lib/deps/popular-packages.json.gz +0 -0
  23. package/lib/deps/popular-pypi.json.gz +0 -0
  24. package/lib/deps/python/capabilities.js +16 -3
  25. package/lib/deps/python/index.js +5 -1
  26. package/lib/deps/python/lockfiles.js +11 -5
  27. package/lib/deps/python/source.js +75 -26
  28. package/lib/deps/python/toml.js +15 -3
  29. package/lib/deps/source.js +28 -9
  30. package/lib/deps/typosquat.js +30 -5
  31. package/lib/fix/apply.js +253 -34
  32. package/lib/fix/index.js +7 -4
  33. package/lib/fix/model.js +25 -5
  34. package/lib/fix/util.js +6 -2
  35. package/lib/fuzzer.js +3 -3
  36. package/lib/git.js +65 -0
  37. package/lib/hook/index.js +157 -73
  38. package/lib/hook/policy.js +10 -4
  39. package/lib/install/index.js +4 -7
  40. package/lib/machine.js +0 -13
  41. package/lib/memory/store.js +11 -4
  42. package/lib/nuclei-loader.js +0 -25
  43. package/lib/owasp-wstg.js +4 -0
  44. package/lib/plugins.js +1 -1
  45. package/lib/provenance.js +1 -0
  46. package/lib/recon.js +12 -31
  47. package/lib/references.json +224 -0
  48. package/lib/retirejs.js +4 -4
  49. package/lib/rules/advisories/advisories.json +186 -0
  50. package/lib/rules/advisories/eol.json +413 -0
  51. package/lib/rules/advisories/pack.json +20 -0
  52. package/lib/rules/advisories/pack.json.sig +6 -0
  53. package/lib/rules/allowed_signers +13 -0
  54. package/lib/rules/core/agent-exec.json +29 -0
  55. package/lib/rules/core/agent-instructions.json +17 -0
  56. package/lib/rules/core/pack.json +30 -0
  57. package/lib/rules/core/pack.json.sig +6 -0
  58. package/lib/rules/core/secrets.json +161 -0
  59. package/lib/rules/core/templates.json +52 -0
  60. package/lib/rules/index.js +107 -0
  61. package/lib/rules/ioc-2025-2026/files.json +43 -0
  62. package/lib/rules/ioc-2025-2026/hosts.json +35 -0
  63. package/lib/rules/ioc-2025-2026/pack.json +25 -0
  64. package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
  65. package/lib/rules/ioc-2025-2026/packages.json +1048 -0
  66. package/lib/rules/pack.js +452 -0
  67. package/lib/rules/regex-guard.js +81 -9
  68. package/lib/rules/sshsig.js +205 -0
  69. package/lib/templates.js +24 -20
  70. package/lib/tmp.js +21 -0
  71. package/lib/whitebox/frameworks.js +1 -1
  72. package/lib/whitebox/secrets.js +117 -41
  73. package/package.json +12 -4
  74. package/scripts/rules/build-pack.mjs +80 -0
  75. package/scripts/rules/check-bundled.mjs +30 -0
  76. package/scripts/rules/sign-pack.sh +32 -0
  77. package/skills/penthera/SKILL.md +16 -15
  78. package/src/agent/git.js +147 -14
  79. package/src/agent/loop.js +165 -46
  80. package/src/cli/agent.js +11 -5
  81. package/src/cli/deps.js +0 -2
  82. package/src/cli/detect.js +14 -7
  83. package/src/cli/fix.js +26 -8
  84. package/src/cli/hook.js +2 -1
  85. package/src/cli/onboarding.js +14 -6
  86. package/src/cli/report-outputs.js +1 -0
  87. package/src/cli/rules.js +144 -0
  88. package/src/cli/ui.js +43 -10
  89. package/src/cli.js +24 -1
  90. package/src/report/evidence.js +105 -21
  91. package/src/report/footer.js +1 -1
  92. package/src/report/md-escape.js +8 -0
  93. package/src/scan-repo.js +6 -0
  94. package/src/scan-url.js +11 -3
  95. package/src/ui/api/data.js +39 -6
  96. package/src/ui/api/routes.js +44 -11
  97. package/src/ui/api/runs.js +32 -7
  98. package/src/ui/security.js +20 -23
  99. package/src/ui/server.js +52 -27
  100. package/src/ui/static/app.css +583 -213
  101. package/src/ui/static/favicon.svg +1 -1
  102. package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
  103. package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
  104. package/src/ui/static/index.html +5 -4
  105. package/src/ui/static/js/api.js +114 -6
  106. package/src/ui/static/js/app.js +81 -42
  107. package/src/ui/static/js/center.js +246 -133
  108. package/src/ui/static/js/charts.js +99 -18
  109. package/src/ui/static/js/copy.js +68 -9
  110. package/src/ui/static/js/dialog.js +152 -0
  111. package/src/ui/static/js/left.js +23 -60
  112. package/src/ui/static/js/menubar.js +170 -0
  113. package/src/ui/static/js/onboarding.js +104 -0
  114. package/src/ui/static/js/panels.js +186 -0
  115. package/src/ui/static/js/right.js +69 -188
  116. package/src/ui/static/js/state.js +36 -5
  117. package/src/ui/static/logo.svg +10 -0
  118. package/src/ui/static/mark.svg +3 -0
  119. package/src/utils/http.js +162 -13
  120. package/docs/METHODOLOGY.md +0 -375
  121. package/docs/commands/README.md +0 -24
  122. package/docs/commands/action.md +0 -21
  123. package/docs/commands/agents.md +0 -38
  124. package/docs/commands/deps.md +0 -115
  125. package/docs/commands/fix.md +0 -53
  126. package/docs/commands/hook.md +0 -69
  127. package/docs/commands/init.md +0 -114
  128. package/docs/commands/install.md +0 -53
  129. package/docs/commands/scan.md +0 -100
  130. package/docs/commands/skill.md +0 -58
  131. package/docs/compare/README.md +0 -25
  132. package/docs/compare/github.md +0 -60
  133. package/docs/compare/semgrep.md +0 -54
  134. package/docs/compare/snyk.md +0 -62
  135. package/docs/compare/socket.md +0 -78
  136. package/docs/compare/zap.md +0 -59
  137. package/docs/compliance-cra.md +0 -141
  138. package/docs/compliance-nis2.md +0 -168
  139. package/docs/dashboard.md +0 -187
  140. package/docs/evidence.md +0 -162
  141. package/docs/funding/due-diligence-checklist.md +0 -79
  142. package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
  143. package/docs/funding/partner-one-pager.md +0 -84
  144. package/docs/funding/sponsor-tiers.md +0 -81
  145. package/docs/index.md +0 -81
  146. package/docs/internal-audit.md +0 -83
  147. package/docs/local-loop.md +0 -276
  148. package/docs/marketing.md +0 -151
  149. package/docs/owasp-wstg-coverage.md +0 -79
  150. package/docs/plan.md +0 -102
  151. package/docs/privacy.md +0 -38
  152. package/docs/references.bib +0 -559
  153. package/docs/references.md +0 -119
  154. package/docs/release-plan.md +0 -228
  155. package/docs/research/socket-positioning-notes.md +0 -183
  156. package/docs/use-cases/README.md +0 -14
  157. package/docs/use-cases/agencies.md +0 -71
  158. package/docs/use-cases/ai-coding-teams.md +0 -81
  159. package/docs/use-cases/eu-sme-compliance.md +0 -68
  160. package/docs/use-cases/open-source-maintainers.md +0 -75
  161. package/docs/use-cases/solo-founder.md +0 -65
  162. package/lib/deps/popular-packages.json +0 -1
  163. package/lib/deps/popular-pypi.json +0 -1
package/SECURITY.md CHANGED
@@ -1,13 +1,16 @@
1
1
  # Security Policy
2
2
 
3
+ > **Starter and pro.** For "I found a vulnerability in Penthera itself: how do I
4
+ > report it?"
5
+
3
6
  ## Supported versions
4
7
 
5
8
  Security fixes land on the latest minor release only. Upgrade to get them.
6
9
 
7
10
  | Version | Supported |
8
11
  | ------- | --------- |
9
- | 1.2.x | Yes |
10
- | < 1.2 | No |
12
+ | 1.8.x | Yes |
13
+ | < 1.8 | No |
11
14
 
12
15
  ## Reporting a vulnerability
13
16
 
@@ -21,7 +24,7 @@ scanning a target), please report it privately:
21
24
 
22
25
  Please do **not** open public issues for undisclosed vulnerabilities.
23
26
 
24
- **What to expect.** Penthera is maintained part-time, but security reports come
27
+ **What to expect.** Penthera is maintained part-time, and security reports come
25
28
  first:
26
29
 
27
30
  - Acknowledgement within 3 working days.
@@ -48,6 +51,5 @@ XSS, command injection, API fuzzing).
48
51
 
49
52
  **Only scan systems you own or have explicit written permission to test.**
50
53
  Unauthorized scanning may be illegal in your jurisdiction. Penthera is built for
51
- defensive research and hardening your own applications — see the full
52
- [ethical-use policy](README.md#ethical-use--authorization) and
53
- [disclaimer](README.md#disclaimer) in the README.
54
+ defensive research and hardening your own applications. See
55
+ [Ethics and authorization](README.md#ethics-and-authorization) in the README.
@@ -50,7 +50,7 @@ jobs:
50
50
  (cd "$RUNNER_TEMP/base" && npm ci --ignore-scripts --no-audit --no-fund)
51
51
  npm ci --ignore-scripts --no-audit --no-fund
52
52
 
53
- - uses: danoszz/penthera@0000000000000000000000000000000000000000 # v1.4.0 (replace with the release commit SHA)
53
+ - uses: danoszz/penthera@525895575f4a9bebe911707a43804a934eac0c4a # v1.6.1 (or a later release commit SHA)
54
54
  with:
55
55
  mode: deps
56
56
  fail-on: none # or high / critical to block the merge
@@ -8,7 +8,7 @@
8
8
  * Results feed into the SecurityKnowledgeGraph for chain discovery.
9
9
  */
10
10
 
11
- import { request } from "../src/utils/http.js";
11
+ import { request, readBody } from "../src/utils/http.js";
12
12
 
13
13
  const DEFAULT_TIMEOUT = 10_000;
14
14
 
@@ -26,9 +26,9 @@ async function probe(base, path, opts = {}) {
26
26
  let body = null;
27
27
  const ct = res.headers.get("content-type") || "";
28
28
  if (ct.includes("json")) {
29
- try { body = await res.json(); } catch { body = null; }
29
+ try { body = JSON.parse(await readBody(res)); } catch { body = null; }
30
30
  } else {
31
- try { body = await res.text(); } catch { body = null; }
31
+ try { body = await readBody(res); } catch { body = null; }
32
32
  }
33
33
  return {
34
34
  url: path, status: res.status,
@@ -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
+ }
@@ -0,0 +1,83 @@
1
+ /**
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
4
+ * rule packs (kind `runtime-advisory`, see lib/rules/pack.js).
5
+ */
6
+
7
+ // Release line ("branch") of a version: the unit a vendor patches and ends
8
+ // support for. Null when the version is too coarse to name one.
9
+ const minorLine = (v) => (v.minor === null ? null : `${v.major}.${v.minor}`);
10
+
11
+ export const PRODUCTS = Object.freeze({
12
+ php: {
13
+ branchOf: minorLine,
14
+ name: "PHP",
15
+ // On the server itself, the version and any distro build string.
16
+ checkCommand: "php -v",
17
+ banner: /\bPHP\/(\d+\.\d+(?:\.\d+)?[^\s,;()]*)/i,
18
+ // X-Powered-By: PHP, with the version stripped
19
+ present: /\bPHP\b/i,
20
+ dockerImages: ["php"],
21
+ toolVersions: ["php"],
22
+ versionFiles: [".php-version"],
23
+ },
24
+ "apache-httpd": {
25
+ branchOf: minorLine,
26
+ name: "Apache HTTP Server",
27
+ checkCommand: "httpd -v (or apache2 -v)",
28
+ banner: /\bApache\/(\d+\.\d+\.\d+[^\s,;()]*)/,
29
+ // not Apache-Coyote (Tomcat) or Apache-HttpClient
30
+ present: /^Apache(?![-\w])/,
31
+ dockerImages: ["httpd"],
32
+ toolVersions: [],
33
+ versionFiles: [],
34
+ },
35
+ nginx: {
36
+ branchOf: minorLine,
37
+ name: "nginx",
38
+ checkCommand: "nginx -v",
39
+ banner: /\bnginx\/(\d+\.\d+\.\d+[^\s,;()]*)/i,
40
+ present: /^nginx\b/i,
41
+ dockerImages: ["nginx"],
42
+ toolVersions: [],
43
+ versionFiles: [],
44
+ },
45
+ nodejs: {
46
+ branchOf: (v) => String(v.major),
47
+ name: "Node.js",
48
+ checkCommand: "node -v",
49
+ banner: null,
50
+ present: null,
51
+ dockerImages: ["node"],
52
+ toolVersions: ["nodejs", "node"],
53
+ versionFiles: [".nvmrc", ".node-version"],
54
+ },
55
+ python: {
56
+ branchOf: minorLine,
57
+ name: "Python",
58
+ checkCommand: "python3 --version",
59
+ // Werkzeug and http.server print "Python/3.12.1" in Server
60
+ banner: /\bPython\/(\d+\.\d+\.\d+[^\s,;()]*)/,
61
+ present: null,
62
+ dockerImages: ["python"],
63
+ toolVersions: ["python"],
64
+ versionFiles: [".python-version"],
65
+ },
66
+ openssl: {
67
+ branchOf: (v) => (v.major >= 3 ? minorLine(v) : v.patch === null ? null : `${v.major}.${v.minor}.${v.patch}`),
68
+ name: "OpenSSL",
69
+ checkCommand: "openssl version",
70
+ banner: /\bOpenSSL\/(\d+\.\d+\.\d+[a-z]{0,2}[^\s,;()]*)/,
71
+ present: null,
72
+ dockerImages: [],
73
+ toolVersions: [],
74
+ versionFiles: [],
75
+ },
76
+ });
77
+
78
+ export const PRODUCT_IDS = Object.freeze(Object.keys(PRODUCTS));
79
+
80
+ // A Server banner that names a distribution: its packages carry backported
81
+ // fixes under the upstream version number (Red Hat even prints no suffix), so
82
+ // every version on that response is treated as a distro build.
83
+ export const DISTRO_HINT = /\b(Debian|Ubuntu|Raspbian|CentOS|Red Hat|RHEL|AlmaLinux|Rocky|Oracle Linux|Amazon Linux|Fedora|SUSE|openSUSE|Alpine)\b/i;