@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
673 lines (648 loc) • 19.6 kB
JavaScript
import { readFileSync } from "node:fs";
import { basename } from "node:path";
import { parseJsonLike } from "./jsonLike.js";
import { classifyMcpReference } from "./mcp.js";
import {
credentialIndicatorsForText,
isLocalHost,
providerNamesForText,
sanitizeMcpRefToken,
} from "./mcpDiscovery.js";
import {
sanitizeBomPropertyValue,
sanitizeBomUrl,
} from "./propertySanitizer.js";
import { scanTextForHiddenUnicode } from "./unicodeScan.js";
const MCP_CONFIG_PATTERNS = [
".mcp.json",
"**/.mcp.json",
"mcp.json",
"**/mcp.json",
".vscode/mcp.json",
"**/.vscode/mcp.json",
".cursor/mcp.json",
"**/.cursor/mcp.json",
"claude_desktop_config.json",
"**/claude_desktop_config.json",
"opencode.json",
"**/opencode.json",
"opencode.jsonc",
"**/opencode.jsonc",
];
const LOCAL_TUNNEL_HOST_PATTERNS = [
/\.ngrok(?:-free)?\.app$/iu,
/\.ngrok\.io$/iu,
/\.trycloudflare\.com$/iu,
/\.localhost\.run$/iu,
/\.serveo\.net$/iu,
];
const SECRET_FIELD_NAME_PATTERN =
/(token|secret|password|api[_-]?key|client[_-]?secret|authorization)/iu;
const ENV_REFERENCE_PATTERN = /(?:\$\{?[A-Z0-9_]+\}?|%[A-Z0-9_]+%)/u;
function addUniqueProperty(properties, name, value) {
const sanitizedValue = sanitizeBomPropertyValue(name, value);
if (
sanitizedValue === undefined ||
sanitizedValue === null ||
sanitizedValue === ""
) {
return;
}
if (
properties.some(
(prop) => prop.name === name && prop.value === String(sanitizedValue),
)
) {
return;
}
properties.push({ name, value: String(sanitizedValue) });
}
function normalizeFilePath(filePath) {
return filePath.replaceAll("\\", "/");
}
function configFormat(filePath) {
const lowerPath = normalizeFilePath(filePath).toLowerCase();
if (lowerPath.endsWith("claude_desktop_config.json")) {
return "claude-desktop";
}
if (lowerPath.endsWith(".mcp.json")) {
return "dot-mcp-json";
}
if (
lowerPath.endsWith("opencode.json") ||
lowerPath.endsWith("opencode.jsonc")
) {
return "opencode";
}
if (
lowerPath.includes("/.vscode/") ||
lowerPath.endsWith(".vscode/mcp.json")
) {
return "vscode";
}
if (
lowerPath.includes("/.cursor/") ||
lowerPath.endsWith(".cursor/mcp.json")
) {
return "cursor";
}
return "generic-mcp-json";
}
function syntaxForFile(filePath) {
const lowerPath = filePath.toLowerCase();
return lowerPath.endsWith(".json") || lowerPath.endsWith(".jsonc")
? "json"
: "text";
}
function extractServerMaps(config) {
const serverMaps = [];
for (const [key, value] of Object.entries(config || {})) {
if (["mcpServers", "context_servers", "servers", "mcp"].includes(key)) {
if (value && typeof value === "object" && !Array.isArray(value)) {
serverMaps.push({ configKey: key, servers: Object.entries(value) });
} else if (Array.isArray(value)) {
serverMaps.push({
configKey: key,
servers: value.map((entry, index) => [
entry?.name || `server-${index + 1}`,
entry,
]),
});
}
}
}
return serverMaps;
}
function authHintsFromValue(value, hints = new Set()) {
const text = JSON.stringify(value || {});
if (!text || text === "{}") {
return hints;
}
if (/\bbearer\b|authorization/iu.test(text)) {
hints.add("bearer");
}
if (
/\boauth\b|authorization_endpoint|token_endpoint|issuer|registration_endpoint/iu.test(
text,
)
) {
hints.add("oauth");
}
if (/\bapi[_ -]?key\b/iu.test(text)) {
hints.add("api-key");
}
if (/\btoken\b/iu.test(text)) {
hints.add("token");
}
return hints;
}
function isEnvReference(value) {
return typeof value === "string" && ENV_REFERENCE_PATTERN.test(value);
}
function detectInlineCredentialIndicators(value) {
if (typeof value !== "string" || isEnvReference(value)) {
return new Set();
}
return new Set(credentialIndicatorsForText(value));
}
function detectConfigCredentialSignals(serverConfig) {
const inlineIndicators = new Set();
const exposureFields = new Set();
const credentialRefs = new Set();
const envConfig = serverConfig?.env || serverConfig?.environment || {};
for (const [envKey, envValue] of Object.entries(envConfig)) {
if (typeof envValue === "string" && isEnvReference(envValue)) {
credentialRefs.add(envKey);
continue;
}
if (
SECRET_FIELD_NAME_PATTERN.test(envKey) ||
detectInlineCredentialIndicators(envValue).size
) {
exposureFields.add(`env:${envKey}`);
detectInlineCredentialIndicators(envValue).forEach((item) => {
inlineIndicators.add(item);
});
if (
typeof envValue === "string" &&
!detectInlineCredentialIndicators(envValue).size
) {
inlineIndicators.add("secret-env-value");
}
}
}
const args = Array.isArray(serverConfig?.args) ? serverConfig.args : [];
for (let index = 0; index < args.length; index++) {
const argValue = String(args[index] || "");
const priorArg = index > 0 ? String(args[index - 1] || "") : "";
const indicators = detectInlineCredentialIndicators(argValue);
const secretFlag =
SECRET_FIELD_NAME_PATTERN.test(argValue) ||
(priorArg.startsWith("--") && SECRET_FIELD_NAME_PATTERN.test(priorArg));
if (indicators.size || (secretFlag && !isEnvReference(argValue))) {
exposureFields.add(
priorArg.startsWith("--") ? `arg:${priorArg}` : `arg:${index}`,
);
indicators.forEach((item) => {
inlineIndicators.add(item);
});
if (secretFlag && !indicators.size) {
inlineIndicators.add("secret-arg-value");
}
}
if (isEnvReference(argValue)) {
credentialRefs.add(argValue);
}
}
for (const [headerName, headerValue] of Object.entries(
serverConfig?.headers || {},
)) {
if (
SECRET_FIELD_NAME_PATTERN.test(headerName) ||
detectInlineCredentialIndicators(headerValue).size
) {
exposureFields.add(`header:${headerName}`);
detectInlineCredentialIndicators(headerValue).forEach((item) => {
inlineIndicators.add(item);
});
if (
typeof headerValue === "string" &&
SECRET_FIELD_NAME_PATTERN.test(headerName) &&
!detectInlineCredentialIndicators(headerValue).size
) {
inlineIndicators.add("secret-header-value");
}
}
}
return {
credentialIndicatorCount: inlineIndicators.size,
credentialReferenceCount: credentialRefs.size,
exposureFieldCount: exposureFields.size,
credentialRefs: Array.from(credentialRefs).sort(),
exposureFields: Array.from(exposureFields).sort(),
inlineIndicators: Array.from(inlineIndicators).sort(),
};
}
function inferTransport(serverConfig, endpoints) {
const declaredTransport = String(
serverConfig?.transport || serverConfig?.type || "",
).toLowerCase();
if (declaredTransport === "local") {
return "stdio";
}
if (declaredTransport.includes("sse")) {
return "sse";
}
if (declaredTransport.includes("websocket") || declaredTransport === "ws") {
return "websocket";
}
if (
declaredTransport.includes("http") ||
declaredTransport === "remote" ||
endpoints.some((endpoint) => endpoint.startsWith("http"))
) {
return "streamable-http";
}
return "stdio";
}
function extractEndpoints(serverConfig) {
const endpoints = new Set();
for (const candidateKey of ["endpoint", "url", "uri"]) {
const value = serverConfig?.[candidateKey];
if (typeof value === "string" && /^https?:\/\//iu.test(value)) {
endpoints.add(value);
}
}
for (const arg of Array.isArray(serverConfig?.args)
? serverConfig.args
: []) {
if (typeof arg === "string" && /^https?:\/\//iu.test(arg)) {
endpoints.add(arg);
}
}
return Array.from(endpoints).sort();
}
function extractPackageRefs(command, args) {
const packageRefs = new Set();
const candidates = [command]
.concat(Array.isArray(args) ? args : [])
.filter((value) => typeof value === "string");
for (const candidate of candidates) {
const normalized = candidate.replace(/^[./]+/u, "");
if (!normalized || normalized.startsWith("-")) {
continue;
}
const classification = classifyMcpReference(normalized);
if (classification.isMcp) {
packageRefs.add(classification.packageName || normalized);
}
}
return Array.from(packageRefs).sort();
}
function normalizeCommandAndArgs(serverConfig) {
if (Array.isArray(serverConfig?.command)) {
const [command, ...args] = serverConfig.command;
return {
args,
command: String(command || ""),
};
}
return {
args: Array.isArray(serverConfig?.args) ? serverConfig.args : [],
command: String(
serverConfig?.command ||
serverConfig?.cmd ||
serverConfig?.executable ||
"",
),
};
}
function authPostureForConfig(serverConfig, endpoints, authHints) {
const posture = new Set();
if (authHints.has("oauth")) {
posture.add("oauth");
}
if (authHints.has("bearer")) {
posture.add("bearer");
}
if (
serverConfig?.resourceServerUrl ||
serverConfig?.protectedResourceMetadata
) {
posture.add("protected-resource-metadata");
}
if (!posture.size && endpoints.length) {
posture.add("none");
}
return Array.from(posture).sort();
}
function dynamicClientRegistrationConfig(serverConfig) {
return Boolean(
serverConfig?.dynamicClientRegistration ||
serverConfig?.supportsDCR ||
serverConfig?.registration_endpoint ||
serverConfig?.auth?.registration_endpoint ||
serverConfig?.oauth?.registration_endpoint,
);
}
function staticClientIdPresent(serverConfig) {
const clientId =
serverConfig?.client_id ||
serverConfig?.clientId ||
serverConfig?.oauth?.client_id ||
serverConfig?.oauth?.clientId;
return (
typeof clientId === "string" && clientId.length && !isEnvReference(clientId)
);
}
function tokenPassthroughRisk(serverConfig) {
const serialized = JSON.stringify(serverConfig || {});
if (
/forward(?:ing)?(?:authorization|auth|access)?token/iu.test(serialized) ||
/tokenPassthrough|passthroughToken/iu.test(serialized)
) {
return "high";
}
return "none";
}
function trustProfile(officialSdk, exposureType, authPosture) {
const hasAuth = authPosture.some((value) => value !== "none");
if (officialSdk && exposureType === "local-only" && hasAuth) {
return "official-sdk+auth+localhost-only";
}
if (officialSdk && exposureType !== "local-only" && hasAuth) {
return "official-sdk+networked+auth";
}
if (!officialSdk && exposureType !== "local-only") {
return hasAuth
? "non-official-sdk+networked"
: "non-official-sdk+unauthenticated-networked";
}
return officialSdk ? "official-sdk+unknown" : "review-needed";
}
function createServiceFromConfig(
filePath,
format,
configKey,
serverName,
serverConfig,
) {
const normalized = normalizeCommandAndArgs(serverConfig);
const command = normalized.command;
const args = normalized.args;
const endpoints = extractEndpoints(serverConfig);
const sanitizedEndpoints = endpoints.map((endpoint) =>
sanitizeBomUrl(endpoint),
);
const transport = inferTransport(serverConfig, endpoints);
const authHints = authHintsFromValue(serverConfig);
const authPosture = authPostureForConfig(serverConfig, endpoints, authHints);
const packageRefs = extractPackageRefs(command, args);
const classifications = packageRefs.map((ref) => classifyMcpReference(ref));
const explicitMcpConfig =
packageRefs.length > 0 ||
transport !== "stdio" ||
Boolean(serverConfig?.mcp || serverConfig?.mcpServer);
if (!explicitMcpConfig) {
return undefined;
}
const providerNames = providerNamesForText(
JSON.stringify({
args,
command,
endpoints: sanitizedEndpoints,
env: serverConfig?.env || serverConfig?.environment || {},
}),
);
const credentialSignals = detectConfigCredentialSignals(serverConfig);
const publicNetwork = endpoints.some((endpoint) => {
try {
return !isLocalHost(new URL(endpoint).hostname);
} catch {
return false;
}
});
const hasTunnelReference = endpoints.some((endpoint) => {
try {
return LOCAL_TUNNEL_HOST_PATTERNS.some((pattern) =>
pattern.test(new URL(endpoint).hostname),
);
} catch {
return false;
}
});
const officialSdk = classifications.some((item) => item.isOfficial)
? true
: classifications.length
? false
: undefined;
const exposureType = publicNetwork
? "networked-public"
: transport === "stdio"
? "stdio-configured"
: "local-only";
const supportsDcr = dynamicClientRegistrationConfig(serverConfig);
const confusedDeputyRisk =
supportsDcr && staticClientIdPresent(serverConfig) ? "high" : "none";
const passthroughRisk = tokenPassthroughRisk(serverConfig);
const version = String(serverConfig?.version || "latest");
const properties = [{ name: "SrcFile", value: filePath }];
addUniqueProperty(properties, "cdx:mcp:serviceType", "configured-server");
addUniqueProperty(properties, "cdx:mcp:inventorySource", "config-file");
addUniqueProperty(properties, "cdx:mcp:configFormat", format);
addUniqueProperty(
properties,
"cdx:mcp:configKey",
`${configKey}.${serverName}`,
);
addUniqueProperty(properties, "cdx:mcp:transport", transport);
addUniqueProperty(properties, "cdx:mcp:exposureType", exposureType);
addUniqueProperty(properties, "cdx:mcp:usageConfidence", "high");
addUniqueProperty(properties, "cdx:mcp:command", command || "configured");
addUniqueProperty(properties, "cdx:mcp:reviewNeeded", "true");
if (typeof officialSdk === "boolean") {
addUniqueProperty(
properties,
"cdx:mcp:officialSdk",
officialSdk ? "true" : "false",
);
}
if (packageRefs.length) {
addUniqueProperty(properties, "cdx:mcp:packageRefs", packageRefs.join(","));
}
if (providerNames.length) {
addUniqueProperty(
properties,
"cdx:mcp:providerNames",
providerNames.join(","),
);
}
if (authPosture.length) {
addUniqueProperty(properties, "cdx:mcp:authPosture", authPosture.join(","));
addUniqueProperty(properties, "cdx:mcp:authMode", authPosture.join(","));
}
if (credentialSignals.inlineIndicators.length) {
addUniqueProperty(properties, "cdx:mcp:credentialExposure", "true");
addUniqueProperty(
properties,
"cdx:mcp:credentialIndicatorCount",
String(credentialSignals.credentialIndicatorCount),
);
}
if (credentialSignals.exposureFields.length) {
addUniqueProperty(
properties,
"cdx:mcp:credentialExposureFieldCount",
String(credentialSignals.exposureFieldCount),
);
}
if (credentialSignals.credentialRefs.length) {
addUniqueProperty(
properties,
"cdx:mcp:credentialReferenceCount",
String(credentialSignals.credentialReferenceCount),
);
}
if (supportsDcr) {
addUniqueProperty(properties, "cdx:mcp:auth:supportsDCR", "true");
}
if (authHints.has("oauth")) {
addUniqueProperty(properties, "cdx:mcp:auth:requiresOAuth", "true");
}
if (
serverConfig?.protectedResourceMetadata ||
serverConfig?.resourceServerUrl ||
serverConfig?.oauth?.resourceServerUrl
) {
addUniqueProperty(
properties,
"cdx:mcp:auth:protectedResourceMetadata",
"true",
);
}
addUniqueProperty(
properties,
"cdx:mcp:security:confusedDeputyRisk",
confusedDeputyRisk,
);
addUniqueProperty(
properties,
"cdx:mcp:security:tokenPassthroughRisk",
passthroughRisk,
);
if (hasTunnelReference) {
addUniqueProperty(properties, "cdx:mcp:hasTunnelReference", "true");
}
addUniqueProperty(
properties,
"cdx:mcp:trustProfile",
trustProfile(officialSdk, exposureType, authPosture),
);
const serviceName = serverConfig?.name || serverName || basename(filePath);
const authenticated =
transport === "stdio"
? authPosture.some((value) => value !== "none")
? true
: undefined
: authPosture.some((value) => value !== "none");
return {
"bom-ref": `urn:service:mcp:${sanitizeMcpRefToken(serviceName)}:${sanitizeMcpRefToken(version)}`,
authenticated,
endpoints: sanitizedEndpoints,
group: "mcp",
name: serviceName,
properties,
version,
};
}
function createConfigComponent(filePath, format, raw, services) {
const hiddenUnicodeScan = scanTextForHiddenUnicode(raw, {
syntax: syntaxForFile(filePath),
});
const properties = [
{ name: "SrcFile", value: filePath },
{ name: "cdx:file:kind", value: "mcp-config" },
{ name: "cdx:mcp:inventorySource", value: "config-file" },
{ name: "cdx:mcp:configFormat", value: format },
{ name: "cdx:mcp:configuredServiceCount", value: String(services.length) },
];
if (services.length) {
addUniqueProperty(
properties,
"cdx:mcp:configuredServiceNames",
services
.map((service) => service.name)
.sort()
.join(","),
);
addUniqueProperty(
properties,
"cdx:mcp:configuredEndpoints",
services
.flatMap((service) => service.endpoints || [])
.filter(Boolean)
.sort()
.join(","),
);
}
if (hiddenUnicodeScan.hasHiddenUnicode) {
addUniqueProperty(properties, "cdx:file:hasHiddenUnicode", "true");
addUniqueProperty(
properties,
"cdx:file:hiddenUnicodeCodePoints",
hiddenUnicodeScan.codePoints.join(","),
);
addUniqueProperty(
properties,
"cdx:file:hiddenUnicodeLineNumbers",
hiddenUnicodeScan.lineNumbers.join(","),
);
}
const credentialServices = services.filter((service) =>
service.properties?.some(
(property) =>
property.name === "cdx:mcp:credentialExposure" &&
property.value === "true",
),
);
if (credentialServices.length) {
addUniqueProperty(properties, "cdx:mcp:credentialExposure", "true");
addUniqueProperty(
properties,
"cdx:mcp:credentialExposedServiceCount",
String(credentialServices.length),
);
}
return {
"bom-ref": `file:${filePath}`,
name: basename(filePath),
properties,
type: "file",
};
}
export const mcpConfigParser = {
id: "mcp-config",
patterns: MCP_CONFIG_PATTERNS,
parse(files, _options = {}) {
const components = [];
const services = [];
for (const filePath of [...new Set(files || [])]) {
let raw;
try {
raw = readFileSync(filePath, "utf-8");
} catch {
continue;
}
let configJson;
try {
configJson = parseJsonLike(raw);
} catch {
continue;
}
const format = configFormat(filePath);
const fileServices = [];
for (const { configKey, servers } of extractServerMaps(configJson)) {
for (const [serverName, serverConfig] of servers) {
const service = createServiceFromConfig(
filePath,
format,
configKey,
serverName,
serverConfig,
);
if (service) {
fileServices.push(service);
}
}
}
if (!fileServices.length) {
continue;
}
services.push(...fileServices);
components.push(
createConfigComponent(filePath, format, raw, fileServices),
);
}
return { components, services };
},
};