UNPKG

@cyclonedx/cdxgen

Version:

Creates CycloneDX Software Bill of Materials (SBOM) from source or container image

1,176 lines (1,157 loc) 38.7 kB
import { readdirSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import process from "node:process"; import { PackageURL } from "packageurl-js"; import { CHROMIUM_EXTENSION_CAPABILITY_CATEGORIES, detectExtensionCapabilities, } from "./analyzer.js"; import { sanitizeBomPropertyValue } from "./propertySanitizer.js"; import { isMac, isWin, safeExistsSync } from "./utils.js"; /** * The purl type for Chrome extensions as defined by the packageurl spec. */ export const CHROME_EXTENSION_PURL_TYPE = "chrome-extension"; const CHROME_EXTENSION_ID_REGEX = /^[a-z]{32}$/i; const BRAVE_SPECIFIC_PERMISSIONS = ["webDiscovery", "settingsPrivate"]; /** * Per-process cache for extension source capability scans. * * Entries are keyed by resolved extension directory and populated on first scan. * Values are reused during a single cdxgen run to avoid repeated Babel AST scans * for the same extension directory. The cache is intentionally process-local and * naturally discarded when the process exits. */ const extensionDirCapabilityCache = new Map(); /** * Infer high-risk extension capabilities from manifest fields and permission hints. * * @param {Object} manifestData Parsed manifest-derived data * @returns {Object<string, boolean>} Capability booleans keyed by * CHROMIUM_EXTENSION_CAPABILITY_CATEGORIES entries; unknown/extra keys are ignored. */ function inferChromiumCapabilitySignals(manifestData) { const permissions = [ ...(manifestData?.permissions || []), ...(manifestData?.optionalPermissions || []), ] .filter(Boolean) .map((permission) => permission.toLowerCase()); const hostPermissions = [ ...(manifestData?.hostPermissions || []), ...(manifestData?.optionalHostPermissions || []), ] .filter(Boolean) .map((permission) => permission.toLowerCase()); const commandNames = (manifestData?.commands || []) .filter(Boolean) .map((commandName) => commandName.toLowerCase()); const contentScripts = Array.isArray(manifestData?.contentScripts) ? manifestData.contentScripts : []; const contentScriptPaths = contentScripts .flatMap((script) => [ ...(Array.isArray(script?.js) ? script.js : []), ...(Array.isArray(script?.css) ? script.css : []), ]) .filter((entry) => typeof entry === "string") .map((entry) => entry.toLowerCase()); const allSignals = [ ...permissions, ...hostPermissions, ...commandNames, ...contentScriptPaths, ]; const hasBroadHosts = hostPermissions.some( (permission) => permission === "<all_urls>" || permission === "*://*/*" || permission.startsWith("file://"), ); const hasContentScripts = contentScripts.length > 0; const hasWebAccessibleResources = Array.isArray( manifestData?.webAccessibleResources, ) ? manifestData.webAccessibleResources.length > 0 : false; return { fileAccess: allSignals.some((signal) => [ "filesystem", "downloads", "filebrowserhandler", "filemanagerprivate", "file://", ].some((token) => signal.includes(token)), ) || Boolean(manifestData?.fileBrowserHandlers), deviceAccess: allSignals.some((signal) => ["usb", "hid", "serial", "nfc", "mediagalleries", "bluetooth"].some( (token) => signal.includes(token), ), ), network: allSignals.some((signal) => [ "webrequest", "declarativenetrequest", "proxy", "webnavigation", "socket", "cookies", ].some((token) => signal.includes(token)), ) || hasBroadHosts || hasWebAccessibleResources, bluetooth: allSignals.some((signal) => signal.includes("bluetooth")), accessibility: allSignals.some((signal) => ["accessibility", "automation", "screenreader"].some((token) => signal.includes(token), ), ), codeInjection: allSignals.some((signal) => [ "scripting", "userscripts", "debugger", "tabs", "execute", "inject", ].some((token) => signal.includes(token)), ) || hasContentScripts, fingerprinting: allSignals.some((signal) => ["history", "fonts", "fontsettings", "webgl", "canvas", "cookies"].some( (token) => signal.includes(token), ), ), }; } /** * Merge one or more capability maps into a normalized set of boolean flags. * * Performs logical OR across known capability keys only; unknown keys are ignored. * * @param {...Object<string, boolean>} capabilityMaps Capability maps from manifest/code analysis * @returns {Object<string, boolean>} Merged capability map */ function mergeCapabilitySignals(...capabilityMaps) { const merged = {}; for (const capabilityName of CHROMIUM_EXTENSION_CAPABILITY_CATEGORIES) { merged[capabilityName] = false; } for (const capabilityMap of capabilityMaps) { for (const capabilityName of CHROMIUM_EXTENSION_CAPABILITY_CATEGORIES) { if (capabilityMap?.[capabilityName]) { merged[capabilityName] = true; } } } return merged; } /** * Detect extension capabilities from source code with per-directory caching. * * @param {string} extensionDir Extension directory * @returns {Object<string, boolean>} Capability signal map for * CHROMIUM_EXTENSION_CAPABILITY_CATEGORIES where each value is boolean. * Uses detectExtensionCapabilities(extensionDir, false), where false excludes * node_modules/deep scanning for performance. */ function detectCachedExtensionCapabilities(extensionDir) { const cacheKey = resolve(extensionDir); if (extensionDirCapabilityCache.has(cacheKey)) { return extensionDirCapabilityCache.get(cacheKey); } const codeCapabilityScan = detectExtensionCapabilities(cacheKey, false); const codeCapabilities = {}; for (const capabilityName of CHROMIUM_EXTENSION_CAPABILITY_CATEGORIES) { codeCapabilities[capabilityName] = codeCapabilityScan.capabilities.includes(capabilityName); } extensionDirCapabilityCache.set(cacheKey, codeCapabilities); return codeCapabilities; } /** * Discover known Chromium-based browser user-data directories. * * @returns {Array<{browser: string, channel: string, dir: string}>} */ export function getChromiumExtensionDirs() { const home = homedir(); const localAppData = process.env.LOCALAPPDATA || join(home, "AppData", "Local"); const xdgConfigHome = process.env.XDG_CONFIG_HOME || join(home, ".config"); const dirs = [ // Google Chrome { browser: "Google Chrome", channel: "stable", dir: isWin ? join(localAppData, "Google", "Chrome", "User Data") : isMac ? join(home, "Library", "Application Support", "Google", "Chrome") : join(xdgConfigHome, "google-chrome"), }, { browser: "Google Chrome", channel: "beta", dir: isWin ? join(localAppData, "Google", "Chrome Beta", "User Data") : isMac ? join( home, "Library", "Application Support", "Google", "Chrome Beta", ) : join(xdgConfigHome, "google-chrome-beta"), }, { browser: "Google Chrome", channel: "dev", dir: isWin ? join(localAppData, "Google", "Chrome Dev", "User Data") : isMac ? join(home, "Library", "Application Support", "Google", "Chrome Dev") : join(xdgConfigHome, "google-chrome-unstable"), }, { browser: "Google Chrome", channel: "canary", dir: isWin ? join(localAppData, "Google", "Chrome SxS", "User Data") : isMac ? join( home, "Library", "Application Support", "Google", "Chrome Canary", ) : "", }, // Chromium { browser: "Chromium", channel: "stable", dir: isWin ? join(localAppData, "Chromium", "User Data") : isMac ? join(home, "Library", "Application Support", "Chromium") : join(xdgConfigHome, "chromium"), }, // Microsoft Edge { browser: "Microsoft Edge", channel: "stable", dir: isWin ? join(localAppData, "Microsoft", "Edge", "User Data") : isMac ? join(home, "Library", "Application Support", "Microsoft Edge") : join(xdgConfigHome, "microsoft-edge"), }, { browser: "Microsoft Edge", channel: "beta", dir: isWin ? join(localAppData, "Microsoft", "Edge Beta", "User Data") : isMac ? join(home, "Library", "Application Support", "Microsoft Edge Beta") : join(xdgConfigHome, "microsoft-edge-beta"), }, { browser: "Microsoft Edge", channel: "dev", dir: isWin ? join(localAppData, "Microsoft", "Edge Dev", "User Data") : isMac ? join(home, "Library", "Application Support", "Microsoft Edge Dev") : join(xdgConfigHome, "microsoft-edge-dev"), }, { browser: "Microsoft Edge", channel: "canary", dir: isWin ? join(localAppData, "Microsoft", "Edge SxS", "User Data") : isMac ? join( home, "Library", "Application Support", "Microsoft Edge Canary", ) : "", }, // Brave { browser: "Brave", channel: "stable", dir: isWin ? join(localAppData, "BraveSoftware", "Brave-Browser", "User Data") : isMac ? join( home, "Library", "Application Support", "BraveSoftware", "Brave-Browser", ) : join(xdgConfigHome, "BraveSoftware", "Brave-Browser"), }, { browser: "Brave", channel: "beta", dir: isWin ? join(localAppData, "BraveSoftware", "Brave-Browser-Beta", "User Data") : isMac ? join( home, "Library", "Application Support", "BraveSoftware", "Brave-Browser-Beta", ) : join(xdgConfigHome, "BraveSoftware", "Brave-Browser-Beta"), }, { browser: "Brave", channel: "dev", dir: isWin ? join(localAppData, "BraveSoftware", "Brave-Browser-Dev", "User Data") : isMac ? join( home, "Library", "Application Support", "BraveSoftware", "Brave-Browser-Dev", ) : join(xdgConfigHome, "BraveSoftware", "Brave-Browser-Dev"), }, { browser: "Brave", channel: "nightly", dir: isWin ? join( localAppData, "BraveSoftware", "Brave-Browser-Nightly", "User Data", ) : isMac ? join( home, "Library", "Application Support", "BraveSoftware", "Brave-Browser-Nightly", ) : join(xdgConfigHome, "BraveSoftware", "Brave-Browser-Nightly"), }, // Vivaldi { browser: "Vivaldi", channel: "stable", dir: isWin ? join(localAppData, "Vivaldi", "User Data") : isMac ? join(home, "Library", "Application Support", "Vivaldi") : join(xdgConfigHome, "vivaldi"), }, { browser: "Vivaldi", channel: "snapshot", dir: isWin ? join(localAppData, "Vivaldi Snapshot", "User Data") : isMac ? join(home, "Library", "Application Support", "Vivaldi Snapshot") : join(xdgConfigHome, "vivaldi-snapshot"), }, ]; return dirs.filter((entry) => entry.dir); } /** * Discover existing Chromium-based browser user-data directories. * * @returns {Array<{browser: string, channel: string, dir: string}>} */ export function discoverChromiumExtensionDirs() { const found = []; const seen = new Set(); for (const browserDir of getChromiumExtensionDirs()) { if (safeExistsSync(browserDir.dir) && !seen.has(browserDir.dir)) { seen.add(browserDir.dir); found.push(browserDir); } } return found; } /** * Compare Chromium extension versions with numeric dot-separated semantics. * * @param {string} leftVersion Left version * @param {string} rightVersion Right version * @returns {number} Negative when left<right, positive when left>right, zero when equal */ export function compareChromiumExtensionVersions(leftVersion, rightVersion) { const leftParts = String(leftVersion || "") .split(".") .map((part) => Number.parseInt(part, 10)); const rightParts = String(rightVersion || "") .split(".") .map((part) => Number.parseInt(part, 10)); const maxLength = Math.max(leftParts.length, rightParts.length); for (let i = 0; i < maxLength; i++) { const leftRawPart = leftParts[i]; const rightRawPart = rightParts[i]; const leftPart = leftRawPart === undefined || Number.isNaN(leftRawPart) ? 0 : leftRawPart; const rightPart = rightRawPart === undefined || Number.isNaN(rightRawPart) ? 0 : rightRawPart; if (leftPart !== rightPart) { return leftPart - rightPart; } } return 0; } /** * Read profile names from Chromium user-data directory. * * @param {string} userDataDir Browser user-data directory * @returns {string[]} Profile directory names */ export function getChromiumProfiles(userDataDir) { const profiles = []; const localStateFile = join(userDataDir, "Local State"); if (safeExistsSync(localStateFile)) { try { const localState = JSON.parse(readFileSync(localStateFile, "utf-8")); const infoCache = localState?.profile?.info_cache; if (infoCache && typeof infoCache === "object") { for (const profileName of Object.keys(infoCache)) { if (safeExistsSync(join(userDataDir, profileName, "Extensions"))) { profiles.push(profileName); } } } const lastUsed = localState?.profile?.last_used; if ( lastUsed && safeExistsSync(join(userDataDir, lastUsed, "Extensions")) && !profiles.includes(lastUsed) ) { profiles.push(lastUsed); } } catch (_err) { // Ignore malformed Local State and fallback to directory scan } } if (profiles.length) { return profiles; } try { const profileDirs = readdirSync(userDataDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .filter((name) => name === "Default" || /^Profile \d+$/.test(name)) .filter((name) => safeExistsSync(join(userDataDir, name, "Extensions"))); if (profileDirs.length) { return profileDirs; } } catch (_err) { // Ignore directory scan errors } return safeExistsSync(join(userDataDir, "Default", "Extensions")) ? ["Default"] : []; } /** * Parse a Chromium extension manifest file. * * @param {string} manifestFile Absolute path to manifest.json * @returns {Object|undefined} Parsed manifest metadata */ export function parseChromiumExtensionManifest(manifestFile) { if (!safeExistsSync(manifestFile)) { return undefined; } try { const manifest = JSON.parse(readFileSync(manifestFile, "utf-8")); const permissions = Array.isArray(manifest.permissions) ? manifest.permissions.filter((value) => typeof value === "string") : []; const optionalPermissions = Array.isArray(manifest.optional_permissions) ? manifest.optional_permissions.filter( (value) => typeof value === "string", ) : []; const declaredHostPermissions = Array.isArray(manifest.host_permissions) ? manifest.host_permissions.filter((value) => typeof value === "string") : []; const optionalHostPermissions = Array.isArray( manifest.optional_host_permissions, ) ? manifest.optional_host_permissions.filter( (value) => typeof value === "string", ) : []; const commands = manifest.commands && typeof manifest.commands === "object" ? Object.keys(manifest.commands).filter(Boolean) : []; const contentScriptsRunAt = Array.isArray(manifest.content_scripts) ? [ ...new Set( manifest.content_scripts .map((script) => script?.run_at) .filter((value) => typeof value === "string"), ), ] : []; const contentScriptsMatches = Array.isArray(manifest.content_scripts) ? [ ...new Set( manifest.content_scripts .flatMap((script) => Array.isArray(script?.matches) ? script.matches : [], ) .filter((value) => typeof value === "string"), ), ] : []; const hostPermissions = [ ...new Set([...declaredHostPermissions, ...contentScriptsMatches]), ]; const hasAutofillInContentScripts = Array.isArray(manifest.content_scripts) ? manifest.content_scripts.some((script) => { if (!script || typeof script !== "object") { return false; } const jsEntries = Array.isArray(script.js) ? script.js : []; const cssEntries = Array.isArray(script.css) ? script.css : []; const hasAutofillInJs = jsEntries.some( (entry) => typeof entry === "string" && entry.toLowerCase().includes("autofill"), ); const hasAutofillInCss = cssEntries.some( (entry) => typeof entry === "string" && entry.toLowerCase().includes("autofill"), ); return hasAutofillInJs || hasAutofillInCss; }) : false; const hasAutofill = permissions.some((permission) => permission.toLowerCase().includes("autofill"), ) || optionalPermissions.some((permission) => permission.toLowerCase().includes("autofill"), ) || hasAutofillInContentScripts || commands.some((commandName) => commandName.toLowerCase().includes("autofill"), ); let contentSecurityPolicy = ""; if (typeof manifest.content_security_policy === "string") { contentSecurityPolicy = manifest.content_security_policy; } else if ( manifest.content_security_policy && typeof manifest.content_security_policy === "object" ) { contentSecurityPolicy = JSON.stringify(manifest.content_security_policy); } const webAccessibleResourceMatches = Array.isArray( manifest.web_accessible_resources, ) ? [ ...new Set( manifest.web_accessible_resources .flatMap((entry) => { if (typeof entry === "string") { return []; } const matches = Array.isArray(entry?.matches) ? entry.matches : []; return matches.filter((value) => typeof value === "string"); }) .filter(Boolean), ), ] : []; const externallyConnectableMatches = Array.isArray( manifest.externally_connectable?.matches, ) ? manifest.externally_connectable.matches.filter( (value) => typeof value === "string", ) : []; const capabilities = inferChromiumCapabilitySignals({ permissions, optionalPermissions, hostPermissions, optionalHostPermissions, commands, contentScripts: manifest.content_scripts, fileBrowserHandlers: manifest.file_browser_handlers, webAccessibleResources: manifest.web_accessible_resources, }); return { name: manifest.name || "", description: manifest.description || "", version: manifest.version || "", versionName: manifest.version_name || "", manifestVersion: manifest.manifest_version, updateUrl: manifest.update_url || "", minimumChromeVersion: manifest.minimum_chrome_version || "", minimumEdgeVersion: manifest.minimum_edge_version || "", incognito: manifest.incognito || "", offlineEnabled: typeof manifest.offline_enabled === "boolean" ? manifest.offline_enabled : undefined, permissions, optionalPermissions, hostPermissions, optionalHostPermissions, commands, contentScriptsRunAt, contentScriptsMatches, contentSecurityPolicy, storageManagedSchema: manifest?.storage?.managed_schema || "", webAccessibleResourceMatches, externallyConnectableMatches, edgeUrlOverrides: manifest.edge_url_overrides || undefined, braveMaybeBackground: manifest.MAYBE_background && typeof manifest.MAYBE_background === "object", bravePermissions: permissions.filter((permission) => BRAVE_SPECIFIC_PERMISSIONS.includes(permission), ), capabilities, hasAutofill, }; } catch (_err) { return undefined; } } /** * Infer browser context from a resolved Chromium extension manifest path. * * @param {string} manifestFile Absolute path to manifest.json * @returns {{browser?: string, channel?: string, profile?: string, profilePath?: string}} */ export function inferChromiumContextFromManifest(manifestFile) { const resolvedManifest = resolve(manifestFile); for (const browserDir of getChromiumExtensionDirs()) { const resolvedBrowserDir = resolve(browserDir.dir); const browserRootPrefix = `${resolvedBrowserDir}${sep}`; if (!resolvedManifest.startsWith(browserRootPrefix)) { continue; } const rel = relative(resolvedBrowserDir, resolvedManifest); const relParts = rel.split(sep); if ( relParts.length >= 5 && relParts[0] && relParts[1] === "Extensions" && CHROME_EXTENSION_ID_REGEX.test(relParts[2]) && relParts[4] === "manifest.json" ) { return { browser: browserDir.browser, channel: browserDir.channel, profile: relParts[0], profilePath: join(resolvedBrowserDir, relParts[0]), }; } } return {}; } /** * Pick the latest installed version directory for an extension-id directory. * * @param {string} extensionIdDir Path to `<...>/Extensions/<extension-id>` * @returns {string|undefined} Absolute path to the latest version directory */ function getLatestExtensionVersionDir(extensionIdDir) { if (!safeExistsSync(extensionIdDir)) { return undefined; } let versionDirs; try { versionDirs = readdirSync(extensionIdDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name); } catch (_err) { return undefined; } if (!versionDirs.length) { return undefined; } versionDirs.sort(compareChromiumExtensionVersions); return join(extensionIdDir, versionDirs[versionDirs.length - 1]); } /** * Convert a manifest file path into a CycloneDX component and extension dir. * * @param {string} manifestFile Absolute path to manifest.json * @returns {{component?: Object, extensionDir?: string}} */ function parseChromeExtensionFromManifestPath(manifestFile) { if (!safeExistsSync(manifestFile)) { return {}; } const extensionDir = dirname(manifestFile); const extensionId = basename(dirname(extensionDir)).toLowerCase(); if (!CHROME_EXTENSION_ID_REGEX.test(extensionId)) { return {}; } const versionFromPath = basename(extensionDir); const manifest = parseChromiumExtensionManifest(manifestFile); const codeCapabilities = detectCachedExtensionCapabilities(extensionDir); const context = inferChromiumContextFromManifest(manifestFile); return { component: toComponent({ extensionId, version: manifest?.version || versionFromPath, displayName: manifest?.name || "", description: manifest?.description || "", manifestVersion: manifest?.manifestVersion, updateUrl: manifest?.updateUrl || "", permissions: manifest?.permissions || [], optionalPermissions: manifest?.optionalPermissions || [], hostPermissions: manifest?.hostPermissions || [], optionalHostPermissions: manifest?.optionalHostPermissions || [], commands: manifest?.commands || [], contentScriptsRunAt: manifest?.contentScriptsRunAt || [], contentScriptsMatches: manifest?.contentScriptsMatches || [], contentSecurityPolicy: manifest?.contentSecurityPolicy || "", storageManagedSchema: manifest?.storageManagedSchema || "", minimumChromeVersion: manifest?.minimumChromeVersion || "", minimumEdgeVersion: manifest?.minimumEdgeVersion || "", versionName: manifest?.versionName || "", incognito: manifest?.incognito || "", offlineEnabled: manifest?.offlineEnabled, webAccessibleResourceMatches: manifest?.webAccessibleResourceMatches || [], externallyConnectableMatches: manifest?.externallyConnectableMatches || [], edgeUrlOverrides: manifest?.edgeUrlOverrides, braveMaybeBackground: manifest?.braveMaybeBackground || false, bravePermissions: manifest?.bravePermissions || [], capabilities: mergeCapabilitySignals( manifest?.capabilities || {}, codeCapabilities, ), hasAutofill: manifest?.hasAutofill || false, srcPath: manifestFile, ...context, }), extensionDir, }; } /** * Collect one directly specified extension from a path. * * Supported path forms: * - `<...>/manifest.json` * - `<...>/<extension-id>/<version>/manifest.json` * - `<...>/<version>/` (contains manifest.json) * - `<...>/<extension-id>/` (contains version subdirectories) * * Note: a standalone `<...>/<version>/` directory is not sufficient unless its * parent directory name is the extension id, because the parser derives the * extension id from the version directory's parent path. * * @param {string} extensionPath Candidate extension path * @returns {{components: Object[], extensionDirs: string[]}} */ export function collectChromeExtensionsFromPath(extensionPath) { if (!extensionPath || !safeExistsSync(extensionPath)) { return { components: [], extensionDirs: [] }; } const resolvedPath = resolve(extensionPath); const manifestCandidates = []; const extensionDirs = []; const seenManifestFiles = new Set(); const name = basename(resolvedPath); if (name === "manifest.json") { manifestCandidates.push(resolvedPath); } else if (safeExistsSync(join(resolvedPath, "manifest.json"))) { manifestCandidates.push(join(resolvedPath, "manifest.json")); } else if (CHROME_EXTENSION_ID_REGEX.test(name)) { const latestVersionDir = getLatestExtensionVersionDir(resolvedPath); if (latestVersionDir) { manifestCandidates.push(join(latestVersionDir, "manifest.json")); } } const components = []; const seenBomRefs = new Set(); for (const manifestFile of manifestCandidates) { if (seenManifestFiles.has(manifestFile)) { continue; } seenManifestFiles.add(manifestFile); const { component, extensionDir } = parseChromeExtensionFromManifestPath(manifestFile); if (extensionDir && !extensionDirs.includes(extensionDir)) { extensionDirs.push(extensionDir); } if (component?.["bom-ref"] && !seenBomRefs.has(component["bom-ref"])) { seenBomRefs.add(component["bom-ref"]); components.push(component); } } return { components, extensionDirs }; } /** * Convert parsed Chromium extension metadata into a CycloneDX component object. * * @param {Object} extInfo Extension metadata * @returns {Object|undefined} CycloneDX component object or undefined */ export function toComponent(extInfo) { if (!extInfo?.extensionId) { return undefined; } const extensionId = extInfo.extensionId.toLowerCase(); const purl = new PackageURL( CHROME_EXTENSION_PURL_TYPE, null, extensionId, extInfo.version || null, null, null, ).toString(); const component = { name: extensionId, version: extInfo.version || "", description: String( sanitizeBomPropertyValue( "cdx:chrome-extension:description", extInfo.displayName || extInfo.description || "", ) || "", ), purl, "bom-ref": decodeURIComponent(purl), type: "application", }; const properties = []; if (extInfo.browser) { properties.push({ name: "cdx:chrome-extension:browser", value: extInfo.browser, }); } if (extInfo.channel) { properties.push({ name: "cdx:chrome-extension:channel", value: extInfo.channel, }); } if (extInfo.profile) { properties.push({ name: "cdx:chrome-extension:profile", value: extInfo.profile, }); } if (extInfo.profilePath) { properties.push({ name: "cdx:chrome-extension:profilePath", value: extInfo.profilePath, }); } if (extInfo.manifestVersion !== undefined) { properties.push({ name: "cdx:chrome-extension:manifestVersion", value: String(extInfo.manifestVersion), }); } if (extInfo.updateUrl) { properties.push({ name: "cdx:chrome-extension:updateUrl", value: extInfo.updateUrl, }); } if (extInfo.permissions?.length) { properties.push({ name: "cdx:chrome-extension:permissions", value: extInfo.permissions.join(", "), }); } if (extInfo.optionalPermissions?.length) { properties.push({ name: "cdx:chrome-extension:optionalPermissions", value: extInfo.optionalPermissions.join(", "), }); } if (extInfo.hostPermissions?.length) { properties.push({ name: "cdx:chrome-extension:hostPermissions", value: extInfo.hostPermissions.join(", "), }); } if (extInfo.optionalHostPermissions?.length) { properties.push({ name: "cdx:chrome-extension:optionalHostPermissions", value: extInfo.optionalHostPermissions.join(", "), }); } if (extInfo.commands?.length) { properties.push({ name: "cdx:chrome-extension:commands", value: extInfo.commands.join(", "), }); } if (extInfo.contentScriptsRunAt?.length) { properties.push({ name: "cdx:chrome-extension:contentScriptsRunAt", value: extInfo.contentScriptsRunAt.join(", "), }); } if (extInfo.contentScriptsMatches?.length) { properties.push({ name: "cdx:chrome-extension:contentScriptsMatches", value: extInfo.contentScriptsMatches.join(", "), }); } if (extInfo.contentSecurityPolicy) { properties.push({ name: "cdx:chrome-extension:contentSecurityPolicy", value: extInfo.contentSecurityPolicy, }); } if (extInfo.storageManagedSchema) { properties.push({ name: "cdx:chrome-extension:storageManagedSchema", value: extInfo.storageManagedSchema, }); } if (extInfo.minimumChromeVersion) { properties.push({ name: "cdx:chrome-extension:minimumChromeVersion", value: extInfo.minimumChromeVersion, }); } if (extInfo.versionName) { properties.push({ name: "cdx:chrome-extension:versionName", value: extInfo.versionName, }); } if (extInfo.incognito) { properties.push({ name: "cdx:chrome-extension:incognito", value: extInfo.incognito, }); } if (typeof extInfo.offlineEnabled === "boolean") { properties.push({ name: "cdx:chrome-extension:offlineEnabled", value: String(extInfo.offlineEnabled), }); } if (extInfo.webAccessibleResourceMatches?.length) { properties.push({ name: "cdx:chrome-extension:webAccessibleResourceMatches", value: extInfo.webAccessibleResourceMatches.join(", "), }); } if (extInfo.externallyConnectableMatches?.length) { properties.push({ name: "cdx:chrome-extension:externallyConnectableMatches", value: extInfo.externallyConnectableMatches.join(", "), }); } if (extInfo.minimumEdgeVersion) { properties.push({ name: "cdx:chrome-extension:edge:minimumVersion", value: extInfo.minimumEdgeVersion, }); } if (extInfo.edgeUrlOverrides) { properties.push({ name: "cdx:chrome-extension:edge:urlOverrides", value: typeof extInfo.edgeUrlOverrides === "string" ? extInfo.edgeUrlOverrides : JSON.stringify(extInfo.edgeUrlOverrides), }); } if (extInfo.braveMaybeBackground) { properties.push({ name: "cdx:chrome-extension:brave:maybeBackground", value: "true", }); } if (extInfo.bravePermissions?.length) { properties.push({ name: "cdx:chrome-extension:brave:permissions", value: extInfo.bravePermissions.join(", "), }); } if (extInfo.capabilities) { const capabilityNames = CHROMIUM_EXTENSION_CAPABILITY_CATEGORIES.filter( (capabilityName) => extInfo.capabilities?.[capabilityName], ); if (capabilityNames.length) { properties.push({ name: "cdx:chrome-extension:capabilities", value: capabilityNames.join(", "), }); for (const capabilityName of capabilityNames) { properties.push({ name: `cdx:chrome-extension:capability:${capabilityName}`, value: "true", }); } } } if (extInfo.hasAutofill) { properties.push({ name: "cdx:chrome-extension:hasAutofill", value: "true", }); } if (extInfo.srcPath) { properties.push({ name: "SrcFile", value: extInfo.srcPath }); } const sanitizedProperties = properties .map((property) => { const sanitizedValue = sanitizeBomPropertyValue( property.name, property.value, ); if ( sanitizedValue === undefined || sanitizedValue === null || sanitizedValue === "" ) { return undefined; } return { name: property.name, value: String(sanitizedValue) }; }) .filter(Boolean); if (sanitizedProperties.length) { component.properties = sanitizedProperties; } return component; } /** * Collect installed Chromium extension components from discovered browser directories. * * @param {Array<{browser: string, channel: string, dir: string}>} browserDirs Browser directories * @returns {Object[]} Array of CycloneDX component objects */ export function collectInstalledChromeExtensions(browserDirs) { const installMap = new Map(); for (const browserDir of browserDirs) { const profiles = getChromiumProfiles(browserDir.dir); for (const profileName of profiles) { const profilePath = join(browserDir.dir, profileName); const extensionsDir = join(profilePath, "Extensions"); if (!safeExistsSync(extensionsDir)) { continue; } let extensionEntries; try { extensionEntries = readdirSync(extensionsDir, { withFileTypes: true }); } catch (_err) { continue; } for (const extensionEntry of extensionEntries) { if (!extensionEntry.isDirectory()) { continue; } const extensionId = extensionEntry.name.toLowerCase(); if (!CHROME_EXTENSION_ID_REGEX.test(extensionId)) { continue; } const versionRoot = join(extensionsDir, extensionEntry.name); let versionEntries; try { versionEntries = readdirSync(versionRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name); } catch (_err) { continue; } if (!versionEntries.length) { continue; } versionEntries.sort(compareChromiumExtensionVersions); const version = versionEntries[versionEntries.length - 1]; const manifestPath = join(versionRoot, version, "manifest.json"); const manifest = parseChromiumExtensionManifest(manifestPath); const extensionDir = join(versionRoot, version); const codeCapabilities = detectCachedExtensionCapabilities(extensionDir); const extInfo = { extensionId, version: manifest?.version || version, displayName: manifest?.name || "", description: manifest?.description || "", manifestVersion: manifest?.manifestVersion, updateUrl: manifest?.updateUrl || "", permissions: manifest?.permissions || [], optionalPermissions: manifest?.optionalPermissions || [], hostPermissions: manifest?.hostPermissions || [], optionalHostPermissions: manifest?.optionalHostPermissions || [], commands: manifest?.commands || [], contentScriptsRunAt: manifest?.contentScriptsRunAt || [], contentSecurityPolicy: manifest?.contentSecurityPolicy || "", storageManagedSchema: manifest?.storageManagedSchema || "", minimumChromeVersion: manifest?.minimumChromeVersion || "", minimumEdgeVersion: manifest?.minimumEdgeVersion || "", versionName: manifest?.versionName || "", incognito: manifest?.incognito || "", offlineEnabled: manifest?.offlineEnabled, webAccessibleResourceMatches: manifest?.webAccessibleResourceMatches || [], externallyConnectableMatches: manifest?.externallyConnectableMatches || [], edgeUrlOverrides: manifest?.edgeUrlOverrides, braveMaybeBackground: manifest?.braveMaybeBackground || false, bravePermissions: manifest?.bravePermissions || [], capabilities: mergeCapabilitySignals( manifest?.capabilities || {}, codeCapabilities, ), hasAutofill: manifest?.hasAutofill || false, browser: browserDir.browser, channel: browserDir.channel, profile: profileName, profilePath, srcPath: manifestPath, }; const key = `${browserDir.browser}|${browserDir.channel}|${profileName}|${extensionId}`; const existing = installMap.get(key); if ( !existing || compareChromiumExtensionVersions(existing.version, extInfo.version) < 0 ) { installMap.set(key, extInfo); } } } } const components = []; const seen = new Set(); for (const extInfo of installMap.values()) { const component = toComponent(extInfo); if (component && !seen.has(component["bom-ref"])) { seen.add(component["bom-ref"]); components.push(component); } } return components; }