@cdxgen/cdxgen 13.4.0 → 13.5.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 (208) hide show
  1. package/README.md +7 -7
  2. package/bin/cdxgen-secure.js +15 -0
  3. package/bin/cdxgen.js +25 -5
  4. package/bin/evinse.js +5 -0
  5. package/bin/hbom.js +15 -7
  6. package/bin/repl.js +29 -0
  7. package/data/cbomosdb-queries.json +13 -1
  8. package/data/component-tags.json +8 -0
  9. package/data/queries-darwin.json +25 -1
  10. package/data/queries-win.json +19 -1
  11. package/data/queries.json +41 -0
  12. package/data/remediations.json +283 -1
  13. package/data/rules/cbom-security.yaml +235 -4
  14. package/data/rules/go-binaries.yaml +46 -0
  15. package/data/rules/golem-go.yaml +80 -0
  16. package/data/rules/hbom-compliance.yaml +40 -22
  17. package/data/rules/hbom-performance.yaml +7 -1
  18. package/data/rules/host-topology.yaml +1 -1
  19. package/data/rules/obom-runtime.yaml +31 -2
  20. package/data/rules/php-runtime.yaml +143 -0
  21. package/lib/audit/index.js +14 -0
  22. package/lib/cli/bomAssembly.js +28 -3
  23. package/lib/cli/cliOptions.js +110 -3
  24. package/lib/cli/index.js +73 -4
  25. package/lib/cli/jvmBom.js +126 -20
  26. package/lib/cli/managedBom.js +711 -71
  27. package/lib/cli/nativeBom.js +225 -28
  28. package/lib/core/env.js +30 -8
  29. package/lib/core/fs.js +28 -19
  30. package/lib/core/httpClient.js +74 -45
  31. package/lib/core/projectConfig.js +1 -0
  32. package/lib/core/projectTooling.js +97 -0
  33. package/lib/core/propertySanitizer.js +72 -0
  34. package/lib/ecosystems/golem.js +550 -6
  35. package/lib/ecosystems/gradleutils.js +30 -20
  36. package/lib/ecosystems/parsers-dotnet.js +117 -18
  37. package/lib/ecosystems/parsers-go.js +1090 -367
  38. package/lib/ecosystems/parsers-jvm.js +259 -17
  39. package/lib/ecosystems/parsers-misc.js +229 -44
  40. package/lib/ecosystems/parsers-python.js +11 -10
  41. package/lib/ecosystems/rubyutils.js +6 -3
  42. package/lib/ecosystems/rusi.js +10 -0
  43. package/lib/ecosystems/scalacliutils.js +2 -1
  44. package/lib/ecosystems/scalasem.js +3 -1
  45. package/lib/evinser/evinser.js +126 -18
  46. package/lib/evinser/swiftsem.js +68 -5
  47. package/lib/inventory/agentSkillSpec.js +17 -0
  48. package/lib/inventory/apkInstalledDb.js +243 -0
  49. package/lib/inventory/atomUtils.js +33 -0
  50. package/lib/inventory/auditCategories.js +3 -3
  51. package/lib/inventory/cacheDir.js +52 -9
  52. package/lib/inventory/certificates.js +2371 -0
  53. package/lib/inventory/communityAiConfigParser.js +4 -9
  54. package/lib/inventory/deps.js +6 -4
  55. package/lib/inventory/display.js +200 -3
  56. package/lib/inventory/dosai.js +500 -58
  57. package/lib/inventory/dynamic.js +45 -3
  58. package/lib/inventory/envcontext.js +168 -5
  59. package/lib/inventory/evidenceUtils.js +436 -23
  60. package/lib/inventory/formulationParsers.js +3 -2
  61. package/lib/inventory/goBuildInfo.js +140 -0
  62. package/lib/inventory/hbom.js +67 -7
  63. package/lib/inventory/hbomAnalysis.js +16 -0
  64. package/lib/inventory/hostTopology.js +21 -0
  65. package/lib/inventory/osPackagePurl.js +244 -0
  66. package/lib/inventory/osPackageResolver.js +89 -87
  67. package/lib/inventory/osinfo.js +79 -31
  68. package/lib/inventory/osqueryTransform.js +114 -5
  69. package/lib/inventory/phpBuildInfo.js +487 -0
  70. package/lib/inventory/phpHostProbe.js +641 -0
  71. package/lib/inventory/rootfsCerts.js +426 -0
  72. package/lib/inventory/source.js +207 -3
  73. package/lib/inventory/spdx.js +144 -0
  74. package/lib/inventory/traceRunner.js +5 -1
  75. package/lib/inventory/tracedCrypto.js +129 -0
  76. package/lib/managers/binary.js +529 -183
  77. package/lib/managers/docker.js +166 -12
  78. package/lib/parsers/email.js +277 -0
  79. package/lib/parsers/largeJson.js +34 -2
  80. package/lib/server/server.js +186 -1
  81. package/lib/stages/postgen/annotator.js +17 -1
  82. package/lib/stages/postgen/introspection/reflect.js +4 -1
  83. package/lib/stages/postgen/postgen.js +5 -5
  84. package/lib/stages/postgen/ruleEngine.js +58 -1
  85. package/lib/stages/postgen/specVersionCompat.js +30 -0
  86. package/lib/stages/pregen/pregen.js +117 -5
  87. package/lib/validator/bomValidator.js +26 -5
  88. package/lib/validator/generated/runtime.mjs +13 -0
  89. package/lib/validator/generated/validate-1.6.mjs +26 -7
  90. package/lib/validator/generated/validate-1.7.mjs +31 -12
  91. package/lib/validator/generated/validate-2.0.mjs +30 -11
  92. package/package.json +17 -17
  93. package/types/bin/cdxgen-secure.d.ts +3 -0
  94. package/types/bin/cdxgen-secure.d.ts.map +1 -0
  95. package/types/bin/repl.d.ts.map +1 -1
  96. package/types/lib/audit/index.d.ts.map +1 -1
  97. package/types/lib/cli/bomAssembly.d.ts +9 -2
  98. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  99. package/types/lib/cli/cliOptions.d.ts +43 -0
  100. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  101. package/types/lib/cli/index.d.ts.map +1 -1
  102. package/types/lib/cli/jvmBom.d.ts +20 -0
  103. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  104. package/types/lib/cli/managedBom.d.ts.map +1 -1
  105. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  106. package/types/lib/core/env.d.ts +18 -2
  107. package/types/lib/core/env.d.ts.map +1 -1
  108. package/types/lib/core/fs.d.ts.map +1 -1
  109. package/types/lib/core/httpClient.d.ts.map +1 -1
  110. package/types/lib/core/projectConfig.d.ts.map +1 -1
  111. package/types/lib/core/projectTooling.d.ts +41 -0
  112. package/types/lib/core/projectTooling.d.ts.map +1 -0
  113. package/types/lib/core/propertySanitizer.d.ts +26 -0
  114. package/types/lib/core/propertySanitizer.d.ts.map +1 -1
  115. package/types/lib/ecosystems/cppEvidence.d.ts +1 -1
  116. package/types/lib/ecosystems/golem.d.ts +81 -1
  117. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  118. package/types/lib/ecosystems/gradleutils.d.ts +10 -8
  119. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  120. package/types/lib/ecosystems/parsers-dotnet.d.ts +40 -4
  121. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  122. package/types/lib/ecosystems/parsers-go.d.ts +85 -13
  123. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  124. package/types/lib/ecosystems/parsers-jvm.d.ts +28 -2
  125. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  126. package/types/lib/ecosystems/parsers-misc.d.ts +18 -58
  127. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  128. package/types/lib/ecosystems/parsers-python.d.ts +4 -3
  129. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  130. package/types/lib/ecosystems/pythonutils.d.ts +1 -1
  131. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  132. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  133. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -1
  134. package/types/lib/ecosystems/scalasem.d.ts.map +1 -1
  135. package/types/lib/evinser/evinser.d.ts +25 -0
  136. package/types/lib/evinser/evinser.d.ts.map +1 -1
  137. package/types/lib/evinser/swiftsem.d.ts +10 -0
  138. package/types/lib/evinser/swiftsem.d.ts.map +1 -1
  139. package/types/lib/inventory/agentSkillSpec.d.ts +13 -0
  140. package/types/lib/inventory/agentSkillSpec.d.ts.map +1 -0
  141. package/types/lib/inventory/apkInstalledDb.d.ts +42 -0
  142. package/types/lib/inventory/apkInstalledDb.d.ts.map +1 -0
  143. package/types/lib/inventory/atomUtils.d.ts +14 -0
  144. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  145. package/types/lib/inventory/cacheDir.d.ts +12 -4
  146. package/types/lib/inventory/cacheDir.d.ts.map +1 -1
  147. package/types/lib/inventory/certificates.d.ts +205 -0
  148. package/types/lib/inventory/certificates.d.ts.map +1 -0
  149. package/types/lib/inventory/communityAiConfigParser.d.ts.map +1 -1
  150. package/types/lib/inventory/deps.d.ts +2 -1
  151. package/types/lib/inventory/deps.d.ts.map +1 -1
  152. package/types/lib/inventory/display.d.ts +18 -0
  153. package/types/lib/inventory/display.d.ts.map +1 -1
  154. package/types/lib/inventory/dosai.d.ts +145 -6
  155. package/types/lib/inventory/dosai.d.ts.map +1 -1
  156. package/types/lib/inventory/dynamic.d.ts +6 -1
  157. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  158. package/types/lib/inventory/envcontext.d.ts +40 -2
  159. package/types/lib/inventory/envcontext.d.ts.map +1 -1
  160. package/types/lib/inventory/evidenceUtils.d.ts +29 -10
  161. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  162. package/types/lib/inventory/formulationParsers.d.ts.map +1 -1
  163. package/types/lib/inventory/goBuildInfo.d.ts +10 -0
  164. package/types/lib/inventory/goBuildInfo.d.ts.map +1 -0
  165. package/types/lib/inventory/hbom.d.ts.map +1 -1
  166. package/types/lib/inventory/hbomAnalysis.d.ts +10 -0
  167. package/types/lib/inventory/hbomAnalysis.d.ts.map +1 -1
  168. package/types/lib/inventory/hostTopology.d.ts.map +1 -1
  169. package/types/lib/inventory/osPackagePurl.d.ts +99 -0
  170. package/types/lib/inventory/osPackagePurl.d.ts.map +1 -0
  171. package/types/lib/inventory/osPackageResolver.d.ts +6 -2
  172. package/types/lib/inventory/osPackageResolver.d.ts.map +1 -1
  173. package/types/lib/inventory/osinfo.d.ts +31 -6
  174. package/types/lib/inventory/osinfo.d.ts.map +1 -1
  175. package/types/lib/inventory/osqueryTransform.d.ts +51 -0
  176. package/types/lib/inventory/osqueryTransform.d.ts.map +1 -1
  177. package/types/lib/inventory/phpBuildInfo.d.ts +142 -0
  178. package/types/lib/inventory/phpBuildInfo.d.ts.map +1 -0
  179. package/types/lib/inventory/phpHostProbe.d.ts +35 -0
  180. package/types/lib/inventory/phpHostProbe.d.ts.map +1 -0
  181. package/types/lib/inventory/rootfsCerts.d.ts +27 -0
  182. package/types/lib/inventory/rootfsCerts.d.ts.map +1 -0
  183. package/types/lib/inventory/source.d.ts +28 -0
  184. package/types/lib/inventory/source.d.ts.map +1 -1
  185. package/types/lib/inventory/spdx.d.ts +15 -0
  186. package/types/lib/inventory/spdx.d.ts.map +1 -1
  187. package/types/lib/inventory/traceRunner.d.ts +4 -1
  188. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  189. package/types/lib/inventory/tracedCrypto.d.ts +22 -0
  190. package/types/lib/inventory/tracedCrypto.d.ts.map +1 -1
  191. package/types/lib/managers/binary.d.ts +10 -0
  192. package/types/lib/managers/binary.d.ts.map +1 -1
  193. package/types/lib/managers/docker.d.ts +12 -0
  194. package/types/lib/managers/docker.d.ts.map +1 -1
  195. package/types/lib/parsers/email.d.ts +19 -0
  196. package/types/lib/parsers/email.d.ts.map +1 -0
  197. package/types/lib/parsers/largeJson.d.ts +13 -0
  198. package/types/lib/parsers/largeJson.d.ts.map +1 -1
  199. package/types/lib/server/server.d.ts +8 -0
  200. package/types/lib/server/server.d.ts.map +1 -1
  201. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  202. package/types/lib/stages/postgen/introspection/reflect.d.ts.map +1 -1
  203. package/types/lib/stages/postgen/ruleEngine.d.ts +7 -4
  204. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  205. package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
  206. package/types/lib/stages/pregen/pregen.d.ts +14 -1
  207. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  208. package/types/lib/validator/bomValidator.d.ts.map +1 -1
@@ -0,0 +1,641 @@
1
+ // Bounded, read-only probe for the PHP no package manager owns on a live host,
2
+ // for `-t os` / obom. osquery's package tables list what dpkg, rpm and the like
3
+ // installed, so a PHP built from source (a Docker Official image, FrankenPHP, a
4
+ // /usr/local or /opt build) is invisible to them, and with it the version a PHP
5
+ // advisory is read against.
6
+ //
7
+ // Candidates come from a few fixed directories, one level deep, never from a
8
+ // walk of the file system. A candidate is opened only after the package
9
+ // databases have said they do not own it: a package manager's file is
10
+ // identified by its package, and distros backport fixes under old PHP version
11
+ // numbers, which the cdx:php properties would misstate. Only files nothing owns
12
+ // are read, for their bytes; nothing is executed.
13
+ //
14
+ // A package database that exists but cannot be read leaves ownership unknown.
15
+ // Unknown is not unpackaged: it emits nothing and records a degradation.
16
+
17
+ import { readdirSync, realpathSync, statSync } from "node:fs";
18
+ import {
19
+ basename,
20
+ dirname,
21
+ isAbsolute,
22
+ join,
23
+ posix,
24
+ relative,
25
+ resolve,
26
+ sep,
27
+ win32,
28
+ } from "node:path";
29
+ import process from "node:process";
30
+
31
+ import {
32
+ isDryRun,
33
+ readEnvironmentVariable,
34
+ recordActivity,
35
+ } from "../core/activity.js";
36
+ import { recordDegradation } from "../core/buildLedger.js";
37
+ import { safeExistsSync, safeReadFileSync } from "../core/fs.js";
38
+ import { thoughtLog } from "../core/logger.js";
39
+ import {
40
+ APK_INSTALLED_DB_PATHS,
41
+ readApkInstalledDb,
42
+ } from "./apkInstalledDb.js";
43
+ import {
44
+ attachPhpBuild,
45
+ classifyPhpSapi,
46
+ createPhpRuntimeInventory,
47
+ MAX_PHP_PROBES,
48
+ packageStoreOf,
49
+ readPhpBuildVersion,
50
+ } from "./phpBuildInfo.js";
51
+ import { tryBuildPurl } from "./purl.js";
52
+
53
+ // One dpkg list, and every dpkg list together. A database past these caps
54
+ // counts as unreadable instead of being read in part; the apk database has the
55
+ // same cap in its reader.
56
+ const MAX_DATABASE_FILE_BYTES = 64 * 1024 * 1024;
57
+ const MAX_DATABASE_TOTAL_BYTES = 256 * 1024 * 1024;
58
+ const MAX_DPKG_LISTS = 100000;
59
+
60
+ // Where the PHP of a source build is installed, relative to the root. `only`
61
+ // narrows a directory to one SAPI. `opt/*/bin` and `opt/*/sbin` read /opt one
62
+ // level down and nothing below that.
63
+ const POSIX_DIRECTORIES = {
64
+ linux: [
65
+ { dir: "usr/local/bin" },
66
+ { dir: "usr/local/sbin" },
67
+ { dir: "usr/local/lib", only: "libphp" },
68
+ { dir: "usr/lib/apache2/modules", only: "libphp" },
69
+ { dir: "usr/lib/httpd/modules", only: "libphp" },
70
+ { dir: "usr/lib64/httpd/modules", only: "libphp" },
71
+ { dir: "opt/*/bin" },
72
+ { dir: "opt/*/sbin" },
73
+ ],
74
+ darwin: [
75
+ { dir: "usr/local/bin" },
76
+ { dir: "usr/local/sbin" },
77
+ { dir: "opt/homebrew/bin" },
78
+ { dir: "opt/homebrew/sbin" },
79
+ ],
80
+ };
81
+
82
+ // The programs of a php.net Windows build. The CGI and debugger executables
83
+ // hold no version of their own; the core DLL beside them does (php8.dll, or
84
+ // php8ts.dll for a thread-safe build).
85
+ const WINDOWS_SAPIS = {
86
+ "php.exe": "cli",
87
+ "php-win.exe": "cli",
88
+ "php-cgi.exe": "cgi",
89
+ "phpdbg.exe": "phpdbg",
90
+ };
91
+
92
+ function isWindowsCoreLibrary(name) {
93
+ const lower = name.toLowerCase();
94
+ if (!lower.startsWith("php") || !lower.endsWith(".dll")) {
95
+ return false;
96
+ }
97
+ const stem = lower.slice("php".length, -".dll".length);
98
+ return /^\d+$/.test(stem.endsWith("ts") ? stem.slice(0, -2) : stem);
99
+ }
100
+
101
+ function listDirectory(directory) {
102
+ try {
103
+ return readdirSync(directory).sort();
104
+ } catch {
105
+ return [];
106
+ }
107
+ }
108
+
109
+ function isDirectory(path) {
110
+ try {
111
+ return statSync(path).isDirectory();
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ function realPathOf(path) {
118
+ try {
119
+ return realpathSync(path);
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ function quoteForOsquery(value) {
126
+ return `'${value.replaceAll("'", "''")}'`;
127
+ }
128
+
129
+ function byBomRef(a, b) {
130
+ const left = a["bom-ref"];
131
+ const right = b["bom-ref"];
132
+ return left < right ? -1 : left > right ? 1 : 0;
133
+ }
134
+
135
+ function readBounded(file, maxBytes) {
136
+ try {
137
+ const stats = statSync(file);
138
+ if (!stats.isFile() || stats.size > maxBytes) {
139
+ return undefined;
140
+ }
141
+ } catch {
142
+ return undefined;
143
+ }
144
+ const text = safeReadFileSync(file, "utf-8");
145
+ return typeof text === "string" ? text : undefined;
146
+ }
147
+
148
+ // A package database: its name and path, `absent` when the host has none,
149
+ // `unreadable` when it has one that could not be read in full, and the paths of
150
+ // the candidates it lists.
151
+ function database(name, path, state = "ok") {
152
+ return { name, path, state, owned: new Set() };
153
+ }
154
+
155
+ // dpkg keeps one <package>.list per package, naming each path as it is on the
156
+ // system.
157
+ function scanDpkg(infoDirectory, wanted) {
158
+ const result = database("dpkg", infoDirectory);
159
+ if (!safeExistsSync(infoDirectory)) {
160
+ result.state = "absent";
161
+ return result;
162
+ }
163
+ const lists = listDirectory(infoDirectory).filter((name) =>
164
+ name.endsWith(".list"),
165
+ );
166
+ if (!isDirectory(infoDirectory) || lists.length > MAX_DPKG_LISTS) {
167
+ result.state = "unreadable";
168
+ }
169
+ let total = 0;
170
+ for (const name of lists.slice(0, MAX_DPKG_LISTS)) {
171
+ const text = readBounded(
172
+ join(infoDirectory, name),
173
+ Math.min(MAX_DATABASE_FILE_BYTES, MAX_DATABASE_TOTAL_BYTES - total),
174
+ );
175
+ if (text === undefined) {
176
+ result.state = "unreadable";
177
+ continue;
178
+ }
179
+ total += text.length;
180
+ for (const line of text.split("\n")) {
181
+ if (wanted.has(line)) {
182
+ result.owned.add(line);
183
+ }
184
+ }
185
+ }
186
+ return result;
187
+ }
188
+
189
+ // apk's installed database is read by the same reader the OS inventory uses, so
190
+ // the packages an OBOM lists and the files this probe treats as owned follow one
191
+ // parser. A dry run never gets here: probeHostPhp returns before it.
192
+ function scanApk(context, wanted) {
193
+ const { apkInstalledFile } = context.databases;
194
+ const apk = readApkInstalledDb({
195
+ rootPath: context.rootPath,
196
+ databaseFile: apkInstalledFile,
197
+ wantedFiles: wanted,
198
+ platform: context.platform,
199
+ });
200
+ const result = database(
201
+ "apk",
202
+ apk.path ||
203
+ apkInstalledFile ||
204
+ join(context.rootPath, APK_INSTALLED_DB_PATHS[0]),
205
+ );
206
+ result.state = { ok: "ok", unreadable: "unreadable" }[apk.state] || "absent";
207
+ for (const pkg of apk.packages) {
208
+ for (const file of pkg.files) {
209
+ result.owned.add(file);
210
+ }
211
+ }
212
+ return result;
213
+ }
214
+
215
+ // osquery's rpm_package_files answers for a path without being told the
216
+ // package.
217
+ function queryRpm(databaseDirectories, wanted, executeOsQuery) {
218
+ const result = database("rpm", databaseDirectories[0]);
219
+ if (!databaseDirectories.some((directory) => safeExistsSync(directory))) {
220
+ result.state = "absent";
221
+ return result;
222
+ }
223
+ if (!wanted.size) {
224
+ return result;
225
+ }
226
+ const rows =
227
+ typeof executeOsQuery === "function"
228
+ ? executeOsQuery(
229
+ `SELECT package, path FROM rpm_package_files WHERE path IN (${[
230
+ ...wanted,
231
+ ]
232
+ .map(quoteForOsquery)
233
+ .join(", ")})`,
234
+ )
235
+ : undefined;
236
+ if (!Array.isArray(rows)) {
237
+ result.state = "unreadable";
238
+ return result;
239
+ }
240
+ for (const row of rows) {
241
+ if (typeof row?.path === "string") {
242
+ result.owned.add(row.path);
243
+ }
244
+ }
245
+ return result;
246
+ }
247
+
248
+ // Chocolatey's database is the list of packages the OS scan has already made.
249
+ // The client is itself a package, so a Chocolatey with none listed was not
250
+ // listed: the query failed.
251
+ function readChocolatey(context) {
252
+ const configured =
253
+ context.readEnv("ChocolateyInstall") ||
254
+ join(context.rootPath, "ProgramData", "chocolatey");
255
+ const result = database("chocolatey", configured);
256
+ if (!safeExistsSync(configured)) {
257
+ result.state = "absent";
258
+ return result;
259
+ }
260
+ const packages = (context.packageComponents || []).filter((component) =>
261
+ component?.purl?.startsWith("pkg:chocolatey/"),
262
+ );
263
+ if (!packages.length) {
264
+ result.state = "unreadable";
265
+ return result;
266
+ }
267
+ result.directory = realPathOf(configured) || resolve(configured);
268
+ result.hasPhp = packages.some(
269
+ (component) => `${component.name}`.toLowerCase() === "php",
270
+ );
271
+ return result;
272
+ }
273
+
274
+ // Chocolatey's php package unpacks under C:\tools by default, outside the
275
+ // package's own folder, so what makes a file Chocolatey's is the package being
276
+ // installed and the file being where the package puts it.
277
+ function chocolateyOwns(chocolatey, context, candidate) {
278
+ if (!chocolatey.hasPhp) {
279
+ return false;
280
+ }
281
+ const first = relative(context.realRoot, candidate.real).split(sep)[0];
282
+ return (
283
+ first.toLowerCase() === "tools" ||
284
+ candidate.real.startsWith(`${chocolatey.directory}${sep}`)
285
+ );
286
+ }
287
+
288
+ // The directories to look in, as absolute paths.
289
+ function candidateDirectories(context) {
290
+ const { rootPath, platform } = context;
291
+ const directories = [];
292
+ if (platform === "win32") {
293
+ const programFiles =
294
+ context.readEnv("ProgramFiles") || join(rootPath, "Program Files");
295
+ for (const parent of [rootPath, join(rootPath, "tools"), programFiles]) {
296
+ for (const name of listDirectory(parent)) {
297
+ const directory = join(parent, name);
298
+ if (name.toLowerCase().startsWith("php") && isDirectory(directory)) {
299
+ directories.push({ directory });
300
+ }
301
+ }
302
+ }
303
+ return directories;
304
+ }
305
+ for (const { dir, only } of POSIX_DIRECTORIES[platform] || []) {
306
+ if (!dir.startsWith("opt/*/")) {
307
+ directories.push({ directory: join(rootPath, dir), only });
308
+ continue;
309
+ }
310
+ const leaf = dir.slice("opt/*/".length);
311
+ for (const name of listDirectory(join(rootPath, "opt"))) {
312
+ const directory = join(rootPath, "opt", name, leaf);
313
+ if (isDirectory(directory)) {
314
+ directories.push({ directory, only });
315
+ }
316
+ }
317
+ }
318
+ return directories;
319
+ }
320
+
321
+ // A path as the system being described names it, and as a purl subpath. `base`
322
+ // is the directory the path hangs off, so that a symlinked root, as a temporary
323
+ // directory often is, does not matter.
324
+ function locate(context, base, absolutePath) {
325
+ const parts = relative(base, absolutePath).split(sep);
326
+ return {
327
+ logical: context.pathApi.join(context.logicalRoot, ...parts),
328
+ subpath: parts.join("/"),
329
+ };
330
+ }
331
+
332
+ /**
333
+ * The distinct PHP files of the fixed directories, at most `maxProbes`. A
334
+ * symlink is read through to the file it names, which must itself be a PHP
335
+ * file under the root, and two names for one file are one candidate.
336
+ *
337
+ * @param {Object} context probe context
338
+ * @returns {Object[]} candidates with their real path, SAPI and locations
339
+ */
340
+ function collectCandidates(context) {
341
+ const { realRoot, platform, maxProbes } = context;
342
+ const classify =
343
+ platform === "win32"
344
+ ? (name) => WINDOWS_SAPIS[name.toLowerCase()]
345
+ : classifyPhpSapi;
346
+ const candidates = [];
347
+ const seen = new Set();
348
+ for (const { directory, only } of candidateDirectories(context)) {
349
+ for (const name of listDirectory(directory)) {
350
+ if (!classify(name)) {
351
+ continue;
352
+ }
353
+ const listed = join(directory, name);
354
+ const real = realPathOf(listed);
355
+ if (!real || seen.has(real)) {
356
+ continue;
357
+ }
358
+ const sapi = classify(basename(real));
359
+ const inside = relative(realRoot, real);
360
+ if (
361
+ !sapi ||
362
+ (only && sapi !== only) ||
363
+ !inside ||
364
+ inside.startsWith("..") ||
365
+ isAbsolute(inside)
366
+ ) {
367
+ continue;
368
+ }
369
+ try {
370
+ if (!statSync(real).isFile()) {
371
+ continue;
372
+ }
373
+ } catch {
374
+ continue;
375
+ }
376
+ if (candidates.length >= maxProbes) {
377
+ thoughtLog(
378
+ `The PHP host probe looked at ${maxProbes} files and left any others unread.`,
379
+ );
380
+ return candidates;
381
+ }
382
+ seen.add(real);
383
+ candidates.push({
384
+ real,
385
+ sapi,
386
+ name: basename(real),
387
+ ...locate(context, realRoot, real),
388
+ listedLogical: locate(context, context.rootPath, listed).logical,
389
+ });
390
+ }
391
+ }
392
+ return candidates;
393
+ }
394
+
395
+ function recordUnreadable(unreadable, recorded) {
396
+ if (recorded.has(unreadable.name)) {
397
+ return;
398
+ }
399
+ recorded.add(unreadable.name);
400
+ recordDegradation("php.host-probe.package-db-unreadable", {
401
+ ecosystem: "php",
402
+ tool: unreadable.name,
403
+ impact: "components",
404
+ path: unreadable.path,
405
+ detail: `The ${unreadable.name} package database could not be read in full, so PHP files it may own were not identified as unpackaged PHP.`,
406
+ });
407
+ }
408
+
409
+ /**
410
+ * Sort the candidates into those a package owns, those whose ownership could
411
+ * not be settled, and the unpackaged rest.
412
+ *
413
+ * @param {Object[]} candidates candidates from collectCandidates
414
+ * @param {Object} context probe context
415
+ * @returns {{unpackaged: Object[], owned: number, unknown: number}}
416
+ */
417
+ function classifyOwnership(candidates, context) {
418
+ const unpackaged = [];
419
+ const recorded = new Set();
420
+ let owned = 0;
421
+ let unknown = 0;
422
+ const pending = candidates.filter((candidate) => {
423
+ if (packageStoreOf(candidate.logical)) {
424
+ owned++;
425
+ return false;
426
+ }
427
+ return true;
428
+ });
429
+ if (!pending.length) {
430
+ return { unpackaged, owned, unknown };
431
+ }
432
+ const wanted = new Set(
433
+ pending.flatMap((candidate) => [
434
+ candidate.logical,
435
+ candidate.listedLogical,
436
+ ]),
437
+ );
438
+ const databases = [];
439
+ if (context.platform === "win32") {
440
+ databases.push(readChocolatey(context));
441
+ } else {
442
+ const { dpkgInfoDirectory, rpmDatabaseDirectories } = context.databases;
443
+ databases.push(
444
+ scanDpkg(dpkgInfoDirectory, wanted),
445
+ scanApk(context, wanted),
446
+ );
447
+ // Only what dpkg and apk did not claim is asked of osquery.
448
+ const claimed = new Set(databases.flatMap((db) => [...db.owned]));
449
+ databases.push(
450
+ queryRpm(
451
+ rpmDatabaseDirectories,
452
+ new Set([...wanted].filter((path) => !claimed.has(path))),
453
+ context.executeOsQuery,
454
+ ),
455
+ );
456
+ }
457
+ for (const candidate of pending) {
458
+ const paths = [candidate.logical, candidate.listedLogical];
459
+ const isOwned = databases.some(
460
+ (db) =>
461
+ db.state === "ok" &&
462
+ (db.name === "chocolatey"
463
+ ? chocolateyOwns(db, context, candidate)
464
+ : paths.some((path) => db.owned.has(path))),
465
+ );
466
+ if (isOwned) {
467
+ owned++;
468
+ continue;
469
+ }
470
+ const unreadable = databases.filter((db) => db.state === "unreadable");
471
+ if (!unreadable.length) {
472
+ unpackaged.push(candidate);
473
+ continue;
474
+ }
475
+ unknown++;
476
+ for (const db of unreadable) {
477
+ recordUnreadable(db, recorded);
478
+ }
479
+ }
480
+ return { unpackaged, owned, unknown };
481
+ }
482
+
483
+ // Like a container's unpackaged files, but named by where they are: a host can
484
+ // hold several binaries of one name.
485
+ function fileComponent(candidate) {
486
+ const purl = tryBuildPurl({
487
+ type: "generic",
488
+ name: candidate.name,
489
+ subpath: candidate.subpath,
490
+ });
491
+ return {
492
+ name: candidate.name,
493
+ type: "file",
494
+ ...(purl ? { purl } : {}),
495
+ "bom-ref": purl ? decodeURIComponent(purl) : `file:${candidate.logical}`,
496
+ properties: [{ name: "internal:SrcFile", value: candidate.logical }],
497
+ evidence: {
498
+ identity: [
499
+ {
500
+ field: "purl",
501
+ confidence: 0,
502
+ methods: [
503
+ { technique: "filename", confidence: 0, value: candidate.logical },
504
+ ],
505
+ concludedValue: candidate.logical,
506
+ },
507
+ ],
508
+ },
509
+ };
510
+ }
511
+
512
+ // On Windows the CGI and debugger executables hold no version; the core DLL in
513
+ // the same directory does.
514
+ function readBuild(context, candidate) {
515
+ const build = readPhpBuildVersion(candidate.real, context.realRoot);
516
+ if (build || context.platform !== "win32") {
517
+ return build;
518
+ }
519
+ const directory = dirname(candidate.real);
520
+ for (const name of listDirectory(directory)) {
521
+ if (isWindowsCoreLibrary(name)) {
522
+ const fromLibrary = readPhpBuildVersion(
523
+ join(directory, name),
524
+ context.realRoot,
525
+ );
526
+ if (fromLibrary) {
527
+ return fromLibrary;
528
+ }
529
+ }
530
+ }
531
+ return undefined;
532
+ }
533
+
534
+ function createContext(options, platform, readEnv, logicalRoot) {
535
+ const rootPath = options.rootPath || logicalRoot;
536
+ const realRoot = realPathOf(rootPath);
537
+ if (!realRoot) {
538
+ return undefined;
539
+ }
540
+ const databases = options.packageDatabases || {};
541
+ return {
542
+ platform,
543
+ readEnv,
544
+ rootPath,
545
+ realRoot,
546
+ logicalRoot,
547
+ pathApi: platform === "win32" ? win32 : posix,
548
+ maxProbes: options.maxProbes || MAX_PHP_PROBES,
549
+ executeOsQuery: options.executeOsQuery,
550
+ packageComponents: options.packageComponents,
551
+ databases: {
552
+ dpkgInfoDirectory:
553
+ databases.dpkgInfoDirectory || join(rootPath, "var/lib/dpkg/info"),
554
+ // Unset, the reader looks where apk keeps the database under the root.
555
+ apkInstalledFile: databases.apkInstalledFile,
556
+ rpmDatabaseDirectories: databases.rpmDatabaseDirectories || [
557
+ join(rootPath, "var/lib/rpm"),
558
+ join(rootPath, "usr/lib/sysimage/rpm"),
559
+ ],
560
+ },
561
+ };
562
+ }
563
+
564
+ /**
565
+ * Look for PHP no package manager owns, in the fixed directories of the host.
566
+ * Components come back only for the files nothing owns: a `file` component for
567
+ * each PHP binary or library with the cdx:php properties, one `platform`
568
+ * component per version with its CPE, and the edges between them.
569
+ *
570
+ * @param {Object} [options] probe options
571
+ * @param {string} [options.rootPath] directory the fixed directories hang off:
572
+ * `/`, or the system drive on Windows. Tests point it at a built tree.
573
+ * @param {string} [options.platform] `linux`, `darwin` or `win32`
574
+ * @param {Object} [options.env] environment read for ProgramFiles, SystemDrive
575
+ * and ChocolateyInstall, instead of the process's
576
+ * @param {{dpkgInfoDirectory?: string, apkInstalledFile?: string, rpmDatabaseDirectories?: string[]}} [options.packageDatabases]
577
+ * where the package databases are, instead of under `rootPath`
578
+ * @param {function(string): (Object[]|undefined)} [options.executeOsQuery] runs
579
+ * an osquery query; the rpm database is only asked through it
580
+ * @param {Object[]} [options.packageComponents] the packages the OS scan has
581
+ * inventoried, from which Chocolatey's are known
582
+ * @param {number} [options.maxProbes] most candidate files to look at
583
+ * @returns {{components: Object[], dependencies: Object[]}}
584
+ */
585
+ export function probeHostPhp(options = {}) {
586
+ const empty = { components: [], dependencies: [] };
587
+ const platform = options.platform || process.platform;
588
+ const readEnv = (name) =>
589
+ options.env ? options.env[name] : readEnvironmentVariable(name);
590
+ const logicalRoot =
591
+ platform === "win32" ? `${readEnv("SystemDrive") || "C:"}\\` : "/";
592
+ if (isDryRun) {
593
+ recordActivity({
594
+ capability: "php-host-probe",
595
+ kind: "read",
596
+ reason:
597
+ "Dry run mode blocks the PHP host probe, which opens executables and package databases outside the project.",
598
+ status: "blocked",
599
+ target: options.rootPath || logicalRoot,
600
+ });
601
+ return empty;
602
+ }
603
+ const context = createContext(options, platform, readEnv, logicalRoot);
604
+ if (!context) {
605
+ return empty;
606
+ }
607
+ const candidates = collectCandidates(context);
608
+ if (!candidates.length) {
609
+ return empty;
610
+ }
611
+ const { unpackaged, owned, unknown } = classifyOwnership(candidates, context);
612
+ const inventory = createPhpRuntimeInventory();
613
+ const files = [];
614
+ for (const candidate of unpackaged) {
615
+ const build = readBuild(context, candidate);
616
+ if (build) {
617
+ const component = fileComponent(candidate);
618
+ attachPhpBuild(inventory, component, build, candidate.sapi);
619
+ files.push(component);
620
+ }
621
+ }
622
+ recordActivity({
623
+ capability: "php-host-probe",
624
+ kind: "read",
625
+ reason: `Looked at ${candidates.length} PHP file(s) in the fixed directories: ${owned} owned by a package, ${unknown} of unknown ownership, ${files.length} unpackaged PHP identified.`,
626
+ status: "completed",
627
+ target: context.rootPath,
628
+ });
629
+ if (files.length) {
630
+ thoughtLog(
631
+ `I found ${files.length} unpackaged PHP file(s) on this host, from ${inventory.runtimeComponents.length} version(s).`,
632
+ );
633
+ }
634
+ return {
635
+ components: [
636
+ ...files.sort(byBomRef),
637
+ ...inventory.runtimeComponents.sort(byBomRef),
638
+ ],
639
+ dependencies: inventory.dependencies,
640
+ };
641
+ }