@cyclonedx/cdxgen 12.3.1 → 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 (56) hide show
  1. package/bin/cdxgen.js +1 -2
  2. package/data/rules/ai-agent-governance.yaml +43 -0
  3. package/data/rules/mcp-servers.yaml +36 -2
  4. package/lib/cli/index.js +295 -17
  5. package/lib/cli/index.poku.js +296 -1
  6. package/lib/helpers/agentFormulationParser.js +4 -1
  7. package/lib/helpers/aiInventory.js +262 -0
  8. package/lib/helpers/aiInventory.poku.js +111 -0
  9. package/lib/helpers/analyzer.js +375 -45
  10. package/lib/helpers/analyzer.poku.js +50 -0
  11. package/lib/helpers/auditCategories.js +76 -0
  12. package/lib/helpers/display.js +5 -2
  13. package/lib/helpers/display.poku.js +25 -0
  14. package/lib/helpers/formulationParsers.js +26 -6
  15. package/lib/helpers/jsonLike.js +21 -20
  16. package/lib/helpers/jsonLike.poku.js +34 -0
  17. package/lib/helpers/mcpConfigParser.js +11 -11
  18. package/lib/helpers/mcpConfigParser.poku.js +67 -0
  19. package/lib/helpers/mcpDiscovery.js +13 -23
  20. package/lib/helpers/mcpDiscovery.poku.js +21 -0
  21. package/lib/helpers/utils.js +2 -1
  22. package/lib/helpers/utils.poku.js +19 -1
  23. package/lib/stages/postgen/annotator.js +2 -1
  24. package/lib/stages/postgen/annotator.poku.js +15 -0
  25. package/lib/stages/postgen/auditBom.js +12 -6
  26. package/lib/stages/postgen/auditBom.poku.js +111 -4
  27. package/lib/stages/postgen/postgen.js +229 -6
  28. package/lib/stages/postgen/postgen.poku.js +180 -0
  29. package/package.json +1 -1
  30. package/types/lib/cli/index.d.ts +1 -0
  31. package/types/lib/cli/index.d.ts.map +1 -1
  32. package/types/lib/helpers/agentFormulationParser.d.ts +19 -0
  33. package/types/lib/helpers/agentFormulationParser.d.ts.map +1 -0
  34. package/types/lib/helpers/aiInventory.d.ts +23 -0
  35. package/types/lib/helpers/aiInventory.d.ts.map +1 -0
  36. package/types/lib/helpers/analyzer.d.ts +5 -0
  37. package/types/lib/helpers/analyzer.d.ts.map +1 -1
  38. package/types/lib/helpers/auditCategories.d.ts +12 -0
  39. package/types/lib/helpers/auditCategories.d.ts.map +1 -0
  40. package/types/lib/helpers/communityAiConfigParser.d.ts +29 -0
  41. package/types/lib/helpers/communityAiConfigParser.d.ts.map +1 -0
  42. package/types/lib/helpers/display.d.ts.map +1 -1
  43. package/types/lib/helpers/formulationParsers.d.ts.map +1 -1
  44. package/types/lib/helpers/jsonLike.d.ts +4 -0
  45. package/types/lib/helpers/jsonLike.d.ts.map +1 -0
  46. package/types/lib/helpers/mcp.d.ts +29 -0
  47. package/types/lib/helpers/mcp.d.ts.map +1 -0
  48. package/types/lib/helpers/mcpConfigParser.d.ts +30 -0
  49. package/types/lib/helpers/mcpConfigParser.d.ts.map +1 -0
  50. package/types/lib/helpers/mcpDiscovery.d.ts +5 -0
  51. package/types/lib/helpers/mcpDiscovery.d.ts.map +1 -0
  52. package/types/lib/helpers/utils.d.ts +2 -0
  53. package/types/lib/helpers/utils.d.ts.map +1 -1
  54. package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
  55. package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
  56. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
@@ -2066,6 +2066,380 @@ const serviceObjectForMcp = (serviceInfo) => {
2066
2066
  };
2067
2067
  };
2068
2068
 
2069
+ const buildMcpInventoryFromServices = (servicesByKey) => {
2070
+ const services = [];
2071
+ const components = [];
2072
+ const dependencies = [];
2073
+ for (const serviceInfo of servicesByKey.values()) {
2074
+ if (
2075
+ !serviceInfo.primitives.length &&
2076
+ !serviceInfo.transports.size &&
2077
+ !serviceInfo.endpoints.size &&
2078
+ !serviceInfo.capabilities.size &&
2079
+ !serviceInfo.sourceLine &&
2080
+ !serviceInfo.outboundHosts.size &&
2081
+ !serviceInfo.usageSignals.size
2082
+ ) {
2083
+ continue;
2084
+ }
2085
+ if (
2086
+ serviceInfo.endpoints.size &&
2087
+ !serviceInfo.transports.has("stdio") &&
2088
+ !serviceInfo.transports.size
2089
+ ) {
2090
+ serviceInfo.transports.add("streamable-http");
2091
+ }
2092
+ if (
2093
+ serviceInfo.transports.has("streamable-http") &&
2094
+ typeof serviceInfo.authenticated === "undefined"
2095
+ ) {
2096
+ serviceInfo.authenticated = false;
2097
+ }
2098
+ const service = serviceObjectForMcp(serviceInfo);
2099
+ services.push(service);
2100
+ const providedRefs = [];
2101
+ for (const primitive of serviceInfo.primitives) {
2102
+ const component = primitiveComponentForMcp(serviceInfo, primitive);
2103
+ components.push(component);
2104
+ providedRefs.push(component["bom-ref"]);
2105
+ }
2106
+ if (providedRefs.length) {
2107
+ dependencies.push({
2108
+ ref: service["bom-ref"],
2109
+ dependsOn: [],
2110
+ provides: providedRefs.sort(),
2111
+ });
2112
+ }
2113
+ }
2114
+ return { components, dependencies, services };
2115
+ };
2116
+
2117
+ // Capture groups:
2118
+ // 1 = module path in `from x import y`
2119
+ // 2 = imported symbols in `from x import y`
2120
+ // 3 = imported modules in `import x, y`
2121
+ const PYTHON_IMPORT_PATTERN =
2122
+ /^\s*(?:from\s+([a-zA-Z0-9_.]+)\s+import\s+([^\n#]+)|import\s+([^\n#]+))/gmu;
2123
+ const PYTHON_DECORATOR_PATTERN = /@([a-zA-Z_][a-zA-Z0-9_]*)\.(\w+)\s*\(/gmu;
2124
+ const PYTHON_STDIO_PATTERN = /\bstdio_server\s*\(/u;
2125
+ const PYTHON_HTTP_TRANSPORT_PATTERN = /\b(streamable|sse|http)\b/iu;
2126
+
2127
+ const PYTHON_DECORATOR_ROLE_MAP = new Map([
2128
+ ["call_tool", "tool"],
2129
+ ["get_prompt", "prompt"],
2130
+ ["list_prompts", "prompt"],
2131
+ ["list_resources", "resource"],
2132
+ ["list_tools", "tool"],
2133
+ ["prompt", "prompt"],
2134
+ ["read_resource", "resource"],
2135
+ ["resource", "resource"],
2136
+ ["resource_template", "resource-template"],
2137
+ ["tool", "tool"],
2138
+ ]);
2139
+
2140
+ const lineNumberForIndex = (text, index) =>
2141
+ text.slice(0, index).split("\n").length || 1;
2142
+
2143
+ const extractPythonNamedString = (argumentText, key) => {
2144
+ const directPattern = new RegExp(`${key}\\s*=\\s*["']([^"'\\n]+)["']`, "u");
2145
+ const directMatch = argumentText.match(directPattern);
2146
+ if (directMatch?.[1]) {
2147
+ return directMatch[1];
2148
+ }
2149
+ const wrappedPattern = new RegExp(
2150
+ `${key}\\s*=\\s*[a-zA-Z_][a-zA-Z0-9_.]*\\(\\s*["']([^"'\\n]+)["']`,
2151
+ "u",
2152
+ );
2153
+ return argumentText.match(wrappedPattern)?.[1];
2154
+ };
2155
+
2156
+ const extractFirstPythonString = (argumentText) =>
2157
+ argumentText.match(/^\s*["']([^"'\n]+)["']/u)?.[1];
2158
+
2159
+ const extractPythonCallArguments = (raw, alias) => {
2160
+ const aliasPattern = new RegExp(
2161
+ `(\\w+)\\s*=\\s*${alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\(`,
2162
+ "gmu",
2163
+ );
2164
+ const calls = [];
2165
+ for (const match of raw.matchAll(aliasPattern)) {
2166
+ let callStart = -1;
2167
+ for (let index = match.index; index < raw.length; index++) {
2168
+ if (raw[index] === "(") {
2169
+ callStart = index;
2170
+ break;
2171
+ }
2172
+ }
2173
+ if (callStart === -1) {
2174
+ continue;
2175
+ }
2176
+ let depth = 0;
2177
+ let callEnd = -1;
2178
+ for (let index = callStart; index < raw.length; index++) {
2179
+ if (raw[index] === "(") {
2180
+ depth += 1;
2181
+ } else if (raw[index] === ")") {
2182
+ depth -= 1;
2183
+ if (depth === 0) {
2184
+ callEnd = index;
2185
+ break;
2186
+ }
2187
+ }
2188
+ }
2189
+ if (callEnd === -1) {
2190
+ continue;
2191
+ }
2192
+ calls.push({
2193
+ argumentText: raw.slice(callStart + 1, callEnd),
2194
+ index: match.index,
2195
+ serviceVarName: match[1],
2196
+ });
2197
+ }
2198
+ return calls;
2199
+ };
2200
+
2201
+ const extractPythonFunctionCalls = (raw, callName) => {
2202
+ const callPattern = new RegExp(
2203
+ `${callName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\(`,
2204
+ "gmu",
2205
+ );
2206
+ const calls = [];
2207
+ for (const match of raw.matchAll(callPattern)) {
2208
+ const callStart = match.index + match[0].lastIndexOf("(");
2209
+ let depth = 0;
2210
+ let callEnd = -1;
2211
+ for (let index = callStart; index < raw.length; index++) {
2212
+ if (raw[index] === "(") {
2213
+ depth += 1;
2214
+ } else if (raw[index] === ")") {
2215
+ depth -= 1;
2216
+ if (depth === 0) {
2217
+ callEnd = index;
2218
+ break;
2219
+ }
2220
+ }
2221
+ }
2222
+ if (callEnd === -1) {
2223
+ continue;
2224
+ }
2225
+ calls.push({
2226
+ argumentText: raw.slice(callStart + 1, callEnd),
2227
+ index: match.index,
2228
+ });
2229
+ }
2230
+ return calls;
2231
+ };
2232
+
2233
+ const parsePythonImports = (raw) => {
2234
+ const imports = [];
2235
+ for (const match of raw.matchAll(PYTHON_IMPORT_PATTERN)) {
2236
+ const fromSource = match[1];
2237
+ const fromImports = match[2];
2238
+ const directImports = match[3];
2239
+ if (fromSource && fromImports) {
2240
+ for (const importEntry of fromImports.split(",")) {
2241
+ const [importedName, localName] = importEntry
2242
+ .trim()
2243
+ .split(/\s+as\s+/u)
2244
+ .map((value) => value?.trim());
2245
+ if (importedName) {
2246
+ imports.push({
2247
+ importedName,
2248
+ localName: localName || importedName,
2249
+ sourceValue: fromSource,
2250
+ });
2251
+ }
2252
+ }
2253
+ continue;
2254
+ }
2255
+ for (const importEntry of (directImports || "").split(",")) {
2256
+ const [sourceValue, localName] = importEntry
2257
+ .trim()
2258
+ .split(/\s+as\s+/u)
2259
+ .map((value) => value?.trim());
2260
+ if (sourceValue) {
2261
+ imports.push({
2262
+ importedName: sourceValue.split(".").pop(),
2263
+ localName: localName || sourceValue.split(".").pop(),
2264
+ sourceValue,
2265
+ });
2266
+ }
2267
+ }
2268
+ }
2269
+ return imports;
2270
+ };
2271
+
2272
+ const registerPythonPrimitive = (
2273
+ serviceInfo,
2274
+ role,
2275
+ name,
2276
+ description,
2277
+ uri,
2278
+ sourceLine,
2279
+ ) => {
2280
+ if (!role) {
2281
+ return;
2282
+ }
2283
+ const primitiveName =
2284
+ name ||
2285
+ uri ||
2286
+ `${role}-${serviceInfo.primitives.filter((item) => item.role === role).length + 1}`;
2287
+ serviceInfo.primitives.push({
2288
+ description,
2289
+ name: primitiveName,
2290
+ role,
2291
+ sourceLine,
2292
+ uri,
2293
+ });
2294
+ if (role === "tool") {
2295
+ serviceInfo.usageSignals.add("registered-tool");
2296
+ }
2297
+ if (["resource", "resource-template"].includes(role)) {
2298
+ serviceInfo.usageSignals.add("registered-resource");
2299
+ }
2300
+ };
2301
+
2302
+ /**
2303
+ * Detect MCP server inventory from Python source using import and decorator heuristics.
2304
+ *
2305
+ * @param {string} src Absolute or relative path to the project source directory
2306
+ * @param {boolean} deep When true, also scans nested paths more aggressively
2307
+ * @returns {{components: Object[], dependencies: Object[], services: Object[]}}
2308
+ */
2309
+ export const detectPythonMcpInventory = (src, deep = false) => {
2310
+ const servicesByKey = new Map();
2311
+ let srcFiles = [];
2312
+ try {
2313
+ srcFiles = getAllFiles(deep, src, ".py");
2314
+ } catch {
2315
+ return { components: [], dependencies: [], services: [] };
2316
+ }
2317
+ for (const file of srcFiles) {
2318
+ let raw;
2319
+ try {
2320
+ raw = readFileSync(file, "utf-8");
2321
+ } catch {
2322
+ continue;
2323
+ }
2324
+ const fileRelativeLoc = relative(src, file);
2325
+ const serverConstructorAliases = new Set(["Server", "FastMCP"]);
2326
+ const importEntries = parsePythonImports(raw);
2327
+ let fileHasMcpImports = false;
2328
+ for (const importEntry of importEntries) {
2329
+ const sourceValue = importEntry.sourceValue;
2330
+ const classification = classifyMcpReference(sourceValue);
2331
+ if (!classification.isMcp && !sourceValue.startsWith("mcp")) {
2332
+ continue;
2333
+ }
2334
+ fileHasMcpImports = true;
2335
+ const serviceInfo = ensureMcpService(
2336
+ servicesByKey,
2337
+ file,
2338
+ fileRelativeLoc,
2339
+ );
2340
+ if (sourceValue.startsWith("mcp")) {
2341
+ serviceInfo.sdkImports.add(sourceValue);
2342
+ serviceInfo.usageSignals.add("mcp-sdk-import");
2343
+ serviceInfo.officialSdk = true;
2344
+ } else {
2345
+ recordMcpSdkImport(serviceInfo, sourceValue);
2346
+ }
2347
+ if (
2348
+ sourceValue.startsWith("mcp.server") ||
2349
+ sourceValue === "fastmcp" ||
2350
+ /server/i.test(importEntry.importedName || "") ||
2351
+ /server/i.test(importEntry.localName || "")
2352
+ ) {
2353
+ serverConstructorAliases.add(importEntry.localName);
2354
+ }
2355
+ }
2356
+ for (const alias of serverConstructorAliases) {
2357
+ for (const match of extractPythonCallArguments(raw, alias)) {
2358
+ const serviceVarName = match.serviceVarName;
2359
+ const argumentText = match.argumentText || "";
2360
+ const serviceInfo = ensureMcpService(
2361
+ servicesByKey,
2362
+ file,
2363
+ fileRelativeLoc,
2364
+ );
2365
+ serviceInfo.name =
2366
+ extractPythonNamedString(argumentText, "name") ||
2367
+ extractFirstPythonString(argumentText) ||
2368
+ serviceInfo.name;
2369
+ serviceInfo.version =
2370
+ extractPythonNamedString(argumentText, "version") ||
2371
+ serviceInfo.version;
2372
+ serviceInfo.description =
2373
+ extractPythonNamedString(argumentText, "instructions") ||
2374
+ extractPythonNamedString(argumentText, "description") ||
2375
+ serviceInfo.description;
2376
+ serviceInfo.serviceKinds.add("server");
2377
+ serviceInfo.usageSignals.add("server-constructor");
2378
+ serviceInfo.sourceLine = lineNumberForIndex(raw, match.index);
2379
+ for (const decoratorMatch of raw.matchAll(PYTHON_DECORATOR_PATTERN)) {
2380
+ if (decoratorMatch[1] !== serviceVarName) {
2381
+ continue;
2382
+ }
2383
+ const primitiveRole = PYTHON_DECORATOR_ROLE_MAP.get(
2384
+ decoratorMatch[2],
2385
+ );
2386
+ if (!primitiveRole) {
2387
+ continue;
2388
+ }
2389
+ if (primitiveRole === "tool") {
2390
+ serviceInfo.capabilities.add("tools");
2391
+ } else if (primitiveRole === "prompt") {
2392
+ serviceInfo.capabilities.add("prompts");
2393
+ } else {
2394
+ serviceInfo.capabilities.add("resources");
2395
+ }
2396
+ }
2397
+ }
2398
+ }
2399
+ if (PYTHON_STDIO_PATTERN.test(raw)) {
2400
+ const serviceInfo = ensureMcpService(
2401
+ servicesByKey,
2402
+ file,
2403
+ fileRelativeLoc,
2404
+ );
2405
+ serviceInfo.transports.add("stdio");
2406
+ } else if (fileHasMcpImports && PYTHON_HTTP_TRANSPORT_PATTERN.test(raw)) {
2407
+ const serviceInfo = ensureMcpService(
2408
+ servicesByKey,
2409
+ file,
2410
+ fileRelativeLoc,
2411
+ );
2412
+ serviceInfo.transports.add("streamable-http");
2413
+ }
2414
+ const primitivePatterns = [
2415
+ ["mtypes.Tool", "tool"],
2416
+ ["mtypes.Prompt", "prompt"],
2417
+ ["mtypes.Resource", "resource"],
2418
+ ];
2419
+ for (const [callName, role] of primitivePatterns) {
2420
+ for (const match of extractPythonFunctionCalls(raw, callName)) {
2421
+ const serviceInfo = ensureMcpService(
2422
+ servicesByKey,
2423
+ file,
2424
+ fileRelativeLoc,
2425
+ );
2426
+ registerPythonPrimitive(
2427
+ serviceInfo,
2428
+ role,
2429
+ extractPythonNamedString(match.argumentText || "", "name"),
2430
+ extractPythonNamedString(match.argumentText || "", "description"),
2431
+ extractPythonNamedString(match.argumentText || "", "uri"),
2432
+ lineNumberForIndex(raw, match.index),
2433
+ );
2434
+ }
2435
+ }
2436
+ if (fileHasMcpImports) {
2437
+ ensureMcpService(servicesByKey, file, fileRelativeLoc);
2438
+ }
2439
+ }
2440
+ return buildMcpInventoryFromServices(servicesByKey);
2441
+ };
2442
+
2069
2443
  /**
2070
2444
  * Detect MCP server inventory from JavaScript/TypeScript source using AST analysis.
2071
2445
  *
@@ -2748,49 +3122,5 @@ export const detectMcpInventory = (src, deep = false) => {
2748
3122
  ensureMcpService(servicesByKey, file, fileRelativeLoc);
2749
3123
  }
2750
3124
  }
2751
- const services = [];
2752
- const components = [];
2753
- const dependencies = [];
2754
- for (const serviceInfo of servicesByKey.values()) {
2755
- if (
2756
- !serviceInfo.primitives.length &&
2757
- !serviceInfo.transports.size &&
2758
- !serviceInfo.endpoints.size &&
2759
- !serviceInfo.capabilities.size &&
2760
- !serviceInfo.sourceLine &&
2761
- !serviceInfo.outboundHosts.size &&
2762
- !serviceInfo.usageSignals.size
2763
- ) {
2764
- continue;
2765
- }
2766
- if (
2767
- serviceInfo.endpoints.size &&
2768
- !serviceInfo.transports.has("stdio") &&
2769
- !serviceInfo.transports.size
2770
- ) {
2771
- serviceInfo.transports.add("streamable-http");
2772
- }
2773
- if (
2774
- serviceInfo.transports.has("streamable-http") &&
2775
- typeof serviceInfo.authenticated === "undefined"
2776
- ) {
2777
- serviceInfo.authenticated = false;
2778
- }
2779
- const service = serviceObjectForMcp(serviceInfo);
2780
- services.push(service);
2781
- const providedRefs = [];
2782
- for (const primitive of serviceInfo.primitives) {
2783
- const component = primitiveComponentForMcp(serviceInfo, primitive);
2784
- components.push(component);
2785
- providedRefs.push(component["bom-ref"]);
2786
- }
2787
- if (providedRefs.length) {
2788
- dependencies.push({
2789
- ref: service["bom-ref"],
2790
- dependsOn: [],
2791
- provides: providedRefs.sort(),
2792
- });
2793
- }
2794
- }
2795
- return { components, dependencies, services };
3125
+ return buildMcpInventoryFromServices(servicesByKey);
2796
3126
  };
@@ -14,6 +14,7 @@ import {
14
14
  analyzeSuspiciousJsFile,
15
15
  detectExtensionCapabilities,
16
16
  detectMcpInventory,
17
+ detectPythonMcpInventory,
17
18
  findJSImportsExports,
18
19
  } from "./analyzer.js";
19
20
 
@@ -530,3 +531,52 @@ describe("detectMcpInventory()", () => {
530
531
  );
531
532
  });
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
+ }
@@ -1103,7 +1103,7 @@ export function displaySelfThreatModel(
1103
1103
  options,
1104
1104
  envAuditFindings,
1105
1105
  ) {
1106
- const TLP = options.tlpClassification || "CLEAR";
1106
+ const TLP = options.tlpClassification;
1107
1107
  const risks = [];
1108
1108
  let riskScore = 0;
1109
1109
 
@@ -1242,8 +1242,11 @@ export function displaySelfThreatModel(
1242
1242
  AMBER_AND_STRICT: "Organisation only. No external sharing.",
1243
1243
  RED: "Named recipients only. Do not forward or store beyond session.",
1244
1244
  };
1245
+ const tlpValue = TLP
1246
+ ? `${TLP} — ${tlpGuidance[TLP]}`
1247
+ : "Not set — no distribution constraints recorded.";
1245
1248
  const headerData = [
1246
- ["TLP Classification", `${TLP} — ${tlpGuidance[TLP]}`],
1249
+ ["TLP Classification", tlpValue],
1247
1250
  ["Risk Score", `${riskScore}/10`],
1248
1251
  ["Risk Level", `${riskColor[riskLevel]}${riskLevel}${reset}`],
1249
1252
  ];
@@ -93,6 +93,31 @@ it("prints a provenance icon for registry-backed components", async () => {
93
93
  }
94
94
  });
95
95
 
96
+ it("displaySelfThreatModel does not assume a default TLP classification", async () => {
97
+ const tableStub = sinon.stub().returns("table-output");
98
+ try {
99
+ const { displaySelfThreatModel } = await esmock("./display.js", {
100
+ "./table.js": {
101
+ createStream: sinon.stub(),
102
+ table: tableStub,
103
+ },
104
+ "./utils.js": {
105
+ isSecureMode: false,
106
+ safeExistsSync: sinon.stub(),
107
+ toCamel: sinon.stub().callsFake((value) => value),
108
+ },
109
+ });
110
+ displaySelfThreatModel("/workspace/project", {}, {}, []);
111
+ const [headerData] = tableStub.firstCall.args;
112
+ assert.deepStrictEqual(headerData[0], [
113
+ "TLP Classification",
114
+ "Not set — no distribution constraints recorded.",
115
+ ]);
116
+ } finally {
117
+ sinon.restore();
118
+ }
119
+ });
120
+
96
121
  it("renders shared dependencies once while including dangling trees", () => {
97
122
  const treeLines = buildDependencyTreeLines([
98
123
  {
@@ -4,14 +4,17 @@ import process from "node:process";
4
4
 
5
5
  import { v4 as uuidv4 } from "uuid";
6
6
 
7
- import { agentFormulationParser } from "./agentFormulationParser.js";
7
+ import {
8
+ AI_INVENTORY_PROJECT_TYPES,
9
+ collectAiInventory,
10
+ optionIncludesAiInventoryProjectType,
11
+ } from "./aiInventory.js";
8
12
  import { collectOSCryptoLibs } from "./cbomutils.js";
9
13
  import { azurePipelinesParser } from "./ciParsers/azurePipelines.js";
10
14
  import { circleCiParser } from "./ciParsers/circleCi.js";
11
15
  import { githubActionsParser } from "./ciParsers/githubActions.js";
12
16
  import { gitlabCiParser } from "./ciParsers/gitlabCi.js";
13
17
  import { jenkinsParser } from "./ciParsers/jenkins.js";
14
- import { communityAiConfigParser } from "./communityAiConfigParser.js";
15
18
  import { trimComponents } from "./depsUtils.js";
16
19
  import {
17
20
  collectEnvInfo,
@@ -20,7 +23,6 @@ import {
20
23
  gitTreeHashes,
21
24
  listFiles,
22
25
  } from "./envcontext.js";
23
- import { mcpConfigParser } from "./mcpConfigParser.js";
24
26
  import { rustFormulationParser } from "./rustFormulationParser.js";
25
27
  import { scanTextForHiddenUnicode } from "./unicodeScan.js";
26
28
  import { getAllFiles } from "./utils.js";
@@ -100,9 +102,6 @@ function buildReadmeSecurityComponents(discoveryPath, options) {
100
102
  */
101
103
  const _parsers = [
102
104
  rustFormulationParser,
103
- agentFormulationParser,
104
- mcpConfigParser,
105
- communityAiConfigParser,
106
105
  githubActionsParser,
107
106
  gitlabCiParser,
108
107
  jenkinsParser,
@@ -312,6 +311,12 @@ export function addFormulationSection(filePath, options, context = {}) {
312
311
  const ciProperties = [];
313
312
 
314
313
  const discoveryPath = projectPath || ".";
314
+ const excludedInventoryTypes = AI_INVENTORY_PROJECT_TYPES.filter((type) => {
315
+ return optionIncludesAiInventoryProjectType(options?.excludeType, type);
316
+ });
317
+ const includedInventoryTypes = AI_INVENTORY_PROJECT_TYPES.filter(
318
+ (type) => !excludedInventoryTypes.includes(type),
319
+ );
315
320
 
316
321
  for (const parser of _parsers) {
317
322
  const matchedFiles = [];
@@ -355,6 +360,21 @@ export function addFormulationSection(filePath, options, context = {}) {
355
360
  }
356
361
  }
357
362
 
363
+ const aiInventory = collectAiInventory(
364
+ discoveryPath,
365
+ options,
366
+ includedInventoryTypes,
367
+ );
368
+ if (aiInventory.components.length) {
369
+ ciComponents.push(...aiInventory.components);
370
+ }
371
+ if (aiInventory.services.length) {
372
+ ciServices.push(...aiInventory.services);
373
+ }
374
+ if (aiInventory.dependencies.length) {
375
+ dependencies.push(...aiInventory.dependencies);
376
+ }
377
+
358
378
  // Merge CI components into the formulation component list
359
379
  if (ciComponents.length) {
360
380
  components = components.concat(ciComponents);