@cyclonedx/cdxgen 12.1.4 → 12.2.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 (184) hide show
  1. package/README.md +47 -39
  2. package/bin/cdxgen.js +181 -90
  3. package/bin/evinse.js +4 -4
  4. package/bin/repl.js +3 -3
  5. package/bin/sign.js +102 -0
  6. package/bin/validate.js +233 -0
  7. package/bin/verify.js +69 -28
  8. package/data/queries.json +1 -1
  9. package/data/rules/ci-permissions.yaml +186 -0
  10. package/data/rules/dependency-sources.yaml +123 -0
  11. package/data/rules/package-integrity.yaml +135 -0
  12. package/data/rules/vscode-extensions.yaml +228 -0
  13. package/lib/cli/index.js +484 -440
  14. package/lib/evinser/db.js +137 -0
  15. package/lib/{helpers → evinser}/db.poku.js +2 -6
  16. package/lib/evinser/evinser.js +5 -18
  17. package/lib/evinser/swiftsem.js +1 -1
  18. package/lib/helpers/bomSigner.js +312 -0
  19. package/lib/helpers/bomSigner.poku.js +156 -0
  20. package/lib/helpers/caxa.js +1 -1
  21. package/lib/helpers/ciParsers/azurePipelines.js +295 -0
  22. package/lib/helpers/ciParsers/azurePipelines.poku.js +253 -0
  23. package/lib/helpers/ciParsers/circleCi.js +286 -0
  24. package/lib/helpers/ciParsers/circleCi.poku.js +230 -0
  25. package/lib/helpers/ciParsers/common.js +24 -0
  26. package/lib/helpers/ciParsers/githubActions.js +636 -0
  27. package/lib/helpers/ciParsers/githubActions.poku.js +802 -0
  28. package/lib/helpers/ciParsers/gitlabCi.js +213 -0
  29. package/lib/helpers/ciParsers/gitlabCi.poku.js +247 -0
  30. package/lib/helpers/ciParsers/jenkins.js +181 -0
  31. package/lib/helpers/ciParsers/jenkins.poku.js +197 -0
  32. package/lib/helpers/depsUtils.js +203 -0
  33. package/lib/helpers/depsUtils.poku.js +150 -0
  34. package/lib/helpers/display.js +429 -14
  35. package/lib/helpers/envcontext.js +23 -8
  36. package/lib/helpers/formulationParsers.js +351 -0
  37. package/lib/helpers/logger.js +14 -0
  38. package/lib/helpers/protobom.js +9 -9
  39. package/lib/helpers/pythonutils.js +305 -0
  40. package/lib/helpers/pythonutils.poku.js +469 -0
  41. package/lib/helpers/utils.js +970 -528
  42. package/lib/helpers/utils.poku.js +139 -256
  43. package/lib/helpers/versutils.js +202 -0
  44. package/lib/helpers/versutils.poku.js +315 -0
  45. package/lib/helpers/vsixutils.js +1061 -0
  46. package/lib/helpers/vsixutils.poku.js +2247 -0
  47. package/lib/managers/binary.js +19 -19
  48. package/lib/managers/docker.js +108 -1
  49. package/lib/managers/oci.js +10 -0
  50. package/lib/managers/piptree.js +4 -10
  51. package/lib/parsers/npmrc.js +92 -0
  52. package/lib/parsers/npmrc.poku.js +528 -0
  53. package/lib/server/openapi.yaml +1 -10
  54. package/lib/server/server.js +58 -16
  55. package/lib/server/server.poku.js +123 -144
  56. package/lib/stages/postgen/annotator.js +1 -1
  57. package/lib/stages/postgen/auditBom.js +197 -0
  58. package/lib/stages/postgen/auditBom.poku.js +378 -0
  59. package/lib/stages/postgen/postgen.js +54 -1
  60. package/lib/stages/postgen/postgen.poku.js +90 -1
  61. package/lib/stages/postgen/ruleEngine.js +369 -0
  62. package/lib/stages/pregen/envAudit.js +299 -0
  63. package/lib/stages/pregen/envAudit.poku.js +572 -0
  64. package/lib/stages/pregen/pregen.js +12 -8
  65. package/lib/third-party/arborist/lib/deepest-nesting-target.js +1 -1
  66. package/lib/third-party/arborist/lib/node.js +3 -3
  67. package/lib/third-party/arborist/lib/shrinkwrap.js +1 -1
  68. package/lib/third-party/arborist/lib/tree-check.js +1 -1
  69. package/lib/{helpers/validator.js → validator/bomValidator.js} +107 -47
  70. package/lib/validator/complianceEngine.js +241 -0
  71. package/lib/validator/complianceEngine.poku.js +168 -0
  72. package/lib/validator/complianceRules.js +1610 -0
  73. package/lib/validator/complianceRules.poku.js +328 -0
  74. package/lib/validator/index.js +222 -0
  75. package/lib/validator/index.poku.js +144 -0
  76. package/lib/validator/reporters/annotations.js +121 -0
  77. package/lib/validator/reporters/console.js +149 -0
  78. package/lib/validator/reporters/index.js +41 -0
  79. package/lib/validator/reporters/json.js +37 -0
  80. package/lib/validator/reporters/sarif.js +184 -0
  81. package/lib/validator/reporters.poku.js +150 -0
  82. package/package.json +8 -8
  83. package/types/bin/sign.d.ts +3 -0
  84. package/types/bin/sign.d.ts.map +1 -0
  85. package/types/bin/validate.d.ts +3 -0
  86. package/types/bin/validate.d.ts.map +1 -0
  87. package/types/helpers/utils.d.ts +0 -1
  88. package/types/lib/cli/index.d.ts +49 -52
  89. package/types/lib/cli/index.d.ts.map +1 -1
  90. package/types/lib/evinser/db.d.ts +34 -0
  91. package/types/lib/evinser/db.d.ts.map +1 -0
  92. package/types/lib/evinser/evinser.d.ts +63 -16
  93. package/types/lib/evinser/evinser.d.ts.map +1 -1
  94. package/types/lib/helpers/bomSigner.d.ts +27 -0
  95. package/types/lib/helpers/bomSigner.d.ts.map +1 -0
  96. package/types/lib/helpers/ciParsers/azurePipelines.d.ts +17 -0
  97. package/types/lib/helpers/ciParsers/azurePipelines.d.ts.map +1 -0
  98. package/types/lib/helpers/ciParsers/circleCi.d.ts +17 -0
  99. package/types/lib/helpers/ciParsers/circleCi.d.ts.map +1 -0
  100. package/types/lib/helpers/ciParsers/common.d.ts +11 -0
  101. package/types/lib/helpers/ciParsers/common.d.ts.map +1 -0
  102. package/types/lib/helpers/ciParsers/githubActions.d.ts +34 -0
  103. package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -0
  104. package/types/lib/helpers/ciParsers/gitlabCi.d.ts +17 -0
  105. package/types/lib/helpers/ciParsers/gitlabCi.d.ts.map +1 -0
  106. package/types/lib/helpers/ciParsers/jenkins.d.ts +17 -0
  107. package/types/lib/helpers/ciParsers/jenkins.d.ts.map +1 -0
  108. package/types/lib/helpers/depsUtils.d.ts +21 -0
  109. package/types/lib/helpers/depsUtils.d.ts.map +1 -0
  110. package/types/lib/helpers/display.d.ts +111 -11
  111. package/types/lib/helpers/display.d.ts.map +1 -1
  112. package/types/lib/helpers/envcontext.d.ts +19 -7
  113. package/types/lib/helpers/envcontext.d.ts.map +1 -1
  114. package/types/lib/helpers/formulationParsers.d.ts +50 -0
  115. package/types/lib/helpers/formulationParsers.d.ts.map +1 -0
  116. package/types/lib/helpers/logger.d.ts +15 -1
  117. package/types/lib/helpers/logger.d.ts.map +1 -1
  118. package/types/lib/helpers/protobom.d.ts +2 -2
  119. package/types/lib/helpers/pythonutils.d.ts +18 -0
  120. package/types/lib/helpers/pythonutils.d.ts.map +1 -0
  121. package/types/lib/helpers/utils.d.ts +532 -128
  122. package/types/lib/helpers/utils.d.ts.map +1 -1
  123. package/types/lib/helpers/versutils.d.ts +8 -0
  124. package/types/lib/helpers/versutils.d.ts.map +1 -0
  125. package/types/lib/helpers/vsixutils.d.ts +130 -0
  126. package/types/lib/helpers/vsixutils.d.ts.map +1 -0
  127. package/types/lib/managers/docker.d.ts +12 -31
  128. package/types/lib/managers/docker.d.ts.map +1 -1
  129. package/types/lib/managers/oci.d.ts +11 -1
  130. package/types/lib/managers/oci.d.ts.map +1 -1
  131. package/types/lib/managers/piptree.d.ts.map +1 -1
  132. package/types/lib/parsers/npmrc.d.ts +26 -0
  133. package/types/lib/parsers/npmrc.d.ts.map +1 -0
  134. package/types/lib/server/server.d.ts +21 -2
  135. package/types/lib/server/server.d.ts.map +1 -1
  136. package/types/lib/stages/postgen/auditBom.d.ts +20 -0
  137. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -0
  138. package/types/lib/stages/postgen/postgen.d.ts +8 -1
  139. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  140. package/types/lib/stages/postgen/ruleEngine.d.ts +18 -0
  141. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -0
  142. package/types/lib/stages/pregen/envAudit.d.ts +8 -0
  143. package/types/lib/stages/pregen/envAudit.d.ts.map +1 -0
  144. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
  145. package/types/lib/{helpers/validator.d.ts → validator/bomValidator.d.ts} +1 -1
  146. package/types/lib/validator/bomValidator.d.ts.map +1 -0
  147. package/types/lib/validator/complianceEngine.d.ts +66 -0
  148. package/types/lib/validator/complianceEngine.d.ts.map +1 -0
  149. package/types/lib/validator/complianceRules.d.ts +70 -0
  150. package/types/lib/validator/complianceRules.d.ts.map +1 -0
  151. package/types/lib/validator/index.d.ts +70 -0
  152. package/types/lib/validator/index.d.ts.map +1 -0
  153. package/types/lib/validator/reporters/annotations.d.ts +31 -0
  154. package/types/lib/validator/reporters/annotations.d.ts.map +1 -0
  155. package/types/lib/validator/reporters/console.d.ts +30 -0
  156. package/types/lib/validator/reporters/console.d.ts.map +1 -0
  157. package/types/lib/validator/reporters/index.d.ts +21 -0
  158. package/types/lib/validator/reporters/index.d.ts.map +1 -0
  159. package/types/lib/validator/reporters/json.d.ts +11 -0
  160. package/types/lib/validator/reporters/json.d.ts.map +1 -0
  161. package/types/lib/validator/reporters/sarif.d.ts +16 -0
  162. package/types/lib/validator/reporters/sarif.d.ts.map +1 -0
  163. package/lib/helpers/db.js +0 -162
  164. package/types/helpers/db.d.ts +0 -35
  165. package/types/helpers/db.d.ts.map +0 -1
  166. package/types/lib/helpers/db.d.ts +0 -35
  167. package/types/lib/helpers/db.d.ts.map +0 -1
  168. package/types/lib/helpers/validator.d.ts.map +0 -1
  169. package/types/managers/binary.d.ts +0 -37
  170. package/types/managers/binary.d.ts.map +0 -1
  171. package/types/managers/docker.d.ts +0 -56
  172. package/types/managers/docker.d.ts.map +0 -1
  173. package/types/managers/oci.d.ts +0 -2
  174. package/types/managers/oci.d.ts.map +0 -1
  175. package/types/managers/piptree.d.ts +0 -2
  176. package/types/managers/piptree.d.ts.map +0 -1
  177. package/types/server/server.d.ts +0 -34
  178. package/types/server/server.d.ts.map +0 -1
  179. package/types/stages/postgen/annotator.d.ts +0 -27
  180. package/types/stages/postgen/annotator.d.ts.map +0 -1
  181. package/types/stages/postgen/postgen.d.ts +0 -51
  182. package/types/stages/postgen/postgen.d.ts.map +0 -1
  183. package/types/stages/pregen/pregen.d.ts +0 -59
  184. package/types/stages/pregen/pregen.d.ts.map +0 -1
@@ -0,0 +1,286 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ import { v4 as uuidv4 } from "uuid";
4
+ import { parse as _load } from "yaml";
5
+
6
+ import { disambiguateSteps } from "./common.js";
7
+
8
+ /**
9
+ * Parse a single CircleCI config file and return formulation-shaped data.
10
+ *
11
+ * @param {string} f Absolute path to the config file
12
+ * @param {Object} _options CLI options
13
+ * @returns {{ workflows: Object[], components: Object[], services: Object[], properties: Object[], dependencies: Object[] }}
14
+ */
15
+ function parseCircleCiFile(f, _options) {
16
+ const workflows = [];
17
+ const components = [];
18
+ const dependencies = [];
19
+
20
+ let raw;
21
+ try {
22
+ raw = readFileSync(f, { encoding: "utf-8" });
23
+ } catch (_e) {
24
+ return {
25
+ workflows,
26
+ components,
27
+ services: [],
28
+ properties: [],
29
+ dependencies,
30
+ };
31
+ }
32
+
33
+ let yamlObj;
34
+ try {
35
+ yamlObj = _load(raw);
36
+ } catch (_e) {
37
+ return {
38
+ workflows,
39
+ components,
40
+ services: [],
41
+ properties: [],
42
+ dependencies,
43
+ };
44
+ }
45
+
46
+ if (!yamlObj || typeof yamlObj !== "object") {
47
+ return {
48
+ workflows,
49
+ components,
50
+ services: [],
51
+ properties: [],
52
+ dependencies,
53
+ };
54
+ }
55
+
56
+ // Collect orbs as components
57
+ if (yamlObj.orbs && typeof yamlObj.orbs === "object") {
58
+ for (const [orbAlias, orbRef] of Object.entries(yamlObj.orbs)) {
59
+ if (typeof orbRef === "string") {
60
+ const atIdx = orbRef.lastIndexOf("@");
61
+ const fullName = atIdx >= 0 ? orbRef.substring(0, atIdx) : orbRef;
62
+ const version = atIdx >= 0 ? orbRef.substring(atIdx + 1) : "";
63
+ const slashIdx = fullName.indexOf("/");
64
+ const namespace = slashIdx >= 0 ? fullName.substring(0, slashIdx) : "";
65
+ const name =
66
+ slashIdx >= 0 ? fullName.substring(slashIdx + 1) : fullName;
67
+ components.push({
68
+ "bom-ref": orbRef,
69
+ type: "application",
70
+ group: namespace,
71
+ name,
72
+ version,
73
+ properties: [
74
+ { name: "SrcFile", value: f },
75
+ { name: "cdx:circleci:orb:alias", value: orbAlias },
76
+ ],
77
+ });
78
+ }
79
+ }
80
+ }
81
+
82
+ // Collect executor images as components
83
+ if (yamlObj.executors && typeof yamlObj.executors === "object") {
84
+ for (const [exName, exDef] of Object.entries(yamlObj.executors)) {
85
+ const image =
86
+ exDef?.docker?.[0]?.image ||
87
+ exDef?.machine?.image ||
88
+ exDef?.macos?.xcode ||
89
+ "";
90
+ if (image) {
91
+ components.push({
92
+ type: "container",
93
+ name: image,
94
+ properties: [
95
+ { name: "SrcFile", value: f },
96
+ { name: "cdx:circleci:executor:name", value: exName },
97
+ ],
98
+ });
99
+ }
100
+ }
101
+ }
102
+
103
+ // Build a workflow/task tree per CircleCI workflow
104
+ const circleCiWorkflows =
105
+ yamlObj.workflows && typeof yamlObj.workflows === "object"
106
+ ? Object.entries(yamlObj.workflows).filter(([key]) => key !== "version")
107
+ : [];
108
+
109
+ for (const [wfName, wfDef] of circleCiWorkflows) {
110
+ if (!wfDef || typeof wfDef !== "object") {
111
+ continue;
112
+ }
113
+ const workflowRef = uuidv4();
114
+ const tasks = [];
115
+ const workflowDependsOn = [];
116
+
117
+ const wfJobs = Array.isArray(wfDef.jobs) ? wfDef.jobs : [];
118
+ for (const jobEntry of wfJobs) {
119
+ // Each entry is either a string (job name) or { jobName: { requires, ... } }
120
+ let jobName;
121
+ let jobConfig = {};
122
+ if (typeof jobEntry === "string") {
123
+ jobName = jobEntry;
124
+ } else if (typeof jobEntry === "object") {
125
+ jobName = Object.keys(jobEntry)[0];
126
+ jobConfig = jobEntry[jobName] || {};
127
+ }
128
+ if (!jobName) {
129
+ continue;
130
+ }
131
+
132
+ const taskRef = uuidv4();
133
+ const taskProperties = [
134
+ { name: "cdx:circleci:job:name", value: jobName },
135
+ ];
136
+
137
+ const requires = Array.isArray(jobConfig.requires)
138
+ ? jobConfig.requires
139
+ : [];
140
+ if (requires.length) {
141
+ taskProperties.push({
142
+ name: "cdx:circleci:job:requires",
143
+ value: requires.join(","),
144
+ });
145
+ }
146
+
147
+ const jobFilters = jobConfig.filters;
148
+ if (jobFilters?.branches) {
149
+ const only = Array.isArray(jobFilters.branches.only)
150
+ ? jobFilters.branches.only.join(",")
151
+ : jobFilters.branches.only || "";
152
+ if (only) {
153
+ taskProperties.push({
154
+ name: "cdx:circleci:job:branch:only",
155
+ value: only,
156
+ });
157
+ }
158
+ }
159
+
160
+ // Look up job definition for steps
161
+ const jobDef = yamlObj.jobs?.[jobName] || {};
162
+ const steps = [];
163
+ for (const step of Array.isArray(jobDef.steps) ? jobDef.steps : []) {
164
+ if (typeof step === "string") {
165
+ steps.push({ name: step });
166
+ } else if (typeof step === "object") {
167
+ const stepKey = Object.keys(step)[0];
168
+ const stepVal = step[stepKey];
169
+ const stepName =
170
+ typeof stepVal?.name === "string" ? stepVal.name : stepKey;
171
+ const command =
172
+ typeof stepVal?.command === "string" ? stepVal.command : undefined;
173
+ steps.push({
174
+ name: stepName,
175
+ commands: command ? [{ executed: command }] : undefined,
176
+ });
177
+ }
178
+ }
179
+
180
+ tasks.push({
181
+ "bom-ref": taskRef,
182
+ uid: taskRef,
183
+ name: jobName,
184
+ taskTypes: ["build"],
185
+ steps: disambiguateSteps(steps),
186
+ properties: taskProperties,
187
+ });
188
+ workflowDependsOn.push(taskRef);
189
+ }
190
+
191
+ const workflow = {
192
+ "bom-ref": workflowRef,
193
+ uid: workflowRef,
194
+ name: wfName,
195
+ taskTypes: ["build"],
196
+ tasks: tasks.length ? tasks : undefined,
197
+ properties: [
198
+ { name: "cdx:circleci:config", value: f },
199
+ { name: "cdx:circleci:workflow:name", value: wfName },
200
+ ],
201
+ };
202
+
203
+ workflows.push(workflow);
204
+ if (workflowDependsOn.length) {
205
+ dependencies.push({ ref: workflowRef, dependsOn: workflowDependsOn });
206
+ }
207
+ }
208
+
209
+ // Fallback: if no workflows block, create a single workflow from jobs
210
+ if (
211
+ workflows.length === 0 &&
212
+ yamlObj.jobs &&
213
+ typeof yamlObj.jobs === "object"
214
+ ) {
215
+ const workflowRef = uuidv4();
216
+ const tasks = [];
217
+ const workflowDependsOn = [];
218
+
219
+ for (const jobName of Object.keys(yamlObj.jobs)) {
220
+ const taskRef = uuidv4();
221
+ tasks.push({
222
+ "bom-ref": taskRef,
223
+ uid: taskRef,
224
+ name: jobName,
225
+ taskTypes: ["build"],
226
+ properties: [{ name: "cdx:circleci:job:name", value: jobName }],
227
+ });
228
+ workflowDependsOn.push(taskRef);
229
+ }
230
+
231
+ workflows.push({
232
+ "bom-ref": workflowRef,
233
+ uid: workflowRef,
234
+ name: "CircleCI Pipeline",
235
+ taskTypes: ["build"],
236
+ tasks: tasks.length ? tasks : undefined,
237
+ properties: [{ name: "cdx:circleci:config", value: f }],
238
+ });
239
+ if (workflowDependsOn.length) {
240
+ dependencies.push({ ref: workflowRef, dependsOn: workflowDependsOn });
241
+ }
242
+ }
243
+
244
+ return { workflows, components, services: [], properties: [], dependencies };
245
+ }
246
+
247
+ /**
248
+ * CircleCI formulation parser.
249
+ *
250
+ * Matches `.circleci/config.yml` and `.circleci/config.yaml` and converts them
251
+ * into CycloneDX formulation workflow objects. Referenced orbs are captured as
252
+ * components.
253
+ *
254
+ * Parser contract: `parse(files, options)` returns
255
+ * `{ workflows, components, services, properties, dependencies }`.
256
+ */
257
+ export const circleCiParser = {
258
+ id: "circleci",
259
+ patterns: [".circleci/config.{yml,yaml}"],
260
+
261
+ /**
262
+ * @param {string[]} files Matched config file paths
263
+ * @param {Object} options CLI options
264
+ * @returns {{ workflows: Object[], components: Object[], services: Object[], properties: Object[], dependencies: Object[] }}
265
+ */
266
+ parse(files, options) {
267
+ const workflows = [];
268
+ const components = [];
269
+ const dependencies = [];
270
+
271
+ for (const f of files) {
272
+ const result = parseCircleCiFile(f, options);
273
+ workflows.push(...result.workflows);
274
+ components.push(...result.components);
275
+ dependencies.push(...result.dependencies);
276
+ }
277
+
278
+ return {
279
+ workflows,
280
+ components,
281
+ services: [],
282
+ properties: [],
283
+ dependencies,
284
+ };
285
+ },
286
+ };
@@ -0,0 +1,230 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { assert, describe, it } from "poku";
5
+
6
+ import { circleCiParser } from "./circleCi.js";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const repoRoot = path.resolve(__dirname, "../../..");
10
+
11
+ describe("circleCiParser", () => {
12
+ it("has correct metadata", () => {
13
+ assert.strictEqual(circleCiParser.id, "circleci");
14
+ assert.ok(Array.isArray(circleCiParser.patterns));
15
+ assert.ok(circleCiParser.patterns.length > 0);
16
+ assert.strictEqual(typeof circleCiParser.parse, "function");
17
+ });
18
+
19
+ it("returns empty arrays for no files", () => {
20
+ const result = circleCiParser.parse([], {});
21
+ assert.deepStrictEqual(result.workflows, []);
22
+ assert.deepStrictEqual(result.components, []);
23
+ assert.deepStrictEqual(result.services, []);
24
+ assert.deepStrictEqual(result.properties, []);
25
+ assert.deepStrictEqual(result.dependencies, []);
26
+ });
27
+
28
+ it("parses the CircleCI fixture", () => {
29
+ const f = path.join(repoRoot, "test", "data", "circleci-config.yml");
30
+ const result = circleCiParser.parse([f], {});
31
+
32
+ assert.ok(Array.isArray(result.workflows));
33
+ assert.ok(result.workflows.length > 0, "expected at least one workflow");
34
+
35
+ // The fixture has one workflow named 'build-test-deploy'
36
+ const wf = result.workflows.find((w) => w.name === "build-test-deploy");
37
+ assert.ok(wf, "expected build-test-deploy workflow");
38
+ assert.ok(wf["bom-ref"]);
39
+ assert.ok(Array.isArray(wf.tasks));
40
+ assert.ok(wf.tasks.length > 0);
41
+
42
+ const taskNames = wf.tasks.map((t) => t.name);
43
+ assert.ok(taskNames.includes("build"), "expected build job");
44
+ assert.ok(taskNames.includes("test"), "expected test job");
45
+ });
46
+
47
+ it("captures orb references as components", () => {
48
+ const f = path.join(repoRoot, "test", "data", "circleci-config.yml");
49
+ const result = circleCiParser.parse([f], {});
50
+
51
+ // The fixture uses circleci/node and circleci/aws-ecr orbs
52
+ assert.ok(result.components.length > 0, "expected orb components");
53
+ const orbNames = result.components.map((c) => c.name);
54
+ assert.ok(orbNames.includes("node"), "expected circleci/node orb");
55
+ assert.ok(orbNames.includes("aws-ecr"), "expected circleci/aws-ecr orb");
56
+ });
57
+
58
+ it("captures executor images as components", () => {
59
+ const f = path.join(repoRoot, "test", "data", "circleci-config.yml");
60
+ const result = circleCiParser.parse([f], {});
61
+
62
+ const containerComps = result.components.filter(
63
+ (c) => c.type === "container",
64
+ );
65
+ assert.ok(
66
+ containerComps.length > 0,
67
+ "expected container executor components",
68
+ );
69
+ assert.ok(
70
+ containerComps.some((c) => c.name?.includes("node")),
71
+ "expected a node executor image component",
72
+ );
73
+ });
74
+
75
+ it("produces workflow dependency links", () => {
76
+ const f = path.join(repoRoot, "test", "data", "circleci-config.yml");
77
+ const result = circleCiParser.parse([f], {});
78
+
79
+ assert.ok(result.dependencies.length > 0);
80
+ const wfDep = result.dependencies.find(
81
+ (d) => d.ref === result.workflows[0]["bom-ref"],
82
+ );
83
+ assert.ok(wfDep);
84
+ assert.ok(wfDep.dependsOn.length > 0);
85
+ });
86
+
87
+ it("gracefully handles missing file", () => {
88
+ const result = circleCiParser.parse(["/no/such/.circleci/config.yml"], {});
89
+ assert.deepStrictEqual(result.workflows, []);
90
+ assert.deepStrictEqual(result.components, []);
91
+ });
92
+
93
+ it("parses circleci-machine.yml: machine executor components extracted", () => {
94
+ const f = path.join(repoRoot, "test", "data", "circleci-machine.yml");
95
+ const result = circleCiParser.parse([f], {});
96
+
97
+ // machine executors produce container components
98
+ const machineComps = result.components.filter(
99
+ (c) => c.type === "container" && c.name?.includes("ubuntu"),
100
+ );
101
+ assert.ok(
102
+ machineComps.length > 0,
103
+ "expected ubuntu machine executor components",
104
+ );
105
+ });
106
+
107
+ it("parses circleci-machine.yml: no orbs — orb components absent", () => {
108
+ const f = path.join(repoRoot, "test", "data", "circleci-machine.yml");
109
+ const result = circleCiParser.parse([f], {});
110
+ const orbComps = result.components.filter((c) => c.type === "application");
111
+ assert.strictEqual(orbComps.length, 0, "no orb components expected");
112
+ });
113
+
114
+ it("parses circleci-machine.yml: approval gate job present", () => {
115
+ const f = path.join(repoRoot, "test", "data", "circleci-machine.yml");
116
+ const result = circleCiParser.parse([f], {});
117
+
118
+ const wf = result.workflows.find((w) => w.name === "ci-cd");
119
+ assert.ok(wf, "expected ci-cd workflow");
120
+ const taskNames = wf.tasks.map((t) => t.name);
121
+ assert.ok(
122
+ taskNames.includes("hold-for-approval"),
123
+ "expected hold-for-approval task",
124
+ );
125
+ assert.ok(
126
+ taskNames.includes("deploy-staging"),
127
+ "expected deploy-staging task",
128
+ );
129
+ assert.ok(
130
+ taskNames.includes("deploy-production"),
131
+ "expected deploy-production task",
132
+ );
133
+ });
134
+
135
+ it("parses circleci-machine.yml: requires chain recorded in task properties", () => {
136
+ const f = path.join(repoRoot, "test", "data", "circleci-machine.yml");
137
+ const result = circleCiParser.parse([f], {});
138
+
139
+ const wf = result.workflows[0];
140
+ const approvalTask = wf.tasks.find((t) => t.name === "hold-for-approval");
141
+ assert.ok(approvalTask, "hold-for-approval task must exist");
142
+ const requiresProp = approvalTask.properties.find(
143
+ (p) => p.name === "cdx:circleci:job:requires",
144
+ );
145
+ assert.ok(requiresProp, "expected cdx:circleci:job:requires property");
146
+ assert.ok(
147
+ requiresProp.value.includes("integration-test"),
148
+ "requires must include integration-test",
149
+ );
150
+ assert.ok(
151
+ requiresProp.value.includes("security-scan"),
152
+ "requires must include security-scan",
153
+ );
154
+ });
155
+
156
+ it("parses circleci-docker-sidecar.yml: multiple workflows extracted", () => {
157
+ const f = path.join(
158
+ repoRoot,
159
+ "test",
160
+ "data",
161
+ "circleci-docker-sidecar.yml",
162
+ );
163
+ const result = circleCiParser.parse([f], {});
164
+
165
+ const wfNames = result.workflows.map((w) => w.name);
166
+ assert.ok(wfNames.includes("test-matrix"), "expected test-matrix workflow");
167
+ assert.ok(
168
+ wfNames.includes("scheduled-tests"),
169
+ "expected scheduled-tests workflow",
170
+ );
171
+ });
172
+
173
+ it("parses circleci-docker-sidecar.yml: sidecar containers as executor components", () => {
174
+ const f = path.join(
175
+ repoRoot,
176
+ "test",
177
+ "data",
178
+ "circleci-docker-sidecar.yml",
179
+ );
180
+ const result = circleCiParser.parse([f], {});
181
+
182
+ // The app-with-db executor has cimg/python as first image
183
+ const pythonComp = result.components.find(
184
+ (c) => c.type === "container" && c.name?.includes("python"),
185
+ );
186
+ assert.ok(pythonComp, "expected Python executor image component");
187
+
188
+ // The app-with-mongo executor has cimg/node:20.0 as primary image
189
+ const nodeComp = result.components.find(
190
+ (c) => c.type === "container" && c.name?.includes("node"),
191
+ );
192
+ assert.ok(nodeComp, "expected Node.js executor image component");
193
+ });
194
+
195
+ it("parses circleci-docker-sidecar.yml: Slack orb captured as component", () => {
196
+ const f = path.join(
197
+ repoRoot,
198
+ "test",
199
+ "data",
200
+ "circleci-docker-sidecar.yml",
201
+ );
202
+ const result = circleCiParser.parse([f], {});
203
+
204
+ const orbComps = result.components.filter((c) => c.type === "application");
205
+ assert.ok(
206
+ orbComps.length > 0,
207
+ "expected at least one circleci orb component",
208
+ );
209
+ assert.ok(
210
+ orbComps.some((c) => c.name === "slack"),
211
+ "expected circleci/slack orb component",
212
+ );
213
+ assert.ok(
214
+ orbComps.some((c) => c.version === "4.12.5"),
215
+ "expected slack orb version 4.12.5",
216
+ );
217
+ });
218
+
219
+ it("parses multiple CircleCI files: two files produce combined results", () => {
220
+ const f1 = path.join(repoRoot, "test", "data", "circleci-config.yml");
221
+ const f2 = path.join(repoRoot, "test", "data", "circleci-machine.yml");
222
+ const result = circleCiParser.parse([f1, f2], {});
223
+ // f1 has 1 workflow, f2 has 1 workflow → combined 2
224
+ assert.strictEqual(
225
+ result.workflows.length,
226
+ 2,
227
+ "expected workflows from both files",
228
+ );
229
+ });
230
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Ensure all step objects in the array are unique (CycloneDX `uniqueItems: true`).
3
+ *
4
+ * Identical steps are disambiguated by appending a ` (N)` counter to the step name.
5
+ * The first occurrence is always left unchanged.
6
+ *
7
+ * @param {Object[]} steps
8
+ * @returns {Object[]|undefined}
9
+ */
10
+ export function disambiguateSteps(steps) {
11
+ if (!steps?.length) {
12
+ return undefined;
13
+ }
14
+ const seenKeys = new Map();
15
+ return steps.map((step) => {
16
+ const key = JSON.stringify(step);
17
+ const count = seenKeys.get(key) ?? 0;
18
+ seenKeys.set(key, count + 1);
19
+ if (count === 0) {
20
+ return step;
21
+ }
22
+ return { ...step, name: `${step.name} (${count + 1})` };
23
+ });
24
+ }