@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
1,070 lines (1,029 loc) • 31.7 kB
JavaScript
import { readdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join, resolve } from "node:path";
import process from "node:process";
import StreamZip from "node-stream-zip";
import { PackageURL } from "packageurl-js";
import { xml2js } from "xml-js";
import {
DEBUG_MODE,
getTmpDir,
isMac,
isWin,
safeExistsSync,
safeExtractArchive,
safeMkdtempSync,
safeRmSync,
} from "./utils.js";
import { toVersRange } from "./versutils.js";
/**
* The purl type for VS Code extensions as defined by the packageurl spec.
*/
export const VSCODE_EXTENSION_PURL_TYPE = "vscode-extension";
/**
* Confidence value for extension metadata discovered via manifest analysis.
*/
const MANIFEST_ANALYSIS_CONFIDENCE = 0.6;
/**
* IDE configuration entries describing where each IDE stores its extensions.
* Each entry contains the IDE name and an array of candidate extension
* directory paths for Windows, macOS, and Linux (including remote/server
* environments).
*
* The paths use platform-specific logic via `homedir()` and common
* environment variables.
*/
export function getIdeExtensionDirs() {
const home = homedir();
const appData = process.env.APPDATA || join(home, "AppData", "Roaming");
const localAppData =
process.env.LOCALAPPDATA || join(home, "AppData", "Local");
const xdgDataHome =
process.env.XDG_DATA_HOME || join(home, ".local", "share");
// Each entry: { name, dirs: string[] }
// Only include directories that are relevant for the current platform,
// plus well-known remote/server paths that are always Linux.
const ides = [
{
name: "VS Code",
dirs: isWin
? [join(appData, "Code", "User", "extensions")]
: isMac
? [
join(
home,
"Library",
"Application Support",
"Code",
"User",
"extensions",
),
]
: [join(home, ".vscode", "extensions")],
},
{
name: "VS Code Insiders",
dirs: isWin
? [join(appData, "Code - Insiders", "User", "extensions")]
: isMac
? [
join(
home,
"Library",
"Application Support",
"Code - Insiders",
"User",
"extensions",
),
]
: [join(home, ".vscode-insiders", "extensions")],
},
{
name: "VSCodium",
dirs: isWin
? [join(appData, "VSCodium", "User", "extensions")]
: isMac
? [
join(
home,
"Library",
"Application Support",
"VSCodium",
"User",
"extensions",
),
]
: [
join(home, ".vscode-oss", "extensions"),
join(home, ".config", "VSCodium", "User", "extensions"),
],
},
{
name: "Cursor",
dirs: isWin
? [
join(appData, "Cursor", "User", "extensions"),
join(localAppData, "cursor", "extensions"),
]
: isMac
? [
join(
home,
"Library",
"Application Support",
"Cursor",
"User",
"extensions",
),
]
: [join(home, ".cursor", "extensions")],
},
{
name: "Windsurf",
dirs: isWin
? [join(appData, "Windsurf", "User", "extensions")]
: isMac
? [
join(
home,
"Library",
"Application Support",
"Windsurf",
"User",
"extensions",
),
]
: [join(home, ".windsurf", "extensions")],
},
{
name: "Positron",
dirs: isWin
? [join(appData, "Positron", "User", "extensions")]
: isMac
? [
join(
home,
"Library",
"Application Support",
"Positron",
"User",
"extensions",
),
]
: [join(home, ".positron", "extensions")],
},
{
name: "Theia",
dirs: isWin
? [join(appData, "Theia", "extensions")]
: isMac
? [
join(
home,
"Library",
"Application Support",
"Theia",
"extensions",
),
]
: [
join(home, ".theia", "extensions"),
join(xdgDataHome, "theia", "extensions"),
],
},
// Remote / server environments (Linux only)
{
name: "code-server",
dirs: [join(xdgDataHome, "code-server", "extensions")],
},
{
name: "VS Code Remote",
dirs: [join(home, ".vscode-remote", "extensions")],
},
{
name: "OpenVSCode Server",
dirs: [join(xdgDataHome, "openvscode-server", "extensions")],
},
{
name: "Trae",
dirs: isWin
? [join(appData, "Trae", "User", "extensions")]
: isMac
? [
join(
home,
"Library",
"Application Support",
"Trae",
"User",
"extensions",
),
]
: [join(home, ".trae", "extensions")],
},
{
name: "Augment Code",
dirs: isWin
? [join(appData, "Augment Code", "User", "extensions")]
: isMac
? [
join(
home,
"Library",
"Application Support",
"Augment Code",
"User",
"extensions",
),
]
: [join(home, ".augment-code", "extensions")],
},
];
return ides;
}
/**
* Discover all existing IDE extension directories on the current system.
*
* @returns {Array<{name: string, dir: string}>} Array of objects with IDE name
* and the existing directory path.
*/
export function discoverIdeExtensionDirs() {
const ides = getIdeExtensionDirs();
const found = [];
for (const ide of ides) {
for (const dir of ide.dirs) {
if (safeExistsSync(dir)) {
found.push({ name: ide.name, dir });
}
}
}
return found;
}
/**
* Parse a `.vsixmanifest` XML string and extract extension metadata.
*
* @param {string} manifestData Raw XML content of a `.vsixmanifest` file
* @returns {Object|undefined} Object with { publisher, name, version, displayName, description, platform, tags } or undefined on failure
*/
export function parseVsixManifest(manifestData) {
if (!manifestData?.trim()) {
return undefined;
}
try {
const parsed = xml2js(manifestData, {
compact: true,
alwaysArray: false,
spaces: 4,
textKey: "_",
attributesKey: "$",
});
const manifest =
parsed.PackageManifest || parsed["PackageManifest:PackageManifest"];
if (!manifest) {
return undefined;
}
const metadata = manifest.Metadata || manifest["PackageManifest:Metadata"];
if (!metadata) {
return undefined;
}
const identity = metadata.Identity || metadata["PackageManifest:Identity"];
if (!identity?.$) {
return undefined;
}
const attrs = identity.$;
const publisher =
attrs.Publisher || attrs.publisher || attrs["d:Publisher"] || "";
const name = attrs.Id || attrs.id || attrs["d:Id"] || "";
const version = attrs.Version || attrs.version || attrs["d:Version"] || "";
const targetPlatform =
attrs.TargetPlatform ||
attrs.targetPlatform ||
attrs["d:TargetPlatform"] ||
"";
const tags = metadata?.Tags?._?.split(",").map((s) => s.trim());
const displayNameNode =
metadata.DisplayName || metadata["PackageManifest:DisplayName"];
const descriptionNode =
metadata.Description || metadata["PackageManifest:Description"];
const displayName = displayNameNode?._ || displayNameNode || "";
const description = descriptionNode?._ || descriptionNode || "";
// Parse Properties tag for additional metadata
const properties = {};
const propsNode = metadata?.Properties;
if (propsNode?.Property) {
const propEntries = Array.isArray(propsNode.Property)
? propsNode.Property
: [propsNode.Property];
for (const prop of propEntries) {
const propId = prop?.$?.Id || "";
const propValue = prop?.$?.Value || "";
if (propId && propValue) {
properties[propId] = propValue;
}
}
}
const result = {
publisher: publisher,
name: name,
version,
displayName: typeof displayName === "string" ? displayName : "",
description: typeof description === "string" ? description : "",
platform: targetPlatform || "",
tags,
};
// Map well-known VSIX properties to structured fields
if (properties["Microsoft.VisualStudio.Code.Engine"]) {
result.vscodeEngine = properties["Microsoft.VisualStudio.Code.Engine"];
}
if (properties["Microsoft.VisualStudio.Code.ExtensionDependencies"]) {
const deps =
properties["Microsoft.VisualStudio.Code.ExtensionDependencies"];
if (deps) {
result.extensionDependencies = deps.split(",").map((s) => s.trim());
}
}
if (properties["Microsoft.VisualStudio.Code.ExtensionPack"]) {
const pack = properties["Microsoft.VisualStudio.Code.ExtensionPack"];
if (pack) {
result.extensionPack = pack.split(",").map((s) => s.trim());
}
}
if (properties["Microsoft.VisualStudio.Code.ExtensionKind"]) {
const kind = properties["Microsoft.VisualStudio.Code.ExtensionKind"];
if (kind) {
result.extensionKind = kind.split(",").map((s) => s.trim());
}
}
if (properties["Microsoft.VisualStudio.Code.ExecutesCode"]) {
result.executesCode =
properties["Microsoft.VisualStudio.Code.ExecutesCode"] === "true";
}
// Collect links from properties
const links = {};
for (const [id, value] of Object.entries(properties)) {
if (id.startsWith("Microsoft.VisualStudio.Services.Links.") && value) {
const linkType = id.replace(
"Microsoft.VisualStudio.Services.Links.",
"",
);
links[linkType] = value;
}
}
if (Object.keys(links).length) {
result.links = links;
}
return result;
} catch (e) {
if (DEBUG_MODE) {
console.log("Error parsing vsixmanifest:", e.message);
}
return undefined;
}
}
/**
* Parse npm-style dependency maps from a VS Code extension's package.json
* and create CycloneDX component objects with versionRange attributes.
*
* @param {Object} pkg Parsed package.json object
* @param {string} extensionPurl The purl of the parent extension (for dependency tree)
* @returns {{ components: Object[], dependencies: Object[] }} CycloneDX components and dependency tree
*/
export function parseExtensionDependencies(pkg, extensionPurl) {
const components = [];
const dependsOn = [];
const seen = new Set();
const depGroups = [
{ key: "dependencies", scope: "required" },
{ key: "devDependencies", scope: "optional" },
{ key: "peerDependencies", scope: "optional" },
{ key: "optionalDependencies", scope: "optional" },
];
for (const { key, scope } of depGroups) {
const deps = pkg[key];
if (!deps || typeof deps !== "object") {
continue;
}
for (const [depName, depVersion] of Object.entries(deps)) {
if (!depName || typeof depVersion !== "string") {
continue;
}
// Parse scoped npm package names
let group = "";
let name = depName;
if (depName.startsWith("@") && depName.includes("/")) {
const parts = depName.split("/");
group = parts[0];
name = parts.slice(1).join("/");
}
const purlObj = new PackageURL(
"npm",
group || null,
name,
null,
null,
null,
);
const purlString = purlObj.toString();
if (seen.has(purlString)) {
continue;
}
seen.add(purlString);
const versRange = toVersRange(depVersion);
const component = {
group,
name,
purl: purlString,
"bom-ref": decodeURIComponent(purlString),
type: "library",
scope,
};
if (versRange) {
component.versionRange = versRange;
}
components.push(component);
dependsOn.push(decodeURIComponent(purlString));
}
}
const dependencies = [];
if (extensionPurl && dependsOn.length) {
dependencies.push({
ref: decodeURIComponent(extensionPurl),
dependsOn: dependsOn.sort(),
});
}
return { components, dependencies };
}
/**
* Parse a VS Code extension's `package.json` and extract metadata
* including deep capability and permission information.
*
* @param {string|Object} packageJsonData Either raw JSON string or parsed object
* @param {string} [srcPath] Optional path to the source directory for evidence
* @returns {Object|undefined} Object with metadata and capabilities or undefined
*/
export function parseVsixPackageJson(packageJsonData, srcPath) {
try {
const pkg =
typeof packageJsonData === "string"
? JSON.parse(packageJsonData)
: packageJsonData;
if (!pkg?.name) {
return undefined;
}
const externalReferences = [];
if (pkg.repository?.url) {
externalReferences.push({ type: "vcs", url: pkg.repository.url });
}
return {
publisher: pkg.publisher || "",
name: pkg.name || "",
version: pkg.version || "",
displayName: pkg.displayName || "",
description: pkg.description || "",
platform: "",
srcPath,
externalReferences: externalReferences.length
? externalReferences
: undefined,
capabilities: extractExtensionCapabilities(pkg),
dependencies: pkg.dependencies,
devDependencies: pkg.devDependencies,
peerDependencies: pkg.peerDependencies,
optionalDependencies: pkg.optionalDependencies,
};
} catch (e) {
if (DEBUG_MODE) {
console.log("Error parsing extension package.json:", e.message);
}
return undefined;
}
}
/**
* Extract deep capability and permission information from a VS Code
* extension package.json.
*
* This captures security-relevant metadata such as:
* - activationEvents: when the extension activates (e.g., `*` means always)
* - extensionKind: where the extension runs (ui, workspace, or both)
* - permissions: workspace trust, virtual workspace support
* - contributes: commands, debuggers, terminal profiles, task providers, fs providers
* - extensionDependencies/extensionPack: required extensions
* - scripts: whether postinstall or other lifecycle scripts exist
* - main/browser: entry points for analysis
*
* @param {Object} pkg Parsed package.json object
* @returns {Object} Capabilities object with structured metadata
*/
export function extractExtensionCapabilities(pkg) {
if (!pkg) {
return {};
}
const capabilities = {};
// Activation events - security relevant: "*" means the extension activates for every workspace
if (pkg.activationEvents?.length) {
capabilities.activationEvents = pkg.activationEvents;
}
// Extension kind - where the extension runs (ui=local, workspace=remote, both)
if (pkg.extensionKind?.length) {
capabilities.extensionKind = pkg.extensionKind;
}
// Extension dependencies - other extensions this requires
if (pkg.extensionDependencies?.length) {
capabilities.extensionDependencies = pkg.extensionDependencies;
}
// Extension pack - bundled extensions
if (pkg.extensionPack?.length) {
capabilities.extensionPack = pkg.extensionPack;
}
// Workspace trust configuration
if (pkg.capabilities?.untrustedWorkspaces) {
capabilities.untrustedWorkspaces = pkg.capabilities.untrustedWorkspaces;
}
if (pkg.capabilities?.virtualWorkspaces) {
capabilities.virtualWorkspaces = pkg.capabilities.virtualWorkspaces;
}
// Contributed features
const contributes = pkg.contributes || {};
const contributedFeatures = [];
for (const feature of [
"authentication",
"breakpoints",
"commands",
"chatInstructions",
"chatPromptFiles",
"customEditors",
"configuration",
"debuggers",
"taskDefinitions",
"terminal",
"views",
]) {
if (contributes[feature]?.length) {
contributedFeatures.push(
`${feature}:count:${contributes[feature].length}`,
);
}
}
if (contributes.terminal?.length || contributes.taskDefinitions?.length) {
contributedFeatures.push("terminal-access");
}
if (contributes["terminal.profiles"]?.length) {
contributedFeatures.push("terminal-profiles");
}
if (
contributes.typescriptServerPlugins?.length ||
contributes.jsonValidation?.length
) {
contributedFeatures.push("language-server-plugins");
}
if (
contributes["resourceLabelFormatters"]?.length ||
contributes["fileSystemProviders"]?.length
) {
contributedFeatures.push("filesystem-provider");
}
if (contributes.authentication?.length) {
contributedFeatures.push("authentication-provider");
}
if (contributes.walkthroughs?.length) {
contributedFeatures.push("walkthroughs");
}
if (contributedFeatures.length) {
capabilities.contributes = contributedFeatures;
}
if (pkg.main) {
capabilities.main = pkg.main;
}
if (pkg.browser) {
capabilities.browser = pkg.browser;
}
const scripts = pkg.scripts || {};
const lifecycleScripts = [];
for (const scriptName of [
"postinstall",
"preinstall",
"install",
"prepare",
"prepublish",
"vscode:prepublish",
"vscode:uninstall",
]) {
if (scripts[scriptName]) {
lifecycleScripts.push(scriptName);
}
}
if (lifecycleScripts.length) {
capabilities.lifecycleScripts = lifecycleScripts;
}
return capabilities;
}
/**
* Convert parsed extension metadata into a CycloneDX component object.
*
* @param {Object} extInfo Object with { publisher, name, version, displayName, description, platform, srcPath, capabilities }
* @param {string} [ideName] Optional IDE name for properties
* @returns {Object|undefined} CycloneDX component object or undefined
*/
export function toComponent(extInfo, ideName) {
if (!extInfo?.name) {
return undefined;
}
const qualifiers = {};
if (extInfo.platform) {
qualifiers.platform = extInfo.platform;
}
const purl = new PackageURL(
VSCODE_EXTENSION_PURL_TYPE,
extInfo.publisher || null,
extInfo.name,
extInfo.version || null,
Object.keys(qualifiers).length ? qualifiers : null,
null,
).toString();
const component = {
publisher: extInfo.publisher || "",
group: extInfo.publisher || "",
name: extInfo.name,
version: extInfo.version || "",
description: extInfo.displayName || extInfo.description || "",
purl,
"bom-ref": decodeURIComponent(purl),
type: "application",
};
if (extInfo.description && extInfo.description !== component.description) {
component.description = extInfo.description;
}
const props = [];
if (ideName) {
props.push({ name: "cdx:vscode-extension:ide", value: ideName });
}
if (extInfo.srcPath) {
props.push({ name: "SrcFile", value: extInfo.srcPath });
}
// Add capability properties from deep extension analysis
const caps = extInfo.capabilities || {};
if (caps.activationEvents?.length) {
props.push({
name: "cdx:vscode-extension:activationEvents",
value: caps.activationEvents.join(", "),
});
}
// extensionKind can come from capabilities (package.json) or directly from manifest Properties
const extensionKind = caps.extensionKind || extInfo.extensionKind;
if (extensionKind?.length) {
props.push({
name: "cdx:vscode-extension:extensionKind",
value: extensionKind.join(", "),
});
}
// extensionDependencies can come from capabilities or manifest Properties
const extensionDeps =
caps.extensionDependencies || extInfo.extensionDependencies;
if (extensionDeps?.length) {
props.push({
name: "cdx:vscode-extension:extensionDependencies",
value: extensionDeps.join(", "),
});
}
// extensionPack can come from capabilities or manifest Properties
const extensionPack = caps.extensionPack || extInfo.extensionPack;
if (extensionPack?.length) {
props.push({
name: "cdx:vscode-extension:extensionPack",
value: extensionPack.join(", "),
});
}
if (caps.untrustedWorkspaces !== undefined) {
const uws = caps.untrustedWorkspaces;
props.push({
name: "cdx:vscode-extension:untrustedWorkspaces",
value:
typeof uws === "object" && uws.supported !== undefined
? String(uws.supported)
: String(uws),
});
}
if (caps.virtualWorkspaces !== undefined) {
const vws = caps.virtualWorkspaces;
props.push({
name: "cdx:vscode-extension:virtualWorkspaces",
value:
typeof vws === "object" && vws.supported !== undefined
? String(vws.supported)
: String(vws),
});
}
if (caps.contributes?.length) {
props.push({
name: "cdx:vscode-extension:contributes",
value: caps.contributes.join(", "),
});
}
if (caps.main) {
props.push({ name: "cdx:vscode-extension:main", value: caps.main });
}
if (caps.browser) {
props.push({ name: "cdx:vscode-extension:browser", value: caps.browser });
}
if (caps.lifecycleScripts?.length) {
props.push({
name: "cdx:vscode-extension:lifecycleScripts",
value: caps.lifecycleScripts.join(", "),
});
}
// Properties from vsixmanifest Properties tag
if (extInfo.executesCode !== undefined) {
props.push({
name: "cdx:vscode-extension:executesCode",
value: String(extInfo.executesCode),
});
}
if (extInfo.vscodeEngine) {
props.push({
name: "cdx:vscode-extension:vscodeEngine",
value: extInfo.vscodeEngine,
});
}
if (props.length) {
component.properties = props;
}
// Build externalReferences from links (manifest Properties) or from package.json repository
const externalRefs = [];
if (extInfo.externalReferences?.length) {
externalRefs.push(...extInfo.externalReferences);
}
if (extInfo.links) {
if (extInfo.links.Source || extInfo.links.GitHub) {
const vcsUrl = extInfo.links.Source || extInfo.links.GitHub;
if (!externalRefs.some((r) => r.type === "vcs")) {
externalRefs.push({ type: "vcs", url: vcsUrl });
}
}
if (extInfo.links.Support) {
externalRefs.push({ type: "issue-tracker", url: extInfo.links.Support });
}
if (extInfo.links.Learn) {
externalRefs.push({ type: "documentation", url: extInfo.links.Learn });
}
if (extInfo.links.Getstarted) {
externalRefs.push({ type: "website", url: extInfo.links.Getstarted });
}
}
if (externalRefs.length) {
component.externalReferences = externalRefs;
}
component.evidence = {
identity: {
field: "purl",
confidence: MANIFEST_ANALYSIS_CONFIDENCE,
methods: [
{
technique: "manifest-analysis",
confidence: MANIFEST_ANALYSIS_CONFIDENCE,
value: extInfo.srcPath || "",
},
],
},
};
return component;
}
/**
* Extract a `.vsix` file (ZIP archive) to a temporary directory for deep
* analysis. The caller is responsible for cleaning up the temp directory.
*
* @param {string} vsixFile Absolute path to the `.vsix` file
* @returns {Promise<string|undefined>} Path to the extracted temp directory, or undefined on failure
*/
export async function extractVsixToTempDir(vsixFile) {
let tempDir;
let zip;
try {
tempDir = safeMkdtempSync(join(getTmpDir(), "vsix-deps-"));
zip = new StreamZip.async({ file: vsixFile });
const extracted = await safeExtractArchive(vsixFile, tempDir, async () => {
await zip.extract(null, tempDir);
});
if (!extracted) {
return undefined;
}
// Most vsix files have content under extension/ subdirectory
const extensionSubDir = join(tempDir, "extension");
if (safeExistsSync(extensionSubDir)) {
return extensionSubDir;
}
return tempDir;
} catch (e) {
if (DEBUG_MODE) {
console.log(`Error extracting vsix file ${vsixFile}:`, e.message);
}
cleanupTempDir(tempDir);
return undefined;
} finally {
if (zip) {
try {
await zip.close();
} catch (_e) {
// Best effort close
}
}
}
}
/**
* Clean up a temporary directory created during vsix extraction.
*
* @param {string} tempDir Path to the temp directory to remove
*/
export function cleanupTempDir(tempDir) {
if (!tempDir) {
return;
}
// The tempDir might be a subdirectory (e.g., "extension" inside the actual temp dir)
// Walk up to verify the parent is under the temp base
const resolvedDir = resolve(tempDir);
const dirToRemove =
basename(resolvedDir) === "extension"
? resolve(resolvedDir, "..")
: resolvedDir;
try {
// Safety: only remove dirs that are direct children of the temp base with vsix-deps- prefix
const expectedBase = resolve(getTmpDir());
const dirBaseName = basename(dirToRemove);
if (
dirBaseName.startsWith("vsix-deps-") &&
resolve(dirToRemove, "..") === expectedBase
) {
safeRmSync(dirToRemove, { recursive: true, force: true });
}
} catch (_e) {
// Best effort cleanup
}
}
/**
* Parse a `.vsix` file (ZIP archive) and extract the extension metadata.
*
* @param {string} vsixFile Absolute path to the `.vsix` file
* @returns {Promise<Object|undefined>} CycloneDX component object or undefined
*/
export async function parseVsixFile(vsixFile) {
let zip;
try {
zip = new StreamZip.async({ file: vsixFile });
const entries = await zip.entries();
let extInfo;
// Try .vsixmanifest first
for (const entry of Object.values(entries)) {
if (entry.isDirectory) {
continue;
}
if (
entry.name.endsWith(".vsixmanifest") ||
entry.name.endsWith("extension.vsixmanifest")
) {
const fileData = await zip.entryData(entry.name);
const manifestData = fileData.toString("utf-8");
extInfo = parseVsixManifest(manifestData);
if (extInfo) {
extInfo.srcPath = vsixFile;
break;
}
}
}
// Fall back to package.json inside the extension/ directory
if (!extInfo) {
for (const entry of Object.values(entries)) {
if (entry.isDirectory) {
continue;
}
if (
entry.name === "extension/package.json" ||
entry.name === "package.json"
) {
const fileData = await zip.entryData(entry.name);
const packageJsonData = fileData.toString("utf-8");
extInfo = parseVsixPackageJson(packageJsonData, vsixFile);
if (extInfo) {
break;
}
}
}
}
if (extInfo) {
return toComponent(extInfo);
}
return undefined;
} catch (e) {
if (DEBUG_MODE) {
console.log(`Error parsing vsix file ${vsixFile}:`, e.message);
}
return undefined;
} finally {
if (zip) {
try {
await zip.close();
} catch (_e) {
// Best effort close
}
}
}
}
/**
* Parse a single installed extension directory (already extracted).
* Looks for `package.json` (preferred) and `.vsixmanifest`.
*
* @param {string} extDir Absolute path to the extension directory (e.g. `~/.vscode/extensions/ms-python.python-2023.1.0`)
* @param {string} [ideName] Optional IDE name
* @returns {Object|undefined} CycloneDX component object or undefined
*/
export function parseInstalledExtensionDir(extDir, ideName) {
// First try package.json at the root of the extension directory
const packageJsonPath = join(extDir, "package.json");
if (safeExistsSync(packageJsonPath)) {
try {
const data = readFileSync(packageJsonPath, { encoding: "utf-8" });
const extInfo = parseVsixPackageJson(data, extDir);
if (extInfo?.name) {
return toComponent(extInfo, ideName);
}
} catch (_e) {
// Fall through to vsixmanifest
}
}
// Try .vsixmanifest at the root
const manifestPath = join(extDir, ".vsixmanifest");
if (safeExistsSync(manifestPath)) {
try {
const data = readFileSync(manifestPath, { encoding: "utf-8" });
const extInfo = parseVsixManifest(data);
if (extInfo) {
extInfo.srcPath = extDir;
return toComponent(extInfo, ideName);
}
} catch (_e) {
// Ignore
}
}
// Try to infer from directory name (publisher.name-version pattern)
return parseExtensionDirName(extDir, ideName);
}
/**
* Attempt to extract extension metadata from a directory name following the
* pattern `publisher.name-version`.
*
* @param {string} extDir Absolute path to extension directory
* @param {string} [ideName] IDE name
* @returns {Object|undefined} CycloneDX component or undefined
*/
export function parseExtensionDirName(extDir, ideName) {
const dirName = extDir.split(/[/\\]/).pop();
if (!dirName) {
return undefined;
}
// Pattern: publisher.name-version (e.g., ms-python.python-2023.25.0)
// Use a non-backtracking approach: split on the last hyphen followed by a digit
const dotIdx = dirName.indexOf(".");
if (dotIdx < 1) {
return undefined;
}
const publisher = dirName.substring(0, dotIdx);
const rest = dirName.substring(dotIdx + 1);
// Find the last hyphen followed by a digit to separate name from version
let versionStart = -1;
for (let i = rest.length - 1; i >= 0; i--) {
if (rest[i] === "-" && i + 1 < rest.length && /\d/.test(rest[i + 1])) {
versionStart = i;
break;
}
}
if (versionStart < 1) {
return undefined;
}
const name = rest.substring(0, versionStart);
const version = rest.substring(versionStart + 1);
if (name && version) {
const extInfo = {
publisher: publisher,
name: name,
version,
displayName: "",
description: "",
platform: "",
srcPath: extDir,
};
return toComponent(extInfo, ideName);
}
return undefined;
}
/**
* Collect all installed extensions from a set of IDE extension directories.
*
* @param {Array<{name: string, dir: string}>} ideDirs Array of { name, dir } from discoverIdeExtensionDirs
* @returns {Object[]} Array of CycloneDX component objects
*/
export function collectInstalledExtensions(ideDirs) {
const pkgList = [];
const seen = new Set();
for (const { name: ideName, dir } of ideDirs) {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch (_e) {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
// Skip hidden directories and special directories
if (entry.name.startsWith(".")) {
continue;
}
const extDir = join(dir, entry.name);
const component = parseInstalledExtensionDir(extDir, ideName);
if (component && !seen.has(component.purl)) {
seen.add(component.purl);
pkgList.push(component);
}
}
}
return pkgList;
}