@cyclonedx/cdxgen 12.4.0 → 12.4.2

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 (71) hide show
  1. package/README.md +6 -4
  2. package/bin/cdxgen.js +32 -11
  3. package/bin/convert.js +12 -8
  4. package/bin/evinse.js +15 -0
  5. package/bin/hbom.js +13 -8
  6. package/bin/repl.js +14 -10
  7. package/bin/validate.js +10 -13
  8. package/bin/verify.js +7 -29
  9. package/data/cyclonedx-2.0-bundled.schema.json +7182 -0
  10. package/lib/audit/index.js +2 -1
  11. package/lib/cli/index.js +77 -16
  12. package/lib/cli/index.poku.js +197 -0
  13. package/lib/evinser/evinser.js +118 -3
  14. package/lib/helpers/bomUtils.js +155 -1
  15. package/lib/helpers/bomUtils.poku.js +79 -1
  16. package/lib/helpers/cbomutils.js +162 -2
  17. package/lib/helpers/cbomutils.poku.js +100 -0
  18. package/lib/helpers/ciParsers/githubActions.js +15 -3
  19. package/lib/helpers/ciParsers/githubActions.poku.js +52 -0
  20. package/lib/helpers/dosai.js +433 -0
  21. package/lib/helpers/dosai.poku.js +302 -0
  22. package/lib/helpers/dosaiParsers.js +103 -0
  23. package/lib/helpers/plugins.js +17 -16
  24. package/lib/helpers/protobom.js +53 -0
  25. package/lib/helpers/protobom.poku.js +44 -1
  26. package/lib/helpers/protobomLoader.js +43 -0
  27. package/lib/helpers/protobomLoader.poku.js +31 -0
  28. package/lib/helpers/utils.js +130 -1
  29. package/lib/helpers/utils.poku.js +295 -0
  30. package/lib/server/server.js +2 -1
  31. package/lib/stages/postgen/annotator.js +2 -1
  32. package/lib/stages/postgen/annotator.poku.js +28 -0
  33. package/lib/stages/postgen/postgen.js +219 -12
  34. package/lib/stages/postgen/postgen.poku.js +163 -0
  35. package/lib/validator/bomValidator.js +90 -38
  36. package/lib/validator/bomValidator.poku.js +90 -0
  37. package/lib/validator/complianceRules.js +4 -2
  38. package/lib/validator/index.poku.js +14 -0
  39. package/package.json +12 -12
  40. package/types/bin/repl.d.ts +1 -1
  41. package/types/bin/repl.d.ts.map +1 -1
  42. package/types/lib/audit/index.d.ts.map +1 -1
  43. package/types/lib/cli/index.d.ts.map +1 -1
  44. package/types/lib/evinser/evinser.d.ts +15 -0
  45. package/types/lib/evinser/evinser.d.ts.map +1 -1
  46. package/types/lib/helpers/bomUtils.d.ts +8 -0
  47. package/types/lib/helpers/bomUtils.d.ts.map +1 -1
  48. package/types/lib/helpers/cbomutils.d.ts +1 -0
  49. package/types/lib/helpers/cbomutils.d.ts.map +1 -1
  50. package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -1
  51. package/types/lib/helpers/dosai.d.ts +24 -0
  52. package/types/lib/helpers/dosai.d.ts.map +1 -0
  53. package/types/lib/helpers/dosaiParsers.d.ts +8 -0
  54. package/types/lib/helpers/dosaiParsers.d.ts.map +1 -0
  55. package/types/lib/helpers/hbomAnalysis.d.ts +14 -0
  56. package/types/lib/helpers/hbomAnalysis.d.ts.map +1 -1
  57. package/types/lib/helpers/hostTopology.d.ts.map +1 -1
  58. package/types/lib/helpers/plugins.d.ts.map +1 -1
  59. package/types/lib/helpers/protobom.d.ts +2 -0
  60. package/types/lib/helpers/protobom.d.ts.map +1 -1
  61. package/types/lib/helpers/protobomLoader.d.ts +17 -0
  62. package/types/lib/helpers/protobomLoader.d.ts.map +1 -0
  63. package/types/lib/helpers/utils.d.ts.map +1 -1
  64. package/types/lib/server/server.d.ts.map +1 -1
  65. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  66. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  67. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  68. package/types/lib/third-party/arborist/lib/node.d.ts +23 -0
  69. package/types/lib/third-party/arborist/lib/node.d.ts.map +1 -1
  70. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  71. package/types/lib/validator/complianceRules.d.ts.map +1 -1
@@ -1,8 +1,16 @@
1
1
  const SPDX_CONTEXT_PREFIX = "https://spdx.org/rdf/";
2
2
  const CYCLONEDX_FORMAT = "CycloneDX";
3
+ const LEGACY_CYCLONEDX_ROOT_KEY = "bomFormat";
4
+ const MODERN_CYCLONEDX_ROOT_KEY = "specFormat";
3
5
  const BOM_FORMAT_CYCLONEDX = "cyclonedx";
4
6
  const BOM_FORMAT_SPDX = "spdx";
5
7
  const BOM_FORMAT_UNKNOWN = "unknown";
8
+ const CYCLONEDX_SPEC_VERSION_PATTERN = /^(\d+)(?:\.(\d+))?$/u;
9
+ const CYCLONEDX_FORMAT_KEYS = new Set([
10
+ LEGACY_CYCLONEDX_ROOT_KEY,
11
+ MODERN_CYCLONEDX_ROOT_KEY,
12
+ "specVersion",
13
+ ]);
6
14
 
7
15
  export const isSpdxJsonLd = (bomJson) =>
8
16
  Boolean(
@@ -11,8 +19,154 @@ export const isSpdxJsonLd = (bomJson) =>
11
19
  bomJson["@graph"].some((element) => element?.type === "SpdxDocument"),
12
20
  );
13
21
 
22
+ const parseCycloneDxSpecVersion = (specVersion) => {
23
+ const match = `${specVersion ?? ""}`
24
+ .trim()
25
+ .match(CYCLONEDX_SPEC_VERSION_PATTERN);
26
+ if (!match) {
27
+ return undefined;
28
+ }
29
+ return {
30
+ major: Number.parseInt(match[1], 10),
31
+ minor: Number.parseInt(match[2] || "0", 10),
32
+ minorText: match[2] || "0",
33
+ };
34
+ };
35
+
36
+ export const normalizeCycloneDxSpecVersion = (specVersion) => {
37
+ const parsed = parseCycloneDxSpecVersion(specVersion);
38
+ if (!parsed) {
39
+ return undefined;
40
+ }
41
+ return Number(`${parsed.major}.${parsed.minor}`);
42
+ };
43
+
44
+ export const toCycloneDxSpecVersionString = (specVersion) => {
45
+ const parsed = parseCycloneDxSpecVersion(specVersion);
46
+ if (!parsed) {
47
+ return undefined;
48
+ }
49
+ if (typeof specVersion === "string" && parsed.minorText !== "0") {
50
+ return `${parsed.major}.${parsed.minorText}`;
51
+ }
52
+ return `${parsed.major}.${parsed.minor}`;
53
+ };
54
+
55
+ export const isCycloneDxSpecVersionAtLeast = (specVersion, minimumVersion) => {
56
+ const parsedSpecVersion = parseCycloneDxSpecVersion(specVersion);
57
+ const parsedMinimumVersion = parseCycloneDxSpecVersion(minimumVersion);
58
+ if (!parsedSpecVersion || !parsedMinimumVersion) {
59
+ return false;
60
+ }
61
+ if (parsedSpecVersion.major !== parsedMinimumVersion.major) {
62
+ return parsedSpecVersion.major > parsedMinimumVersion.major;
63
+ }
64
+ return parsedSpecVersion.minor >= parsedMinimumVersion.minor;
65
+ };
66
+
67
+ export const isCycloneDx20SpecVersion = (specVersion) =>
68
+ isCycloneDxSpecVersionAtLeast(specVersion, 2);
69
+
70
+ export const getCycloneDxRootFormatKey = (specVersionOrBom) => {
71
+ const specVersion =
72
+ specVersionOrBom && typeof specVersionOrBom === "object"
73
+ ? specVersionOrBom.specVersion
74
+ : specVersionOrBom;
75
+ return isCycloneDx20SpecVersion(specVersion)
76
+ ? MODERN_CYCLONEDX_ROOT_KEY
77
+ : LEGACY_CYCLONEDX_ROOT_KEY;
78
+ };
79
+
80
+ export const getCycloneDxFormat = (bomJson) =>
81
+ bomJson?.specFormat || bomJson?.bomFormat;
82
+
83
+ export const hasCycloneDxFormat = (bomJson) =>
84
+ getCycloneDxFormat(bomJson) === CYCLONEDX_FORMAT;
85
+
14
86
  export const isCycloneDxBom = (bomJson) =>
15
- bomJson?.bomFormat === CYCLONEDX_FORMAT && Boolean(bomJson?.specVersion);
87
+ hasCycloneDxFormat(bomJson) &&
88
+ normalizeCycloneDxSpecVersion(bomJson?.specVersion) !== undefined;
89
+
90
+ const rewriteCycloneDxRootFields = (
91
+ bomJson,
92
+ rootKey,
93
+ specVersion,
94
+ preserveLegacyBomFormat,
95
+ ) => {
96
+ const remainingEntries = Object.entries(bomJson).filter(
97
+ ([key]) => !CYCLONEDX_FORMAT_KEYS.has(key),
98
+ );
99
+ for (const key of Object.keys(bomJson)) {
100
+ delete bomJson[key];
101
+ }
102
+ if (rootKey === LEGACY_CYCLONEDX_ROOT_KEY) {
103
+ bomJson.bomFormat = CYCLONEDX_FORMAT;
104
+ if (specVersion !== undefined) {
105
+ bomJson.specVersion = specVersion;
106
+ }
107
+ } else if (preserveLegacyBomFormat) {
108
+ bomJson.bomFormat = CYCLONEDX_FORMAT;
109
+ if (specVersion !== undefined) {
110
+ bomJson.specVersion = specVersion;
111
+ }
112
+ bomJson.specFormat = CYCLONEDX_FORMAT;
113
+ } else {
114
+ bomJson.specFormat = CYCLONEDX_FORMAT;
115
+ if (specVersion !== undefined) {
116
+ bomJson.specVersion = specVersion;
117
+ }
118
+ }
119
+ for (const [key, value] of remainingEntries) {
120
+ bomJson[key] = value;
121
+ }
122
+ };
123
+
124
+ /**
125
+ * Mutates a CycloneDX BOM object so the appropriate root format key is present
126
+ * for the requested spec version, while preserving conventional serialized
127
+ * root-key ordering (`bomFormat`/`specFormat` and `specVersion` first). Only the currently
128
+ * supported CycloneDX major.minor version shape is accepted; multi-component
129
+ * future versions such as `2.0.1` intentionally return `undefined` from the
130
+ * normalizer rather than being silently truncated.
131
+ *
132
+ * @param {object} bomJson BOM JSON object to mutate.
133
+ * @param {string|number} specVersion Desired CycloneDX spec version.
134
+ * @param {object} options Root-key compatibility options.
135
+ * @returns {object} The same `bomJson` object, after in-place mutation.
136
+ */
137
+ export const setCycloneDxFormat = (
138
+ bomJson,
139
+ specVersion,
140
+ { preserveLegacyBomFormat = false } = {},
141
+ ) => {
142
+ if (!bomJson || typeof bomJson !== "object" || Array.isArray(bomJson)) {
143
+ return bomJson;
144
+ }
145
+ const resolvedSpecVersion =
146
+ toCycloneDxSpecVersionString(specVersion ?? bomJson.specVersion) ||
147
+ bomJson.specVersion;
148
+ if (resolvedSpecVersion !== undefined) {
149
+ bomJson.specVersion = resolvedSpecVersion;
150
+ }
151
+ if (
152
+ getCycloneDxRootFormatKey(resolvedSpecVersion) === MODERN_CYCLONEDX_ROOT_KEY
153
+ ) {
154
+ rewriteCycloneDxRootFields(
155
+ bomJson,
156
+ MODERN_CYCLONEDX_ROOT_KEY,
157
+ resolvedSpecVersion,
158
+ preserveLegacyBomFormat,
159
+ );
160
+ return bomJson;
161
+ }
162
+ rewriteCycloneDxRootFields(
163
+ bomJson,
164
+ LEGACY_CYCLONEDX_ROOT_KEY,
165
+ resolvedSpecVersion,
166
+ false,
167
+ );
168
+ return bomJson;
169
+ };
16
170
 
17
171
  export const detectBomFormat = (bomJson) => {
18
172
  if (isCycloneDxBom(bomJson)) {
@@ -2,9 +2,16 @@ import { assert, describe, it } from "poku";
2
2
 
3
3
  import {
4
4
  detectBomFormat,
5
+ getCycloneDxFormat,
6
+ getCycloneDxRootFormatKey,
5
7
  getNonCycloneDxErrorMessage,
8
+ isCycloneDx20SpecVersion,
6
9
  isCycloneDxBom,
10
+ isCycloneDxSpecVersionAtLeast,
7
11
  isSpdxJsonLd,
12
+ normalizeCycloneDxSpecVersion,
13
+ setCycloneDxFormat,
14
+ toCycloneDxSpecVersionString,
8
15
  } from "./bomUtils.js";
9
16
 
10
17
  const sampleSpdx = {
@@ -13,7 +20,7 @@ const sampleSpdx = {
13
20
  };
14
21
 
15
22
  describe("bomUtils", () => {
16
- it("detects CycloneDX documents", () => {
23
+ it("detects CycloneDX documents across root format styles", () => {
17
24
  assert.strictEqual(
18
25
  isCycloneDxBom({
19
26
  bomFormat: "CycloneDX",
@@ -21,6 +28,13 @@ describe("bomUtils", () => {
21
28
  }),
22
29
  true,
23
30
  );
31
+ assert.strictEqual(
32
+ isCycloneDxBom({
33
+ specFormat: "CycloneDX",
34
+ specVersion: "2.0",
35
+ }),
36
+ true,
37
+ );
24
38
  assert.strictEqual(isCycloneDxBom(sampleSpdx), false);
25
39
  });
26
40
 
@@ -35,6 +49,10 @@ describe("bomUtils", () => {
35
49
  detectBomFormat({ bomFormat: "CycloneDX", specVersion: "1.6" }),
36
50
  "cyclonedx",
37
51
  );
52
+ assert.strictEqual(
53
+ detectBomFormat({ specFormat: "CycloneDX", specVersion: "2.0" }),
54
+ "cyclonedx",
55
+ );
38
56
  assert.strictEqual(detectBomFormat({ foo: "bar" }), "unknown");
39
57
  });
40
58
 
@@ -48,4 +66,64 @@ describe("bomUtils", () => {
48
66
  "cdx-sign expects a CycloneDX JSON BOM.",
49
67
  );
50
68
  });
69
+
70
+ it("normalizes CycloneDX spec versions and capability checks", () => {
71
+ assert.strictEqual(normalizeCycloneDxSpecVersion("2.0"), 2);
72
+ assert.strictEqual(normalizeCycloneDxSpecVersion(undefined), undefined);
73
+ assert.strictEqual(normalizeCycloneDxSpecVersion("2.0.1"), undefined);
74
+ assert.strictEqual(toCycloneDxSpecVersionString(2), "2.0");
75
+ assert.strictEqual(toCycloneDxSpecVersionString("1.10"), "1.10");
76
+ assert.strictEqual(toCycloneDxSpecVersionString("2.0.1"), undefined);
77
+ assert.strictEqual(isCycloneDx20SpecVersion("2.0"), true);
78
+ assert.strictEqual(isCycloneDx20SpecVersion("1.7"), false);
79
+ assert.strictEqual(isCycloneDxSpecVersionAtLeast("2.0", 1.7), true);
80
+ assert.strictEqual(isCycloneDxSpecVersionAtLeast("1.10", "1.7"), true);
81
+ assert.strictEqual(isCycloneDxSpecVersionAtLeast(undefined, 1.7), false);
82
+ });
83
+
84
+ it("selects and writes the correct CycloneDX root format key", () => {
85
+ assert.strictEqual(getCycloneDxRootFormatKey("1.7"), "bomFormat");
86
+ assert.strictEqual(getCycloneDxRootFormatKey("2.0"), "specFormat");
87
+
88
+ const bom17 = setCycloneDxFormat(
89
+ { name: "demo", specVersion: "1.7" },
90
+ "1.7",
91
+ );
92
+ assert.strictEqual(bom17.bomFormat, "CycloneDX");
93
+ assert.strictEqual(bom17.specFormat, undefined);
94
+ assert.strictEqual(getCycloneDxFormat(bom17), "CycloneDX");
95
+ assert.deepStrictEqual(Object.keys(bom17), [
96
+ "bomFormat",
97
+ "specVersion",
98
+ "name",
99
+ ]);
100
+
101
+ const bom20Input = { name: "demo", specVersion: 2 };
102
+ const bom20 = setCycloneDxFormat(bom20Input, 2);
103
+ assert.strictEqual(bom20, bom20Input);
104
+ assert.strictEqual(bom20.specFormat, "CycloneDX");
105
+ assert.strictEqual(bom20.bomFormat, undefined);
106
+ assert.strictEqual(bom20.specVersion, "2.0");
107
+ assert.deepStrictEqual(Object.keys(bom20), [
108
+ "specFormat",
109
+ "specVersion",
110
+ "name",
111
+ ]);
112
+
113
+ const internalBom20 = setCycloneDxFormat(
114
+ { name: "demo", specVersion: 2 },
115
+ 2,
116
+ {
117
+ preserveLegacyBomFormat: true,
118
+ },
119
+ );
120
+ assert.strictEqual(internalBom20.specFormat, "CycloneDX");
121
+ assert.strictEqual(internalBom20.bomFormat, "CycloneDX");
122
+ assert.deepStrictEqual(Object.keys(internalBom20), [
123
+ "bomFormat",
124
+ "specVersion",
125
+ "specFormat",
126
+ "name",
127
+ ]);
128
+ });
51
129
  });
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
 
4
4
  import { executeOsQuery } from "../managers/binary.js";
5
5
  import { detectJsCryptoInventory } from "./analyzer.js";
6
+ import { analyzeDosaiCrypto } from "./dosai.js";
6
7
  import {
7
8
  createOccurrenceEvidence,
8
9
  formatOccurrenceEvidence,
@@ -239,16 +240,19 @@ function mergeAlgorithmComponentUsage(component, usage, src, options) {
239
240
  }
240
241
  }
241
242
  if (usage.source) {
243
+ const sourceType =
244
+ usage.source === "dosai" ? undefined : `js-ast:${usage.source}`;
242
245
  if (
246
+ sourceType &&
243
247
  !properties.some(
244
248
  (property) =>
245
249
  property.name === "cdx:crypto:sourceType" &&
246
- property.value === `js-ast:${usage.source}`,
250
+ property.value === sourceType,
247
251
  )
248
252
  ) {
249
253
  properties.push({
250
254
  name: "cdx:crypto:sourceType",
251
- value: `js-ast:${usage.source}`,
255
+ value: sourceType,
252
256
  });
253
257
  }
254
258
  }
@@ -271,6 +275,93 @@ function mergeAlgorithmComponentUsage(component, usage, src, options) {
271
275
  return component;
272
276
  }
273
277
 
278
+ function normalizeDosaiCryptoNames(cryptoObject) {
279
+ const rawName = cryptoObject?.Name || cryptoObject;
280
+ const names = new Set([rawName]);
281
+ const cleanName = String(rawName || "").trim();
282
+ if (!cleanName) {
283
+ return [];
284
+ }
285
+ if (cleanName.includes("/")) {
286
+ for (const part of cleanName
287
+ .split("/")
288
+ .map((candidate) => candidate.trim())
289
+ .filter(Boolean)) {
290
+ names.add(part);
291
+ }
292
+ }
293
+ if (/^SHA-?256$/i.test(cleanName)) {
294
+ names.add("sha-256");
295
+ } else if (/^SHA-?384$/i.test(cleanName)) {
296
+ names.add("sha-384");
297
+ } else if (/^SHA-?512$/i.test(cleanName)) {
298
+ names.add("sha-512");
299
+ } else if (/^SHA-?1$/i.test(cleanName)) {
300
+ names.add("sha-1");
301
+ }
302
+ const context = [
303
+ cryptoObject?.Symbol,
304
+ cryptoObject?.Code,
305
+ cryptoObject?.Algorithm,
306
+ ]
307
+ .filter(Boolean)
308
+ .join(" ");
309
+ if (/^SHA-?2$/i.test(cleanName)) {
310
+ if (/SHA-?256/i.test(context)) {
311
+ names.add("sha-256");
312
+ }
313
+ if (/SHA-?384/i.test(context)) {
314
+ names.add("sha-384");
315
+ }
316
+ if (/SHA-?512/i.test(context)) {
317
+ names.add("sha-512");
318
+ }
319
+ }
320
+ return Array.from(names);
321
+ }
322
+
323
+ function dosaiCryptoUsage(assetOrOperation) {
324
+ const location = assetOrOperation.Location || {};
325
+ return {
326
+ fileName: location.Path || location.FileName,
327
+ lineNumber: location.LineNumber || undefined,
328
+ columnNumber: location.ColumnNumber || undefined,
329
+ primitive: assetOrOperation.Family || assetOrOperation.OperationType,
330
+ source: "dosai",
331
+ };
332
+ }
333
+
334
+ function addDosaiProperties(component, dosaiObject, evidenceType) {
335
+ const properties = component.properties || [];
336
+ const addProperty = (name, value) => {
337
+ if (value === undefined || value === null || value === "") {
338
+ return;
339
+ }
340
+ if (
341
+ !properties.some(
342
+ (property) =>
343
+ property.name === name && property.value === String(value),
344
+ )
345
+ ) {
346
+ properties.push({ name, value: String(value) });
347
+ }
348
+ };
349
+ addProperty("cdx:crypto:sourceType", `dosai:${evidenceType}`);
350
+ addProperty("cdx:dosai:crypto:id", dosaiObject.Id);
351
+ addProperty("cdx:dosai:crypto:strength", dosaiObject.Strength);
352
+ addProperty(
353
+ "cdx:dosai:crypto:reachableFromEntryPoint",
354
+ dosaiObject.ReachableFromEntryPoint,
355
+ );
356
+ if (dosaiObject.EntryPointIds?.length) {
357
+ addProperty(
358
+ "cdx:dosai:crypto:entryPointCount",
359
+ dosaiObject.EntryPointIds.length,
360
+ );
361
+ }
362
+ component.properties = properties;
363
+ }
364
+
274
365
  export async function collectSourceCryptoComponents(src, options = {}) {
275
366
  const inventory = await detectJsCryptoInventory(src, Boolean(options.deep));
276
367
  const componentsByRef = new Map();
@@ -317,6 +408,75 @@ export async function collectSourceCryptoComponents(src, options = {}) {
317
408
  );
318
409
  }
319
410
 
411
+ export async function collectDosaiCryptoComponents(src, options = {}) {
412
+ const dosaiCrypto = analyzeDosaiCrypto(src, options);
413
+ if (!dosaiCrypto) {
414
+ return [];
415
+ }
416
+ const componentsByRef = new Map();
417
+ const cryptoObjects = [
418
+ ...(dosaiCrypto.Assets || []).filter(
419
+ (asset) => asset.AssetType === "algorithm",
420
+ ),
421
+ ...(dosaiCrypto.Operations || []).map((operation) => ({
422
+ ...operation,
423
+ Name: operation.Algorithm,
424
+ Family: operation.OperationType,
425
+ })),
426
+ ];
427
+ for (const cryptoObject of cryptoObjects) {
428
+ for (const candidateName of normalizeDosaiCryptoNames(cryptoObject)) {
429
+ const normalizedName = normalizeDetectedCryptoAlgorithmName(
430
+ candidateName,
431
+ "algorithm",
432
+ );
433
+ const algorithmMetadata =
434
+ cbomCryptoOids[normalizedName] || cbomCryptoOids[candidateName];
435
+ if (!algorithmMetadata?.oid) {
436
+ continue;
437
+ }
438
+ const bomRef = cryptoAlgorithmBomRef(
439
+ normalizedName,
440
+ algorithmMetadata.oid,
441
+ );
442
+ const component = componentsByRef.get(bomRef) || {
443
+ type: "cryptographic-asset",
444
+ name: normalizedName,
445
+ "bom-ref": bomRef,
446
+ description:
447
+ algorithmMetadata.description ||
448
+ "Cryptographic algorithm detected by dosai source analysis",
449
+ cryptoProperties: {
450
+ assetType: "algorithm",
451
+ oid: algorithmMetadata.oid,
452
+ },
453
+ properties: [],
454
+ };
455
+ mergeAlgorithmComponentUsage(
456
+ component,
457
+ dosaiCryptoUsage(cryptoObject),
458
+ src,
459
+ options,
460
+ );
461
+ addDosaiProperties(
462
+ component,
463
+ cryptoObject,
464
+ cryptoObject.OperationType ? "operation" : "asset",
465
+ );
466
+ componentsByRef.set(bomRef, component);
467
+ }
468
+ }
469
+ const components = Array.from(componentsByRef.values());
470
+ components.forEach((component) => {
471
+ normalizeCryptoComponentEvidence(component, options);
472
+ });
473
+ return components.sort((left, right) =>
474
+ `${left.name}:${left["bom-ref"]}`.localeCompare(
475
+ `${right.name}:${right["bom-ref"]}`,
476
+ ),
477
+ );
478
+ }
479
+
320
480
  /**
321
481
  * Find crypto algorithm in the given code snippet
322
482
  *
@@ -2,6 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
+ import esmock from "esmock";
5
6
  import { assert, describe, it } from "poku";
6
7
 
7
8
  import {
@@ -248,4 +249,103 @@ describe("cbom utils", () => {
248
249
  rmSync(projectDir, { recursive: true, force: true });
249
250
  }
250
251
  });
252
+
253
+ it("collectDosaiCryptoComponents() maps dosai algorithms to CBOM components with OIDs", async () => {
254
+ const { collectDosaiCryptoComponents } = await esmock("./cbomutils.js", {
255
+ "./dosai.js": {
256
+ analyzeDosaiCrypto: () => ({
257
+ Assets: [
258
+ {
259
+ Id: "cas1",
260
+ AssetType: "algorithm",
261
+ Name: "SHA-256",
262
+ Family: "hash",
263
+ Strength: "strong",
264
+ Location: {
265
+ Path: "Program.cs",
266
+ FileName: "Program.cs",
267
+ LineNumber: 12,
268
+ ColumnNumber: 9,
269
+ },
270
+ ReachableFromEntryPoint: true,
271
+ EntryPointIds: ["ep1"],
272
+ },
273
+ {
274
+ Id: "cas2",
275
+ AssetType: "algorithm",
276
+ Name: "UnknownCipher",
277
+ Location: {
278
+ Path: "Program.cs",
279
+ FileName: "Program.cs",
280
+ LineNumber: 20,
281
+ ColumnNumber: 9,
282
+ },
283
+ },
284
+ ],
285
+ Operations: [
286
+ {
287
+ Id: "cop1",
288
+ OperationType: "hash",
289
+ Algorithm: "SHA-256",
290
+ Location: {
291
+ Path: "Program.cs",
292
+ FileName: "Program.cs",
293
+ LineNumber: 12,
294
+ ColumnNumber: 9,
295
+ },
296
+ },
297
+ {
298
+ Id: "cop2",
299
+ OperationType: "use",
300
+ Algorithm: "SHA-2",
301
+ Symbol: "SHA256.HashData",
302
+ Location: {
303
+ Path: "Program.vb",
304
+ FileName: "Program.vb",
305
+ LineNumber: 42,
306
+ ColumnNumber: 22,
307
+ },
308
+ },
309
+ ],
310
+ }),
311
+ },
312
+ });
313
+
314
+ const components = await collectDosaiCryptoComponents("/tmp/project", {
315
+ evidence: true,
316
+ specVersion: 1.7,
317
+ });
318
+
319
+ assert.strictEqual(components.length, 1);
320
+ assert.strictEqual(components[0].name, "sha-256");
321
+ assert.strictEqual(components[0].type, "cryptographic-asset");
322
+ assert.strictEqual(components[0].cryptoProperties.assetType, "algorithm");
323
+ assert.ok(components[0].cryptoProperties.oid);
324
+ assert.ok(
325
+ components[0].properties.some(
326
+ (property) =>
327
+ property.name === "cdx:crypto:sourceType" &&
328
+ property.value === "dosai:operation",
329
+ ),
330
+ );
331
+ assert.ok(
332
+ !components[0].properties.some(
333
+ (property) =>
334
+ property.name === "cdx:crypto:sourceType" &&
335
+ ["dosai", "js-ast:dosai"].includes(property.value),
336
+ ),
337
+ );
338
+ assert.ok(
339
+ components[0].evidence.occurrences.some(
340
+ (occurrence) =>
341
+ occurrence.location === "Program.cs" && occurrence.line === 12,
342
+ ),
343
+ );
344
+ assert.ok(
345
+ components[0].evidence.occurrences.some(
346
+ (occurrence) =>
347
+ occurrence.location === "Program.vb" && occurrence.line === 42,
348
+ ),
349
+ );
350
+ });
251
351
  });
@@ -342,9 +342,21 @@ function normalizeRunnerLabels(runsOn) {
342
342
  .map((label) => label.trim())
343
343
  .filter(Boolean);
344
344
  }
345
+ if (typeof runsOn === "object") {
346
+ return normalizeRunnerLabels(runsOn.labels);
347
+ }
345
348
  return [];
346
349
  }
347
350
 
351
+ function normalizeRunnerValue(runsOn) {
352
+ const labels = normalizeRunnerLabels(runsOn);
353
+ if (runsOn && typeof runsOn === "object" && !Array.isArray(runsOn)) {
354
+ const group = runsOn.group ? String(runsOn.group).trim() : "";
355
+ return [group, ...labels].filter(Boolean).join(",") || "unknown";
356
+ }
357
+ return labels.join(",") || "unknown";
358
+ }
359
+
348
360
  function isSelfHostedRunner(runsOn) {
349
361
  return normalizeRunnerLabels(runsOn).some((label) =>
350
362
  label.toLowerCase().includes("self-hosted"),
@@ -1761,7 +1773,7 @@ function buildReusableWorkflowComponent(
1761
1773
  { name: "cdx:github:job:name", value: jobName },
1762
1774
  {
1763
1775
  name: "cdx:github:job:runner",
1764
- value: Array.isArray(jobRunner) ? jobRunner.join(",") : jobRunner,
1776
+ value: normalizeRunnerValue(jobRunner),
1765
1777
  },
1766
1778
  { name: "cdx:github:reusableWorkflow:uses", value: uses },
1767
1779
  {
@@ -1951,7 +1963,7 @@ export function parseWorkflowFile(f, options) {
1951
1963
  { name: "cdx:github:job:name", value: jobName },
1952
1964
  {
1953
1965
  name: "cdx:github:job:runner",
1954
- value: Array.isArray(jobRunner) ? jobRunner.join(",") : jobRunner,
1966
+ value: normalizeRunnerValue(jobRunner),
1955
1967
  },
1956
1968
  ];
1957
1969
  if (jobEnvironment) {
@@ -2042,7 +2054,7 @@ export function parseWorkflowFile(f, options) {
2042
2054
  { name: "cdx:github:job:name", value: jobName },
2043
2055
  {
2044
2056
  name: "cdx:github:job:runner",
2045
- value: Array.isArray(jobRunner) ? jobRunner.join(",") : jobRunner,
2057
+ value: normalizeRunnerValue(jobRunner),
2046
2058
  },
2047
2059
  { name: "cdx:github:action:uses", value: step.uses },
2048
2060
  {
@@ -180,6 +180,58 @@ describe("githubActionsParser", () => {
180
180
  }
181
181
  });
182
182
 
183
+ it("normalizes object-form runs-on values", () => {
184
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-gha-"));
185
+ const workflowFile = path.join(tmpDir, "object-runs-on.yml");
186
+ writeFileSync(
187
+ workflowFile,
188
+ [
189
+ "name: Object runs-on",
190
+ "on: push",
191
+ "jobs:",
192
+ " grouped:",
193
+ " runs-on:",
194
+ " group: ubuntu-runners",
195
+ " labels: ubuntu-20.04-16core",
196
+ " steps:",
197
+ " - uses: actions/checkout@v4",
198
+ " selfHosted:",
199
+ " runs-on:",
200
+ " group: larger-runners",
201
+ " labels: [self-hosted, linux, x64]",
202
+ " steps:",
203
+ ' - run: echo "ok"',
204
+ ].join("\n"),
205
+ );
206
+
207
+ try {
208
+ const result = parseWorkflowFile(workflowFile, { specVersion: 1.7 });
209
+ const actionComp = result.components.find(
210
+ (component) =>
211
+ getProp(component, "cdx:github:action:uses") ===
212
+ "actions/checkout@v4",
213
+ );
214
+ assert.strictEqual(
215
+ getProp(actionComp, "cdx:github:job:runner"),
216
+ "ubuntu-runners,ubuntu-20.04-16core",
217
+ );
218
+
219
+ const selfHostedTask = result.workflows[0].tasks.find(
220
+ (task) => task.name === "selfHosted",
221
+ );
222
+ assert.strictEqual(
223
+ getProp(selfHostedTask, "cdx:github:job:runner"),
224
+ "larger-runners,self-hosted,linux,x64",
225
+ );
226
+ assert.strictEqual(
227
+ getProp(selfHostedTask, "cdx:github:job:isSelfHosted"),
228
+ "true",
229
+ );
230
+ } finally {
231
+ rmSync(tmpDir, { force: true, recursive: true });
232
+ }
233
+ });
234
+
183
235
  it("derives unnamed workflow names from the file stem without leaking Windows-style path segments", () => {
184
236
  const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-gha-"));
185
237
  const workflowFile = path.join(tmpDir, "nested\\workflow-file.yml");