@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,426 @@
1
+ import {
2
+ closeSync,
3
+ lstatSync,
4
+ openSync,
5
+ readdirSync,
6
+ readSync,
7
+ realpathSync,
8
+ } from "node:fs";
9
+ import { basename, join, relative, sep } from "node:path";
10
+
11
+ import {
12
+ androidNetworkSecurityConfigPins,
13
+ CertificateCollector,
14
+ certRecordFromDer,
15
+ classifyCryptoFile,
16
+ kubernetesTlsSecretRecords,
17
+ linkFallbackIssuers,
18
+ } from "./certificates.js";
19
+
20
+ // Bounds mirror trustinspector's rootfs mode (PLAN section 4.1): 16 MiB per
21
+ // file, 20,000 files read and 500,000 entries visited per tree, depth 12.
22
+ export const ROOTFS_CERT_LIMITS = Object.freeze({
23
+ maxFileBytes: 16 * 1024 * 1024,
24
+ maxFiles: 20000,
25
+ maxEntries: 500000,
26
+ maxDepth: 12,
27
+ });
28
+
29
+ // Coverage table: the same locations and sourceTypes as trustinspector's
30
+ // rootfs mode (thirdparty/trustinspector/rootfs.go candidateDirs).
31
+ const CA_STORE_DIRS = [
32
+ "etc/ssl/certs",
33
+ "usr/local/share/ca-certificates",
34
+ "usr/share/ca-certificates",
35
+ "etc/pki/tls/certs",
36
+ "etc/pki/ca-trust/source/anchors",
37
+ "etc/pki/ca-trust/extracted",
38
+ "var/lib/ca-certificates",
39
+ "usr/share/pki/ca-trust-source/anchors",
40
+ ];
41
+ const CA_STORE_FILES = ["etc/ssl/cert.pem"];
42
+ const CA_BLOCKLIST_DIRS = [
43
+ "etc/pki/ca-trust/source/blocklist",
44
+ "etc/pki/ca-trust/source/blacklist",
45
+ "usr/share/pki/ca-trust-source/blocklist",
46
+ "usr/share/pki/ca-trust-source/blacklist",
47
+ ];
48
+ const SERVER_CONFIG_DIRS = [
49
+ "etc/nginx",
50
+ "etc/apache2",
51
+ "etc/httpd",
52
+ "etc/haproxy",
53
+ "etc/letsencrypt/live",
54
+ "etc/letsencrypt/archive",
55
+ "etc/kubernetes/pki",
56
+ "var/lib/kubelet/pki",
57
+ "etc/docker/certs.d",
58
+ "etc/containerd/certs.d",
59
+ "etc/envoy",
60
+ "etc/traefik",
61
+ ];
62
+ const JAVA_DIRS = [
63
+ "usr/lib/jvm",
64
+ "opt",
65
+ "etc/ssl/certs/java",
66
+ "etc/pki/java",
67
+ "usr/share/elasticsearch",
68
+ ];
69
+ const LANGUAGE_BUNDLE_DIRS = ["usr/lib", "usr/local/lib", "opt"];
70
+ // Kubernetes secrets and Android configs are cdxgen-side producers: the
71
+ // plugin has no YAML or Android parser.
72
+ const SECRET_YAML_DIRS = [
73
+ "etc/kubernetes",
74
+ "var/run/secrets",
75
+ "run/secrets",
76
+ "var/lib/kubelet",
77
+ "srv",
78
+ "app",
79
+ "opt",
80
+ "home",
81
+ "root",
82
+ ];
83
+ const ANDROID_DIRS = ["app", "opt", "srv", "data", "home", "root"];
84
+
85
+ const CERT_SUFFIXES = [
86
+ ".pem",
87
+ ".crt",
88
+ ".cer",
89
+ ".der",
90
+ ".p7b",
91
+ ".p7c",
92
+ ".p12",
93
+ ".pfx",
94
+ ".jks",
95
+ ".jceks",
96
+ ".truststore",
97
+ ".key",
98
+ ];
99
+
100
+ function isJavaKeystoreName(base) {
101
+ if (base === "cacerts" || base === "jssecacerts") {
102
+ return true;
103
+ }
104
+ return [".jks", ".jceks", ".truststore", ".p12", ".pfx"].some((suffix) =>
105
+ base.endsWith(suffix),
106
+ );
107
+ }
108
+
109
+ // Mirrors looksLikeTrustMaterialPath in trustinspector: private-key
110
+ // directories are never read.
111
+ function looksLikeTrustMaterialPath(rel, sourceType) {
112
+ const lower = rel.toLowerCase();
113
+ if (
114
+ lower.startsWith("etc/ssl/private/") ||
115
+ lower.startsWith("etc/pki/tls/private/")
116
+ ) {
117
+ return false;
118
+ }
119
+ const base = basename(lower);
120
+ if (sourceType === "language-bundle") {
121
+ return base === "cacert.pem";
122
+ }
123
+ if (sourceType === "java-truststore") {
124
+ return isJavaKeystoreName(base);
125
+ }
126
+ if (isJavaKeystoreName(base)) {
127
+ return true;
128
+ }
129
+ return (
130
+ CERT_SUFFIXES.some((suffix) => lower.endsWith(suffix)) ||
131
+ lower.includes("/certs/")
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Read at most `maxBytes` of a regular file. The cap applies to the read
137
+ * itself, so a file that grows after the size check cannot exceed it. Files
138
+ * that are not regular (FIFOs, devices, sockets) are never opened.
139
+ */
140
+ function readCapped(absolute, maxBytes) {
141
+ const before = lstatSync(absolute);
142
+ if (!before.isFile() || before.size > maxBytes) {
143
+ return undefined;
144
+ }
145
+ const fd = openSync(absolute, "r");
146
+ try {
147
+ const buffer = Buffer.alloc(Math.min(before.size, maxBytes) + 1);
148
+ let total = 0;
149
+ while (total < buffer.length) {
150
+ const n = readSync(fd, buffer, total, buffer.length - total, null);
151
+ if (n === 0) {
152
+ break;
153
+ }
154
+ total += n;
155
+ }
156
+ if (total > maxBytes) {
157
+ return undefined;
158
+ }
159
+ return buffer.subarray(0, total);
160
+ } finally {
161
+ closeSync(fd);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Pure-JS inventory of the certificate locations trustinspector's rootfs
167
+ * mode covers, plus Kubernetes TLS secrets and Android pins. Used when the
168
+ * plugin is not available. Produces components through the shared
169
+ * CertificateCollector, so bom-refs, names and deduplication match the other
170
+ * producers; locations are root-relative ("/etc/ssl/certs/x.crt").
171
+ *
172
+ * Every access stays inside the root: symlinks (files or directories) are
173
+ * skipped, only regular files are opened, and the walk is bounded by entry,
174
+ * file, size and depth limits.
175
+ *
176
+ * @param {string} rootDir extracted root filesystem
177
+ * @param {object} [limits] overrides for ROOTFS_CERT_LIMITS (tests)
178
+ * @returns {{components: object[], dependencies: object[], warnings: string[]}}
179
+ */
180
+ export function collectRootfsCertificates(rootDir, limits = {}) {
181
+ const bounds = { ...ROOTFS_CERT_LIMITS, ...limits };
182
+ const warnings = [];
183
+ let realRoot;
184
+ try {
185
+ realRoot = realpathSync(rootDir);
186
+ } catch {
187
+ return { components: [], dependencies: [], warnings };
188
+ }
189
+ const collector = new CertificateCollector();
190
+ const records = [];
191
+ const components = [];
192
+ const seenFiles = new Set();
193
+ let filesRead = 0;
194
+
195
+ // Resolve a root-relative directory to an absolute path that is proven to
196
+ // stay inside the root; undefined when it does not exist, is not a
197
+ // directory, or any symlink on the way leaves the root.
198
+ const resolveInside = (rel) => {
199
+ const absolute = join(realRoot, rel);
200
+ let real;
201
+ try {
202
+ real = realpathSync(absolute);
203
+ } catch {
204
+ return undefined;
205
+ }
206
+ const inside = relative(realRoot, real);
207
+ if (inside.startsWith("..") || inside.split(sep).includes("..")) {
208
+ return undefined;
209
+ }
210
+ return real;
211
+ };
212
+
213
+ const walk = (relBase, visit) => {
214
+ const start = resolveInside(relBase);
215
+ if (!start) {
216
+ return;
217
+ }
218
+ let startStat;
219
+ try {
220
+ startStat = lstatSync(start);
221
+ } catch {
222
+ return;
223
+ }
224
+ if (!startStat.isDirectory()) {
225
+ return;
226
+ }
227
+ let entries = 0;
228
+ const stack = [{ abs: start, rel: relBase, depth: 0 }];
229
+ while (stack.length) {
230
+ const { abs, rel, depth } = stack.pop();
231
+ let dirents;
232
+ try {
233
+ dirents = readdirSync(abs, { withFileTypes: true });
234
+ } catch {
235
+ continue;
236
+ }
237
+ dirents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
238
+ for (const dirent of dirents) {
239
+ entries += 1;
240
+ if (entries > bounds.maxEntries) {
241
+ warnings.push(
242
+ `entry limit (${bounds.maxEntries}) reached in /${relBase}`,
243
+ );
244
+ return;
245
+ }
246
+ // Symlinks and special files are skipped outright.
247
+ if (dirent.isSymbolicLink()) {
248
+ continue;
249
+ }
250
+ const childRel = `${rel}/${dirent.name}`;
251
+ if (dirent.isDirectory()) {
252
+ if (depth + 1 >= bounds.maxDepth) {
253
+ warnings.push(
254
+ `depth limit (${bounds.maxDepth}) exceeded in /${relBase}`,
255
+ );
256
+ continue;
257
+ }
258
+ stack.push({
259
+ abs: join(abs, dirent.name),
260
+ rel: childRel,
261
+ depth: depth + 1,
262
+ });
263
+ } else if (dirent.isFile()) {
264
+ if (visit(childRel, join(abs, dirent.name)) === false) {
265
+ return;
266
+ }
267
+ }
268
+ }
269
+ }
270
+ };
271
+
272
+ const readRel = (rel, abs) => {
273
+ if (filesRead >= bounds.maxFiles) {
274
+ warnings.push(`file count limit (${bounds.maxFiles}) reached`);
275
+ return undefined;
276
+ }
277
+ filesRead += 1;
278
+ try {
279
+ const bytes = readCapped(abs, bounds.maxFileBytes);
280
+ if (!bytes) {
281
+ warnings.push(
282
+ `file over ${bounds.maxFileBytes} bytes skipped: /${rel}`,
283
+ );
284
+ }
285
+ return bytes;
286
+ } catch {
287
+ return undefined;
288
+ }
289
+ };
290
+
291
+ const consumeMaterial = (rel, abs, sourceType) => {
292
+ if (seenFiles.has(rel) || !looksLikeTrustMaterialPath(rel, sourceType)) {
293
+ return;
294
+ }
295
+ const bytes = readRel(rel, abs);
296
+ if (!bytes?.length) {
297
+ return;
298
+ }
299
+ seenFiles.add(rel);
300
+ const location = `/${rel}`;
301
+ const classification = classifyCryptoFile(bytes);
302
+ if (classification.kind === "certificates") {
303
+ const dot = basename(rel).lastIndexOf(".");
304
+ for (const der of classification.ders) {
305
+ const record = certRecordFromDer(der, { location, sourceType });
306
+ if (record) {
307
+ record.fileExtension =
308
+ dot > 0 ? basename(rel).slice(dot + 1) : undefined;
309
+ records.push(record);
310
+ }
311
+ }
312
+ } else if (classification.kind === "private-key") {
313
+ components.push(
314
+ rootfsMaterialComponent("private-key", location, {
315
+ format: classification.format,
316
+ sourceType,
317
+ }),
318
+ );
319
+ } else if (classification.kind === "keystore") {
320
+ components.push(
321
+ rootfsMaterialComponent("keystore", location, {
322
+ format: classification.format,
323
+ sourceType,
324
+ }),
325
+ );
326
+ }
327
+ };
328
+
329
+ // Most specific location first, so a file under two candidates takes the
330
+ // sourceType of the narrower one (same rule as the plugin).
331
+ const candidates = [
332
+ ...CA_STORE_DIRS.map((rel) => ({ rel, sourceType: "ca-store" })),
333
+ ...CA_BLOCKLIST_DIRS.map((rel) => ({ rel, sourceType: "ca-blocklist" })),
334
+ ...SERVER_CONFIG_DIRS.map((rel) => ({ rel, sourceType: "server-config" })),
335
+ ...JAVA_DIRS.map((rel) => ({ rel, sourceType: "java-truststore" })),
336
+ ...LANGUAGE_BUNDLE_DIRS.map((rel) => ({
337
+ rel,
338
+ sourceType: "language-bundle",
339
+ })),
340
+ ].sort((a, b) => b.rel.split("/").length - a.rel.split("/").length);
341
+ for (const file of CA_STORE_FILES) {
342
+ const abs = resolveInside(file);
343
+ if (abs && lstatSync(abs).isFile()) {
344
+ consumeMaterial(file, abs, "ca-store");
345
+ }
346
+ }
347
+ for (const { rel, sourceType } of candidates) {
348
+ walk(rel, (childRel, abs) => {
349
+ consumeMaterial(childRel, abs, sourceType);
350
+ });
351
+ }
352
+
353
+ const secretFiles = new Set();
354
+ for (const dir of SECRET_YAML_DIRS) {
355
+ walk(dir, (childRel, abs) => {
356
+ if (/\.ya?ml$/i.test(childRel) && !secretFiles.has(childRel)) {
357
+ secretFiles.add(childRel);
358
+ const bytes = readRel(childRel, abs);
359
+ const text = bytes?.toString("utf-8");
360
+ // Cheap pre-filter, as in the source scan.
361
+ if (text?.includes("kubernetes.io/tls")) {
362
+ records.push(
363
+ ...kubernetesTlsSecretRecords(text, { file: `/${childRel}` }),
364
+ );
365
+ }
366
+ }
367
+ });
368
+ }
369
+ const androidFiles = new Set();
370
+ for (const dir of ANDROID_DIRS) {
371
+ walk(dir, (childRel, abs) => {
372
+ if (
373
+ /\/res\/xml\/[^/]+\.xml$/i.test(childRel) &&
374
+ !androidFiles.has(childRel)
375
+ ) {
376
+ androidFiles.add(childRel);
377
+ const text = readRel(childRel, abs)?.toString("utf-8");
378
+ if (text?.includes("<network-security-config")) {
379
+ for (const pin of androidNetworkSecurityConfigPins(text, {
380
+ file: `/${childRel}`,
381
+ })) {
382
+ collector.addPin(pin);
383
+ }
384
+ }
385
+ }
386
+ });
387
+ }
388
+
389
+ if (records.length) {
390
+ linkFallbackIssuers(records);
391
+ collector.addRecords(records);
392
+ }
393
+ const built = collector.build();
394
+ return {
395
+ components: [...built.components, ...components],
396
+ dependencies: built.dependencies,
397
+ warnings,
398
+ };
399
+ }
400
+
401
+ // Same shape as the keystore and private-key components the other producers
402
+ // emit (managedBom sourceKeystoreComponent / sourcePrivateKeyComponent).
403
+ function rootfsMaterialComponent(kind, location, { format, sourceType }) {
404
+ return {
405
+ name: basename(location),
406
+ type: "cryptographic-asset",
407
+ "bom-ref": `crypto/related-crypto-material/${kind}/${encodeURIComponent(location)}`,
408
+ cryptoProperties: {
409
+ assetType: "related-crypto-material",
410
+ relatedCryptoMaterialProperties: {
411
+ type: kind === "keystore" ? "other" : "private-key",
412
+ ...(format ? { format } : {}),
413
+ },
414
+ },
415
+ evidence: { occurrences: [{ location }] },
416
+ properties: [
417
+ { name: "internal:SrcFile", value: location },
418
+ ...(sourceType
419
+ ? [{ name: "cdx:crypto:sourceType", value: sourceType }]
420
+ : []),
421
+ ...(kind === "keystore"
422
+ ? [{ name: "cdx:crypto:keystoreReadable", value: "false" }]
423
+ : []),
424
+ ],
425
+ };
426
+ }
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { URL } from "node:url";
4
+ import { fileURLToPath, URL } from "node:url";
5
5
 
6
6
  import { Purl } from "@cdxgen/cdx-purl";
7
7
  import { coerce, diff, prerelease } from "semver";
@@ -111,6 +111,9 @@ function inferGitOperation(args = []) {
111
111
  * @param {string[]} args git arguments
112
112
  * @param {Object} options command options
113
113
  * @param {string|undefined} options.cwd working directory
114
+ * @param {boolean|undefined} options.noRepository run outside any repository,
115
+ * so the config of a repository in the working directory, such as url.*.insteadOf
116
+ * or core.sshCommand, cannot change where a remote command connects
114
117
  * @returns {Object} spawn result
115
118
  */
116
119
  export function hardenedGitCommand(args, options = {}) {
@@ -122,8 +125,14 @@ export function hardenedGitCommand(args, options = {}) {
122
125
  GIT_CONFIG_VALUE_0: "false",
123
126
  GIT_CONFIG_KEY_1: "safe.bareRepository",
124
127
  GIT_CONFIG_VALUE_1: "explicit",
128
+ GIT_NO_LAZY_FETCH: "1",
125
129
  GIT_TERMINAL_PROMPT: "0",
126
130
  };
131
+ if (options.noRepository) {
132
+ // ls-remote works without a repository. Pointing GIT_DIR at a path that
133
+ // does not exist stops git from discovering one in the working directory.
134
+ envConfigs.GIT_DIR = path.join(getTmpDir(), "cdxgen-no-repository.invalid");
135
+ }
127
136
  const env = isSecureMode
128
137
  ? {
129
138
  ...process.env,
@@ -275,10 +284,14 @@ export function buildReleaseNotesFromGit(repoPath, options = {}) {
275
284
  typeof remoteUrl === "string"
276
285
  ? validateAndRejectGitSource(remoteUrl)
277
286
  : null;
287
+ // remote.origin.url comes from the scanned repository. Only a remote that
288
+ // validateAndRejectGitSource recognises and checks may reach ls-remote; git
289
+ // also treats forms such as host:path as remotes, which skip that check.
278
290
  const canDiscoverRemoteTags =
279
291
  (!currentTag || !previousTag) &&
280
292
  typeof remoteUrl === "string" &&
281
293
  !remoteUrl.startsWith("-") &&
294
+ maybeRemotePath(remoteUrl) &&
282
295
  !remoteUrlValidationError &&
283
296
  /github\.com[:/]/i.test(remoteUrl);
284
297
  if (canDiscoverRemoteTags) {
@@ -298,7 +311,7 @@ export function buildReleaseNotesFromGit(repoPath, options = {}) {
298
311
  "--",
299
312
  remoteUrl,
300
313
  ],
301
- {},
314
+ { noRepository: true },
302
315
  );
303
316
  const remoteTags = parseLsRemoteTags(remoteTagsResult.stdout);
304
317
  if (!currentTag && remoteTags.length) {
@@ -508,6 +521,59 @@ export function isAllowedPath(p) {
508
521
  });
509
522
  }
510
523
 
524
+ /**
525
+ * Resolve symlinks in a path. When the path does not exist yet, the nearest
526
+ * existing parent is resolved and the remaining segments are appended, so
527
+ * /tmp/new on macOS still becomes /private/tmp/new.
528
+ *
529
+ * @param {string} p Path string
530
+ * @returns {string} real path
531
+ */
532
+ function realPathOrResolved(p) {
533
+ let current = path.resolve(p);
534
+ const missing = [];
535
+ while (true) {
536
+ try {
537
+ return path.join(fs.realpathSync(current), ...missing);
538
+ } catch (_err) {
539
+ const parent = path.dirname(current);
540
+ if (parent === current) {
541
+ return path.resolve(p);
542
+ }
543
+ missing.unshift(path.basename(current));
544
+ current = parent;
545
+ }
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Checks where the given path really points, after following symlinks, against
551
+ * where the allowed paths really point. Both sides are resolved, so an allowed
552
+ * directory that is itself reached through a link (such as /tmp on macOS) still
553
+ * matches, while a link inside an allowed directory that leads outside it does
554
+ * not.
555
+ *
556
+ * @param {string} p Path string to check
557
+ * @returns {boolean} true if the real path is inside a real allowed path
558
+ */
559
+ export function isAllowedRealPath(p) {
560
+ if (typeof p !== "string" || hasDangerousUnicode(p)) {
561
+ return false;
562
+ }
563
+ const allowedPaths = getAllowedPaths();
564
+ if (!allowedPaths.length) {
565
+ return true;
566
+ }
567
+ const realP = realPathOrResolved(p);
568
+ return allowedPaths.some((ap) => {
569
+ const relativePath = path.relative(realPathOrResolved(ap), realP);
570
+ return (
571
+ relativePath === "" ||
572
+ (!relativePath.startsWith("..") && !path.isAbsolute(relativePath))
573
+ );
574
+ });
575
+ }
576
+
511
577
  /**
512
578
  * Determine if the path could be a package URL.
513
579
  *
@@ -586,6 +652,49 @@ export function validateAndRejectGitSource(filePath) {
586
652
  };
587
653
  }
588
654
 
655
+ if (hasAmbiguousRemoteCharacters(filePath)) {
656
+ return {
657
+ status: 400,
658
+ error: "Invalid URL Format",
659
+ details:
660
+ "The provided Git URL contains backslashes, whitespace or control characters.",
661
+ };
662
+ }
663
+
664
+ // git reads file:// URLs from the local disk, so they get the path
665
+ // allowlist rather than the host allowlist.
666
+ if (gitUrlObj.protocol === "file:") {
667
+ let localPath;
668
+ try {
669
+ localPath = fileURLToPath(gitUrlObj);
670
+ } catch (_err) {
671
+ localPath = undefined;
672
+ }
673
+ if (
674
+ gitUrlObj.hostname ||
675
+ !localPath ||
676
+ !isAllowedPath(localPath) ||
677
+ (isWin && !isAllowedWinPath(localPath))
678
+ ) {
679
+ return {
680
+ status: 403,
681
+ error: "Path Not Allowed",
682
+ details: "Path is not allowed as per the allowlist.",
683
+ };
684
+ }
685
+ return null;
686
+ }
687
+
688
+ // git, libcurl and OpenSSH must connect to the host that was checked.
689
+ const gitRemoteHost = getGitRemoteHost(filePath);
690
+ if (!gitRemoteHost || gitUrlObj.hostname.toLowerCase() !== gitRemoteHost) {
691
+ return {
692
+ status: 400,
693
+ error: "Invalid URL Format",
694
+ details: "The host of the provided Git URL is ambiguous.",
695
+ };
696
+ }
697
+
589
698
  if (!isAllowedHost(gitUrlObj.hostname)) {
590
699
  return {
591
700
  status: 403,
@@ -598,6 +707,94 @@ export function validateAndRejectGitSource(filePath) {
598
707
  return null;
599
708
  }
600
709
 
710
+ /**
711
+ * Detect characters that git, libcurl or OpenSSH read differently from the
712
+ * WHATWG URL parser. A backslash ends the host for new URL() but is part of
713
+ * the user name for git, and new URL() silently drops tabs and newlines.
714
+ *
715
+ * @param {string} remote Git URL or SCP-like address
716
+ * @returns {boolean} true if the remote contains such characters
717
+ */
718
+ function hasAmbiguousRemoteCharacters(remote) {
719
+ if (hasDangerousUnicode(remote)) {
720
+ return true;
721
+ }
722
+ for (const ch of remote) {
723
+ const code = ch.codePointAt(0);
724
+ if (ch === "\\" || code <= 0x20 || (code >= 0x7f && code <= 0x9f)) {
725
+ return true;
726
+ }
727
+ }
728
+ return false;
729
+ }
730
+
731
+ /**
732
+ * Return the host a git client connects to for a remote, read the way git,
733
+ * libcurl and OpenSSH read it rather than the way new URL() does. git ends the
734
+ * authority of ssh:// and git:// URLs only at the first slash, percent-decodes
735
+ * it before splitting off the host, and reads a leading "[...]" as the host.
736
+ * OpenSSH takes the host after the last "@". So a "?" or "#" before the first
737
+ * slash, a second "@", brackets in the user name, or a percent sign in an
738
+ * ssh:// or git:// authority make the host ambiguous.
739
+ *
740
+ * @param {string} remote Git URL or SCP-like address
741
+ * @returns {string|undefined} lower-cased host, or undefined when it is ambiguous
742
+ */
743
+ export function getGitRemoteHost(remote) {
744
+ if (typeof remote !== "string") {
745
+ return undefined;
746
+ }
747
+ let authority;
748
+ const schemeEnd = remote.indexOf("://");
749
+ if (schemeEnd > -1) {
750
+ const scheme = remote.slice(0, schemeEnd).toLowerCase();
751
+ const rest = remote.slice(schemeEnd + 3);
752
+ const slash = rest.indexOf("/");
753
+ authority = slash > -1 ? rest.slice(0, slash) : rest;
754
+ // libcurl reads the http(s) authority as written. git decodes the others.
755
+ if (!["http", "https"].includes(scheme) && authority.includes("%")) {
756
+ return undefined;
757
+ }
758
+ } else {
759
+ // git reads user@host:path as SCP-like only when the colon comes before
760
+ // any slash. Otherwise it is a local path.
761
+ const colon = remote.indexOf(":");
762
+ const slash = remote.indexOf("/");
763
+ if (colon < 0 || (slash > -1 && slash < colon)) {
764
+ return undefined;
765
+ }
766
+ authority = remote.slice(0, colon);
767
+ }
768
+ if (authority.includes("?") || authority.includes("#")) {
769
+ return undefined;
770
+ }
771
+ const at = authority.indexOf("@");
772
+ if (at !== authority.lastIndexOf("@")) {
773
+ return undefined;
774
+ }
775
+ const userInfo = at > -1 ? authority.slice(0, at) : "";
776
+ if (userInfo.includes("[") || userInfo.includes("]")) {
777
+ return undefined;
778
+ }
779
+ let host = authority.slice(at + 1);
780
+ if (host.startsWith("[")) {
781
+ const close = host.indexOf("]");
782
+ if (close < 0) {
783
+ return undefined;
784
+ }
785
+ host = host.slice(0, close + 1);
786
+ } else {
787
+ const portSep = host.indexOf(":");
788
+ if (portSep > -1) {
789
+ host = host.slice(0, portSep);
790
+ }
791
+ }
792
+ if (!host || host.startsWith("-")) {
793
+ return undefined;
794
+ }
795
+ return host.toLowerCase();
796
+ }
797
+
601
798
  /**
602
799
  * Clone a git repository into a temporary directory.
603
800
  *
@@ -606,6 +803,11 @@ export function validateAndRejectGitSource(filePath) {
606
803
  * @returns {string} cloned directory path
607
804
  */
608
805
  export function gitClone(repoUrl, branch = null) {
806
+ // Callers validate first, but git must never see a remote that fails it.
807
+ const validationError = validateAndRejectGitSource(repoUrl);
808
+ if (validationError) {
809
+ throw new Error(`${validationError.error}: ${validationError.details}`);
810
+ }
609
811
  let baseDirName = path.basename(repoUrl);
610
812
  if (!/^[a-zA-Z0-9_-]+$/.test(baseDirName)) {
611
813
  baseDirName = "repo-";
@@ -635,6 +837,8 @@ export function gitClone(repoUrl, branch = null) {
635
837
  "safe.bareRepository=explicit",
636
838
  "-c",
637
839
  "core.hooksPath=/dev/null",
840
+ "-c",
841
+ "core.symlinks=false",
638
842
  "clone",
639
843
  "--template=",
640
844
  repoUrl,
@@ -735,7 +939,7 @@ export function findGitRefForPurlVersion(repoUrl, purlResolution) {
735
939
  "--",
736
940
  repoUrl,
737
941
  ],
738
- {},
942
+ { noRepository: true },
739
943
  );
740
944
  if (result.status !== 0 || !result.stdout) {
741
945
  return undefined;