@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
@@ -0,0 +1,302 @@
1
+ import esmock from "esmock";
2
+ import { assert, describe, it } from "poku";
3
+ import sinon from "sinon";
4
+
5
+ import {
6
+ buildPurlAliasMap,
7
+ collectDosaiDataFlowFrames,
8
+ collectDosaiPurlEvidence,
9
+ collectDosaiServicesFromMethods,
10
+ isDosaiDotnetLanguage,
11
+ normalizeDosaiServiceMap,
12
+ resolveComponentPurl,
13
+ } from "./dosai.js";
14
+
15
+ describe("dosai helpers", () => {
16
+ it("recognizes C#, VB.NET, and F# language aliases", () => {
17
+ for (const language of [
18
+ "csharp",
19
+ "dotnet",
20
+ "vb",
21
+ "vbnet",
22
+ "visualbasic",
23
+ "f#",
24
+ "fs",
25
+ "fsharp",
26
+ ]) {
27
+ assert.strictEqual(isDosaiDotnetLanguage(language), true);
28
+ }
29
+ });
30
+
31
+ it("matches versionless dosai purls to cdxgen component purls", () => {
32
+ const components = [{ purl: "pkg:nuget/System.Text.Json@10.0.0" }];
33
+ const aliases = buildPurlAliasMap(components);
34
+
35
+ assert.strictEqual(
36
+ resolveComponentPurl("pkg:nuget/System.Text.Json", aliases),
37
+ "pkg:nuget/System.Text.Json@10.0.0",
38
+ );
39
+ });
40
+
41
+ it("collects package occurrence evidence from dosai PackageReachability", () => {
42
+ const methodsSlice = {
43
+ CallGraph: {
44
+ Edges: [
45
+ {
46
+ Id: "e1",
47
+ FileName: "System.Text.Json.dll",
48
+ LineNumber: 12,
49
+ CalledMethodName: "System.Text.Json.JsonSerializer.Deserialize",
50
+ TargetName: "Deserialize",
51
+ },
52
+ ],
53
+ Nodes: [
54
+ {
55
+ Id: "n1",
56
+ FileName: "Program.cs",
57
+ LineNumber: 10,
58
+ ClassName: "Program",
59
+ Name: "Main",
60
+ },
61
+ {
62
+ Id: "n2",
63
+ FileName: "System.Text.Json.dll",
64
+ LineNumber: 0,
65
+ ClassName: "JsonSerializer",
66
+ Name: "Deserialize",
67
+ },
68
+ ],
69
+ },
70
+ PackageReachability: [
71
+ {
72
+ Purl: "pkg:nuget/System.Text.Json",
73
+ EdgeIds: ["e1"],
74
+ NodeIds: ["n1", "n2"],
75
+ SourceLocations: [
76
+ {
77
+ Path: "Controllers/Parser.cs",
78
+ FileName: "Parser.cs",
79
+ LineNumber: 42,
80
+ ColumnNumber: 13,
81
+ Kind: "CallGraphEdge",
82
+ },
83
+ ],
84
+ },
85
+ ],
86
+ };
87
+ const retMap = collectDosaiPurlEvidence(methodsSlice, [
88
+ { purl: "pkg:nuget/System.Text.Json@10.0.0" },
89
+ ]);
90
+
91
+ assert.deepStrictEqual(
92
+ Array.from(
93
+ retMap.purlLocationMap["pkg:nuget/System.Text.Json@10.0.0"],
94
+ ).sort(),
95
+ ["Controllers/Parser.cs#42"],
96
+ );
97
+ assert.ok(
98
+ retMap.purlMethodsMap["pkg:nuget/System.Text.Json@10.0.0"].has(
99
+ "System.Text.Json.JsonSerializer.Deserialize",
100
+ ),
101
+ );
102
+ });
103
+
104
+ it("keeps PackageReachability fallback occurrence evidence source-only", () => {
105
+ const retMap = collectDosaiPurlEvidence(
106
+ {
107
+ CallGraph: {
108
+ Edges: [
109
+ {
110
+ Id: "e1",
111
+ FileName: "System.Text.Json.dll",
112
+ LineNumber: 12,
113
+ CalledMethodName: "System.Text.Json.JsonSerializer.Deserialize",
114
+ CallLocation: {
115
+ FileName: "Program.fs",
116
+ LineNumber: 8,
117
+ },
118
+ },
119
+ {
120
+ Id: "e2",
121
+ FileName: "Controllers/EpisodesController.cs",
122
+ LineNumber: 17,
123
+ CalledMethodName: "System.Text.Json.JsonSerializer.Serialize",
124
+ },
125
+ ],
126
+ },
127
+ PackageReachability: [
128
+ {
129
+ Purl: "pkg:nuget/System.Text.Json",
130
+ EdgeIds: ["e1", "e2"],
131
+ },
132
+ ],
133
+ },
134
+ [{ purl: "pkg:nuget/System.Text.Json@10.0.0" }],
135
+ );
136
+
137
+ assert.deepStrictEqual(
138
+ Array.from(retMap.purlLocationMap["pkg:nuget/System.Text.Json@10.0.0"]),
139
+ ["Program.fs#8", "Controllers/EpisodesController.cs#17"],
140
+ );
141
+ assert.ok(
142
+ !Array.from(
143
+ retMap.purlLocationMap["pkg:nuget/System.Text.Json@10.0.0"],
144
+ ).some((location) => location.includes(".dll")),
145
+ );
146
+ });
147
+
148
+ it("collects package occurrence evidence from dosai Dependencies with purls", () => {
149
+ const retMap = collectDosaiPurlEvidence(
150
+ {
151
+ Dependencies: [
152
+ {
153
+ Path: "Program.vb",
154
+ FileName: "Program.vb",
155
+ Name: "Newtonsoft.Json",
156
+ Purl: "pkg:nuget/Newtonsoft.Json@13.0.3",
157
+ LineNumber: 4,
158
+ ColumnNumber: 9,
159
+ },
160
+ ],
161
+ },
162
+ [{ purl: "pkg:nuget/Newtonsoft.Json@13.0.3" }],
163
+ );
164
+
165
+ assert.deepStrictEqual(
166
+ Array.from(retMap.purlLocationMap["pkg:nuget/Newtonsoft.Json@13.0.3"]),
167
+ ["Program.vb#4"],
168
+ );
169
+ assert.ok(
170
+ retMap.purlModulesMap["pkg:nuget/Newtonsoft.Json@13.0.3"].has(
171
+ "Newtonsoft.Json",
172
+ ),
173
+ );
174
+ });
175
+
176
+ it("builds CycloneDX services from dosai ApiEndpoints without raw policy names", () => {
177
+ const servicesMap = collectDosaiServicesFromMethods({
178
+ ApiEndpoints: [
179
+ {
180
+ Route: "/api/podcasts?sig=secret",
181
+ FileName: "EpisodesController.cs",
182
+ Path: "Controllers/EpisodesController.cs",
183
+ ClassName: "EpisodesController",
184
+ MethodName: "Get",
185
+ HttpMethod: "GET",
186
+ EndpointKind: "Attribute",
187
+ AuthorizationRequired: true,
188
+ AuthorizationPolicies: ["InternalPolicyName"],
189
+ Roles: ["Admin"],
190
+ AllowAnonymous: false,
191
+ LineNumber: 42,
192
+ ColumnNumber: 9,
193
+ },
194
+ ],
195
+ });
196
+ const services = normalizeDosaiServiceMap(servicesMap);
197
+
198
+ assert.strictEqual(services.length, 1);
199
+ assert.deepStrictEqual(services[0].endpoints, ["/api/podcasts"]);
200
+ assert.strictEqual(services[0].authenticated, true);
201
+ assert.ok(
202
+ services[0].properties.some(
203
+ (property) =>
204
+ property.name === "cdx:dosai:authorizationPolicyCount" &&
205
+ property.value === "1",
206
+ ),
207
+ );
208
+ assert.ok(!JSON.stringify(services[0]).includes("InternalPolicyName"));
209
+ });
210
+
211
+ it("collects callstack frames from dosai data-flow slices", () => {
212
+ const frames = collectDosaiDataFlowFrames(
213
+ {
214
+ Nodes: [
215
+ {
216
+ Id: "dfn1",
217
+ Path: "Controllers/EpisodesController.cs",
218
+ Namespace: "Podcast.Api",
219
+ ClassName: "EpisodesController",
220
+ MethodName: "Get",
221
+ LineNumber: 12,
222
+ ColumnNumber: 5,
223
+ },
224
+ {
225
+ Id: "dfn2",
226
+ Path: "Services/JsonLoader.cs",
227
+ Namespace: "Podcast.Api",
228
+ ClassName: "JsonLoader",
229
+ MethodName: "Load",
230
+ LineNumber: 20,
231
+ ColumnNumber: 9,
232
+ },
233
+ ],
234
+ Slices: [
235
+ {
236
+ NodeIds: ["dfn1", "dfn2"],
237
+ Purls: ["pkg:nuget/System.Text.Json"],
238
+ },
239
+ ],
240
+ },
241
+ [{ purl: "pkg:nuget/System.Text.Json@10.0.0" }],
242
+ );
243
+
244
+ assert.strictEqual(frames["pkg:nuget/System.Text.Json@10.0.0"].length, 1);
245
+ assert.strictEqual(
246
+ frames["pkg:nuget/System.Text.Json@10.0.0"][0][1].function,
247
+ "Load",
248
+ );
249
+ });
250
+
251
+ it("rejects unsafe dosai command inputs before spawning", async () => {
252
+ const safeSpawnSync = sinon.stub().returns({ status: 0 });
253
+ const { runDosaiCommand } = await esmock("./dosai.js", {
254
+ "./plugins.js": { resolvePluginBinary: sinon.stub().returns("dosai") },
255
+ "./utils.js": {
256
+ DEBUG_MODE: false,
257
+ getTmpDir: sinon.stub().returns("/tmp"),
258
+ safeExistsSync: sinon.stub().returns(true),
259
+ safeMkdtempSync: sinon.stub(),
260
+ safeRmSync: sinon.stub(),
261
+ safeSpawnSync,
262
+ },
263
+ });
264
+
265
+ assert.strictEqual(
266
+ runDosaiCommand("methods;rm -rf /", "/tmp/project", "/tmp/out.json"),
267
+ false,
268
+ );
269
+ assert.strictEqual(
270
+ runDosaiCommand("methods", "/tmp/project\n--bad", "/tmp/out.json"),
271
+ false,
272
+ );
273
+ sinon.assert.notCalled(safeSpawnSync);
274
+ });
275
+
276
+ it("spawns dosai with argument arrays and shell disabled", async () => {
277
+ const safeSpawnSync = sinon.stub().returns({ status: 0 });
278
+ const { runDosaiCommand } = await esmock("./dosai.js", {
279
+ "./plugins.js": { resolvePluginBinary: sinon.stub().returns("dosai") },
280
+ "./utils.js": {
281
+ DEBUG_MODE: false,
282
+ getTmpDir: sinon.stub().returns("/tmp"),
283
+ safeExistsSync: sinon.stub().returns(true),
284
+ safeMkdtempSync: sinon.stub(),
285
+ safeRmSync: sinon.stub(),
286
+ safeSpawnSync,
287
+ },
288
+ });
289
+
290
+ assert.strictEqual(
291
+ runDosaiCommand("dataflows", "/tmp/project", "/tmp/out.json", {
292
+ dataFlowPatterns: "/tmp/patterns.json",
293
+ patternPacks: "/tmp/packs",
294
+ }),
295
+ true,
296
+ );
297
+ sinon.assert.calledOnce(safeSpawnSync);
298
+ assert.strictEqual(safeSpawnSync.firstCall.args[0], "dosai");
299
+ assert.ok(Array.isArray(safeSpawnSync.firstCall.args[1]));
300
+ assert.strictEqual(safeSpawnSync.firstCall.args[2].shell, false);
301
+ });
302
+ });
@@ -0,0 +1,103 @@
1
+ import { PackageURL } from "packageurl-js";
2
+
3
+ export function normalizeDosaiPurlKey(purl) {
4
+ if (!purl || typeof purl !== "string") {
5
+ return undefined;
6
+ }
7
+ try {
8
+ const purlObj = PackageURL.fromString(purl);
9
+ return [
10
+ purlObj.type?.toLowerCase(),
11
+ purlObj.namespace?.toLowerCase() || "",
12
+ purlObj.name?.toLowerCase(),
13
+ ].join("/");
14
+ } catch (_err) {
15
+ return purl.split("?")[0].split("#")[0].split("@")[0].toLowerCase();
16
+ }
17
+ }
18
+
19
+ export function addDosaiSetValue(map, key, value) {
20
+ if (!key || !value) {
21
+ return;
22
+ }
23
+ map[key] ??= new Set();
24
+ map[key].add(value);
25
+ }
26
+
27
+ export function dosaiLocation(item) {
28
+ const location = item?.Location || item?.CallLocation || item;
29
+ const fileName =
30
+ location?.Path || location?.FileName || item?.Path || item?.FileName;
31
+ if (!fileName || fileName === "<unknown>") {
32
+ return undefined;
33
+ }
34
+ const lineNumber = location?.LineNumber || item?.LineNumber;
35
+ if (lineNumber && lineNumber > 0) {
36
+ return `${fileName}#${lineNumber}`;
37
+ }
38
+ return fileName;
39
+ }
40
+
41
+ function dosaiSourceFileName(item) {
42
+ const location = item?.Location || item?.CallLocation || item;
43
+ return String(
44
+ location?.Path || location?.FileName || item?.Path || item?.FileName || "",
45
+ );
46
+ }
47
+
48
+ function dosaiSourceLineNumber(item) {
49
+ const location = item?.Location || item?.CallLocation || item;
50
+ return location?.LineNumber || item?.LineNumber;
51
+ }
52
+
53
+ export function dosaiSourceLocationFromNode(node) {
54
+ const location = dosaiLocation(node);
55
+ const fileName = dosaiSourceFileName(node).toLowerCase();
56
+ const lineNumber = dosaiSourceLineNumber(node);
57
+ if (!location || !/\.(cs|vb|fs|fsx)$/i.test(fileName)) {
58
+ return undefined;
59
+ }
60
+ if (!lineNumber || lineNumber <= 0) {
61
+ return undefined;
62
+ }
63
+ return location;
64
+ }
65
+
66
+ export function dosaiSourceLocation(location) {
67
+ const sourceLocation = dosaiLocation(location);
68
+ const fileName = dosaiSourceFileName(location);
69
+ const lineNumber = dosaiSourceLineNumber(location);
70
+ if (!sourceLocation || !/\.(cs|vb|fs|fsx)$/i.test(fileName)) {
71
+ return undefined;
72
+ }
73
+ if (!lineNumber || lineNumber <= 0) {
74
+ return undefined;
75
+ }
76
+ return sourceLocation;
77
+ }
78
+
79
+ export function buildDosaiPurlAliasMap(components = []) {
80
+ const purlAliasMap = new Map();
81
+ for (const component of components) {
82
+ if (!component?.purl) {
83
+ continue;
84
+ }
85
+ purlAliasMap.set(component.purl, component.purl);
86
+ const key = normalizeDosaiPurlKey(component.purl);
87
+ if (key && !purlAliasMap.has(key)) {
88
+ purlAliasMap.set(key, component.purl);
89
+ }
90
+ }
91
+ return purlAliasMap;
92
+ }
93
+
94
+ export function resolveDosaiComponentPurl(purl, purlAliasMap) {
95
+ if (!purl) {
96
+ return undefined;
97
+ }
98
+ return (
99
+ purlAliasMap.get(purl) ||
100
+ purlAliasMap.get(normalizeDosaiPurlKey(purl)) ||
101
+ purl
102
+ );
103
+ }
@@ -24,6 +24,21 @@ function isMusl() {
24
24
  return result?.stdout?.includes("musl") || result?.stderr?.includes("musl");
25
25
  }
26
26
 
27
+ function hasUsablePluginsDir(pluginsDir) {
28
+ return (
29
+ safeExistsSync(pluginsDir) &&
30
+ (safeExistsSync(join(pluginsDir, "plugins-manifest.json")) ||
31
+ [
32
+ "cargo-auditable",
33
+ "dosai",
34
+ "osquery",
35
+ "sourcekitten",
36
+ "trivy",
37
+ "trustinspector",
38
+ ].some((pluginName) => safeExistsSync(join(pluginsDir, pluginName))))
39
+ );
40
+ }
41
+
27
42
  /**
28
43
  * Determine the normalized plugin target tuple for the current runtime.
29
44
  *
@@ -81,17 +96,13 @@ export function resolveCdxgenPlugins() {
81
96
  let pluginsDir = process.env.CDXGEN_PLUGINS_DIR || "";
82
97
  let extraNMBinPath;
83
98
 
84
- if (
85
- !pluginsDir &&
86
- safeExistsSync(join(dirNameStr, "plugins")) &&
87
- safeExistsSync(join(dirNameStr, "plugins", "trivy"))
88
- ) {
99
+ if (!pluginsDir && hasUsablePluginsDir(join(dirNameStr, "plugins"))) {
89
100
  pluginsDir = join(dirNameStr, "plugins");
90
101
  }
91
102
 
92
103
  if (
93
104
  !pluginsDir &&
94
- safeExistsSync(
105
+ hasUsablePluginsDir(
95
106
  join(
96
107
  dirNameStr,
97
108
  "node_modules",
@@ -99,16 +110,6 @@ export function resolveCdxgenPlugins() {
99
110
  `cdxgen-plugins-bin${target.pluginsBinSuffix}`,
100
111
  "plugins",
101
112
  ),
102
- ) &&
103
- safeExistsSync(
104
- join(
105
- dirNameStr,
106
- "node_modules",
107
- "@cdxgen",
108
- `cdxgen-plugins-bin${target.pluginsBinSuffix}`,
109
- "plugins",
110
- "trivy",
111
- ),
112
113
  )
113
114
  ) {
114
115
  pluginsDir = join(
@@ -11,6 +11,7 @@ import {
11
11
  supportedSpecVersions,
12
12
  } from "@appthreat/cdx-proto";
13
13
 
14
+ import { toCycloneDxSpecVersionString } from "./bomUtils.js";
14
15
  import { safeExistsSync, safeWriteSync } from "./utils.js";
15
16
 
16
17
  const JSON_READ_OPTIONS = {
@@ -29,6 +30,11 @@ const PROTO_BOM_FILE_EXTENSIONS = [".cdx", ".cdx.bin", ".proto"];
29
30
 
30
31
  const DEFAULT_SPEC_VERSION =
31
32
  supportedSpecVersions[supportedSpecVersions.length - 1];
33
+ const PROTO_SUPPORTED_SPEC_VERSIONS = new Set(
34
+ supportedSpecVersions.map((specVersion) =>
35
+ toCycloneDxSpecVersionString(specVersion),
36
+ ),
37
+ );
32
38
 
33
39
  const isProtoMessageBom = (bom) =>
34
40
  Boolean(
@@ -47,6 +53,42 @@ const hasExplicitSpecVersion = (bomJson) =>
47
53
  (bomJson.specVersion !== undefined || bomJson.spec_version !== undefined),
48
54
  );
49
55
 
56
+ const resolveExplicitSpecVersion = (bomJson) =>
57
+ bomJson?.specVersion ?? bomJson?.spec_version;
58
+
59
+ const hasProvidedSpecVersion = (specVersion) =>
60
+ specVersion !== undefined &&
61
+ specVersion !== null &&
62
+ `${specVersion}`.trim() !== "";
63
+
64
+ export const isProtoSupportedSpecVersion = (specVersion) => {
65
+ if (!hasProvidedSpecVersion(specVersion)) {
66
+ return true;
67
+ }
68
+ const normalizedSpecVersion = toCycloneDxSpecVersionString(specVersion);
69
+ return (
70
+ normalizedSpecVersion !== undefined &&
71
+ PROTO_SUPPORTED_SPEC_VERSIONS.has(normalizedSpecVersion)
72
+ );
73
+ };
74
+
75
+ export const assertProtoSupportedSpecVersion = (
76
+ specVersion,
77
+ operation = "protobuf operations",
78
+ ) => {
79
+ if (!hasProvidedSpecVersion(specVersion)) {
80
+ return;
81
+ }
82
+ const normalizedSpecVersion = toCycloneDxSpecVersionString(specVersion);
83
+ if (isProtoSupportedSpecVersion(specVersion)) {
84
+ return;
85
+ }
86
+ const displaySpecVersion = normalizedSpecVersion || `${specVersion}`.trim();
87
+ throw new Error(
88
+ `CycloneDX ${displaySpecVersion} is not currently supported for ${operation}. @appthreat/cdx-proto supports ${supportedSpecVersions.join(", ")} only.`,
89
+ );
90
+ };
91
+
50
92
  const OBJECT_WRAPPED_LIST_FIELDS = ["declarations", "definitions"];
51
93
 
52
94
  const isPlainObject = (value) =>
@@ -121,15 +163,25 @@ const resolveBomMessage = (bomJson, specVersion = DEFAULT_SPEC_VERSION) => {
121
163
  JSON.parse(`${bomJson}`),
122
164
  );
123
165
  if (hasExplicitSpecVersion(parsedBomJson)) {
166
+ assertProtoSupportedSpecVersion(
167
+ resolveExplicitSpecVersion(parsedBomJson),
168
+ "protobuf serialization",
169
+ );
124
170
  return parseBomJson(parsedBomJson, JSON_READ_OPTIONS);
125
171
  }
172
+ assertProtoSupportedSpecVersion(specVersion, "protobuf serialization");
126
173
  return decodeBomJson(specVersion, parsedBomJson, JSON_READ_OPTIONS);
127
174
  }
128
175
  if (bomJson && typeof bomJson === "object" && !Array.isArray(bomJson)) {
129
176
  const normalizedBomJson = normalizeObjectWrappedListsForProto(bomJson);
130
177
  if (hasExplicitSpecVersion(normalizedBomJson)) {
178
+ assertProtoSupportedSpecVersion(
179
+ resolveExplicitSpecVersion(normalizedBomJson),
180
+ "protobuf serialization",
181
+ );
131
182
  return parseBomJson(normalizedBomJson, JSON_READ_OPTIONS);
132
183
  }
184
+ assertProtoSupportedSpecVersion(specVersion, "protobuf serialization");
133
185
  return decodeBomJson(specVersion, normalizedBomJson, JSON_READ_OPTIONS);
134
186
  }
135
187
  return createBom(specVersion);
@@ -175,6 +227,7 @@ export const writeBinary = (
175
227
  */
176
228
  export const readBinary = (binFile, asJson, specVersion) => {
177
229
  asJson = asJson ?? true;
230
+ assertProtoSupportedSpecVersion(specVersion, "protobuf decoding");
178
231
  if (!safeExistsSync(binFile)) {
179
232
  return undefined;
180
233
  }
@@ -3,7 +3,12 @@ import { join } from "node:path";
3
3
 
4
4
  import { assert, it } from "poku";
5
5
 
6
- import { isProtoBomFile, readBinary, writeBinary } from "./protobom.js";
6
+ import {
7
+ assertProtoSupportedSpecVersion,
8
+ isProtoBomFile,
9
+ readBinary,
10
+ writeBinary,
11
+ } from "./protobom.js";
7
12
  import { getTmpDir } from "./utils.js";
8
13
 
9
14
  const testBom = JSON.parse(
@@ -137,6 +142,44 @@ it("keeps canonical definitions and declarations as objects during proto round-t
137
142
  cleanupTempDir(tempDir);
138
143
  });
139
144
 
145
+ it("rejects unsupported CycloneDX 2.0 protobuf operations with a clear error", () => {
146
+ const tempDir = createTempDir();
147
+ const binFile = join(tempDir, "unsupported-2.0.cdx");
148
+ assert.throws(
149
+ () =>
150
+ writeBinary(
151
+ {
152
+ specFormat: "CycloneDX",
153
+ specVersion: "2.0",
154
+ version: 1,
155
+ },
156
+ binFile,
157
+ ),
158
+ /CycloneDX 2\.0 is not currently supported for protobuf serialization/,
159
+ );
160
+ assert.throws(
161
+ () => assertProtoSupportedSpecVersion("2.0", "protobuf export"),
162
+ /@appthreat\/cdx-proto supports 1\.5, 1\.6, 1\.7 only/,
163
+ );
164
+ assert.throws(
165
+ () =>
166
+ writeBinary(
167
+ {
168
+ bomFormat: "CycloneDX",
169
+ specVersion: "2.0.1",
170
+ version: 1,
171
+ },
172
+ binFile,
173
+ ),
174
+ /CycloneDX 2\.0\.1 is not currently supported for protobuf serialization/,
175
+ );
176
+ assert.throws(
177
+ () => assertProtoSupportedSpecVersion("2.0.1", "protobuf export"),
178
+ /CycloneDX 2\.0\.1 is not currently supported for protobuf export/,
179
+ );
180
+ cleanupTempDir(tempDir);
181
+ });
182
+
140
183
  it("round-trips real CBOM fixture data with cryptographic assets intact", () => {
141
184
  const tempDir = createTempDir();
142
185
  const binFile = join(tempDir, "cbom-fixture.cdx");
@@ -0,0 +1,43 @@
1
+ const PROTO_BOM_FILE_EXTENSIONS = [".cdx", ".cdx.bin", ".proto"];
2
+
3
+ /**
4
+ * Determine whether a path looks like a CycloneDX protobuf BOM file.
5
+ *
6
+ * @param {string} filePath File path
7
+ * @returns {boolean} true when the path uses a protobuf BOM extension
8
+ */
9
+ export function isProtoBomPath(filePath) {
10
+ const normalizedPath = `${filePath || ""}`.toLowerCase();
11
+ return PROTO_BOM_FILE_EXTENSIONS.some((extension) =>
12
+ normalizedPath.endsWith(extension),
13
+ );
14
+ }
15
+
16
+ /**
17
+ * Import protobuf BOM helpers and replace optional-dependency loader failures
18
+ * with actionable command-specific messages.
19
+ *
20
+ * @param {string} [commandName="cdxgen"] CLI command name
21
+ * @param {string} [featureDescription="protobuf support"] Feature being used
22
+ * @returns {Promise<object>} Loaded protobom module namespace
23
+ */
24
+ export async function importProtobomModule(
25
+ commandName = "cdxgen",
26
+ featureDescription = "protobuf support",
27
+ ) {
28
+ try {
29
+ return await import("./protobom.js");
30
+ } catch (error) {
31
+ const message = `${error?.message || ""}`;
32
+ if (
33
+ error?.code === "ERR_MODULE_NOT_FOUND" ||
34
+ message.includes("@appthreat/cdx-proto") ||
35
+ message.includes("@bufbuild/protobuf")
36
+ ) {
37
+ throw new Error(
38
+ `${commandName} ${featureDescription} requires the optional '@appthreat/cdx-proto' and '@bufbuild/protobuf' dependencies. Install optional dependencies or use a binary that bundles protobuf support.`,
39
+ );
40
+ }
41
+ throw error;
42
+ }
43
+ }
@@ -0,0 +1,31 @@
1
+ import { assert, describe, it } from "poku";
2
+
3
+ import { importProtobomModule, isProtoBomPath } from "./protobomLoader.js";
4
+
5
+ describe("protobomLoader", () => {
6
+ it("detects protobuf BOM file extensions", () => {
7
+ assert.strictEqual(isProtoBomPath("bom.cdx"), true);
8
+ assert.strictEqual(isProtoBomPath("bom.CDX.BIN"), true);
9
+ assert.strictEqual(isProtoBomPath("bom.proto"), true);
10
+ assert.strictEqual(isProtoBomPath("bom.json"), false);
11
+ assert.strictEqual(isProtoBomPath(""), false);
12
+ });
13
+
14
+ it("imports the protobuf BOM helper when optional support is installed", async () => {
15
+ let protobomModule;
16
+ try {
17
+ protobomModule = await importProtobomModule(
18
+ "cdx-test",
19
+ "protobuf BOM input",
20
+ );
21
+ } catch (error) {
22
+ assert.match(
23
+ error.message,
24
+ /requires the optional '@appthreat\/cdx-proto' and '@bufbuild\/protobuf' dependencies/u,
25
+ );
26
+ return;
27
+ }
28
+ assert.strictEqual(typeof protobomModule.readBinary, "function");
29
+ assert.strictEqual(typeof protobomModule.writeBinary, "function");
30
+ });
31
+ });