@cyclonedx/cdxgen 12.2.1 → 12.3.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 (170) hide show
  1. package/README.md +239 -90
  2. package/bin/audit.js +191 -0
  3. package/bin/cdxgen.js +513 -167
  4. package/bin/convert.js +99 -0
  5. package/bin/evinse.js +23 -0
  6. package/bin/repl.js +339 -8
  7. package/bin/sign.js +8 -0
  8. package/bin/validate.js +8 -0
  9. package/bin/verify.js +8 -0
  10. package/data/container-knowledge-index.json +125 -0
  11. package/data/gtfobins-index.json +6296 -0
  12. package/data/lolbas-index.json +150 -0
  13. package/data/queries-darwin.json +63 -3
  14. package/data/queries-win.json +45 -3
  15. package/data/queries.json +74 -2
  16. package/data/rules/chrome-extensions.yaml +240 -0
  17. package/data/rules/ci-permissions.yaml +478 -18
  18. package/data/rules/container-risk.yaml +270 -0
  19. package/data/rules/obom-runtime.yaml +891 -0
  20. package/data/rules/package-integrity.yaml +49 -0
  21. package/data/spdx-export.schema.json +6794 -0
  22. package/data/spdx-model-v3.0.1.jsonld +15999 -0
  23. package/lib/audit/index.js +1924 -0
  24. package/lib/audit/index.poku.js +1488 -0
  25. package/lib/audit/progress.js +137 -0
  26. package/lib/audit/progress.poku.js +188 -0
  27. package/lib/audit/reporters.js +618 -0
  28. package/lib/audit/scoring.js +310 -0
  29. package/lib/audit/scoring.poku.js +341 -0
  30. package/lib/audit/targets.js +260 -0
  31. package/lib/audit/targets.poku.js +331 -0
  32. package/lib/cli/index.js +154 -11
  33. package/lib/cli/index.poku.js +251 -0
  34. package/lib/helpers/analyzer.js +446 -2
  35. package/lib/helpers/analyzer.poku.js +72 -1
  36. package/lib/helpers/annotationFormatter.js +49 -0
  37. package/lib/helpers/annotationFormatter.poku.js +44 -0
  38. package/lib/helpers/bomUtils.js +36 -0
  39. package/lib/helpers/bomUtils.poku.js +51 -0
  40. package/lib/helpers/caxa.js +2 -2
  41. package/lib/helpers/chromextutils.js +1153 -0
  42. package/lib/helpers/chromextutils.poku.js +493 -0
  43. package/lib/helpers/ciParsers/githubActions.js +1632 -45
  44. package/lib/helpers/ciParsers/githubActions.poku.js +853 -1
  45. package/lib/helpers/containerRisk.js +186 -0
  46. package/lib/helpers/containerRisk.poku.js +52 -0
  47. package/lib/helpers/display.js +241 -59
  48. package/lib/helpers/display.poku.js +162 -2
  49. package/lib/helpers/exportUtils.js +123 -0
  50. package/lib/helpers/exportUtils.poku.js +60 -0
  51. package/lib/helpers/formulationParsers.js +69 -0
  52. package/lib/helpers/formulationParsers.poku.js +44 -0
  53. package/lib/helpers/gtfobins.js +189 -0
  54. package/lib/helpers/gtfobins.poku.js +49 -0
  55. package/lib/helpers/lolbas.js +267 -0
  56. package/lib/helpers/lolbas.poku.js +39 -0
  57. package/lib/helpers/osqueryTransform.js +84 -0
  58. package/lib/helpers/osqueryTransform.poku.js +49 -0
  59. package/lib/helpers/provenanceUtils.js +193 -0
  60. package/lib/helpers/provenanceUtils.poku.js +145 -0
  61. package/lib/helpers/pylockutils.js +281 -0
  62. package/lib/helpers/pylockutils.poku.js +48 -0
  63. package/lib/helpers/registryProvenance.js +793 -0
  64. package/lib/helpers/registryProvenance.poku.js +452 -0
  65. package/lib/helpers/source.js +1267 -0
  66. package/lib/helpers/source.poku.js +771 -0
  67. package/lib/helpers/spdxUtils.js +97 -0
  68. package/lib/helpers/spdxUtils.poku.js +70 -0
  69. package/lib/helpers/unicodeScan.js +147 -0
  70. package/lib/helpers/unicodeScan.poku.js +45 -0
  71. package/lib/helpers/utils.js +700 -128
  72. package/lib/helpers/utils.poku.js +877 -80
  73. package/lib/managers/binary.js +29 -5
  74. package/lib/managers/docker.js +179 -52
  75. package/lib/managers/docker.poku.js +327 -28
  76. package/lib/managers/oci.js +107 -23
  77. package/lib/managers/oci.poku.js +132 -0
  78. package/lib/server/openapi.yaml +17 -0
  79. package/lib/server/server.js +225 -336
  80. package/lib/server/server.poku.js +16 -10
  81. package/lib/stages/postgen/annotator.js +7 -0
  82. package/lib/stages/postgen/annotator.poku.js +40 -0
  83. package/lib/stages/postgen/auditBom.js +19 -3
  84. package/lib/stages/postgen/auditBom.poku.js +1729 -67
  85. package/lib/stages/postgen/postgen.js +40 -0
  86. package/lib/stages/postgen/postgen.poku.js +47 -0
  87. package/lib/stages/postgen/ruleEngine.js +80 -2
  88. package/lib/stages/postgen/spdxConverter.js +796 -0
  89. package/lib/stages/postgen/spdxConverter.poku.js +341 -0
  90. package/lib/validator/bomValidator.js +232 -0
  91. package/lib/validator/bomValidator.poku.js +70 -0
  92. package/lib/validator/complianceRules.js +70 -7
  93. package/lib/validator/complianceRules.poku.js +30 -0
  94. package/lib/validator/reporters/annotations.js +2 -2
  95. package/lib/validator/reporters/console.js +11 -0
  96. package/lib/validator/reporters.poku.js +13 -0
  97. package/package.json +10 -7
  98. package/types/bin/audit.d.ts +3 -0
  99. package/types/bin/audit.d.ts.map +1 -0
  100. package/types/bin/convert.d.ts +3 -0
  101. package/types/bin/convert.d.ts.map +1 -0
  102. package/types/bin/repl.d.ts.map +1 -1
  103. package/types/lib/audit/index.d.ts +115 -0
  104. package/types/lib/audit/index.d.ts.map +1 -0
  105. package/types/lib/audit/progress.d.ts +27 -0
  106. package/types/lib/audit/progress.d.ts.map +1 -0
  107. package/types/lib/audit/reporters.d.ts +35 -0
  108. package/types/lib/audit/reporters.d.ts.map +1 -0
  109. package/types/lib/audit/scoring.d.ts +35 -0
  110. package/types/lib/audit/scoring.d.ts.map +1 -0
  111. package/types/lib/audit/targets.d.ts +63 -0
  112. package/types/lib/audit/targets.d.ts.map +1 -0
  113. package/types/lib/cli/index.d.ts +8 -0
  114. package/types/lib/cli/index.d.ts.map +1 -1
  115. package/types/lib/helpers/analyzer.d.ts +13 -0
  116. package/types/lib/helpers/analyzer.d.ts.map +1 -1
  117. package/types/lib/helpers/annotationFormatter.d.ts +23 -0
  118. package/types/lib/helpers/annotationFormatter.d.ts.map +1 -0
  119. package/types/lib/helpers/bomUtils.d.ts +5 -0
  120. package/types/lib/helpers/bomUtils.d.ts.map +1 -0
  121. package/types/lib/helpers/chromextutils.d.ts +97 -0
  122. package/types/lib/helpers/chromextutils.d.ts.map +1 -0
  123. package/types/lib/helpers/ciParsers/githubActions.d.ts +3 -8
  124. package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -1
  125. package/types/lib/helpers/containerRisk.d.ts +17 -0
  126. package/types/lib/helpers/containerRisk.d.ts.map +1 -0
  127. package/types/lib/helpers/display.d.ts +4 -1
  128. package/types/lib/helpers/display.d.ts.map +1 -1
  129. package/types/lib/helpers/exportUtils.d.ts +40 -0
  130. package/types/lib/helpers/exportUtils.d.ts.map +1 -0
  131. package/types/lib/helpers/formulationParsers.d.ts.map +1 -1
  132. package/types/lib/helpers/gtfobins.d.ts +17 -0
  133. package/types/lib/helpers/gtfobins.d.ts.map +1 -0
  134. package/types/lib/helpers/lolbas.d.ts +16 -0
  135. package/types/lib/helpers/lolbas.d.ts.map +1 -0
  136. package/types/lib/helpers/osqueryTransform.d.ts +7 -0
  137. package/types/lib/helpers/osqueryTransform.d.ts.map +1 -0
  138. package/types/lib/helpers/provenanceUtils.d.ts +90 -0
  139. package/types/lib/helpers/provenanceUtils.d.ts.map +1 -0
  140. package/types/lib/helpers/pylockutils.d.ts +51 -0
  141. package/types/lib/helpers/pylockutils.d.ts.map +1 -0
  142. package/types/lib/helpers/registryProvenance.d.ts +17 -0
  143. package/types/lib/helpers/registryProvenance.d.ts.map +1 -0
  144. package/types/lib/helpers/source.d.ts +141 -0
  145. package/types/lib/helpers/source.d.ts.map +1 -0
  146. package/types/lib/helpers/spdxUtils.d.ts +2 -0
  147. package/types/lib/helpers/spdxUtils.d.ts.map +1 -0
  148. package/types/lib/helpers/unicodeScan.d.ts +46 -0
  149. package/types/lib/helpers/unicodeScan.d.ts.map +1 -0
  150. package/types/lib/helpers/utils.d.ts +29 -11
  151. package/types/lib/helpers/utils.d.ts.map +1 -1
  152. package/types/lib/managers/binary.d.ts.map +1 -1
  153. package/types/lib/managers/docker.d.ts.map +1 -1
  154. package/types/lib/managers/oci.d.ts.map +1 -1
  155. package/types/lib/server/server.d.ts +0 -36
  156. package/types/lib/server/server.d.ts.map +1 -1
  157. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  158. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  159. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  160. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  161. package/types/lib/stages/postgen/spdxConverter.d.ts +11 -0
  162. package/types/lib/stages/postgen/spdxConverter.d.ts.map +1 -0
  163. package/types/lib/validator/bomValidator.d.ts +1 -0
  164. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  165. package/types/lib/validator/complianceRules.d.ts.map +1 -1
  166. package/types/lib/validator/reporters/console.d.ts.map +1 -1
  167. package/types/bin/dependencies.d.ts +0 -3
  168. package/types/bin/dependencies.d.ts.map +0 -1
  169. package/types/bin/licenses.d.ts +0 -3
  170. package/types/bin/licenses.d.ts.map +0 -1
@@ -1,28 +1,22 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
1
4
  import process from "node:process";
2
5
 
3
- import { assert, beforeEach, describe, it, skip } from "poku";
6
+ import esmock from "esmock";
7
+ import { assert, beforeEach, describe, it } from "poku";
8
+ import sinon from "sinon";
9
+ import { create as createTar } from "tar";
4
10
 
5
11
  import {
6
12
  addSkippedSrcFiles,
13
+ exportArchive,
7
14
  exportImage,
8
- getConnection,
9
- getImage,
15
+ extractFromManifest,
10
16
  isWin,
11
17
  parseImageName,
12
- removeImage,
13
18
  } from "./docker.js";
14
19
 
15
- if (process.env.CI === "true" && (isWin || process.platform === "darwin")) {
16
- skip("Skipping Docker tests on Windows and Mac");
17
- }
18
-
19
- await it("docker connection", async () => {
20
- const dockerConn = await getConnection();
21
- if (dockerConn) {
22
- assert.ok(dockerConn);
23
- }
24
- });
25
-
26
20
  it("parseImageName tests", () => {
27
21
  if (isWin && process.env.CI === "true") {
28
22
  return;
@@ -127,25 +121,330 @@ it("parseImageName tests", () => {
127
121
  );
128
122
  });
129
123
 
130
- await it("docker getImage", async () => {
131
- if (isWin && process.env.CI === "true") {
132
- return;
124
+ async function loadDockerModule({ clientResponse, utilsOverrides } = {}) {
125
+ const dockerClient = sinon.stub().resolves(
126
+ clientResponse || {
127
+ Id: "sha256:hello-world",
128
+ RepoTags: ["hello-world:latest"],
129
+ },
130
+ );
131
+ dockerClient.stream = sinon.stub();
132
+ const gotStub = {
133
+ extend: sinon.stub().returns(dockerClient),
134
+ get: sinon.stub().resolves({ body: "OK" }),
135
+ };
136
+ const utilsStub = {
137
+ DEBUG_MODE: false,
138
+ extractPathEnv: sinon.stub().returns([]),
139
+ getAllFiles: sinon.stub().returns([]),
140
+ getTmpDir: sinon.stub().returns("/tmp"),
141
+ safeExistsSync: sinon.stub().returns(false),
142
+ safeMkdirSync: sinon.stub(),
143
+ safeSpawnSync: sinon.stub().returns({ status: 1, stdout: "", stderr: "" }),
144
+ ...utilsOverrides,
145
+ };
146
+ const dockerModule = await esmock("./docker.js", {
147
+ got: { default: gotStub },
148
+ "../helpers/utils.js": utilsStub,
149
+ });
150
+ return { dockerClient, dockerModule, gotStub, utilsStub };
151
+ }
152
+
153
+ await it("docker connection uses the detected daemon client", async () => {
154
+ const { dockerModule, gotStub, dockerClient } = await loadDockerModule();
155
+ const dockerConn = await dockerModule.getConnection();
156
+ assert.strictEqual(dockerConn, dockerClient);
157
+ sinon.assert.calledOnce(gotStub.get);
158
+ sinon.assert.calledOnce(gotStub.extend);
159
+ });
160
+
161
+ await it("docker getImage returns inspect data from the daemon client", async () => {
162
+ const inspectData = {
163
+ Id: "sha256:hello-world",
164
+ RepoTags: ["hello-world:latest"],
165
+ };
166
+ const { dockerModule, dockerClient } = await loadDockerModule({
167
+ clientResponse: inspectData,
168
+ });
169
+ const imageData = await dockerModule.getImage("hello-world:latest");
170
+ assert.deepStrictEqual(imageData, inspectData);
171
+ sinon.assert.calledWith(
172
+ dockerClient,
173
+ "images/hello-world:latest/json",
174
+ sinon.match.has("method", "GET"),
175
+ );
176
+ });
177
+
178
+ await it("docker getImage falls back to the daemon client when cli inspect fails", async () => {
179
+ const originalDockerUseCli = process.env.DOCKER_USE_CLI;
180
+ process.env.DOCKER_USE_CLI = "1";
181
+ try {
182
+ const inspectData = {
183
+ Id: "sha256:hello-world",
184
+ RepoTags: ["hello-world:latest"],
185
+ };
186
+ const { dockerModule, dockerClient } = await loadDockerModule({
187
+ clientResponse: inspectData,
188
+ });
189
+ const imageData = await dockerModule.getImage("hello-world:latest");
190
+ assert.deepStrictEqual(imageData, inspectData);
191
+ sinon.assert.calledWith(
192
+ dockerClient,
193
+ "images/hello-world:latest/json",
194
+ sinon.match.has("method", "GET"),
195
+ );
196
+ } finally {
197
+ if (originalDockerUseCli === undefined) {
198
+ delete process.env.DOCKER_USE_CLI;
199
+ } else {
200
+ process.env.DOCKER_USE_CLI = originalDockerUseCli;
201
+ }
133
202
  }
134
- const imageData = await getImage("hello-world:latest");
135
- if (imageData) {
136
- const removeData = await removeImage("hello-world:latest");
137
- if (removeData) {
138
- assert.ok(removeData);
203
+ });
204
+
205
+ await it("docker getImage uses nerdctl when DOCKER_CMD is configured", async () => {
206
+ const originalDockerCmd = process.env.DOCKER_CMD;
207
+ const originalDockerUseCli = process.env.DOCKER_USE_CLI;
208
+ process.env.DOCKER_CMD = "nerdctl";
209
+ delete process.env.DOCKER_USE_CLI;
210
+ try {
211
+ const inspectData = {
212
+ Id: "sha256:hello-world",
213
+ RepoTags: ["hello-world:latest"],
214
+ };
215
+ const safeSpawnSync = sinon.stub();
216
+ safeSpawnSync
217
+ .onCall(0)
218
+ .returns({
219
+ status: 0,
220
+ stdout: '{"Repository":"hello-world","Tag":"latest"}\n',
221
+ stderr: "",
222
+ })
223
+ .onCall(1)
224
+ .returns({
225
+ status: 0,
226
+ stdout: JSON.stringify([inspectData]),
227
+ stderr: "",
228
+ });
229
+ const { dockerModule, utilsStub } = await loadDockerModule({
230
+ clientResponse: inspectData,
231
+ utilsOverrides: {
232
+ safeSpawnSync,
233
+ },
234
+ });
235
+ const imageData = await dockerModule.getImage("hello-world:latest");
236
+ assert.deepStrictEqual(imageData, inspectData);
237
+ sinon.assert.calledWithExactly(safeSpawnSync, "nerdctl", [
238
+ "images",
239
+ "--format=json",
240
+ ]);
241
+ sinon.assert.calledWithExactly(safeSpawnSync, "nerdctl", [
242
+ "inspect",
243
+ "hello-world:latest",
244
+ ]);
245
+ sinon.assert.notCalled(utilsStub.safeMkdirSync);
246
+ } finally {
247
+ if (originalDockerCmd === undefined) {
248
+ delete process.env.DOCKER_CMD;
249
+ } else {
250
+ process.env.DOCKER_CMD = originalDockerCmd;
251
+ }
252
+ if (originalDockerUseCli === undefined) {
253
+ delete process.env.DOCKER_USE_CLI;
254
+ } else {
255
+ process.env.DOCKER_USE_CLI = originalDockerUseCli;
139
256
  }
140
257
  }
141
258
  });
142
259
 
143
- await it("docker getImage", async () => {
144
- if (isWin && process.env.CI === "true") {
145
- return;
260
+ await it("docker exportImage ignores local directories", async () => {
261
+ const imageData = await exportImage(".");
262
+ assert.strictEqual(imageData, undefined);
263
+ });
264
+
265
+ await it("extractFromManifest derives PATH metadata from archive config", async () => {
266
+ const tempDir = mkdtempSync(join(tmpdir(), "cdxgen-docker-"));
267
+ try {
268
+ const allLayersExplodedDir = join(tempDir, "all-layers");
269
+ const manifestFile = join(tempDir, "manifest.json");
270
+ mkdirSync(allLayersExplodedDir, { recursive: true });
271
+ writeFileSync(
272
+ manifestFile,
273
+ JSON.stringify([
274
+ {
275
+ Config: "blobs/sha256/config.json",
276
+ Layers: ["blobs/sha256/layer.tar"],
277
+ },
278
+ ]),
279
+ );
280
+ mkdirSync(join(tempDir, "blobs", "sha256"), { recursive: true });
281
+ writeFileSync(
282
+ join(tempDir, "blobs", "sha256", "config.json"),
283
+ JSON.stringify({
284
+ config: {
285
+ Env: [
286
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
287
+ ],
288
+ WorkingDir: "/work",
289
+ },
290
+ }),
291
+ );
292
+ writeFileSync(join(tempDir, "blobs", "sha256", "layer.tar"), "");
293
+
294
+ const exportData = await extractFromManifest(
295
+ manifestFile,
296
+ {},
297
+ tempDir,
298
+ allLayersExplodedDir,
299
+ {},
300
+ );
301
+
302
+ assert.deepStrictEqual(exportData.binPaths, [
303
+ "/usr/local/sbin",
304
+ "/usr/local/bin",
305
+ "/usr/sbin",
306
+ "/usr/bin",
307
+ "/sbin",
308
+ "/bin",
309
+ ]);
310
+ assert.deepStrictEqual(exportData.inspectData?.Config?.Env, [
311
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
312
+ ]);
313
+ assert.strictEqual(
314
+ exportData.lastWorkingDir,
315
+ join(allLayersExplodedDir, "/work"),
316
+ );
317
+ } finally {
318
+ rmSync(tempDir, { force: true, recursive: true });
319
+ }
320
+ });
321
+
322
+ await it("extractFromManifest resolves OCI index manifests", async () => {
323
+ const tempDir = mkdtempSync(join(tmpdir(), "cdxgen-docker-"));
324
+ try {
325
+ const allLayersExplodedDir = join(tempDir, "all-layers");
326
+ const manifestFile = join(tempDir, "index.json");
327
+ mkdirSync(allLayersExplodedDir, { recursive: true });
328
+ mkdirSync(join(tempDir, "blobs", "sha256"), { recursive: true });
329
+ writeFileSync(
330
+ manifestFile,
331
+ JSON.stringify({
332
+ schemaVersion: 2,
333
+ manifests: [
334
+ {
335
+ digest: "sha256:manifest-blob",
336
+ mediaType: "application/vnd.oci.image.manifest.v1+json",
337
+ },
338
+ ],
339
+ }),
340
+ );
341
+ writeFileSync(
342
+ join(tempDir, "blobs", "sha256", "manifest-blob"),
343
+ JSON.stringify({
344
+ schemaVersion: 2,
345
+ config: {
346
+ digest: "sha256:config-blob",
347
+ },
348
+ layers: [
349
+ {
350
+ digest: "sha256:layer-blob",
351
+ },
352
+ ],
353
+ }),
354
+ );
355
+ writeFileSync(
356
+ join(tempDir, "blobs", "sha256", "config-blob"),
357
+ JSON.stringify({
358
+ config: {
359
+ Env: ["PATH=/usr/local/bin:/usr/bin:/bin"],
360
+ WorkingDir: "/workspace",
361
+ },
362
+ }),
363
+ );
364
+ writeFileSync(join(tempDir, "blobs", "sha256", "layer-blob"), "");
365
+
366
+ const exportData = await extractFromManifest(
367
+ manifestFile,
368
+ {},
369
+ tempDir,
370
+ allLayersExplodedDir,
371
+ {},
372
+ );
373
+
374
+ assert.deepStrictEqual(exportData.binPaths, [
375
+ "/usr/local/bin",
376
+ "/usr/bin",
377
+ "/bin",
378
+ ]);
379
+ assert.deepStrictEqual(exportData.inspectData?.Config?.Env, [
380
+ "PATH=/usr/local/bin:/usr/bin:/bin",
381
+ ]);
382
+ assert.strictEqual(
383
+ exportData.lastWorkingDir,
384
+ join(allLayersExplodedDir, "/workspace"),
385
+ );
386
+ } finally {
387
+ rmSync(tempDir, { force: true, recursive: true });
388
+ }
389
+ });
390
+
391
+ await it("exportArchive derives PATH metadata from blobs-only podman archives", async () => {
392
+ const tempDir = mkdtempSync(join(tmpdir(), "cdxgen-docker-"));
393
+ try {
394
+ const archiveDir = join(tempDir, "archive");
395
+ const archiveFile = join(tempDir, "podman-archive.tar");
396
+ mkdirSync(join(archiveDir, "blobs", "sha256"), { recursive: true });
397
+ writeFileSync(
398
+ join(archiveDir, "blobs", "sha256", "manifest-blob"),
399
+ JSON.stringify({
400
+ schemaVersion: 2,
401
+ config: {
402
+ digest: "sha256:config-blob",
403
+ },
404
+ layers: [
405
+ {
406
+ digest: "sha256:layer-blob",
407
+ },
408
+ ],
409
+ }),
410
+ );
411
+ writeFileSync(
412
+ join(archiveDir, "blobs", "sha256", "config-blob"),
413
+ JSON.stringify({
414
+ config: {
415
+ Env: ["PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/bin"],
416
+ WorkingDir: "/app",
417
+ },
418
+ }),
419
+ );
420
+ writeFileSync(join(archiveDir, "blobs", "sha256", "layer-blob"), "");
421
+ await createTar(
422
+ {
423
+ cwd: archiveDir,
424
+ file: archiveFile,
425
+ portable: true,
426
+ },
427
+ ["blobs"],
428
+ );
429
+
430
+ const exportData = await exportArchive(archiveFile, {});
431
+
432
+ assert.deepStrictEqual(exportData.binPaths, [
433
+ "/usr/local/sbin",
434
+ "/usr/local/bin",
435
+ "/usr/bin",
436
+ "/bin",
437
+ ]);
438
+ assert.deepStrictEqual(exportData.inspectData?.Config?.Env, [
439
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/bin",
440
+ ]);
441
+ assert.strictEqual(
442
+ exportData.lastWorkingDir,
443
+ join(exportData.allLayersExplodedDir, "app"),
444
+ );
445
+ } finally {
446
+ rmSync(tempDir, { force: true, recursive: true });
146
447
  }
147
- const imageData = await exportImage("hello-world:latest");
148
- assert.ok(imageData);
149
448
  });
150
449
 
151
450
  describe("addSkippedSrcFiles tests", () => {
@@ -2,6 +2,7 @@ import { Buffer } from "node:buffer";
2
2
  import fs from "node:fs";
3
3
  import { arch } from "node:os";
4
4
 
5
+ import { isCycloneDxBom } from "../helpers/bomUtils.js";
5
6
  import {
6
7
  getAllFiles,
7
8
  getTmpDir,
@@ -9,6 +10,100 @@ import {
9
10
  safeSpawnSync,
10
11
  } from "../helpers/utils.js";
11
12
 
13
+ const ORAS_CREATED_ANNOTATION = "org.opencontainers.image.created";
14
+
15
+ function getManifestDescriptors(manifestObj) {
16
+ if (Array.isArray(manifestObj?.manifests)) {
17
+ return manifestObj.manifests;
18
+ }
19
+ if (Array.isArray(manifestObj?.referrers)) {
20
+ return manifestObj.referrers;
21
+ }
22
+ return [];
23
+ }
24
+
25
+ function getRepositoryRef(image) {
26
+ let repositoryRef = image;
27
+ const digestIndex = repositoryRef.indexOf("@");
28
+ if (digestIndex !== -1) {
29
+ repositoryRef = repositoryRef.slice(0, digestIndex);
30
+ }
31
+ const lastSlashIndex = repositoryRef.lastIndexOf("/");
32
+ const tagIndex = repositoryRef.lastIndexOf(":");
33
+ if (tagIndex > lastSlashIndex) {
34
+ repositoryRef = repositoryRef.slice(0, tagIndex);
35
+ }
36
+ return repositoryRef;
37
+ }
38
+
39
+ function getManifestImageRef(image, manifest) {
40
+ if (manifest?.reference) {
41
+ return manifest.reference;
42
+ }
43
+ if (manifest?.digest) {
44
+ return `${getRepositoryRef(image)}@${manifest.digest}`;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ function getManifestCreatedAt(manifest) {
50
+ const createdAt = manifest?.annotations?.[ORAS_CREATED_ANNOTATION];
51
+ if (!createdAt) {
52
+ return undefined;
53
+ }
54
+ const createdAtTimestamp = Date.parse(createdAt);
55
+ if (Number.isNaN(createdAtTimestamp)) {
56
+ return undefined;
57
+ }
58
+ return createdAtTimestamp;
59
+ }
60
+
61
+ function selectManifestImageRef(image, manifestObj) {
62
+ const manifestDescriptors = getManifestDescriptors(manifestObj);
63
+ const candidates = manifestDescriptors
64
+ .map((manifest, index) => {
65
+ const imageRef = getManifestImageRef(image, manifest);
66
+ if (!imageRef) {
67
+ return undefined;
68
+ }
69
+ return {
70
+ createdAt: getManifestCreatedAt(manifest),
71
+ imageRef,
72
+ index,
73
+ };
74
+ })
75
+ .filter(Boolean);
76
+ if (!candidates.length) {
77
+ return undefined;
78
+ }
79
+ candidates.sort((a, b) => {
80
+ if (a.createdAt !== undefined || b.createdAt !== undefined) {
81
+ if (a.createdAt === undefined) {
82
+ return 1;
83
+ }
84
+ if (b.createdAt === undefined) {
85
+ return -1;
86
+ }
87
+ if (b.createdAt !== a.createdAt) {
88
+ return b.createdAt - a.createdAt;
89
+ }
90
+ }
91
+ return b.index - a.index;
92
+ });
93
+ return candidates[0]?.imageRef;
94
+ }
95
+
96
+ function getBomFiles(tmpDir) {
97
+ let bomFiles = getAllFiles(tmpDir, "**/*.{bom,cdx}.json");
98
+ if (!bomFiles.length) {
99
+ bomFiles = getAllFiles(tmpDir, "**/bom.json");
100
+ }
101
+ if (!bomFiles.length) {
102
+ bomFiles = getAllFiles(tmpDir, "**/*.json");
103
+ }
104
+ return bomFiles;
105
+ }
106
+
12
107
  /**
13
108
  * Retrieves a CycloneDX BOM attached to an OCI image using the `oras` CLI tool.
14
109
  * Discovers SBOM attachments via `oras discover`, pulls the first matching
@@ -50,26 +145,8 @@ export function getBomWithOras(image, platform = undefined) {
50
145
  const out = Buffer.from(result.stdout).toString();
51
146
  try {
52
147
  const manifestObj = JSON.parse(out);
53
- let manifest;
54
- if (manifestObj?.manifests) {
55
- if (
56
- manifestObj?.manifests?.length &&
57
- Array.isArray(manifestObj.manifests) &&
58
- manifestObj.manifests[0]?.reference
59
- ) {
60
- manifest = manifestObj.manifests[0];
61
- }
62
- } else if (manifestObj?.referrers) {
63
- if (
64
- manifestObj?.referrers?.length &&
65
- Array.isArray(manifestObj.referrers) &&
66
- manifestObj.referrers[0]?.reference
67
- ) {
68
- manifest = manifestObj.referrers[0];
69
- }
70
- }
71
- if (manifest != null) {
72
- const imageRef = manifest.reference;
148
+ const imageRef = selectManifestImageRef(image, manifestObj);
149
+ if (imageRef) {
73
150
  const tmpDir = getTmpDir();
74
151
  result = safeSpawnSync("oras", ["pull", imageRef, "-o", tmpDir], {
75
152
  shell: isWin,
@@ -80,9 +157,16 @@ export function getBomWithOras(image, platform = undefined) {
80
157
  );
81
158
  return undefined;
82
159
  }
83
- const bomFiles = getAllFiles(tmpDir, "**/*.{bom,cdx}.json");
84
- if (bomFiles.length) {
85
- return JSON.parse(fs.readFileSync(bomFiles.pop(), "utf8"));
160
+ const bomFiles = getBomFiles(tmpDir);
161
+ for (const bomFile of bomFiles) {
162
+ try {
163
+ const bomJson = JSON.parse(fs.readFileSync(bomFile, "utf8"));
164
+ if (isCycloneDxBom(bomJson)) {
165
+ return bomJson;
166
+ }
167
+ } catch {
168
+ // Ignore unrelated or malformed JSON files pulled alongside the SBOM.
169
+ }
86
170
  }
87
171
  } else {
88
172
  console.log(`${image} does not contain any SBOM attachment!`);
@@ -0,0 +1,132 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import esmock from "esmock";
6
+ import { assert, describe, it } from "poku";
7
+ import sinon from "sinon";
8
+
9
+ async function loadOciModule({ getAllFiles, getTmpDir, safeSpawnSync }) {
10
+ return esmock("./oci.js", {
11
+ "../helpers/utils.js": {
12
+ getAllFiles,
13
+ getTmpDir,
14
+ isWin: false,
15
+ safeSpawnSync,
16
+ },
17
+ });
18
+ }
19
+
20
+ describe("getBomWithOras()", () => {
21
+ it("pulls the newest digest-only SBOM referrer", async () => {
22
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-oci-poku-"));
23
+ const bomFile = path.join(tmpDir, "sbom-oci-image.cdx.json");
24
+ const bomJson = {
25
+ bomFormat: "CycloneDX",
26
+ specVersion: "1.6",
27
+ signature: {
28
+ algorithm: "RS512",
29
+ value: "signed",
30
+ },
31
+ };
32
+ const safeSpawnSync = sinon.stub();
33
+ const getAllFiles = sinon.stub();
34
+ try {
35
+ writeFileSync(bomFile, JSON.stringify(bomJson), "utf8");
36
+ safeSpawnSync
37
+ .onCall(0)
38
+ .returns({
39
+ status: 0,
40
+ stdout: JSON.stringify({
41
+ referrers: [
42
+ {
43
+ digest: "sha256:older",
44
+ annotations: {
45
+ "org.opencontainers.image.created": "2026-04-29T01:26:38Z",
46
+ },
47
+ },
48
+ {
49
+ digest: "sha256:newer",
50
+ annotations: {
51
+ "org.opencontainers.image.created": "2026-04-29T02:00:20Z",
52
+ },
53
+ },
54
+ ],
55
+ }),
56
+ })
57
+ .onCall(1)
58
+ .returns({
59
+ status: 0,
60
+ stdout: "",
61
+ });
62
+ getAllFiles.withArgs(tmpDir, "**/*.{bom,cdx}.json").returns([bomFile]);
63
+ const { getBomWithOras } = await loadOciModule({
64
+ getAllFiles,
65
+ getTmpDir: sinon.stub().returns(tmpDir),
66
+ safeSpawnSync,
67
+ });
68
+ const result = getBomWithOras("ghcr.io/cdxgen/alpine-python313:master");
69
+ assert.deepStrictEqual(result, bomJson);
70
+ sinon.assert.calledWith(
71
+ safeSpawnSync,
72
+ "oras",
73
+ ["pull", "ghcr.io/cdxgen/alpine-python313@sha256:newer", "-o", tmpDir],
74
+ { shell: false },
75
+ );
76
+ } finally {
77
+ rmSync(tmpDir, { force: true, recursive: true });
78
+ }
79
+ });
80
+
81
+ it("falls back to bom.json when oras pulls a plain BOM filename", async () => {
82
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-oci-poku-"));
83
+ const bomFile = path.join(tmpDir, "bom.json");
84
+ const bomJson = {
85
+ bomFormat: "CycloneDX",
86
+ specVersion: "1.6",
87
+ signature: {
88
+ algorithm: "RS512",
89
+ value: "signed",
90
+ },
91
+ };
92
+ const safeSpawnSync = sinon.stub();
93
+ const getAllFiles = sinon.stub();
94
+ try {
95
+ writeFileSync(bomFile, JSON.stringify(bomJson), "utf8");
96
+ safeSpawnSync
97
+ .onCall(0)
98
+ .returns({
99
+ status: 0,
100
+ stdout: JSON.stringify({
101
+ manifests: [
102
+ {
103
+ reference: "ghcr.io/cdxgen/demo@sha256:latest",
104
+ },
105
+ ],
106
+ }),
107
+ })
108
+ .onCall(1)
109
+ .returns({
110
+ status: 0,
111
+ stdout: "",
112
+ });
113
+ getAllFiles.withArgs(tmpDir, "**/*.{bom,cdx}.json").returns([]);
114
+ getAllFiles.withArgs(tmpDir, "**/bom.json").returns([bomFile]);
115
+ const { getBomWithOras } = await loadOciModule({
116
+ getAllFiles,
117
+ getTmpDir: sinon.stub().returns(tmpDir),
118
+ safeSpawnSync,
119
+ });
120
+ const result = getBomWithOras("ghcr.io/cdxgen/demo:master");
121
+ assert.deepStrictEqual(result, bomJson);
122
+ sinon.assert.calledWith(
123
+ safeSpawnSync,
124
+ "oras",
125
+ ["pull", "ghcr.io/cdxgen/demo@sha256:latest", "-o", tmpDir],
126
+ { shell: false },
127
+ );
128
+ } finally {
129
+ rmSync(tmpDir, { force: true, recursive: true });
130
+ }
131
+ });
132
+ });
@@ -134,6 +134,11 @@ paths:
134
134
  required: false
135
135
  schema:
136
136
  $ref: '#/components/schemas/CDXGEN/properties/specVersion'
137
+ - name: format
138
+ in: query
139
+ required: false
140
+ schema:
141
+ $ref: '#/components/schemas/CDXGEN/properties/format'
137
142
  - name: filter
138
143
  in: query
139
144
  required: false
@@ -298,6 +303,18 @@ components:
298
303
  type: string
299
304
  description: CycloneDX Specification version to use
300
305
  default: "1.7"
306
+ format:
307
+ type: array
308
+ items:
309
+ type: string
310
+ enum:
311
+ - cyclonedx
312
+ - cdx
313
+ - spdx
314
+ - spdx-json
315
+ - spdx3
316
+ - spdx3-json
317
+ description: Export format(s). Supports cyclonedx and spdx.
301
318
  filter:
302
319
  type: array
303
320
  items: