@cyclonedx/cdxgen 12.3.2 → 12.4.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 (182) hide show
  1. package/README.md +70 -22
  2. package/bin/audit.js +21 -7
  3. package/bin/cdxgen.js +238 -116
  4. package/bin/convert.js +28 -13
  5. package/bin/hbom.js +490 -0
  6. package/bin/repl.js +580 -29
  7. package/bin/validate.js +34 -4
  8. package/bin/verify.js +40 -5
  9. package/data/README.md +298 -25
  10. package/data/component-tags.json +6 -0
  11. package/data/crypto-oid.json +16 -0
  12. package/data/predictive-audit-allowlist.json +11 -0
  13. package/data/queries-darwin.json +12 -1
  14. package/data/queries-win.json +7 -1
  15. package/data/queries.json +39 -2
  16. package/data/rules/ai-agent-governance.yaml +16 -0
  17. package/data/rules/asar-archives.yaml +150 -0
  18. package/data/rules/chrome-extensions.yaml +8 -0
  19. package/data/rules/ci-permissions.yaml +171 -15
  20. package/data/rules/container-risk.yaml +14 -7
  21. package/data/rules/dependency-sources.yaml +76 -5
  22. package/data/rules/hbom-compliance.yaml +325 -0
  23. package/data/rules/hbom-performance.yaml +307 -0
  24. package/data/rules/hbom-security.yaml +248 -0
  25. package/data/rules/host-topology.yaml +165 -0
  26. package/data/rules/mcp-servers.yaml +18 -3
  27. package/data/rules/obom-runtime.yaml +907 -22
  28. package/data/rules/package-integrity.yaml +36 -0
  29. package/data/rules/rootfs-hardening.yaml +179 -0
  30. package/data/rules/vscode-extensions.yaml +9 -0
  31. package/lib/audit/index.js +209 -8
  32. package/lib/audit/index.poku.js +332 -0
  33. package/lib/audit/reporters.js +222 -0
  34. package/lib/audit/targets.js +146 -1
  35. package/lib/audit/targets.poku.js +186 -0
  36. package/lib/cli/asar.poku.js +328 -0
  37. package/lib/cli/index.js +647 -127
  38. package/lib/cli/index.poku.js +1905 -187
  39. package/lib/evinser/evinser.js +14 -9
  40. package/lib/helpers/agentFormulationParser.js +6 -2
  41. package/lib/helpers/agentFormulationParser.poku.js +42 -0
  42. package/lib/helpers/analyzer.js +1444 -38
  43. package/lib/helpers/analyzer.poku.js +409 -0
  44. package/lib/helpers/analyzerScope.js +712 -0
  45. package/lib/helpers/asarutils.js +1556 -0
  46. package/lib/helpers/asarutils.poku.js +443 -0
  47. package/lib/helpers/auditCategories.js +12 -0
  48. package/lib/helpers/auditCategories.poku.js +32 -0
  49. package/lib/helpers/cbomutils.js +271 -1
  50. package/lib/helpers/cbomutils.poku.js +248 -5
  51. package/lib/helpers/chromextutils.js +25 -3
  52. package/lib/helpers/chromextutils.poku.js +68 -0
  53. package/lib/helpers/ciParsers/githubActions.js +79 -0
  54. package/lib/helpers/ciParsers/githubActions.poku.js +103 -0
  55. package/lib/helpers/communityAiConfigParser.js +15 -5
  56. package/lib/helpers/communityAiConfigParser.poku.js +71 -0
  57. package/lib/helpers/depsUtils.js +5 -0
  58. package/lib/helpers/depsUtils.poku.js +55 -0
  59. package/lib/helpers/display.js +336 -23
  60. package/lib/helpers/display.poku.js +179 -43
  61. package/lib/helpers/evidenceUtils.js +58 -0
  62. package/lib/helpers/evidenceUtils.poku.js +54 -0
  63. package/lib/helpers/exportUtils.js +9 -0
  64. package/lib/helpers/gtfobins.js +142 -8
  65. package/lib/helpers/gtfobins.poku.js +24 -1
  66. package/lib/helpers/hbom.js +710 -0
  67. package/lib/helpers/hbom.poku.js +496 -0
  68. package/lib/helpers/hbomAnalysis.js +268 -0
  69. package/lib/helpers/hbomAnalysis.poku.js +249 -0
  70. package/lib/helpers/hbomLoader.js +35 -0
  71. package/lib/helpers/hostTopology.js +803 -0
  72. package/lib/helpers/hostTopology.poku.js +363 -0
  73. package/lib/helpers/inventoryStats.js +69 -0
  74. package/lib/helpers/inventoryStats.poku.js +86 -0
  75. package/lib/helpers/lolbas.js +19 -1
  76. package/lib/helpers/lolbas.poku.js +23 -0
  77. package/lib/helpers/mcpConfigParser.js +21 -5
  78. package/lib/helpers/mcpConfigParser.poku.js +39 -2
  79. package/lib/helpers/osqueryTransform.js +47 -0
  80. package/lib/helpers/osqueryTransform.poku.js +47 -0
  81. package/lib/helpers/plugins.js +349 -0
  82. package/lib/helpers/plugins.poku.js +57 -0
  83. package/lib/helpers/propertySanitizer.js +121 -0
  84. package/lib/helpers/protobom.js +156 -45
  85. package/lib/helpers/protobom.poku.js +140 -5
  86. package/lib/helpers/remote/dependency-track.js +36 -3
  87. package/lib/helpers/remote/dependency-track.poku.js +44 -0
  88. package/lib/helpers/source.js +24 -0
  89. package/lib/helpers/source.poku.js +32 -0
  90. package/lib/helpers/utils.js +2454 -198
  91. package/lib/helpers/utils.poku.js +1798 -74
  92. package/lib/managers/binary.e2e.poku.js +367 -0
  93. package/lib/managers/binary.js +2306 -350
  94. package/lib/managers/binary.poku.js +1700 -1
  95. package/lib/managers/docker.js +441 -95
  96. package/lib/managers/docker.poku.js +1479 -14
  97. package/lib/server/server.js +2 -24
  98. package/lib/server/server.poku.js +36 -1
  99. package/lib/stages/postgen/annotator.js +38 -0
  100. package/lib/stages/postgen/annotator.poku.js +107 -1
  101. package/lib/stages/postgen/auditBom.js +121 -18
  102. package/lib/stages/postgen/auditBom.poku.js +2967 -990
  103. package/lib/stages/postgen/hostTopologyAudit.poku.js +186 -0
  104. package/lib/stages/postgen/postgen.js +192 -1
  105. package/lib/stages/postgen/postgen.poku.js +321 -0
  106. package/lib/stages/postgen/ruleEngine.js +116 -0
  107. package/lib/stages/pregen/envAudit.js +14 -3
  108. package/package.json +24 -21
  109. package/types/bin/hbom.d.ts +3 -0
  110. package/types/bin/hbom.d.ts.map +1 -0
  111. package/types/bin/repl.d.ts.map +1 -1
  112. package/types/lib/audit/index.d.ts +44 -0
  113. package/types/lib/audit/index.d.ts.map +1 -1
  114. package/types/lib/audit/reporters.d.ts +16 -0
  115. package/types/lib/audit/reporters.d.ts.map +1 -1
  116. package/types/lib/audit/targets.d.ts.map +1 -1
  117. package/types/lib/cli/index.d.ts +16 -0
  118. package/types/lib/cli/index.d.ts.map +1 -1
  119. package/types/lib/evinser/evinser.d.ts +4 -0
  120. package/types/lib/evinser/evinser.d.ts.map +1 -1
  121. package/types/lib/helpers/agentFormulationParser.d.ts.map +1 -1
  122. package/types/lib/helpers/analyzer.d.ts +33 -0
  123. package/types/lib/helpers/analyzer.d.ts.map +1 -1
  124. package/types/lib/helpers/analyzerScope.d.ts +11 -0
  125. package/types/lib/helpers/analyzerScope.d.ts.map +1 -0
  126. package/types/lib/helpers/asarutils.d.ts +34 -0
  127. package/types/lib/helpers/asarutils.d.ts.map +1 -0
  128. package/types/lib/helpers/auditCategories.d.ts +5 -0
  129. package/types/lib/helpers/auditCategories.d.ts.map +1 -1
  130. package/types/lib/helpers/cbomutils.d.ts +3 -2
  131. package/types/lib/helpers/cbomutils.d.ts.map +1 -1
  132. package/types/lib/helpers/chromextutils.d.ts.map +1 -1
  133. package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -1
  134. package/types/lib/helpers/communityAiConfigParser.d.ts.map +1 -1
  135. package/types/lib/helpers/depsUtils.d.ts.map +1 -1
  136. package/types/lib/helpers/display.d.ts +1 -0
  137. package/types/lib/helpers/display.d.ts.map +1 -1
  138. package/types/lib/helpers/evidenceUtils.d.ts +8 -0
  139. package/types/lib/helpers/evidenceUtils.d.ts.map +1 -0
  140. package/types/lib/helpers/exportUtils.d.ts.map +1 -1
  141. package/types/lib/helpers/gtfobins.d.ts +8 -0
  142. package/types/lib/helpers/gtfobins.d.ts.map +1 -1
  143. package/types/lib/helpers/hbom.d.ts +49 -0
  144. package/types/lib/helpers/hbom.d.ts.map +1 -0
  145. package/types/lib/helpers/hbomAnalysis.d.ts +62 -0
  146. package/types/lib/helpers/hbomAnalysis.d.ts.map +1 -0
  147. package/types/lib/helpers/hbomLoader.d.ts +7 -0
  148. package/types/lib/helpers/hbomLoader.d.ts.map +1 -0
  149. package/types/lib/helpers/hostTopology.d.ts +12 -0
  150. package/types/lib/helpers/hostTopology.d.ts.map +1 -0
  151. package/types/lib/helpers/inventoryStats.d.ts +11 -0
  152. package/types/lib/helpers/inventoryStats.d.ts.map +1 -0
  153. package/types/lib/helpers/lolbas.d.ts.map +1 -1
  154. package/types/lib/helpers/mcpConfigParser.d.ts +1 -1
  155. package/types/lib/helpers/mcpConfigParser.d.ts.map +1 -1
  156. package/types/lib/helpers/osqueryTransform.d.ts +3 -0
  157. package/types/lib/helpers/osqueryTransform.d.ts.map +1 -1
  158. package/types/lib/helpers/plugins.d.ts +58 -0
  159. package/types/lib/helpers/plugins.d.ts.map +1 -0
  160. package/types/lib/helpers/propertySanitizer.d.ts +3 -0
  161. package/types/lib/helpers/propertySanitizer.d.ts.map +1 -0
  162. package/types/lib/helpers/protobom.d.ts +3 -4
  163. package/types/lib/helpers/protobom.d.ts.map +1 -1
  164. package/types/lib/helpers/remote/dependency-track.d.ts +10 -3
  165. package/types/lib/helpers/remote/dependency-track.d.ts.map +1 -1
  166. package/types/lib/helpers/source.d.ts.map +1 -1
  167. package/types/lib/helpers/utils.d.ts +74 -8
  168. package/types/lib/helpers/utils.d.ts.map +1 -1
  169. package/types/lib/managers/binary.d.ts +5 -0
  170. package/types/lib/managers/binary.d.ts.map +1 -1
  171. package/types/lib/managers/docker.d.ts +3 -0
  172. package/types/lib/managers/docker.d.ts.map +1 -1
  173. package/types/lib/server/server.d.ts +2 -0
  174. package/types/lib/server/server.d.ts.map +1 -1
  175. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  176. package/types/lib/stages/postgen/auditBom.d.ts +26 -1
  177. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  178. package/types/lib/stages/postgen/postgen.d.ts +2 -1
  179. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  180. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  181. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -1
  182. package/data/spdx-model-v3.0.1.jsonld +0 -15999
@@ -121,7 +121,13 @@ it("parseImageName tests", () => {
121
121
  );
122
122
  });
123
123
 
124
- async function loadDockerModule({ clientResponse, utilsOverrides } = {}) {
124
+ async function loadDockerModule({
125
+ clientResponse,
126
+ fsOverrides,
127
+ streamOverrides,
128
+ tarOverrides,
129
+ utilsOverrides,
130
+ } = {}) {
125
131
  const dockerClient = sinon.stub().resolves(
126
132
  clientResponse || {
127
133
  Id: "sha256:hello-world",
@@ -129,6 +135,13 @@ async function loadDockerModule({ clientResponse, utilsOverrides } = {}) {
129
135
  },
130
136
  );
131
137
  dockerClient.stream = sinon.stub();
138
+ const fsStub = {
139
+ createReadStream: sinon.stub(),
140
+ lstatSync: sinon.stub(),
141
+ readdirSync: sinon.stub().returns([]),
142
+ readFileSync: sinon.stub(),
143
+ ...fsOverrides,
144
+ };
132
145
  const gotStub = {
133
146
  extend: sinon.stub().returns(dockerClient),
134
147
  get: sinon.stub().resolves({ body: "OK" }),
@@ -140,7 +153,13 @@ async function loadDockerModule({ clientResponse, utilsOverrides } = {}) {
140
153
  getAllFiles: sinon.stub().returns([]),
141
154
  getTmpDir: sinon.stub().returns("/tmp"),
142
155
  isDryRun: false,
156
+ readEnvironmentVariable: sinon
157
+ .stub()
158
+ .callsFake((varName) => process.env[varName]),
143
159
  recordActivity: sinon.stub(),
160
+ recordDecisionActivity: sinon.stub(),
161
+ recordSensitiveFileRead: sinon.stub(),
162
+ safeExtractArchive: sinon.stub().resolves(true),
144
163
  safeExistsSync: sinon.stub().returns(false),
145
164
  safeMkdirSync: sinon.stub(),
146
165
  safeMkdtempSync: sinon.stub().returns("/tmp/docker-images-test"),
@@ -150,12 +169,115 @@ async function loadDockerModule({ clientResponse, utilsOverrides } = {}) {
150
169
  ...utilsOverrides,
151
170
  };
152
171
  const dockerModule = await esmock("./docker.js", {
172
+ "node:fs": fsStub,
173
+ "node:stream/promises": {
174
+ pipeline: sinon.stub().resolves(),
175
+ ...streamOverrides,
176
+ },
153
177
  got: { default: gotStub },
178
+ tar: {
179
+ x: sinon.stub().returns("extractor"),
180
+ ...tarOverrides,
181
+ },
154
182
  "../helpers/utils.js": utilsStub,
155
183
  });
156
- return { dockerClient, dockerModule, gotStub, utilsStub };
184
+ return { dockerClient, dockerModule, fsStub, gotStub, utilsStub };
185
+ }
186
+
187
+ const decodeRegistryAuthHeader = (header) =>
188
+ JSON.parse(Buffer.from(header, "base64url").toString("utf-8"));
189
+
190
+ const dockerConfigExistsStub = () =>
191
+ sinon.stub().callsFake((filePath) => filePath.endsWith("config.json"));
192
+
193
+ const encodedAuth = Buffer.from("trusted-user:trusted-pass").toString("base64");
194
+
195
+ const authConfigData = (configuredRegistry) =>
196
+ JSON.stringify({
197
+ auths: {
198
+ [configuredRegistry]: {
199
+ auth: encodedAuth,
200
+ },
201
+ },
202
+ });
203
+
204
+ const credHelperConfigData = (configuredRegistry) =>
205
+ JSON.stringify({
206
+ credHelpers: {
207
+ [configuredRegistry]: "osxkeychain",
208
+ },
209
+ });
210
+
211
+ const credHelperExe = (helperSuffix) =>
212
+ isWin
213
+ ? `docker-credential-${helperSuffix}.exe`
214
+ : `docker-credential-${helperSuffix}`;
215
+
216
+ async function loadDockerModuleWithAuths(configuredRegistry) {
217
+ return await loadDockerModule({
218
+ fsOverrides: {
219
+ readFileSync: sinon.stub().returns(authConfigData(configuredRegistry)),
220
+ },
221
+ utilsOverrides: {
222
+ safeExistsSync: dockerConfigExistsStub(),
223
+ },
224
+ });
225
+ }
226
+
227
+ async function loadDockerModuleWithCredHelpers(
228
+ configuredRegistry,
229
+ safeSpawnSync,
230
+ ) {
231
+ return await loadDockerModule({
232
+ fsOverrides: {
233
+ readFileSync: sinon
234
+ .stub()
235
+ .returns(credHelperConfigData(configuredRegistry)),
236
+ },
237
+ utilsOverrides: {
238
+ safeExistsSync: dockerConfigExistsStub(),
239
+ safeSpawnSync,
240
+ },
241
+ });
157
242
  }
158
243
 
244
+ const withDockerConfig = async (callback) => {
245
+ const originalDockerConfig = process.env.DOCKER_CONFIG;
246
+ process.env.DOCKER_CONFIG = "/tmp/cdxgen-docker-config";
247
+ try {
248
+ await callback();
249
+ } finally {
250
+ if (originalDockerConfig === undefined) {
251
+ delete process.env.DOCKER_CONFIG;
252
+ } else {
253
+ process.env.DOCKER_CONFIG = originalDockerConfig;
254
+ }
255
+ }
256
+ };
257
+
258
+ const withEnv = async (updates, callback) => {
259
+ const originalEnv = {};
260
+ for (const envKey of Object.keys(updates)) {
261
+ originalEnv[envKey] = process.env[envKey];
262
+ if (updates[envKey] === undefined) {
263
+ delete process.env[envKey];
264
+ } else {
265
+ process.env[envKey] = updates[envKey];
266
+ }
267
+ }
268
+ try {
269
+ await callback();
270
+ } finally {
271
+ for (const envKey of Object.keys(updates)) {
272
+ if (originalEnv[envKey] === undefined) {
273
+ delete process.env[envKey];
274
+ } else {
275
+ process.env[envKey] = originalEnv[envKey];
276
+ }
277
+ }
278
+ }
279
+ };
280
+
159
281
  await it("docker connection uses the detected daemon client", async () => {
160
282
  const { dockerModule, gotStub, dockerClient } = await loadDockerModule();
161
283
  const dockerConn = await dockerModule.getConnection();
@@ -280,12 +402,218 @@ await it("docker getConnection reports blocked network activity in dry-run mode"
280
402
  });
281
403
  });
282
404
 
283
- await it("docker extractTar reports a blocked untar activity in dry-run mode", async () => {
405
+ await it("docker getConnection skips dry-run tracing on containerd runtimes", async () => {
406
+ const recordActivity = sinon.stub();
407
+ const recordSensitiveFileRead = sinon.stub();
408
+ await withEnv(
409
+ {
410
+ CONTAINERD_ADDRESS: "/run/containerd/containerd.sock",
411
+ },
412
+ async () => {
413
+ const { dockerModule } = await loadDockerModule({
414
+ fsOverrides: {
415
+ readFileSync: sinon.stub().returns(authConfigData("docker.io")),
416
+ },
417
+ utilsOverrides: {
418
+ isDryRun: true,
419
+ recordActivity,
420
+ recordSensitiveFileRead,
421
+ safeExistsSync: dockerConfigExistsStub(),
422
+ },
423
+ });
424
+ const conn = await dockerModule.getConnection({}, "docker.io");
425
+ assert.strictEqual(conn, undefined);
426
+ },
427
+ );
428
+ sinon.assert.notCalled(recordActivity);
429
+ sinon.assert.notCalled(recordSensitiveFileRead);
430
+ });
431
+
432
+ await it("docker getConnection traces docker credential file reads in dry-run mode", async () => {
284
433
  const recordActivity = sinon.stub();
434
+ const recordSensitiveFileRead = sinon.stub();
435
+ await withDockerConfig(async () => {
436
+ const { dockerModule } = await loadDockerModule({
437
+ fsOverrides: {
438
+ readFileSync: sinon.stub().returns(authConfigData("docker.io")),
439
+ },
440
+ utilsOverrides: {
441
+ isDryRun: true,
442
+ recordActivity,
443
+ recordSensitiveFileRead,
444
+ safeExistsSync: dockerConfigExistsStub(),
445
+ },
446
+ });
447
+ await dockerModule.getConnection({}, "docker.io");
448
+ });
449
+ sinon.assert.calledWithMatch(recordSensitiveFileRead, sinon.match.string, {
450
+ label: "Docker credential file",
451
+ });
452
+ sinon.assert.calledWithMatch(recordActivity, {
453
+ kind: "network",
454
+ status: "blocked",
455
+ target: "docker.io",
456
+ });
457
+ });
458
+
459
+ await it("docker makeRequest does not trace docker credential file reads when the read fails", async () => {
460
+ const recordSensitiveFileRead = sinon.stub();
461
+ await withEnv(
462
+ {
463
+ DOCKER_AUTH_CONFIG: undefined,
464
+ DOCKER_EMAIL: undefined,
465
+ DOCKER_PASSWORD: undefined,
466
+ DOCKER_USER: undefined,
467
+ },
468
+ async () => {
469
+ await withDockerConfig(async () => {
470
+ const { dockerModule } = await loadDockerModule({
471
+ fsOverrides: {
472
+ readFileSync: sinon.stub().throws(new Error("read failed")),
473
+ },
474
+ utilsOverrides: {
475
+ recordSensitiveFileRead,
476
+ safeExistsSync: dockerConfigExistsStub(),
477
+ },
478
+ });
479
+ await assert.rejects(() =>
480
+ dockerModule.makeRequest(
481
+ "images/create?fromImage=docker.io/library/alpine:latest",
482
+ "POST",
483
+ "docker.io/library/alpine:latest",
484
+ ),
485
+ );
486
+ });
487
+ },
488
+ );
489
+ sinon.assert.notCalled(recordSensitiveFileRead);
490
+ });
491
+
492
+ await it("docker getConnection does not trace TLS client files when reading them fails", async () => {
493
+ const recordSensitiveFileRead = sinon.stub();
494
+ await withEnv(
495
+ {
496
+ DOCKER_AUTH_CONFIG: undefined,
497
+ DOCKER_CERT_PATH: "/tmp/docker-certs",
498
+ DOCKER_EMAIL: undefined,
499
+ DOCKER_HOST: "tcp://docker.example.test:2376",
500
+ DOCKER_PASSWORD: undefined,
501
+ DOCKER_USER: undefined,
502
+ },
503
+ async () => {
504
+ const { dockerModule } = await loadDockerModule({
505
+ fsOverrides: {
506
+ readFileSync: sinon
507
+ .stub()
508
+ .onFirstCall()
509
+ .throws(new Error("cert read failed")),
510
+ },
511
+ utilsOverrides: {
512
+ recordSensitiveFileRead,
513
+ },
514
+ });
515
+ await assert.rejects(() => dockerModule.getConnection({}, "docker.io"));
516
+ },
517
+ );
518
+ sinon.assert.notCalled(recordSensitiveFileRead);
519
+ });
520
+
521
+ await it("docker makeRequest does not trace TLS client files when reading them fails", async () => {
522
+ const recordSensitiveFileRead = sinon.stub();
523
+ await withEnv(
524
+ {
525
+ DOCKER_AUTH_CONFIG: undefined,
526
+ DOCKER_CERT_PATH: "/tmp/docker-certs",
527
+ DOCKER_EMAIL: undefined,
528
+ DOCKER_HOST: "tcp://docker.example.test:2376",
529
+ DOCKER_PASSWORD: undefined,
530
+ DOCKER_USER: undefined,
531
+ },
532
+ async () => {
533
+ const { dockerModule } = await loadDockerModule({
534
+ fsOverrides: {
535
+ readFileSync: sinon
536
+ .stub()
537
+ .onFirstCall()
538
+ .throws(new Error("cert read failed")),
539
+ },
540
+ utilsOverrides: {
541
+ recordSensitiveFileRead,
542
+ },
543
+ });
544
+ await assert.rejects(() =>
545
+ dockerModule.makeRequest(
546
+ "images/create?fromImage=docker.io/library/alpine:latest",
547
+ "POST",
548
+ "docker.io/library/alpine:latest",
549
+ ),
550
+ );
551
+ },
552
+ );
553
+ sinon.assert.notCalled(recordSensitiveFileRead);
554
+ });
555
+
556
+ await it("docker getConnection records which credential source was selected", async () => {
557
+ const recordDecisionActivity = sinon.stub();
558
+ await withDockerConfig(async () => {
559
+ const { dockerModule } = await loadDockerModule({
560
+ fsOverrides: {
561
+ readFileSync: sinon.stub().returns(authConfigData("docker.io")),
562
+ },
563
+ utilsOverrides: {
564
+ isDryRun: true,
565
+ recordDecisionActivity,
566
+ safeExistsSync: dockerConfigExistsStub(),
567
+ },
568
+ });
569
+ await dockerModule.getConnection({}, "docker.io");
570
+ });
571
+ sinon.assert.calledWithMatch(
572
+ recordDecisionActivity,
573
+ "docker-auth:docker.io",
574
+ {
575
+ metadata: sinon.match({
576
+ decisionType: "credential-source-selection",
577
+ selectedSource: "docker-config-auth",
578
+ }),
579
+ },
580
+ );
581
+ });
582
+
583
+ await it("docker getConnection traces credential helper resolution in dry-run mode", async () => {
584
+ const safeSpawnSync = sinon.stub().returns({
585
+ status: 1,
586
+ stdout: "",
587
+ stderr: "",
588
+ });
589
+ await withDockerConfig(async () => {
590
+ const { dockerModule } = await loadDockerModule({
591
+ fsOverrides: {
592
+ readFileSync: sinon.stub().returns(credHelperConfigData("docker.io")),
593
+ },
594
+ utilsOverrides: {
595
+ isDryRun: true,
596
+ safeExistsSync: dockerConfigExistsStub(),
597
+ safeSpawnSync,
598
+ },
599
+ });
600
+ await dockerModule.getConnection({}, "docker.io");
601
+ });
602
+ sinon.assert.calledWithExactly(
603
+ safeSpawnSync,
604
+ credHelperExe("osxkeychain"),
605
+ ["get"],
606
+ {
607
+ input: "docker.io",
608
+ },
609
+ );
610
+ });
611
+
612
+ await it("docker extractTar reports a blocked untar activity in dry-run mode", async () => {
613
+ const safeExtractArchive = sinon.stub().resolves(false);
285
614
  const { dockerModule } = await loadDockerModule({
286
615
  utilsOverrides: {
287
- isDryRun: true,
288
- recordActivity,
616
+ safeExtractArchive,
289
617
  },
290
618
  });
291
619
  const result = await dockerModule.extractTar(
@@ -294,28 +622,131 @@ await it("docker extractTar reports a blocked untar activity in dry-run mode", a
294
622
  {},
295
623
  );
296
624
  assert.strictEqual(result, false);
625
+ sinon.assert.calledWithMatch(
626
+ safeExtractArchive,
627
+ "/tmp/image.tar",
628
+ "/tmp/out",
629
+ sinon.match.func,
630
+ "untar",
631
+ {
632
+ blockedReason:
633
+ "Dry run mode blocks untar and layer extraction operations because they create files on disk.",
634
+ metadata: {
635
+ archiveFormat: "tar",
636
+ },
637
+ },
638
+ );
639
+ });
640
+
641
+ await it("docker extractTar delegates successful untar tracing to safeExtractArchive", async () => {
642
+ const safeExtractArchive = sinon.stub().resolves(true);
643
+ const { dockerModule } = await loadDockerModule({
644
+ utilsOverrides: {
645
+ safeExtractArchive,
646
+ },
647
+ });
648
+ const result = await dockerModule.extractTar(
649
+ "/tmp/image.tar",
650
+ "/tmp/out",
651
+ {},
652
+ );
653
+ assert.strictEqual(result, true);
654
+ sinon.assert.calledOnce(safeExtractArchive);
655
+ });
656
+
657
+ await it("docker extractTar preserves failure handling after safeExtractArchive rejects", async () => {
658
+ const extractionError = new Error("permission denied");
659
+ extractionError.code = "EACCES";
660
+ const safeExtractArchive = sinon.stub().rejects(extractionError);
661
+ const { dockerModule } = await loadDockerModule({
662
+ utilsOverrides: {
663
+ safeExtractArchive,
664
+ },
665
+ });
666
+ const result = await dockerModule.extractTar(
667
+ "/tmp/image.tar",
668
+ "/tmp/out",
669
+ {},
670
+ );
671
+ assert.strictEqual(result, false);
672
+ sinon.assert.calledOnce(safeExtractArchive);
673
+ });
674
+
675
+ await it("docker exportImage reports a blocked container activity in dry-run mode", async () => {
676
+ const recordActivity = sinon.stub();
677
+ const recordSensitiveFileRead = sinon.stub();
678
+ await withDockerConfig(async () => {
679
+ const { dockerModule } = await loadDockerModule({
680
+ fsOverrides: {
681
+ readFileSync: sinon.stub().returns(authConfigData("docker.io")),
682
+ },
683
+ utilsOverrides: {
684
+ isDryRun: true,
685
+ recordActivity,
686
+ recordSensitiveFileRead,
687
+ safeExistsSync: dockerConfigExistsStub(),
688
+ },
689
+ });
690
+ const result = await dockerModule.exportImage("alpine:3.20", {});
691
+ assert.strictEqual(result, undefined);
692
+ });
693
+ sinon.assert.calledWithMatch(recordSensitiveFileRead, sinon.match.string, {
694
+ label: "Docker credential file",
695
+ });
297
696
  sinon.assert.calledWithMatch(recordActivity, {
298
- kind: "untar",
697
+ kind: "container",
299
698
  status: "blocked",
300
- target: "/tmp/image.tar -> /tmp/out",
699
+ target: "alpine:3.20",
301
700
  });
302
701
  });
303
702
 
304
- await it("docker exportImage reports a blocked container activity in dry-run mode", async () => {
703
+ await it("docker exportImage preserves scoped registry refs for dry-run auth tracing", async () => {
704
+ const recordDecisionActivity = sinon.stub();
705
+ await withDockerConfig(async () => {
706
+ const { dockerModule } = await loadDockerModule({
707
+ fsOverrides: {
708
+ readFileSync: sinon
709
+ .stub()
710
+ .returns(authConfigData("registry.example.com/team")),
711
+ },
712
+ utilsOverrides: {
713
+ isDryRun: true,
714
+ recordDecisionActivity,
715
+ safeExistsSync: dockerConfigExistsStub(),
716
+ },
717
+ });
718
+ const result = await dockerModule.exportImage(
719
+ "registry.example.com/team/app:latest",
720
+ {},
721
+ );
722
+ assert.strictEqual(result, undefined);
723
+ });
724
+ sinon.assert.calledWithMatch(
725
+ recordDecisionActivity,
726
+ "docker-auth:registry.example.com/team/app",
727
+ {
728
+ metadata: sinon.match({
729
+ selectedSource: "docker-config-auth",
730
+ }),
731
+ },
732
+ );
733
+ });
734
+
735
+ await it("docker exportImage skips dry-run tracing for local paths", async () => {
305
736
  const recordActivity = sinon.stub();
737
+ const recordSensitiveFileRead = sinon.stub();
306
738
  const { dockerModule } = await loadDockerModule({
307
739
  utilsOverrides: {
308
740
  isDryRun: true,
309
741
  recordActivity,
742
+ recordSensitiveFileRead,
743
+ safeExistsSync: sinon.stub().returns(true),
310
744
  },
311
745
  });
312
- const result = await dockerModule.exportImage("alpine:3.20", {});
746
+ const result = await dockerModule.exportImage("/tmp/image.tar", {});
313
747
  assert.strictEqual(result, undefined);
314
- sinon.assert.calledWithMatch(recordActivity, {
315
- kind: "container",
316
- status: "blocked",
317
- target: "alpine:3.20",
318
- });
748
+ sinon.assert.notCalled(recordActivity);
749
+ sinon.assert.notCalled(recordSensitiveFileRead);
319
750
  });
320
751
 
321
752
  await it("docker exportImage ignores local directories", async () => {
@@ -323,6 +754,1040 @@ await it("docker exportImage ignores local directories", async () => {
323
754
  assert.strictEqual(imageData, undefined);
324
755
  });
325
756
 
757
+ await it("docker makeRequest prefers DOCKER_AUTH_CONFIG over config.json entries for all registries", async () => {
758
+ await withDockerConfig(async () => {
759
+ await withEnv(
760
+ {
761
+ DOCKER_AUTH_CONFIG: "opaque-global-auth-token",
762
+ },
763
+ async () => {
764
+ const safeSpawnSync = sinon.stub().returns({
765
+ status: 0,
766
+ stdout: JSON.stringify({
767
+ username: "helper-user",
768
+ Secret: "helper-pass",
769
+ }),
770
+ stderr: "",
771
+ });
772
+ const { dockerClient, dockerModule } = await loadDockerModule({
773
+ fsOverrides: {
774
+ readFileSync: sinon.stub().returns(
775
+ JSON.stringify({
776
+ auths: {
777
+ "registry.example.com": {
778
+ auth: Buffer.from("trusted-user:trusted-pass").toString(
779
+ "base64",
780
+ ),
781
+ },
782
+ },
783
+ credHelpers: {
784
+ "registry.example.com": "osxkeychain",
785
+ },
786
+ }),
787
+ ),
788
+ },
789
+ utilsOverrides: {
790
+ safeExistsSync: dockerConfigExistsStub(),
791
+ safeSpawnSync,
792
+ },
793
+ });
794
+
795
+ await dockerModule.makeRequest(
796
+ "images/create?fromImage=registry.example.com/team/app:latest",
797
+ "POST",
798
+ "registry.example.com/team/app",
799
+ );
800
+
801
+ const requestOptions = dockerClient.firstCall.args[1];
802
+ assert.strictEqual(
803
+ requestOptions.headers["X-Registry-Auth"],
804
+ "opaque-global-auth-token",
805
+ );
806
+ sinon.assert.notCalled(safeSpawnSync);
807
+ },
808
+ );
809
+ });
810
+ });
811
+
812
+ await it("docker makeRequest prefers DOCKER_USER credentials over matching config.json entries", async () => {
813
+ await withDockerConfig(async () => {
814
+ await withEnv(
815
+ {
816
+ DOCKER_USER: "env-user",
817
+ DOCKER_PASSWORD: "env-pass",
818
+ DOCKER_EMAIL: "env@example.com",
819
+ },
820
+ async () => {
821
+ const { dockerClient, dockerModule } = await loadDockerModule({
822
+ fsOverrides: {
823
+ readFileSync: sinon.stub().returns(
824
+ JSON.stringify({
825
+ auths: {
826
+ "registry.example.com": {
827
+ auth: Buffer.from("trusted-user:trusted-pass").toString(
828
+ "base64",
829
+ ),
830
+ },
831
+ },
832
+ }),
833
+ ),
834
+ },
835
+ utilsOverrides: {
836
+ safeExistsSync: dockerConfigExistsStub(),
837
+ },
838
+ });
839
+
840
+ await dockerModule.makeRequest(
841
+ "images/create?fromImage=registry.example.com/team/app:latest",
842
+ "POST",
843
+ "registry.example.com/team/app",
844
+ );
845
+
846
+ const registryAuthHeader =
847
+ dockerClient.firstCall.args[1].headers["X-Registry-Auth"];
848
+ assert.deepStrictEqual(decodeRegistryAuthHeader(registryAuthHeader), {
849
+ username: "env-user",
850
+ password: "env-pass",
851
+ email: "env@example.com",
852
+ serveraddress: "registry.example.com",
853
+ });
854
+ },
855
+ );
856
+ });
857
+ });
858
+
859
+ await it("docker makeRequest applies DOCKER_USER credentials regardless of configured registry entries", async () => {
860
+ await withDockerConfig(async () => {
861
+ await withEnv(
862
+ {
863
+ DOCKER_USER: "env-user",
864
+ DOCKER_PASSWORD: "env-pass",
865
+ DOCKER_EMAIL: "env@example.com",
866
+ },
867
+ async () => {
868
+ const safeSpawnSync = sinon.stub().returns({
869
+ status: 0,
870
+ stdout: JSON.stringify({
871
+ username: "helper-user",
872
+ Secret: "helper-pass",
873
+ }),
874
+ stderr: "",
875
+ });
876
+ const { dockerClient, dockerModule } = await loadDockerModule({
877
+ fsOverrides: {
878
+ readFileSync: sinon.stub().returns(
879
+ JSON.stringify({
880
+ auths: {
881
+ "other-registry.example.com": {
882
+ auth: Buffer.from("trusted-user:trusted-pass").toString(
883
+ "base64",
884
+ ),
885
+ },
886
+ },
887
+ credHelpers: {
888
+ "other-registry.example.com": "osxkeychain",
889
+ },
890
+ }),
891
+ ),
892
+ },
893
+ utilsOverrides: {
894
+ safeExistsSync: dockerConfigExistsStub(),
895
+ safeSpawnSync,
896
+ },
897
+ });
898
+
899
+ await dockerModule.makeRequest(
900
+ "images/create?fromImage=registry.example.com/team/app:latest",
901
+ "POST",
902
+ "registry.example.com/team/app",
903
+ );
904
+
905
+ const registryAuthHeader =
906
+ dockerClient.firstCall.args[1].headers["X-Registry-Auth"];
907
+ assert.deepStrictEqual(decodeRegistryAuthHeader(registryAuthHeader), {
908
+ username: "env-user",
909
+ password: "env-pass",
910
+ email: "env@example.com",
911
+ serveraddress: "registry.example.com",
912
+ });
913
+ sinon.assert.notCalled(safeSpawnSync);
914
+ },
915
+ );
916
+ });
917
+ });
918
+
919
+ await it("docker makeRequest does not forward auth for substring-matched registries", async () => {
920
+ const originalDockerConfig = process.env.DOCKER_CONFIG;
921
+ process.env.DOCKER_CONFIG = "/tmp/cdxgen-docker-config";
922
+ try {
923
+ const { dockerClient, dockerModule } = await loadDockerModule({
924
+ fsOverrides: {
925
+ readFileSync: sinon.stub().returns(
926
+ JSON.stringify({
927
+ auths: {
928
+ "private-registry.example.com": {
929
+ auth: Buffer.from("trusted-user:trusted-pass").toString(
930
+ "base64",
931
+ ),
932
+ },
933
+ },
934
+ }),
935
+ ),
936
+ },
937
+ utilsOverrides: {
938
+ safeExistsSync: sinon
939
+ .stub()
940
+ .callsFake((filePath) => filePath.endsWith("config.json")),
941
+ },
942
+ });
943
+
944
+ await dockerModule.makeRequest(
945
+ "images/create?fromImage=registry.example.com/team/app:latest",
946
+ "POST",
947
+ "registry.example.com",
948
+ );
949
+
950
+ const requestOptions = dockerClient.firstCall.args[1];
951
+ assert.strictEqual(requestOptions.headers, undefined);
952
+ } finally {
953
+ if (originalDockerConfig === undefined) {
954
+ delete process.env.DOCKER_CONFIG;
955
+ } else {
956
+ process.env.DOCKER_CONFIG = originalDockerConfig;
957
+ }
958
+ }
959
+ });
960
+
961
+ await it("docker makeRequest accepts exact normalized registry matches from config auths", async () => {
962
+ const originalDockerConfig = process.env.DOCKER_CONFIG;
963
+ process.env.DOCKER_CONFIG = "/tmp/cdxgen-docker-config";
964
+ try {
965
+ const { dockerClient, dockerModule } = await loadDockerModule({
966
+ fsOverrides: {
967
+ readFileSync: sinon.stub().returns(
968
+ JSON.stringify({
969
+ auths: {
970
+ "https://registry.example.com/v2/": {
971
+ auth: Buffer.from("trusted-user:trusted-pass").toString(
972
+ "base64",
973
+ ),
974
+ },
975
+ },
976
+ }),
977
+ ),
978
+ },
979
+ utilsOverrides: {
980
+ safeExistsSync: sinon
981
+ .stub()
982
+ .callsFake((filePath) => filePath.endsWith("config.json")),
983
+ },
984
+ });
985
+
986
+ await dockerModule.makeRequest(
987
+ "images/create?fromImage=registry.example.com/team/app:latest",
988
+ "POST",
989
+ "registry.example.com/team/app",
990
+ );
991
+
992
+ const registryAuthHeader =
993
+ dockerClient.firstCall.args[1].headers["X-Registry-Auth"];
994
+ assert.deepStrictEqual(decodeRegistryAuthHeader(registryAuthHeader), {
995
+ username: "trusted-user",
996
+ password: "trusted-pass",
997
+ serveraddress: "https://registry.example.com/v2/",
998
+ });
999
+ } finally {
1000
+ if (originalDockerConfig === undefined) {
1001
+ delete process.env.DOCKER_CONFIG;
1002
+ } else {
1003
+ process.env.DOCKER_CONFIG = originalDockerConfig;
1004
+ }
1005
+ }
1006
+ });
1007
+
1008
+ await it("docker makeRequest accepts normalized exact matches across ipv4 ipv6 explicit ports and scoped subpaths from config auths", async () => {
1009
+ const cases = [
1010
+ {
1011
+ configuredRegistry: "127.0.0.1:5000",
1012
+ requestedRegistry: "127.0.0.1:5000/team/app",
1013
+ expectedServerAddress: "127.0.0.1:5000",
1014
+ },
1015
+ {
1016
+ configuredRegistry: "[::1]:5000",
1017
+ requestedRegistry: "[::1]:5000/team/app",
1018
+ expectedServerAddress: "[::1]:5000",
1019
+ },
1020
+ {
1021
+ configuredRegistry: "https://[2001:db8::1]:5000/v2/",
1022
+ requestedRegistry: "[2001:db8::1]:5000/team/app",
1023
+ expectedServerAddress: "https://[2001:db8::1]:5000/v2/",
1024
+ },
1025
+ {
1026
+ configuredRegistry: "HTTPS://REGISTRY.EXAMPLE.COM/V2/",
1027
+ requestedRegistry: "registry.example.com/team/app",
1028
+ expectedServerAddress: "HTTPS://REGISTRY.EXAMPLE.COM/V2/",
1029
+ },
1030
+ {
1031
+ configuredRegistry: "https://registry.example.com:443/v2/",
1032
+ requestedRegistry: "registry.example.com:443/team/app",
1033
+ expectedServerAddress: "https://registry.example.com:443/v2/",
1034
+ },
1035
+ {
1036
+ configuredRegistry: "http://registry.example.com:80/v2/",
1037
+ requestedRegistry: "registry.example.com:80/team/app",
1038
+ expectedServerAddress: "http://registry.example.com:80/v2/",
1039
+ },
1040
+ {
1041
+ configuredRegistry: "https://registry.example.com/custom/subpath",
1042
+ requestedRegistry: "registry.example.com/custom/subpath/team/app",
1043
+ expectedServerAddress: "https://registry.example.com/custom/subpath",
1044
+ },
1045
+ {
1046
+ configuredRegistry: "https://registry.example.com/custom/subpath/v2/",
1047
+ requestedRegistry: "registry.example.com/custom/subpath/team/app",
1048
+ expectedServerAddress: "https://registry.example.com/custom/subpath/v2/",
1049
+ },
1050
+ ];
1051
+
1052
+ await withDockerConfig(async () => {
1053
+ for (const testCase of cases) {
1054
+ const { dockerClient, dockerModule } = await loadDockerModuleWithAuths(
1055
+ testCase.configuredRegistry,
1056
+ );
1057
+
1058
+ await dockerModule.makeRequest(
1059
+ `images/create?fromImage=${testCase.requestedRegistry}:latest`,
1060
+ "POST",
1061
+ testCase.requestedRegistry,
1062
+ );
1063
+
1064
+ const registryAuthHeader =
1065
+ dockerClient.firstCall.args[1].headers["X-Registry-Auth"];
1066
+ assert.deepStrictEqual(decodeRegistryAuthHeader(registryAuthHeader), {
1067
+ username: "trusted-user",
1068
+ password: "trusted-pass",
1069
+ serveraddress: testCase.expectedServerAddress,
1070
+ });
1071
+ }
1072
+ });
1073
+ });
1074
+
1075
+ await it("docker makeRequest rejects wildcard unicode bidi explicit-default-port port-boundary and unrelated scoped-path mismatches from config auths", async () => {
1076
+ const bidiRegistry = "reg\u202eistry.example.com";
1077
+ const unicodeConfusableRegistry = "reg\u0456stry.example.com";
1078
+ const cases = [
1079
+ {
1080
+ configuredRegistry: "*.example.com",
1081
+ requestedRegistry: "team.example.com/app",
1082
+ },
1083
+ {
1084
+ configuredRegistry: "registry.example.com",
1085
+ requestedRegistry: "registry.example.com:80/team/app",
1086
+ },
1087
+ {
1088
+ configuredRegistry: "registry.example.com:443",
1089
+ requestedRegistry: "registry.example.com/team/app",
1090
+ },
1091
+ {
1092
+ configuredRegistry: "127.0.0.1:5001",
1093
+ requestedRegistry: "127.0.0.1:5000/team/app",
1094
+ },
1095
+ {
1096
+ configuredRegistry: "[::1]:5001",
1097
+ requestedRegistry: "[::1]:5000/team/app",
1098
+ },
1099
+ {
1100
+ configuredRegistry: "https://registry.example.com.evil.invalid/v2/",
1101
+ requestedRegistry: "registry.example.com/team/app",
1102
+ },
1103
+ {
1104
+ configuredRegistry: "https://registry.example.com/custom/subpath",
1105
+ requestedRegistry: "registry.example.com/team/app",
1106
+ },
1107
+ {
1108
+ configuredRegistry: "https://registry.example.com/custom/subpath",
1109
+ requestedRegistry: "registry.example.com/custom/subpathology/team/app",
1110
+ },
1111
+ {
1112
+ configuredRegistry: "https://registry.example.com:443/v2/",
1113
+ requestedRegistry: "registry.example.com:444/team/app",
1114
+ },
1115
+ {
1116
+ configuredRegistry: unicodeConfusableRegistry,
1117
+ requestedRegistry: "registry.example.com/team/app",
1118
+ },
1119
+ {
1120
+ configuredRegistry: bidiRegistry,
1121
+ requestedRegistry: "registry.example.com/team/app",
1122
+ },
1123
+ ];
1124
+
1125
+ await withDockerConfig(async () => {
1126
+ for (const testCase of cases) {
1127
+ const { dockerClient, dockerModule } = await loadDockerModuleWithAuths(
1128
+ testCase.configuredRegistry,
1129
+ );
1130
+
1131
+ await dockerModule.makeRequest(
1132
+ `images/create?fromImage=${testCase.requestedRegistry}:latest`,
1133
+ "POST",
1134
+ testCase.requestedRegistry,
1135
+ );
1136
+
1137
+ const requestOptions = dockerClient.firstCall.args[1];
1138
+ assert.strictEqual(requestOptions.headers, undefined);
1139
+ }
1140
+ });
1141
+ });
1142
+
1143
+ await it("docker makeRequest accepts raw host:port registry matches from config auths", async () => {
1144
+ await withDockerConfig(async () => {
1145
+ const { dockerClient, dockerModule } = await loadDockerModule({
1146
+ fsOverrides: {
1147
+ readFileSync: sinon.stub().returns(
1148
+ JSON.stringify({
1149
+ auths: {
1150
+ "localhost:5000": {
1151
+ auth: Buffer.from("trusted-user:trusted-pass").toString(
1152
+ "base64",
1153
+ ),
1154
+ },
1155
+ },
1156
+ }),
1157
+ ),
1158
+ },
1159
+ utilsOverrides: {
1160
+ safeExistsSync: sinon
1161
+ .stub()
1162
+ .callsFake((filePath) => filePath.endsWith("config.json")),
1163
+ },
1164
+ });
1165
+
1166
+ await dockerModule.makeRequest(
1167
+ "images/create?fromImage=localhost:5000/team/app:latest",
1168
+ "POST",
1169
+ "localhost:5000/team/app",
1170
+ );
1171
+
1172
+ const registryAuthHeader =
1173
+ dockerClient.firstCall.args[1].headers["X-Registry-Auth"];
1174
+ assert.deepStrictEqual(decodeRegistryAuthHeader(registryAuthHeader), {
1175
+ username: "trusted-user",
1176
+ password: "trusted-pass",
1177
+ serveraddress: "localhost:5000",
1178
+ });
1179
+ });
1180
+ });
1181
+
1182
+ await it("docker makeRequest keeps raw host:port registries separated by port", async () => {
1183
+ await withDockerConfig(async () => {
1184
+ const { dockerClient, dockerModule } = await loadDockerModule({
1185
+ fsOverrides: {
1186
+ readFileSync: sinon.stub().returns(
1187
+ JSON.stringify({
1188
+ auths: {
1189
+ "localhost:5001": {
1190
+ auth: Buffer.from("trusted-user:trusted-pass").toString(
1191
+ "base64",
1192
+ ),
1193
+ },
1194
+ },
1195
+ }),
1196
+ ),
1197
+ },
1198
+ utilsOverrides: {
1199
+ safeExistsSync: sinon
1200
+ .stub()
1201
+ .callsFake((filePath) => filePath.endsWith("config.json")),
1202
+ },
1203
+ });
1204
+
1205
+ await dockerModule.makeRequest(
1206
+ "images/create?fromImage=localhost:5000/team/app:latest",
1207
+ "POST",
1208
+ "localhost:5000/team/app",
1209
+ );
1210
+
1211
+ const requestOptions = dockerClient.firstCall.args[1];
1212
+ assert.strictEqual(requestOptions.headers, undefined);
1213
+ });
1214
+ });
1215
+
1216
+ await it("docker makeRequest preserves Docker Hub auth aliases without substring matching", async () => {
1217
+ const originalDockerConfig = process.env.DOCKER_CONFIG;
1218
+ process.env.DOCKER_CONFIG = "/tmp/cdxgen-docker-config";
1219
+ try {
1220
+ const { dockerClient, dockerModule } = await loadDockerModule({
1221
+ fsOverrides: {
1222
+ readFileSync: sinon.stub().returns(
1223
+ JSON.stringify({
1224
+ auths: {
1225
+ "https://index.docker.io/v1/": {
1226
+ auth: Buffer.from("hub-user:hub-pass").toString("base64"),
1227
+ },
1228
+ },
1229
+ }),
1230
+ ),
1231
+ },
1232
+ utilsOverrides: {
1233
+ safeExistsSync: sinon
1234
+ .stub()
1235
+ .callsFake((filePath) => filePath.endsWith("config.json")),
1236
+ },
1237
+ });
1238
+
1239
+ await dockerModule.makeRequest(
1240
+ "images/create?fromImage=docker.io/library/alpine:latest",
1241
+ "POST",
1242
+ "docker.io",
1243
+ );
1244
+
1245
+ const registryAuthHeader =
1246
+ dockerClient.firstCall.args[1].headers["X-Registry-Auth"];
1247
+ assert.deepStrictEqual(decodeRegistryAuthHeader(registryAuthHeader), {
1248
+ username: "hub-user",
1249
+ password: "hub-pass",
1250
+ serveraddress: "https://index.docker.io/v1/",
1251
+ });
1252
+ } finally {
1253
+ if (originalDockerConfig === undefined) {
1254
+ delete process.env.DOCKER_CONFIG;
1255
+ } else {
1256
+ process.env.DOCKER_CONFIG = originalDockerConfig;
1257
+ }
1258
+ }
1259
+ });
1260
+
1261
+ await it("docker makeRequest resolves unqualified image pulls to Docker Hub auth entries", async () => {
1262
+ const requestedImages = ["myorg/app:latest", "alpine:latest"];
1263
+
1264
+ await withDockerConfig(async () => {
1265
+ for (const requestedImage of requestedImages) {
1266
+ const { dockerClient, dockerModule } = await loadDockerModule({
1267
+ fsOverrides: {
1268
+ readFileSync: sinon.stub().returns(
1269
+ JSON.stringify({
1270
+ auths: {
1271
+ "https://index.docker.io/v1/": {
1272
+ auth: Buffer.from("hub-user:hub-pass").toString("base64"),
1273
+ },
1274
+ },
1275
+ }),
1276
+ ),
1277
+ },
1278
+ utilsOverrides: {
1279
+ safeExistsSync: dockerConfigExistsStub(),
1280
+ },
1281
+ });
1282
+
1283
+ await dockerModule.makeRequest(
1284
+ `images/create?fromImage=${requestedImage}`,
1285
+ "POST",
1286
+ "",
1287
+ );
1288
+
1289
+ const registryAuthHeader =
1290
+ dockerClient.firstCall.args[1].headers["X-Registry-Auth"];
1291
+ assert.deepStrictEqual(decodeRegistryAuthHeader(registryAuthHeader), {
1292
+ username: "hub-user",
1293
+ password: "hub-pass",
1294
+ serveraddress: "https://index.docker.io/v1/",
1295
+ });
1296
+ }
1297
+ });
1298
+ });
1299
+
1300
+ await it("docker makeRequest skips credHelpers for substring-matched registries", async () => {
1301
+ const originalDockerConfig = process.env.DOCKER_CONFIG;
1302
+ process.env.DOCKER_CONFIG = "/tmp/cdxgen-docker-config";
1303
+ try {
1304
+ const safeSpawnSync = sinon.stub().returns({
1305
+ status: 0,
1306
+ stdout: JSON.stringify({
1307
+ Username: "trusted-user",
1308
+ Secret: "trusted-pass",
1309
+ }),
1310
+ stderr: "",
1311
+ });
1312
+ const { dockerClient, dockerModule } = await loadDockerModule({
1313
+ fsOverrides: {
1314
+ readFileSync: sinon.stub().returns(
1315
+ JSON.stringify({
1316
+ credHelpers: {
1317
+ "private-registry.example.com": "osxkeychain",
1318
+ },
1319
+ }),
1320
+ ),
1321
+ },
1322
+ utilsOverrides: {
1323
+ safeExistsSync: sinon
1324
+ .stub()
1325
+ .callsFake((filePath) => filePath.endsWith("config.json")),
1326
+ safeSpawnSync,
1327
+ },
1328
+ });
1329
+
1330
+ await dockerModule.makeRequest(
1331
+ "images/create?fromImage=registry.example.com/team/app:latest",
1332
+ "POST",
1333
+ "registry.example.com",
1334
+ );
1335
+
1336
+ const requestOptions = dockerClient.firstCall.args[1];
1337
+ assert.strictEqual(requestOptions.headers, undefined);
1338
+ sinon.assert.notCalled(safeSpawnSync);
1339
+ } finally {
1340
+ if (originalDockerConfig === undefined) {
1341
+ delete process.env.DOCKER_CONFIG;
1342
+ } else {
1343
+ process.env.DOCKER_CONFIG = originalDockerConfig;
1344
+ }
1345
+ }
1346
+ });
1347
+
1348
+ await it("docker makeRequest accepts raw host:port registry matches from credHelpers", async () => {
1349
+ await withDockerConfig(async () => {
1350
+ const safeSpawnSync = sinon.stub().returns({
1351
+ status: 0,
1352
+ stdout: JSON.stringify({
1353
+ username: "trusted-user",
1354
+ Secret: "trusted-pass",
1355
+ }),
1356
+ stderr: "",
1357
+ });
1358
+ const { dockerClient, dockerModule } = await loadDockerModule({
1359
+ fsOverrides: {
1360
+ readFileSync: sinon.stub().returns(
1361
+ JSON.stringify({
1362
+ credHelpers: {
1363
+ "localhost:5000": "osxkeychain",
1364
+ },
1365
+ }),
1366
+ ),
1367
+ },
1368
+ utilsOverrides: {
1369
+ safeExistsSync: sinon
1370
+ .stub()
1371
+ .callsFake((filePath) => filePath.endsWith("config.json")),
1372
+ safeSpawnSync,
1373
+ },
1374
+ });
1375
+
1376
+ await dockerModule.makeRequest(
1377
+ "images/create?fromImage=localhost:5000/team/app:latest",
1378
+ "POST",
1379
+ "localhost:5000/team/app",
1380
+ );
1381
+
1382
+ sinon.assert.calledOnceWithExactly(
1383
+ safeSpawnSync,
1384
+ credHelperExe("osxkeychain"),
1385
+ ["get"],
1386
+ {
1387
+ input: "localhost:5000",
1388
+ },
1389
+ );
1390
+ const registryAuthHeader =
1391
+ dockerClient.firstCall.args[1].headers["X-Registry-Auth"];
1392
+ assert.deepStrictEqual(decodeRegistryAuthHeader(registryAuthHeader), {
1393
+ username: "trusted-user",
1394
+ password: "trusted-pass",
1395
+ email: "trusted-user",
1396
+ serveraddress: "localhost:5000",
1397
+ });
1398
+ });
1399
+ });
1400
+
1401
+ await it("docker getCredsFromHelper normalizes cache keys for equivalent registry hosts", async () => {
1402
+ const safeSpawnSync = sinon.stub().returns({
1403
+ status: 0,
1404
+ stdout: JSON.stringify({
1405
+ username: "trusted-user",
1406
+ Secret: "trusted-pass",
1407
+ }),
1408
+ stderr: "",
1409
+ });
1410
+ const { dockerModule } = await loadDockerModule({
1411
+ utilsOverrides: {
1412
+ safeSpawnSync,
1413
+ },
1414
+ });
1415
+
1416
+ const firstToken = dockerModule.getCredsFromHelper(
1417
+ "osxkeychain",
1418
+ "registry.example.com",
1419
+ );
1420
+ const secondToken = dockerModule.getCredsFromHelper(
1421
+ "osxkeychain",
1422
+ "https://registry.example.com/v2/",
1423
+ );
1424
+
1425
+ assert.strictEqual(firstToken, secondToken);
1426
+ sinon.assert.calledOnceWithExactly(
1427
+ safeSpawnSync,
1428
+ credHelperExe("osxkeychain"),
1429
+ ["get"],
1430
+ {
1431
+ input: "registry.example.com",
1432
+ },
1433
+ );
1434
+ });
1435
+
1436
+ await it("docker getCredsFromHelper keeps scoped path cache keys isolated", async () => {
1437
+ const safeSpawnSync = sinon.stub().returns({
1438
+ status: 0,
1439
+ stdout: JSON.stringify({
1440
+ username: "trusted-user",
1441
+ Secret: "trusted-pass",
1442
+ }),
1443
+ stderr: "",
1444
+ });
1445
+ const { dockerModule } = await loadDockerModule({
1446
+ utilsOverrides: {
1447
+ safeSpawnSync,
1448
+ },
1449
+ });
1450
+
1451
+ const firstToken = dockerModule.getCredsFromHelper(
1452
+ "osxkeychain",
1453
+ "https://registry.example.com/custom/subpath/v2/",
1454
+ );
1455
+ const secondToken = dockerModule.getCredsFromHelper(
1456
+ "osxkeychain",
1457
+ "https://registry.example.com/custom/subpath/v2/",
1458
+ );
1459
+ const thirdToken = dockerModule.getCredsFromHelper(
1460
+ "osxkeychain",
1461
+ "https://registry.example.com/other/subpath/v2/",
1462
+ );
1463
+
1464
+ assert.strictEqual(firstToken, secondToken);
1465
+ assert.notStrictEqual(firstToken, thirdToken);
1466
+ assert.deepStrictEqual(decodeRegistryAuthHeader(firstToken), {
1467
+ username: "trusted-user",
1468
+ password: "trusted-pass",
1469
+ email: "trusted-user",
1470
+ serveraddress: "https://registry.example.com/custom/subpath/v2/",
1471
+ });
1472
+ sinon.assert.calledTwice(safeSpawnSync);
1473
+ });
1474
+
1475
+ await it("docker makeRequest accepts ipv4 ipv6 explicit-port and scoped-subpath registry matches from credHelpers", async () => {
1476
+ const cases = [
1477
+ {
1478
+ configuredRegistry: "127.0.0.1:5000",
1479
+ requestedRegistry: "127.0.0.1:5000/team/app",
1480
+ },
1481
+ {
1482
+ configuredRegistry: "[::1]:5000",
1483
+ requestedRegistry: "[::1]:5000/team/app",
1484
+ },
1485
+ {
1486
+ configuredRegistry: "https://registry.example.com:443/v2/",
1487
+ requestedRegistry: "registry.example.com:443/team/app",
1488
+ },
1489
+ {
1490
+ configuredRegistry: "http://registry.example.com:80/v2/",
1491
+ requestedRegistry: "registry.example.com:80/team/app",
1492
+ },
1493
+ {
1494
+ configuredRegistry: "https://registry.example.com/custom/subpath/v2/",
1495
+ requestedRegistry: "registry.example.com/custom/subpath/team/app",
1496
+ },
1497
+ ];
1498
+
1499
+ await withDockerConfig(async () => {
1500
+ for (const testCase of cases) {
1501
+ const safeSpawnSync = sinon.stub().returns({
1502
+ status: 0,
1503
+ stdout: JSON.stringify({
1504
+ username: "trusted-user",
1505
+ Secret: "trusted-pass",
1506
+ }),
1507
+ stderr: "",
1508
+ });
1509
+ const { dockerClient, dockerModule } =
1510
+ await loadDockerModuleWithCredHelpers(
1511
+ testCase.configuredRegistry,
1512
+ safeSpawnSync,
1513
+ );
1514
+
1515
+ await dockerModule.makeRequest(
1516
+ `images/create?fromImage=${testCase.requestedRegistry}:latest`,
1517
+ "POST",
1518
+ testCase.requestedRegistry,
1519
+ );
1520
+
1521
+ sinon.assert.calledOnceWithExactly(
1522
+ safeSpawnSync,
1523
+ credHelperExe("osxkeychain"),
1524
+ ["get"],
1525
+ {
1526
+ input: testCase.configuredRegistry,
1527
+ },
1528
+ );
1529
+ const registryAuthHeader =
1530
+ dockerClient.firstCall.args[1].headers["X-Registry-Auth"];
1531
+ assert.deepStrictEqual(decodeRegistryAuthHeader(registryAuthHeader), {
1532
+ username: "trusted-user",
1533
+ password: "trusted-pass",
1534
+ email: "trusted-user",
1535
+ serveraddress: testCase.configuredRegistry,
1536
+ });
1537
+ }
1538
+ });
1539
+ });
1540
+
1541
+ await it("docker makeRequest does not invoke credHelpers for wildcard unicode bidi explicit-default-port or port-boundary mismatches", async () => {
1542
+ const bidiRegistry = "reg\u202eistry.example.com";
1543
+ const unicodeConfusableRegistry = "reg\u0456stry.example.com";
1544
+ const cases = [
1545
+ {
1546
+ configuredRegistry: "*.example.com",
1547
+ requestedRegistry: "team.example.com/app",
1548
+ },
1549
+ {
1550
+ configuredRegistry: "registry.example.com",
1551
+ requestedRegistry: "registry.example.com:80/team/app",
1552
+ },
1553
+ {
1554
+ configuredRegistry: "registry.example.com:443",
1555
+ requestedRegistry: "registry.example.com/team/app",
1556
+ },
1557
+ {
1558
+ configuredRegistry: "127.0.0.1:5001",
1559
+ requestedRegistry: "127.0.0.1:5000/team/app",
1560
+ },
1561
+ {
1562
+ configuredRegistry: "[::1]:5001",
1563
+ requestedRegistry: "[::1]:5000/team/app",
1564
+ },
1565
+ {
1566
+ configuredRegistry: "https://registry.example.com/custom/subpath/v2/",
1567
+ requestedRegistry: "registry.example.com/team/app",
1568
+ },
1569
+ {
1570
+ configuredRegistry: "https://registry.example.com/custom/subpath/v2/",
1571
+ requestedRegistry: "registry.example.com/custom/subpathology/team/app",
1572
+ },
1573
+ {
1574
+ configuredRegistry: "https://registry.example.com:443/v2/",
1575
+ requestedRegistry: "registry.example.com:444/team/app",
1576
+ },
1577
+ {
1578
+ configuredRegistry: unicodeConfusableRegistry,
1579
+ requestedRegistry: "registry.example.com/team/app",
1580
+ },
1581
+ {
1582
+ configuredRegistry: bidiRegistry,
1583
+ requestedRegistry: "registry.example.com/team/app",
1584
+ },
1585
+ ];
1586
+
1587
+ await withDockerConfig(async () => {
1588
+ for (const testCase of cases) {
1589
+ const safeSpawnSync = sinon.stub().returns({
1590
+ status: 0,
1591
+ stdout: JSON.stringify({
1592
+ username: "trusted-user",
1593
+ Secret: "trusted-pass",
1594
+ }),
1595
+ stderr: "",
1596
+ });
1597
+ const { dockerClient, dockerModule } =
1598
+ await loadDockerModuleWithCredHelpers(
1599
+ testCase.configuredRegistry,
1600
+ safeSpawnSync,
1601
+ );
1602
+
1603
+ await dockerModule.makeRequest(
1604
+ `images/create?fromImage=${testCase.requestedRegistry}:latest`,
1605
+ "POST",
1606
+ testCase.requestedRegistry,
1607
+ );
1608
+
1609
+ const requestOptions = dockerClient.firstCall.args[1];
1610
+ assert.strictEqual(requestOptions.headers, undefined);
1611
+ sinon.assert.notCalled(safeSpawnSync);
1612
+ }
1613
+ });
1614
+ });
1615
+
1616
+ await it("docker makeRequest resolves unqualified image pulls to Docker Hub credHelpers", async () => {
1617
+ const requestedImages = ["myorg/app:latest", "alpine:latest"];
1618
+
1619
+ await withDockerConfig(async () => {
1620
+ for (const requestedImage of requestedImages) {
1621
+ const safeSpawnSync = sinon.stub().returns({
1622
+ status: 0,
1623
+ stdout: JSON.stringify({
1624
+ username: "hub-user",
1625
+ Secret: "hub-pass",
1626
+ }),
1627
+ stderr: "",
1628
+ });
1629
+ const { dockerClient, dockerModule } = await loadDockerModule({
1630
+ fsOverrides: {
1631
+ readFileSync: sinon.stub().returns(
1632
+ JSON.stringify({
1633
+ credHelpers: {
1634
+ "docker.io": "osxkeychain",
1635
+ },
1636
+ }),
1637
+ ),
1638
+ },
1639
+ utilsOverrides: {
1640
+ safeExistsSync: dockerConfigExistsStub(),
1641
+ safeSpawnSync,
1642
+ },
1643
+ });
1644
+
1645
+ await dockerModule.makeRequest(
1646
+ `images/create?fromImage=${requestedImage}`,
1647
+ "POST",
1648
+ "",
1649
+ );
1650
+
1651
+ sinon.assert.calledOnceWithExactly(
1652
+ safeSpawnSync,
1653
+ credHelperExe("osxkeychain"),
1654
+ ["get"],
1655
+ {
1656
+ input: "docker.io",
1657
+ },
1658
+ );
1659
+ const registryAuthHeader =
1660
+ dockerClient.firstCall.args[1].headers["X-Registry-Auth"];
1661
+ assert.deepStrictEqual(decodeRegistryAuthHeader(registryAuthHeader), {
1662
+ username: "hub-user",
1663
+ password: "hub-pass",
1664
+ email: "hub-user",
1665
+ serveraddress: "docker.io",
1666
+ });
1667
+ }
1668
+ });
1669
+ });
1670
+
1671
+ await it("docker makeRequest accepts normalized exact matches for common public registries without aliasing hosts", async () => {
1672
+ const cases = [
1673
+ {
1674
+ configuredRegistry: "https://ghcr.io/v2/",
1675
+ requestedRegistry: "ghcr.io/org/image",
1676
+ },
1677
+ {
1678
+ configuredRegistry: "https://quay.io/v2/",
1679
+ requestedRegistry: "quay.io/org/image",
1680
+ },
1681
+ {
1682
+ configuredRegistry: "https://public.ecr.aws/v2/",
1683
+ requestedRegistry: "public.ecr.aws/alias/image",
1684
+ },
1685
+ {
1686
+ configuredRegistry: "https://gcr.io/v2/",
1687
+ requestedRegistry: "gcr.io/project/image",
1688
+ },
1689
+ ];
1690
+
1691
+ await withDockerConfig(async () => {
1692
+ for (const { configuredRegistry, requestedRegistry } of cases) {
1693
+ const { dockerClient, dockerModule } = await loadDockerModule({
1694
+ fsOverrides: {
1695
+ readFileSync: sinon.stub().returns(
1696
+ JSON.stringify({
1697
+ auths: {
1698
+ [configuredRegistry]: {
1699
+ auth: Buffer.from("trusted-user:trusted-pass").toString(
1700
+ "base64",
1701
+ ),
1702
+ },
1703
+ },
1704
+ }),
1705
+ ),
1706
+ },
1707
+ utilsOverrides: {
1708
+ safeExistsSync: sinon
1709
+ .stub()
1710
+ .callsFake((filePath) => filePath.endsWith("config.json")),
1711
+ },
1712
+ });
1713
+
1714
+ await dockerModule.makeRequest(
1715
+ `images/create?fromImage=${requestedRegistry}:latest`,
1716
+ "POST",
1717
+ requestedRegistry,
1718
+ );
1719
+
1720
+ const registryAuthHeader =
1721
+ dockerClient.firstCall.args[1].headers["X-Registry-Auth"];
1722
+ assert.deepStrictEqual(decodeRegistryAuthHeader(registryAuthHeader), {
1723
+ username: "trusted-user",
1724
+ password: "trusted-pass",
1725
+ serveraddress: configuredRegistry,
1726
+ });
1727
+ }
1728
+ });
1729
+ });
1730
+
1731
+ await it("docker makeRequest keeps ghcr quay aws and gcp registries on separate trust boundaries", async () => {
1732
+ const cases = [
1733
+ {
1734
+ configuredRegistry: "https://tenant.ghcr.io/v2/",
1735
+ requestedRegistry: "ghcr.io",
1736
+ },
1737
+ {
1738
+ configuredRegistry: "https://quay.io.evil.example/v2/",
1739
+ requestedRegistry: "quay.io",
1740
+ },
1741
+ {
1742
+ configuredRegistry:
1743
+ "https://123456789012.dkr.ecr.us-east-1.amazonaws.com/v2/",
1744
+ requestedRegistry: "public.ecr.aws",
1745
+ },
1746
+ {
1747
+ configuredRegistry: "https://mirror.gcr.io/v2/",
1748
+ requestedRegistry: "gcr.io",
1749
+ },
1750
+ {
1751
+ configuredRegistry: "https://us-docker.pkg.dev/v2/",
1752
+ requestedRegistry: "gcr.io",
1753
+ },
1754
+ ];
1755
+
1756
+ await withDockerConfig(async () => {
1757
+ for (const { configuredRegistry, requestedRegistry } of cases) {
1758
+ const { dockerClient, dockerModule } = await loadDockerModule({
1759
+ fsOverrides: {
1760
+ readFileSync: sinon.stub().returns(
1761
+ JSON.stringify({
1762
+ auths: {
1763
+ [configuredRegistry]: {
1764
+ auth: Buffer.from("trusted-user:trusted-pass").toString(
1765
+ "base64",
1766
+ ),
1767
+ },
1768
+ },
1769
+ }),
1770
+ ),
1771
+ },
1772
+ utilsOverrides: {
1773
+ safeExistsSync: sinon
1774
+ .stub()
1775
+ .callsFake((filePath) => filePath.endsWith("config.json")),
1776
+ },
1777
+ });
1778
+
1779
+ await dockerModule.makeRequest(
1780
+ `images/create?fromImage=${requestedRegistry}/team/app:latest`,
1781
+ "POST",
1782
+ requestedRegistry,
1783
+ );
1784
+
1785
+ const requestOptions = dockerClient.firstCall.args[1];
1786
+ assert.strictEqual(requestOptions.headers, undefined);
1787
+ }
1788
+ });
1789
+ });
1790
+
326
1791
  await it("extractFromManifest derives PATH metadata from archive config", async () => {
327
1792
  const tempDir = mkdtempSync(join(tmpdir(), "cdxgen-docker-"));
328
1793
  try {