@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.
- package/README.md +15 -5
- package/bin/audit.js +7 -0
- package/bin/cdxgen.js +241 -81
- package/bin/repl.js +138 -0
- package/data/rules/ai-agent-governance.yaml +249 -0
- package/data/rules/dependency-sources.yaml +41 -0
- package/data/rules/mcp-servers.yaml +304 -0
- package/data/rules/package-integrity.yaml +123 -0
- package/lib/audit/index.js +353 -29
- package/lib/audit/index.poku.js +247 -7
- package/lib/audit/reporters.js +26 -0
- package/lib/audit/scoring.js +262 -13
- package/lib/audit/scoring.poku.js +179 -0
- package/lib/audit/targets.js +391 -2
- package/lib/audit/targets.poku.js +416 -3
- package/lib/cli/index.js +588 -45
- package/lib/cli/index.poku.js +735 -1
- package/lib/evinser/evinser.js +8 -5
- package/lib/helpers/agentFormulationParser.js +318 -0
- package/lib/helpers/aiInventory.js +262 -0
- package/lib/helpers/aiInventory.poku.js +111 -0
- package/lib/helpers/analyzer.js +1769 -0
- package/lib/helpers/analyzer.poku.js +284 -3
- package/lib/helpers/auditCategories.js +76 -0
- package/lib/helpers/ciParsers/githubActions.js +140 -16
- package/lib/helpers/ciParsers/githubActions.poku.js +110 -0
- package/lib/helpers/communityAiConfigParser.js +672 -0
- package/lib/helpers/communityAiConfigParser.poku.js +63 -0
- package/lib/helpers/depsUtils.js +108 -0
- package/lib/helpers/depsUtils.poku.js +72 -1
- package/lib/helpers/display.js +325 -3
- package/lib/helpers/display.poku.js +301 -0
- package/lib/helpers/formulationParsers.js +28 -0
- package/lib/helpers/formulationParsers.poku.js +504 -1
- package/lib/helpers/jsonLike.js +102 -0
- package/lib/helpers/jsonLike.poku.js +34 -0
- package/lib/helpers/mcp.js +248 -0
- package/lib/helpers/mcp.poku.js +101 -0
- package/lib/helpers/mcpConfigParser.js +656 -0
- package/lib/helpers/mcpConfigParser.poku.js +126 -0
- package/lib/helpers/mcpDiscovery.js +84 -0
- package/lib/helpers/mcpDiscovery.poku.js +21 -0
- package/lib/helpers/protobom.js +3 -3
- package/lib/helpers/provenanceUtils.js +29 -4
- package/lib/helpers/provenanceUtils.poku.js +29 -3
- package/lib/helpers/registryProvenance.js +210 -0
- package/lib/helpers/registryProvenance.poku.js +144 -0
- package/lib/helpers/rustFormulationParser.js +330 -0
- package/lib/helpers/source.js +21 -2
- package/lib/helpers/source.poku.js +38 -0
- package/lib/helpers/utils.js +1331 -83
- package/lib/helpers/utils.poku.js +599 -188
- package/lib/helpers/vsixutils.js +12 -4
- package/lib/helpers/vsixutils.poku.js +34 -0
- package/lib/managers/binary.js +36 -12
- package/lib/managers/binary.poku.js +68 -0
- package/lib/managers/docker.js +59 -9
- package/lib/managers/docker.poku.js +61 -0
- package/lib/managers/piptree.js +12 -7
- package/lib/managers/piptree.poku.js +44 -0
- package/lib/stages/postgen/annotator.js +2 -1
- package/lib/stages/postgen/annotator.poku.js +15 -0
- package/lib/stages/postgen/auditBom.js +20 -6
- package/lib/stages/postgen/auditBom.poku.js +694 -1
- package/lib/stages/postgen/postgen.js +262 -11
- package/lib/stages/postgen/postgen.poku.js +306 -2
- package/lib/stages/postgen/ruleEngine.js +49 -1
- package/lib/stages/postgen/spdxConverter.poku.js +70 -0
- package/lib/stages/pregen/pregen.js +6 -4
- package/package.json +1 -1
- package/types/bin/repl.d.ts.map +1 -1
- package/types/lib/audit/index.d.ts.map +1 -1
- package/types/lib/audit/reporters.d.ts.map +1 -1
- package/types/lib/audit/scoring.d.ts.map +1 -1
- package/types/lib/audit/targets.d.ts +12 -0
- package/types/lib/audit/targets.d.ts.map +1 -1
- package/types/lib/cli/index.d.ts +2 -8
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/helpers/agentFormulationParser.d.ts +19 -0
- package/types/lib/helpers/agentFormulationParser.d.ts.map +1 -0
- package/types/lib/helpers/aiInventory.d.ts +23 -0
- package/types/lib/helpers/aiInventory.d.ts.map +1 -0
- package/types/lib/helpers/analyzer.d.ts +10 -0
- package/types/lib/helpers/analyzer.d.ts.map +1 -1
- package/types/lib/helpers/auditCategories.d.ts +12 -0
- package/types/lib/helpers/auditCategories.d.ts.map +1 -0
- package/types/lib/helpers/ciParsers/githubActions.d.ts.map +1 -1
- package/types/lib/helpers/communityAiConfigParser.d.ts +29 -0
- package/types/lib/helpers/communityAiConfigParser.d.ts.map +1 -0
- package/types/lib/helpers/depsUtils.d.ts +8 -0
- package/types/lib/helpers/depsUtils.d.ts.map +1 -1
- package/types/lib/helpers/display.d.ts +17 -1
- package/types/lib/helpers/display.d.ts.map +1 -1
- package/types/lib/helpers/formulationParsers.d.ts.map +1 -1
- package/types/lib/helpers/jsonLike.d.ts +4 -0
- package/types/lib/helpers/jsonLike.d.ts.map +1 -0
- package/types/lib/helpers/mcp.d.ts +29 -0
- package/types/lib/helpers/mcp.d.ts.map +1 -0
- package/types/lib/helpers/mcpConfigParser.d.ts +30 -0
- package/types/lib/helpers/mcpConfigParser.d.ts.map +1 -0
- package/types/lib/helpers/mcpDiscovery.d.ts +5 -0
- package/types/lib/helpers/mcpDiscovery.d.ts.map +1 -0
- package/types/lib/helpers/provenanceUtils.d.ts +5 -3
- package/types/lib/helpers/provenanceUtils.d.ts.map +1 -1
- package/types/lib/helpers/registryProvenance.d.ts +9 -0
- package/types/lib/helpers/registryProvenance.d.ts.map +1 -1
- package/types/lib/helpers/rustFormulationParser.d.ts +17 -0
- package/types/lib/helpers/rustFormulationParser.d.ts.map +1 -0
- package/types/lib/helpers/source.d.ts.map +1 -1
- package/types/lib/helpers/utils.d.ts +31 -1
- package/types/lib/helpers/utils.d.ts.map +1 -1
- package/types/lib/helpers/vsixutils.d.ts.map +1 -1
- package/types/lib/managers/binary.d.ts.map +1 -1
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/managers/piptree.d.ts.map +1 -1
- package/types/lib/stages/postgen/annotator.d.ts.map +1 -1
- package/types/lib/stages/postgen/auditBom.d.ts.map +1 -1
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/stages/postgen/ruleEngine.d.ts.map +1 -1
- 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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
1986
|
-
|
|
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:
|
|
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 } =
|
|
2019
|
-
|
|
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 } =
|
|
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 } =
|
|
2043
|
-
|
|
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
|
-
|
|
2177
|
+
normalizedRun,
|
|
2057
2178
|
effectiveEnv,
|
|
2058
2179
|
);
|
|
2059
|
-
const dispatchInfo = detectWorkflowDispatchInvocations(
|
|
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(
|
|
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(
|
|
2209
|
+
? detectOutboundExfiltrationIndicators(
|
|
2210
|
+
normalizedRun,
|
|
2211
|
+
sensitiveContextRefs,
|
|
2212
|
+
)
|
|
2089
2213
|
: [];
|
|
2090
2214
|
if (exfiltrationIndicators.length) {
|
|
2091
2215
|
runProperties.push({
|