@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.
- package/bin/cdxgen.js +1 -2
- package/data/rules/ai-agent-governance.yaml +43 -0
- package/data/rules/mcp-servers.yaml +36 -2
- package/lib/cli/index.js +295 -17
- package/lib/cli/index.poku.js +296 -1
- package/lib/helpers/agentFormulationParser.js +4 -1
- package/lib/helpers/aiInventory.js +262 -0
- package/lib/helpers/aiInventory.poku.js +111 -0
- package/lib/helpers/analyzer.js +375 -45
- package/lib/helpers/analyzer.poku.js +50 -0
- package/lib/helpers/auditCategories.js +76 -0
- package/lib/helpers/display.js +5 -2
- package/lib/helpers/display.poku.js +25 -0
- package/lib/helpers/formulationParsers.js +26 -6
- package/lib/helpers/jsonLike.js +21 -20
- package/lib/helpers/jsonLike.poku.js +34 -0
- package/lib/helpers/mcpConfigParser.js +11 -11
- package/lib/helpers/mcpConfigParser.poku.js +67 -0
- package/lib/helpers/mcpDiscovery.js +13 -23
- package/lib/helpers/mcpDiscovery.poku.js +21 -0
- package/lib/helpers/utils.js +2 -1
- package/lib/helpers/utils.poku.js +19 -1
- package/lib/stages/postgen/annotator.js +2 -1
- package/lib/stages/postgen/annotator.poku.js +15 -0
- package/lib/stages/postgen/auditBom.js +12 -6
- package/lib/stages/postgen/auditBom.poku.js +111 -4
- package/lib/stages/postgen/postgen.js +229 -6
- package/lib/stages/postgen/postgen.poku.js +180 -0
- package/package.json +1 -1
- package/types/lib/cli/index.d.ts +1 -0
- package/types/lib/cli/index.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 +5 -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/communityAiConfigParser.d.ts +29 -0
- package/types/lib/helpers/communityAiConfigParser.d.ts.map +1 -0
- 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/utils.d.ts +2 -0
- package/types/lib/helpers/utils.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/lib/helpers/analyzer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/lib/helpers/display.js
CHANGED
|
@@ -1103,7 +1103,7 @@ export function displaySelfThreatModel(
|
|
|
1103
1103
|
options,
|
|
1104
1104
|
envAuditFindings,
|
|
1105
1105
|
) {
|
|
1106
|
-
const TLP = options.tlpClassification
|
|
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",
|
|
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 {
|
|
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);
|