@cyclonedx/cdxgen 12.3.0 → 12.3.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 (121) hide show
  1. package/README.md +15 -5
  2. package/bin/audit.js +7 -0
  3. package/bin/cdxgen.js +241 -81
  4. package/bin/repl.js +138 -0
  5. package/data/rules/ai-agent-governance.yaml +249 -0
  6. package/data/rules/dependency-sources.yaml +41 -0
  7. package/data/rules/mcp-servers.yaml +304 -0
  8. package/data/rules/package-integrity.yaml +123 -0
  9. package/lib/audit/index.js +353 -29
  10. package/lib/audit/index.poku.js +247 -7
  11. package/lib/audit/reporters.js +26 -0
  12. package/lib/audit/scoring.js +262 -13
  13. package/lib/audit/scoring.poku.js +179 -0
  14. package/lib/audit/targets.js +391 -2
  15. package/lib/audit/targets.poku.js +416 -3
  16. package/lib/cli/index.js +588 -45
  17. package/lib/cli/index.poku.js +735 -1
  18. package/lib/evinser/evinser.js +8 -5
  19. package/lib/helpers/agentFormulationParser.js +318 -0
  20. package/lib/helpers/aiInventory.js +262 -0
  21. package/lib/helpers/aiInventory.poku.js +111 -0
  22. package/lib/helpers/analyzer.js +1769 -0
  23. package/lib/helpers/analyzer.poku.js +284 -3
  24. package/lib/helpers/auditCategories.js +76 -0
  25. package/lib/helpers/ciParsers/githubActions.js +140 -16
  26. package/lib/helpers/ciParsers/githubActions.poku.js +110 -0
  27. package/lib/helpers/communityAiConfigParser.js +672 -0
  28. package/lib/helpers/communityAiConfigParser.poku.js +63 -0
  29. package/lib/helpers/depsUtils.js +108 -0
  30. package/lib/helpers/depsUtils.poku.js +72 -1
  31. package/lib/helpers/display.js +325 -3
  32. package/lib/helpers/display.poku.js +301 -0
  33. package/lib/helpers/formulationParsers.js +28 -0
  34. package/lib/helpers/formulationParsers.poku.js +504 -1
  35. package/lib/helpers/jsonLike.js +102 -0
  36. package/lib/helpers/jsonLike.poku.js +34 -0
  37. package/lib/helpers/mcp.js +248 -0
  38. package/lib/helpers/mcp.poku.js +101 -0
  39. package/lib/helpers/mcpConfigParser.js +656 -0
  40. package/lib/helpers/mcpConfigParser.poku.js +126 -0
  41. package/lib/helpers/mcpDiscovery.js +84 -0
  42. package/lib/helpers/mcpDiscovery.poku.js +21 -0
  43. package/lib/helpers/protobom.js +3 -3
  44. package/lib/helpers/provenanceUtils.js +29 -4
  45. package/lib/helpers/provenanceUtils.poku.js +29 -3
  46. package/lib/helpers/registryProvenance.js +210 -0
  47. package/lib/helpers/registryProvenance.poku.js +144 -0
  48. package/lib/helpers/rustFormulationParser.js +330 -0
  49. package/lib/helpers/source.js +21 -2
  50. package/lib/helpers/source.poku.js +38 -0
  51. package/lib/helpers/utils.js +1331 -83
  52. package/lib/helpers/utils.poku.js +599 -188
  53. package/lib/helpers/vsixutils.js +12 -4
  54. package/lib/helpers/vsixutils.poku.js +34 -0
  55. package/lib/managers/binary.js +36 -12
  56. package/lib/managers/binary.poku.js +68 -0
  57. package/lib/managers/docker.js +59 -9
  58. package/lib/managers/docker.poku.js +61 -0
  59. package/lib/managers/piptree.js +12 -7
  60. package/lib/managers/piptree.poku.js +44 -0
  61. package/lib/stages/postgen/annotator.js +2 -1
  62. package/lib/stages/postgen/annotator.poku.js +15 -0
  63. package/lib/stages/postgen/auditBom.js +20 -6
  64. package/lib/stages/postgen/auditBom.poku.js +694 -1
  65. package/lib/stages/postgen/postgen.js +262 -11
  66. package/lib/stages/postgen/postgen.poku.js +306 -2
  67. package/lib/stages/postgen/ruleEngine.js +49 -1
  68. package/lib/stages/postgen/spdxConverter.poku.js +70 -0
  69. package/lib/stages/pregen/pregen.js +6 -4
  70. package/package.json +1 -1
  71. package/types/bin/repl.d.ts.map +1 -1
  72. package/types/lib/audit/index.d.ts.map +1 -1
  73. package/types/lib/audit/reporters.d.ts.map +1 -1
  74. package/types/lib/audit/scoring.d.ts.map +1 -1
  75. package/types/lib/audit/targets.d.ts +12 -0
  76. package/types/lib/audit/targets.d.ts.map +1 -1
  77. package/types/lib/cli/index.d.ts +2 -8
  78. package/types/lib/cli/index.d.ts.map +1 -1
  79. package/types/lib/evinser/evinser.d.ts.map +1 -1
  80. package/types/lib/helpers/agentFormulationParser.d.ts +19 -0
  81. package/types/lib/helpers/agentFormulationParser.d.ts.map +1 -0
  82. package/types/lib/helpers/aiInventory.d.ts +23 -0
  83. package/types/lib/helpers/aiInventory.d.ts.map +1 -0
  84. package/types/lib/helpers/analyzer.d.ts +10 -0
  85. package/types/lib/helpers/analyzer.d.ts.map +1 -1
  86. package/types/lib/helpers/auditCategories.d.ts +12 -0
  87. package/types/lib/helpers/auditCategories.d.ts.map +1 -0
  88. package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -1
  89. package/types/lib/helpers/communityAiConfigParser.d.ts +29 -0
  90. package/types/lib/helpers/communityAiConfigParser.d.ts.map +1 -0
  91. package/types/lib/helpers/depsUtils.d.ts +8 -0
  92. package/types/lib/helpers/depsUtils.d.ts.map +1 -1
  93. package/types/lib/helpers/display.d.ts +17 -1
  94. package/types/lib/helpers/display.d.ts.map +1 -1
  95. package/types/lib/helpers/formulationParsers.d.ts.map +1 -1
  96. package/types/lib/helpers/jsonLike.d.ts +4 -0
  97. package/types/lib/helpers/jsonLike.d.ts.map +1 -0
  98. package/types/lib/helpers/mcp.d.ts +29 -0
  99. package/types/lib/helpers/mcp.d.ts.map +1 -0
  100. package/types/lib/helpers/mcpConfigParser.d.ts +30 -0
  101. package/types/lib/helpers/mcpConfigParser.d.ts.map +1 -0
  102. package/types/lib/helpers/mcpDiscovery.d.ts +5 -0
  103. package/types/lib/helpers/mcpDiscovery.d.ts.map +1 -0
  104. package/types/lib/helpers/provenanceUtils.d.ts +5 -3
  105. package/types/lib/helpers/provenanceUtils.d.ts.map +1 -1
  106. package/types/lib/helpers/registryProvenance.d.ts +9 -0
  107. package/types/lib/helpers/registryProvenance.d.ts.map +1 -1
  108. package/types/lib/helpers/rustFormulationParser.d.ts +17 -0
  109. package/types/lib/helpers/rustFormulationParser.d.ts.map +1 -0
  110. package/types/lib/helpers/source.d.ts.map +1 -1
  111. package/types/lib/helpers/utils.d.ts +31 -1
  112. package/types/lib/helpers/utils.d.ts.map +1 -1
  113. package/types/lib/helpers/vsixutils.d.ts.map +1 -1
  114. package/types/lib/managers/binary.d.ts.map +1 -1
  115. package/types/lib/managers/docker.d.ts.map +1 -1
  116. package/types/lib/managers/piptree.d.ts.map +1 -1
  117. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  118. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  119. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  120. package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
  121. package/types/lib/stages/pregen/pregen.d.ts.map +1 -1
@@ -6,13 +6,15 @@ import {
6
6
  writeFileSync,
7
7
  } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
- import { join } from "node:path";
9
+ import { dirname, join } from "node:path";
10
10
 
11
11
  import { assert, describe, it } from "poku";
12
12
 
13
13
  import {
14
14
  analyzeSuspiciousJsFile,
15
15
  detectExtensionCapabilities,
16
+ detectMcpInventory,
17
+ detectPythonMcpInventory,
16
18
  findJSImportsExports,
17
19
  } from "./analyzer.js";
18
20
 
@@ -42,6 +44,25 @@ const createProjectFromFixture = (subDirName, fixtureFileName) => {
42
44
  return projectDir;
43
45
  };
44
46
 
47
+ const createProjectFiles = (subDirName, fileMap) => {
48
+ const projectDir = join(baseTempDir, subDirName);
49
+ mkdirSync(projectDir, { recursive: true });
50
+ for (const [fileName, content] of Object.entries(fileMap)) {
51
+ const fullPath = join(projectDir, fileName);
52
+ mkdirSync(dirname(fullPath), { recursive: true });
53
+ writeFileSync(fullPath, content, { encoding: "utf-8" });
54
+ }
55
+ return projectDir;
56
+ };
57
+
58
+ function getProp(obj, name) {
59
+ return obj?.properties?.find((property) => property.name === name)?.value;
60
+ }
61
+
62
+ function normalizePathForAssertion(filePath) {
63
+ return String(filePath || "").replaceAll("\\", "/");
64
+ }
65
+
45
66
  describe("findJSImportsExports() wasm and wasi detection", () => {
46
67
  it("captures wasm exports from WebAssembly.instantiate() flow", async () => {
47
68
  const projectDir = createProject(
@@ -66,7 +87,7 @@ console.log(add(5, 6));
66
87
  );
67
88
  assert.ok(addOccurrence, "expected add symbol occurrence to exist");
68
89
  assert.ok(
69
- addOccurrence.fileName?.includes("index.js"),
90
+ normalizePathForAssertion(addOccurrence.fileName).endsWith("index.js"),
70
91
  "expected source filename to be tracked",
71
92
  );
72
93
  assert.strictEqual(addOccurrence.lineNumber, 4);
@@ -189,7 +210,9 @@ wasi.start(instance);
189
210
  assert.ok(
190
211
  wasmImportOccurrences.some(
191
212
  (occ) =>
192
- occ.fileName?.includes("libmagic-wrapper.js") &&
213
+ normalizePathForAssertion(occ.fileName).endsWith(
214
+ "libmagic-wrapper.js",
215
+ ) &&
193
216
  typeof occ.lineNumber === "number" &&
194
217
  typeof occ.columnNumber === "number",
195
218
  ),
@@ -299,3 +322,261 @@ describe("analyzeSuspiciousJsFile()", () => {
299
322
  assert.match(analysis.networkIndicators.join(","), /network-request/);
300
323
  });
301
324
  });
325
+
326
+ describe("detectMcpInventory()", () => {
327
+ it("detects an official authenticated streamable HTTP MCP server", () => {
328
+ const projectDir = createProjectFiles("mcp-http-server", {
329
+ "src/server.js": [
330
+ "import { McpServer } from '@modelcontextprotocol/server';",
331
+ "import { Client } from '@modelcontextprotocol/client';",
332
+ "import { createMcpExpressApp, mcpAuthMetadataRouter, requireBearerAuth } from '@modelcontextprotocol/express';",
333
+ "import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';",
334
+ "import OpenAI from 'openai';",
335
+ "const app = createMcpExpressApp();",
336
+ "const oauthMetadata = { issuer: 'https://auth.example.com', authorization_endpoint: 'https://auth.example.com/authorize', token_endpoint: 'https://auth.example.com/token' };",
337
+ "const mcpServerUrl = new URL('http://localhost:3000/mcp');",
338
+ "const server = new McpServer({ name: 'demo-http-server', version: '1.2.3' }, { capabilities: { logging: {}, resources: { subscribe: true }, tools: { listChanged: true } } });",
339
+ "const upstream = new Client({ name: 'relay-client', version: '0.0.1' });",
340
+ "server.registerTool('summarize', { description: 'Summarize text', annotations: { readOnlyHint: true } }, async () => ({ content: [] }));",
341
+ "server.registerPrompt('ask-user', { description: 'Prompt template' }, async () => ({ messages: [] }));",
342
+ "server.registerResource('docs', 'file:///{path}', { description: 'Workspace docs' }, async () => ({ contents: [] }));",
343
+ "const auth = requireBearerAuth({ requiredScopes: ['mcp'] });",
344
+ "app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl }));",
345
+ "app.post('/mcp', auth, async () => {});",
346
+ "const transport = new NodeStreamableHTTPServerTransport();",
347
+ "await server.connect(transport);",
348
+ "const openai = new OpenAI({ apiKey: 'sk-test' });",
349
+ "await fetch('https://api.openai.com/v1/responses');",
350
+ "await upstream.callTool({ name: 'summarize' });",
351
+ "const provider = 'anthropic';",
352
+ "const model = 'claude-3-5-sonnet';",
353
+ "void provider; void model;",
354
+ ].join("\n"),
355
+ });
356
+ const inventory = detectMcpInventory(projectDir);
357
+ assert.strictEqual(inventory.services.length, 1);
358
+ assert.strictEqual(inventory.components.length, 3);
359
+ const service = inventory.services[0];
360
+ assert.strictEqual(service.name, "demo-http-server");
361
+ assert.strictEqual(service.version, "1.2.3");
362
+ assert.strictEqual(service.authenticated, true);
363
+ assert.ok(service.endpoints.includes("/mcp"));
364
+ assert.ok(service.endpoints.includes("http://localhost:3000/mcp"));
365
+ assert.ok(
366
+ service.properties.some(
367
+ (prop) =>
368
+ prop.name === "cdx:mcp:capabilities:resources.subscribe" &&
369
+ prop.value === "true",
370
+ ),
371
+ );
372
+ assert.ok(
373
+ service.properties.some(
374
+ (prop) =>
375
+ prop.name === "cdx:mcp:modelNames" &&
376
+ prop.value.includes("claude-3-5-sonnet"),
377
+ ),
378
+ );
379
+ assert.ok(
380
+ service.properties.some(
381
+ (prop) =>
382
+ prop.name === "cdx:mcp:serviceType" && prop.value === "gateway",
383
+ ),
384
+ );
385
+ assert.ok(
386
+ service.properties.some(
387
+ (prop) =>
388
+ prop.name === "cdx:mcp:providerFamilies" &&
389
+ prop.value.includes("anthropic") &&
390
+ prop.value.includes("openai"),
391
+ ),
392
+ );
393
+ assert.ok(
394
+ new Set((getProp(service, "cdx:mcp:outboundHosts") || "").split(",")).has(
395
+ "api.openai.com",
396
+ ),
397
+ );
398
+ assert.ok(
399
+ service.properties.some(
400
+ (prop) =>
401
+ prop.name === "cdx:mcp:usageConfidence" && prop.value === "high",
402
+ ),
403
+ );
404
+ assert.ok(
405
+ inventory.dependencies.some(
406
+ (dependency) =>
407
+ dependency.ref === service["bom-ref"] &&
408
+ dependency.provides.length === 3,
409
+ ),
410
+ );
411
+ });
412
+
413
+ it("detects an unauthenticated non-official HTTP MCP server", () => {
414
+ const projectDir = createProjectFiles("mcp-unsafe-server", {
415
+ "index.js": [
416
+ "import express from 'express';",
417
+ "import { Server as AcmeMcpServer } from '@acme/mcp-server';",
418
+ "const app = express();",
419
+ "const server = new AcmeMcpServer({ name: 'unsafe-http-server', version: '0.1.0' });",
420
+ "server.registerTool('run_shell', { description: 'Run a command' }, async () => ({ content: [] }));",
421
+ "app.post('/mcp-unsafe', async () => {});",
422
+ ].join("\n"),
423
+ });
424
+ const inventory = detectMcpInventory(projectDir);
425
+ assert.strictEqual(inventory.services.length, 1);
426
+ const service = inventory.services[0];
427
+ assert.strictEqual(service.name, "unsafe-http-server");
428
+ assert.strictEqual(service.authenticated, false);
429
+ assert.ok(service.endpoints.includes("/mcp-unsafe"));
430
+ assert.ok(
431
+ service.properties.some(
432
+ (prop) => prop.name === "cdx:mcp:officialSdk" && prop.value === "false",
433
+ ),
434
+ );
435
+ });
436
+
437
+ it("detects MCP client-only usage and provider wiring", () => {
438
+ const projectDir = createProjectFiles("mcp-client-only", {
439
+ "index.js": [
440
+ "import { Client } from '@modelcontextprotocol/client';",
441
+ "import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';",
442
+ "import Anthropic from '@anthropic-ai/sdk';",
443
+ "const client = new Client({ name: 'demo-client', version: '0.1.0' });",
444
+ "const transport = new StreamableHTTPClientTransport(new URL('https://mcp.example.com/mcp'));",
445
+ "await client.connect(transport);",
446
+ "const anthropic = new Anthropic({ apiKey: 'test' });",
447
+ "await client.listTools();",
448
+ "await fetch('https://api.anthropic.com/v1/messages');",
449
+ "const modelName = 'claude-3-7-sonnet';",
450
+ "void anthropic; void modelName;",
451
+ ].join("\n"),
452
+ });
453
+ const inventory = detectMcpInventory(projectDir);
454
+ assert.strictEqual(inventory.services.length, 1);
455
+ const service = inventory.services[0];
456
+ assert.ok(
457
+ service.properties.some(
458
+ (prop) =>
459
+ prop.name === "cdx:mcp:serviceType" && prop.value === "client",
460
+ ),
461
+ );
462
+ assert.ok(
463
+ service.properties.some(
464
+ (prop) =>
465
+ prop.name === "cdx:mcp:exposureType" &&
466
+ prop.value === "networked-public",
467
+ ),
468
+ );
469
+ assert.ok(
470
+ ["mcp.example.com", "api.anthropic.com"].every((hostname) =>
471
+ getProp(service, "cdx:mcp:outboundHosts")
472
+ ?.split(",")
473
+ .includes(hostname),
474
+ ),
475
+ );
476
+ assert.ok(
477
+ service.properties.some(
478
+ (prop) =>
479
+ prop.name === "cdx:mcp:providerFamilies" &&
480
+ prop.value.includes("anthropic"),
481
+ ),
482
+ );
483
+ assert.ok(
484
+ service.properties.some(
485
+ (prop) =>
486
+ prop.name === "cdx:mcp:inventorySource" &&
487
+ prop.value === "source-code-analysis",
488
+ ),
489
+ );
490
+ assert.ok(
491
+ service.properties.some(
492
+ (prop) => prop.name === "cdx:mcp:reviewNeeded" && prop.value === "true",
493
+ ),
494
+ );
495
+ });
496
+
497
+ it("detects a TypeScript stdio MCP server and emits source-code-analysis inventory", () => {
498
+ const projectDir = createProjectFiles("mcp-ts-stdio-server", {
499
+ "src/server.ts": [
500
+ "import { McpServer } from '@modelcontextprotocol/server';",
501
+ "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';",
502
+ "const server = new McpServer({ name: 'ts-stdio-server', version: '0.2.0' }, { capabilities: { tools: {}, prompts: {}, resources: {} } });",
503
+ "server.registerTool('lint', { description: 'Lint source files' }, async () => ({ content: [] }));",
504
+ "server.registerPrompt('review', { description: 'Prompt review guidance' }, async () => ({ messages: [] }));",
505
+ "server.registerResource('workspace-docs', 'file:///docs/{path}', { description: 'Workspace docs' }, async () => ({ contents: [] }));",
506
+ "const transport = new StdioServerTransport();",
507
+ "await server.connect(transport);",
508
+ ].join("\n"),
509
+ });
510
+ const inventory = detectMcpInventory(projectDir);
511
+ assert.strictEqual(inventory.services.length, 1);
512
+ assert.strictEqual(inventory.components.length, 3);
513
+ const service = inventory.services[0];
514
+ assert.strictEqual(service.name, "ts-stdio-server");
515
+ assert.strictEqual(service.version, "0.2.0");
516
+ assert.strictEqual(getProp(service, "cdx:mcp:transport"), "stdio");
517
+ assert.strictEqual(
518
+ getProp(service, "cdx:mcp:inventorySource"),
519
+ "source-code-analysis",
520
+ );
521
+ assert.strictEqual(getProp(service, "cdx:mcp:serviceType"), "gateway");
522
+ assert.strictEqual(getProp(service, "cdx:mcp:toolCount"), "1");
523
+ assert.strictEqual(getProp(service, "cdx:mcp:promptCount"), "1");
524
+ assert.strictEqual(getProp(service, "cdx:mcp:resourceCount"), "1");
525
+ assert.ok(
526
+ inventory.dependencies.some(
527
+ (dependency) =>
528
+ dependency.ref === service["bom-ref"] &&
529
+ dependency.provides.length === 3,
530
+ ),
531
+ );
532
+ });
533
+ });
534
+
535
+ describe("detectPythonMcpInventory()", () => {
536
+ it("detects a Python stdio MCP server and exported primitives", () => {
537
+ const projectDir = createProjectFiles("mcp-python-server", {
538
+ "src/server.py": [
539
+ "import mcp.server.stdio",
540
+ "import mcp.types as mtypes",
541
+ "from mcp.server import NotificationOptions, Server",
542
+ "",
543
+ 'server = Server("appthreat-vulnerability-db", version="1.0.1")',
544
+ "",
545
+ "@server.list_resources()",
546
+ "async def handle_list_resources():",
547
+ ' return [mtypes.Resource(uri=mtypes.AnyUrl("cve://"), name="CVE Information", description="Get detailed information about a CVE")]',
548
+ "",
549
+ "@server.list_tools()",
550
+ "async def handle_list_tools():",
551
+ ' return [mtypes.Tool(name="search_by_purl_like", description="Search by purl", inputSchema={"type": "object"})]',
552
+ "",
553
+ "async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):",
554
+ " await server.run(",
555
+ " read_stream,",
556
+ " write_stream,",
557
+ ' InitializationOptions(server_name="appthreat-vulnerability-db", server_version="1.0.1", capabilities=server.get_capabilities(notification_options=NotificationOptions(), experimental_capabilities={}))',
558
+ " )",
559
+ ].join("\n"),
560
+ });
561
+ const inventory = detectPythonMcpInventory(projectDir);
562
+ assert.strictEqual(inventory.services.length, 1);
563
+ assert.strictEqual(inventory.components.length, 2);
564
+ const service = inventory.services[0];
565
+ assert.strictEqual(service.name, "appthreat-vulnerability-db");
566
+ assert.strictEqual(service.version, "1.0.1");
567
+ assert.strictEqual(getProp(service, "cdx:mcp:transport"), "stdio");
568
+ assert.strictEqual(getProp(service, "cdx:mcp:officialSdk"), "true");
569
+ assert.strictEqual(getProp(service, "cdx:mcp:toolCount"), "1");
570
+ assert.strictEqual(getProp(service, "cdx:mcp:resourceCount"), "1");
571
+ assert.ok(
572
+ inventory.components.some(
573
+ (component) => component.name === "search_by_purl_like",
574
+ ),
575
+ );
576
+ assert.ok(
577
+ inventory.components.some(
578
+ (component) => component.name === "CVE Information",
579
+ ),
580
+ );
581
+ });
582
+ });
@@ -0,0 +1,76 @@
1
+ export const BOM_AUDIT_CATEGORY_ALIASES = Object.freeze({
2
+ "ai-inventory": ["ai-agent", "mcp-server"],
3
+ });
4
+
5
+ function uniqueNonEmptyCategories(categories) {
6
+ return [...new Set((categories || []).filter(Boolean))];
7
+ }
8
+
9
+ export function normalizeBomAuditCategories(categories) {
10
+ if (Array.isArray(categories)) {
11
+ return uniqueNonEmptyCategories(
12
+ categories.map((category) => String(category).trim()).filter(Boolean),
13
+ );
14
+ }
15
+ if (typeof categories !== "string") {
16
+ return [];
17
+ }
18
+ return uniqueNonEmptyCategories(
19
+ categories
20
+ .split(",")
21
+ .map((category) => category.trim())
22
+ .filter(Boolean),
23
+ );
24
+ }
25
+
26
+ export function expandBomAuditCategories(categories) {
27
+ const normalizedCategories = normalizeBomAuditCategories(categories);
28
+ const expandedCategories = [];
29
+ for (const category of normalizedCategories) {
30
+ if (BOM_AUDIT_CATEGORY_ALIASES[category]?.length) {
31
+ expandedCategories.push(...BOM_AUDIT_CATEGORY_ALIASES[category]);
32
+ continue;
33
+ }
34
+ expandedCategories.push(category);
35
+ }
36
+ return uniqueNonEmptyCategories(expandedCategories);
37
+ }
38
+
39
+ export function availableBomAuditCategories(rules) {
40
+ return uniqueNonEmptyCategories(
41
+ (rules || []).map((rule) => rule?.category).filter(Boolean),
42
+ ).sort();
43
+ }
44
+
45
+ function formatBomAuditCategoryOption(category) {
46
+ const aliasedCategories = BOM_AUDIT_CATEGORY_ALIASES[category];
47
+ if (!aliasedCategories?.length) {
48
+ return category;
49
+ }
50
+ return `${category} (alias for ${aliasedCategories.join(",")})`;
51
+ }
52
+
53
+ export function validateBomAuditCategories(categories, rules) {
54
+ const normalizedCategories = normalizeBomAuditCategories(categories);
55
+ const validCategories = availableBomAuditCategories(rules);
56
+ const allowedCategories = new Set([
57
+ ...validCategories,
58
+ ...Object.keys(BOM_AUDIT_CATEGORY_ALIASES),
59
+ ]);
60
+ const invalidCategories = normalizedCategories.filter(
61
+ (category) => !allowedCategories.has(category),
62
+ );
63
+ if (invalidCategories.length) {
64
+ const validCategoryOptions = [...allowedCategories]
65
+ .sort()
66
+ .map((category) => formatBomAuditCategoryOption(category));
67
+ throw new Error(
68
+ `Unknown BOM audit categor${invalidCategories.length === 1 ? "y" : "ies"}: ${invalidCategories.join(", ")}. Valid categories: ${validCategoryOptions.join(", ")}.`,
69
+ );
70
+ }
71
+ return {
72
+ categories: normalizedCategories,
73
+ expandedCategories: expandBomAuditCategories(normalizedCategories),
74
+ validCategories,
75
+ };
76
+ }
@@ -102,6 +102,16 @@ const KNOWN_DISPATCH_ACTIONS = [
102
102
  },
103
103
  ];
104
104
 
105
+ const CARGO_TOOLCHAIN_ACTION_PATTERNS = [
106
+ /^dtolnay\/rust-toolchain(?:@|$)/i,
107
+ /^actions-rs\/toolchain(?:@|$)/i,
108
+ /^moonrepo\/setup-rust(?:@|$)/i,
109
+ ];
110
+
111
+ const CARGO_CACHE_ACTION_PATTERNS = [/^swatinem\/rust-cache(?:@|$)/i];
112
+
113
+ const CARGO_TOOL_INSTALL_ACTION_PATTERNS = [/^taiki-e\/install-action(?:@|$)/i];
114
+
105
115
  const FORK_CONTEXT_PATTERNS = [
106
116
  [
107
117
  "github.event.pull_request.head.repo.fork",
@@ -425,6 +435,81 @@ function analyzeCacheStep(step) {
425
435
  return props;
426
436
  }
427
437
 
438
+ function analyzeCargoActionStep(step) {
439
+ const props = [];
440
+ if (!step?.uses || typeof step.uses !== "string") {
441
+ return props;
442
+ }
443
+ const cargoRoles = new Set();
444
+ if (
445
+ CARGO_TOOLCHAIN_ACTION_PATTERNS.some((pattern) => pattern.test(step.uses))
446
+ ) {
447
+ cargoRoles.add("toolchain");
448
+ }
449
+ if (CARGO_CACHE_ACTION_PATTERNS.some((pattern) => pattern.test(step.uses))) {
450
+ cargoRoles.add("cache");
451
+ }
452
+ if (
453
+ CARGO_TOOL_INSTALL_ACTION_PATTERNS.some((pattern) =>
454
+ pattern.test(step.uses),
455
+ )
456
+ ) {
457
+ cargoRoles.add("tool-install");
458
+ }
459
+ if (
460
+ step.uses.includes("actions/cache") &&
461
+ typeof step.with?.path === "string" &&
462
+ /(?:^|[\\/])\.cargo(?:[\\/]|$)|cargo[\\/](?:registry|git)/i.test(
463
+ step.with.path,
464
+ )
465
+ ) {
466
+ cargoRoles.add("cache");
467
+ }
468
+ if (!cargoRoles.size) {
469
+ return props;
470
+ }
471
+ props.push({
472
+ name: "cdx:github:action:ecosystem",
473
+ value: "cargo",
474
+ });
475
+ props.push({
476
+ name: "cdx:github:action:role",
477
+ value: [...cargoRoles].join(","),
478
+ });
479
+ return props;
480
+ }
481
+
482
+ function analyzeCargoRunStep(normalizedRun) {
483
+ const props = [];
484
+ if (!normalizedRun || typeof normalizedRun !== "string") {
485
+ return props;
486
+ }
487
+ const cargoSubcommands = new Set();
488
+ for (const match of normalizedRun.matchAll(/\bcargo\s+([a-z][\w-]*)/gi)) {
489
+ if (match[1]) {
490
+ cargoSubcommands.add(match[1].toLowerCase());
491
+ }
492
+ }
493
+ if (!cargoSubcommands.size) {
494
+ return props;
495
+ }
496
+ props.push({
497
+ name: "cdx:github:step:usesCargo",
498
+ value: "true",
499
+ });
500
+ props.push({
501
+ name: "cdx:github:step:cargoSubcommands",
502
+ value: [...cargoSubcommands].join(","),
503
+ });
504
+ if (/\s--workspace\b|\s--all\b|\s--all-targets\b/i.test(normalizedRun)) {
505
+ props.push({
506
+ name: "cdx:github:step:cargoWorkspaceScope",
507
+ value: "true",
508
+ });
509
+ }
510
+ return props;
511
+ }
512
+
428
513
  /**
429
514
  * Detect untrusted expression interpolation in `run:` blocks.
430
515
  *
@@ -500,14 +585,42 @@ function detectPublishEcosystem(runValue) {
500
585
  return undefined;
501
586
  }
502
587
 
588
+ function normalizeRunValueEntry(entry) {
589
+ if (
590
+ typeof entry === "string" ||
591
+ typeof entry === "number" ||
592
+ typeof entry === "boolean"
593
+ ) {
594
+ return String(entry);
595
+ }
596
+ return "";
597
+ }
598
+
599
+ function normalizeRunValue(runValue) {
600
+ if (typeof runValue === "string") {
601
+ return runValue;
602
+ }
603
+ if (typeof runValue === "number" || typeof runValue === "boolean") {
604
+ return String(runValue);
605
+ }
606
+ if (Array.isArray(runValue)) {
607
+ const normalizedEntries = runValue
608
+ .map((entry) => normalizeRunValueEntry(entry))
609
+ .filter(Boolean);
610
+ return normalizedEntries.length ? normalizedEntries.join("\n") : undefined;
611
+ }
612
+ return undefined;
613
+ }
614
+
503
615
  function analyzeLegacyPublishStep(step, effectiveEnv) {
504
616
  const props = [];
505
- const publishEcosystem = detectPublishEcosystem(step?.run);
617
+ const normalizedRun = normalizeRunValue(step?.run);
618
+ const publishEcosystem = detectPublishEcosystem(normalizedRun);
506
619
  if (!publishEcosystem) {
507
620
  return props;
508
621
  }
509
622
  const tokenSources = [];
510
- if (/\B--token(?:=|\s+\S+)/i.test(step.run)) {
623
+ if (normalizedRun && /\B--token(?:=|\s+\S+)/i.test(normalizedRun)) {
511
624
  tokenSources.push("cli-flag");
512
625
  }
513
626
  const legacyEnvNames = Object.keys(effectiveEnv || {}).filter(
@@ -1904,6 +2017,7 @@ export function parseWorkflowFile(f, options) {
1904
2017
  });
1905
2018
  actionProperties.push(...analyzeCheckoutStep(step));
1906
2019
  actionProperties.push(...analyzeCacheStep(step));
2020
+ actionProperties.push(...analyzeCargoActionStep(step));
1907
2021
  actionProperties.push(...analyzeDispatchActionStep(step));
1908
2022
  if (
1909
2023
  step.uses?.includes("actions/github-script") &&
@@ -1982,8 +2096,15 @@ export function parseWorkflowFile(f, options) {
1982
2096
  components.push(acomp);
1983
2097
  jobDependsOn.push(purl);
1984
2098
  }
1985
- } else if (step.run) {
1986
- commands.push({ executed: step?.run?.trim().split("\n")[0] });
2099
+ } else {
2100
+ const normalizedRun = normalizeRunValue(step.run);
2101
+ if (normalizedRun === undefined) {
2102
+ steps.push({
2103
+ name: stepName,
2104
+ });
2105
+ continue;
2106
+ }
2107
+ commands.push({ executed: normalizedRun.trim().split("\n")[0] });
1987
2108
  const stepRef = `${jobRef}-step-${steps.length + 1}`;
1988
2109
  const runProperties = [
1989
2110
  { name: "SrcFile", value: f },
@@ -1993,7 +2114,7 @@ export function parseWorkflowFile(f, options) {
1993
2114
  { name: "cdx:github:step:type", value: "run" },
1994
2115
  {
1995
2116
  name: "cdx:github:step:command",
1996
- value: step?.run?.trim().split("\n")[0],
2117
+ value: normalizedRun.trim().split("\n")[0],
1997
2118
  },
1998
2119
  ];
1999
2120
  if (step.if) {
@@ -2015,9 +2136,9 @@ export function parseWorkflowFile(f, options) {
2015
2136
  runProperties.push(...sharedJobCtxProps);
2016
2137
  runProperties.push(...sharedCtxProps);
2017
2138
 
2018
- const { hasInterpolation, vars } = detectUntrustedInterpolation(
2019
- step.run,
2020
- );
2139
+ const { hasInterpolation, vars } =
2140
+ detectUntrustedInterpolation(normalizedRun);
2141
+ runProperties.push(...analyzeCargoRunStep(normalizedRun));
2021
2142
  if (hasInterpolation) {
2022
2143
  runProperties.push({
2023
2144
  name: "cdx:github:step:hasUntrustedInterpolation",
@@ -2028,7 +2149,8 @@ export function parseWorkflowFile(f, options) {
2028
2149
  value: vars.join(","),
2029
2150
  });
2030
2151
  }
2031
- const { hasMutation, targets } = detectRunnerStateMutation(step.run);
2152
+ const { hasMutation, targets } =
2153
+ detectRunnerStateMutation(normalizedRun);
2032
2154
  if (hasMutation) {
2033
2155
  runProperties.push({
2034
2156
  name: "cdx:github:step:mutatesRunnerState",
@@ -2039,9 +2161,8 @@ export function parseWorkflowFile(f, options) {
2039
2161
  value: targets.join(","),
2040
2162
  });
2041
2163
  }
2042
- const { hasOutboundCommand, tools } = detectOutboundNetworkCommand(
2043
- step.run,
2044
- );
2164
+ const { hasOutboundCommand, tools } =
2165
+ detectOutboundNetworkCommand(normalizedRun);
2045
2166
  if (hasOutboundCommand) {
2046
2167
  runProperties.push({
2047
2168
  name: "cdx:github:step:hasOutboundNetworkCommand",
@@ -2053,10 +2174,10 @@ export function parseWorkflowFile(f, options) {
2053
2174
  });
2054
2175
  }
2055
2176
  const sensitiveContextRefs = detectSensitiveContextReferences(
2056
- step.run,
2177
+ normalizedRun,
2057
2178
  effectiveEnv,
2058
2179
  );
2059
- const dispatchInfo = detectWorkflowDispatchInvocations(step.run);
2180
+ const dispatchInfo = detectWorkflowDispatchInvocations(normalizedRun);
2060
2181
  if (dispatchInfo.hasDispatch) {
2061
2182
  collectSensitiveEnvBindings(effectiveEnv).forEach((ref) => {
2062
2183
  sensitiveContextRefs.push(ref);
@@ -2073,7 +2194,7 @@ export function parseWorkflowFile(f, options) {
2073
2194
  });
2074
2195
  }
2075
2196
  appendDispatchProperties(runProperties, dispatchInfo);
2076
- const forkContextRefs = detectForkContextReferences(step.run);
2197
+ const forkContextRefs = detectForkContextReferences(normalizedRun);
2077
2198
  if (forkContextRefs.length) {
2078
2199
  runProperties.push({
2079
2200
  name: "cdx:github:step:referencesForkContext",
@@ -2085,7 +2206,10 @@ export function parseWorkflowFile(f, options) {
2085
2206
  });
2086
2207
  }
2087
2208
  const exfiltrationIndicators = hasOutboundCommand
2088
- ? detectOutboundExfiltrationIndicators(step.run, sensitiveContextRefs)
2209
+ ? detectOutboundExfiltrationIndicators(
2210
+ normalizedRun,
2211
+ sensitiveContextRefs,
2212
+ )
2089
2213
  : [];
2090
2214
  if (exfiltrationIndicators.length) {
2091
2215
  runProperties.push({