UNPKG

@cyclonedx/cdxgen

Version:

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

1,805 lines (1,754 loc) 136 kB
import { lstatSync, readdirSync, readFileSync } from "node:fs"; import { basename, isAbsolute, join, matchesGlob, relative, resolve, } from "node:path"; import process from "node:process"; import { URL } from "node:url"; import { parse } from "@babel/parser"; import traverse from "@babel/traverse"; import { getScopedStaticValueByName, getStaticObjectProperty, resolveStaticValue, } from "./analyzerScope.js"; import { classifyMcpReference } from "./mcp.js"; import { isLocalHost, sanitizeMcpRefToken } from "./mcpDiscovery.js"; import { sanitizeBomPropertyValue, sanitizeBomUrl, } from "./propertySanitizer.js"; const IGNORE_DIRS = process.env.ASTGEN_IGNORE_DIRS ? process.env.ASTGEN_IGNORE_DIRS.split(",") : [ "venv", "docs", "test", "tests", "e2e", "examples", "cypress", "site-packages", "typings", "api_docs", "dev_docs", "types", "mock", "mocks", "jest-cache", "eslint-rules", "codemods", "flow-typed", "i18n", "coverage", ]; const IGNORE_FILE_PATTERN = new RegExp( process.env.ASTGEN_IGNORE_FILE_PATTERN || "(conf|config|test|spec|mock|setup-jest|\\.d)\\.(js|ts|tsx)$", "i", ); const normalizeAnalyzerPathForGlob = (filePath) => String(filePath || "").replaceAll("\\", "/"); const normalizeAnalyzerSearchOptions = (deepOrOptions = false) => { if (deepOrOptions && typeof deepOrOptions === "object") { return { deep: Boolean(deepOrOptions.deep), exclude: Array.isArray(deepOrOptions.exclude) ? deepOrOptions.exclude : [], }; } return { deep: Boolean(deepOrOptions), exclude: [], }; }; const shouldExcludeAnalyzerPath = ( rootDir, filePath, excludePatterns, isDirectory = false, ) => { if (!excludePatterns?.length) { return false; } const normalizedAbsolutePath = normalizeAnalyzerPathForGlob( resolve(filePath), ); const normalizedRelativePath = normalizeAnalyzerPathForGlob( relative(rootDir, filePath), ); const candidatePaths = [ normalizedAbsolutePath, normalizedRelativePath, normalizedRelativePath ? `./${normalizedRelativePath}` : "", ].filter(Boolean); if (isDirectory) { candidatePaths.push( `${normalizedAbsolutePath}/`, normalizedRelativePath ? `${normalizedRelativePath}/` : "", normalizedRelativePath ? `./${normalizedRelativePath}/` : "", ); } return excludePatterns.some((pattern) => { const normalizedPattern = normalizeAnalyzerPathForGlob(pattern); return candidatePaths.some((candidatePath) => matchesGlob(candidatePath, normalizedPattern), ); }); }; const getAllFiles = ( deep, dir, extn, files, result, regex, rootDir, excludePatterns, ) => { files = files || readdirSync(dir); result = result || []; regex = regex || new RegExp(`\\${extn}$`); rootDir = rootDir || dir; excludePatterns = excludePatterns || []; for (let i = 0; i < files.length; i++) { if (IGNORE_FILE_PATTERN.test(files[i]) || files[i].startsWith(".")) { continue; } const file = join(dir, files[i]); const fileStat = lstatSync(file); if (fileStat.isSymbolicLink()) { continue; } if ( shouldExcludeAnalyzerPath( rootDir, file, excludePatterns, fileStat.isDirectory(), ) ) { continue; } if (fileStat.isDirectory()) { // Ignore directories const dirName = basename(file); if ( dirName.startsWith(".") || dirName.startsWith("__") || IGNORE_DIRS.includes(dirName.toLowerCase()) ) { continue; } // We need to include node_modules in deep mode to track exports // Ignore only for non-deep analysis if (!deep && dirName === "node_modules") { continue; } try { result = getAllFiles( deep, file, extn, readdirSync(file), result, regex, rootDir, excludePatterns, ); } catch (_error) { // ignore } } else { if (regex.test(file)) { result.push(file); } } } return result; }; const babelParserOptions = { sourceType: "unambiguous", allowImportExportEverywhere: true, allowAwaitOutsideFunction: true, allowNewTargetOutsideFunction: true, allowReturnOutsideFunction: true, allowSuperOutsideMethod: true, errorRecovery: true, allowUndeclaredExports: true, createImportExpressions: true, tokens: true, attachComment: false, plugins: [ "optionalChaining", "classProperties", "decorators-legacy", "exportDefaultFrom", "doExpressions", "numericSeparator", "dynamicImport", "jsx", "typescript", ], }; /** * Filter only references to (t|jsx?) or (less|scss) files for now. * Opt to use our relative paths. */ const setFileRef = ( allImports, allExports, src, file, pathnode, specifiers = [], ) => { const pathway = pathnode.value || pathnode.name; const sourceLoc = pathnode.loc?.start; if (!pathway) { return; } const fileRelativeLoc = relative(src, file); // remove unexpected extension imports if (/\.(svg|png|jpg|json|d\.ts)/.test(pathway)) { return; } const importedModules = specifiers .map((s) => s.imported?.name) .filter((v) => v !== undefined); const exportedModules = specifiers .map((s) => s.exported?.name) .filter((v) => v !== undefined); const occurrence = { importedAs: pathway, importedModules, exportedModules, isExternal: true, fileName: fileRelativeLoc, lineNumber: sourceLoc?.line ?? undefined, columnNumber: sourceLoc?.column ?? undefined, }; // replace relative imports with full path let moduleFullPath = pathway; let wasAbsolute = false; if (/\.\//g.test(pathway) || /\.\.\//g.test(pathway)) { moduleFullPath = resolve(file, "..", pathway); if (isAbsolute(moduleFullPath)) { moduleFullPath = relative(src, moduleFullPath); wasAbsolute = true; } if (!moduleFullPath.startsWith("node_modules/")) { occurrence.isExternal = false; } } allImports[moduleFullPath] = allImports[moduleFullPath] || new Set(); allImports[moduleFullPath].add(occurrence); // Handle module package name // Eg: zone.js/dist/zone will be referred to as zone.js in package.json if (!wasAbsolute && moduleFullPath.includes("/")) { const modPkg = moduleFullPath.split("/")[0]; allImports[modPkg] = allImports[modPkg] || new Set(); allImports[modPkg].add(occurrence); } if (exportedModules?.length) { moduleFullPath = moduleFullPath .replace("node_modules/", "") .replace("dist/", "") .replace(/\.(js|ts|cjs|mjs)$/g, "") .replace("src/", ""); allExports[moduleFullPath] = allExports[moduleFullPath] || new Set(); occurrence.exportedModules = exportedModules; allExports[moduleFullPath].add(occurrence); } }; const vueCleaningRegex = /<\/*script.*>|<style[\s\S]*style>|<\/*br>/gi; const vueTemplateRegex = /(<template.*>)([\s\S]*)(<\/template>)/gi; const vueCommentRegex = /<!--[\s\S]*?-->/gi; const vueBindRegex = /(:\[)([\s\S]*?)(])/gi; const vuePropRegex = /\s([.:@])([a-zA-Z]*?=)/gi; const fileToParseableCode = (file) => { let code = readFileSync(file, "utf-8"); if (file.endsWith(".vue") || file.endsWith(".svelte")) { code = code .replace(vueCommentRegex, (match) => match.replaceAll(/\S/g, " ")) .replace( vueCleaningRegex, (match) => `${match.replaceAll(/\S/g, " ").substring(1)};`, ) .replace( vueBindRegex, (_match, grA, grB, grC) => grA.replaceAll(/\S/g, " ") + grB + grC.replaceAll(/\S/g, " "), ) .replace( vuePropRegex, (_match, grA, grB) => ` ${grA.replace(/[.:@]/g, " ")}${grB}`, ) .replace( vueTemplateRegex, (_match, grA, grB, grC) => grA + grB.replaceAll("{{", "{ ").replaceAll("}}", " }") + grC, ); } return code; }; const isWasmPath = (modulePath) => typeof modulePath === "string" && /\.wasm([?#].*)?$/i.test(modulePath); const getStringValue = (astNode) => { if (!astNode) { return undefined; } if (astNode.type === "StringLiteral") { return astNode.value; } if ( astNode.type === "TemplateLiteral" && astNode.expressions.length === 0 && astNode.quasis.length === 1 ) { return astNode.quasis[0].value.cooked; } return undefined; }; const unwrapAwait = (astNode) => astNode?.type === "AwaitExpression" ? astNode.argument : astNode; const isImportMetaUrl = (astNode) => astNode?.type === "MemberExpression" && astNode.object?.type === "MetaProperty" && astNode.object.meta?.name === "import" && astNode.object.property?.name === "meta" && astNode.property?.type === "Identifier" && astNode.property.name === "url"; const getMemberExpressionPropertyName = (propertyNode) => { if (!propertyNode) { return undefined; } if (propertyNode.type === "Identifier") { return propertyNode.name; } if (propertyNode.type === "StringLiteral") { return propertyNode.value; } return undefined; }; const resolveWasmLiteralFromNode = (astNode, wasmBufferByVarName) => { const normalizedNode = unwrapAwait(astNode); const directLiteral = getStringValue(normalizedNode); if (isWasmPath(directLiteral)) { return directLiteral; } if (normalizedNode?.type === "Identifier") { return wasmBufferByVarName.get(normalizedNode.name); } if (normalizedNode?.type === "CallExpression") { if ( normalizedNode.callee?.type === "Identifier" && normalizedNode.callee.name === "fetch" && normalizedNode.arguments?.length ) { return resolveWasmLiteralFromNode( normalizedNode.arguments[0], wasmBufferByVarName, ); } } if (normalizedNode?.type === "NewExpression") { if ( normalizedNode.callee?.type === "Identifier" && normalizedNode.callee.name === "URL" && normalizedNode.arguments?.length ) { const urlLiteral = getStringValue(normalizedNode.arguments[0]); const baseArg = normalizedNode.arguments[1]; if (isWasmPath(urlLiteral) && (!baseArg || isImportMetaUrl(baseArg))) { return urlLiteral; } } } return undefined; }; const getWasmSourceFromInstantiateCall = (callNode, wasmBufferByVarName) => { if (!callNode?.callee || callNode.callee.type !== "MemberExpression") { return undefined; } const objectNode = callNode.callee.object; const propertyNode = callNode.callee.property; const calleeObjectName = getMemberExpressionPropertyName(objectNode); const calleePropertyName = getMemberExpressionPropertyName(propertyNode); if (calleeObjectName !== "WebAssembly") { return undefined; } if ( calleePropertyName !== "instantiate" && calleePropertyName !== "instantiateStreaming" && calleePropertyName !== "compile" && calleePropertyName !== "compileStreaming" ) { return undefined; } if (!callNode.arguments?.length) { return undefined; } return resolveWasmLiteralFromNode(callNode.arguments[0], wasmBufferByVarName); }; const getWasmSourceFromCallExpression = (callNode, wasmBufferByVarName) => { const wasmSourceFromInstantiate = getWasmSourceFromInstantiateCall( callNode, wasmBufferByVarName, ); if (wasmSourceFromInstantiate) { return wasmSourceFromInstantiate; } if ( callNode?.callee?.type === "Identifier" && ["fetch", "locateFile"].includes(callNode.callee.name) && callNode.arguments?.length ) { return resolveWasmLiteralFromNode( callNode.arguments[0], wasmBufferByVarName, ); } return undefined; }; const getNamedImportsFromObjectPattern = (idNode) => { const namedImports = []; if (!idNode || idNode.type !== "ObjectPattern") { return namedImports; } for (const prop of idNode.properties || []) { if (prop.type !== "ObjectProperty") { continue; } const keyName = getMemberExpressionPropertyName(prop.key); if (keyName) { namedImports.push(keyName); } } return namedImports; }; const setSyntheticImportRef = ( allImports, allExports, src, file, importPath, modules, sourceLoc, ) => { if (!importPath) { return; } const safeModules = modules || []; const syntheticSpecifiers = safeModules.map((moduleName) => ({ imported: { name: moduleName }, })); setFileRef( allImports, allExports, src, file, { value: importPath, loc: sourceLoc ? { start: sourceLoc } : undefined }, syntheticSpecifiers, ); }; const setSyntheticExportRef = ( allImports, allExports, src, file, importPath, modules, sourceLoc, ) => { if (!importPath) { return; } const safeModules = modules || []; const syntheticSpecifiers = safeModules.map((moduleName) => ({ exported: { name: moduleName }, })); setFileRef( allImports, allExports, src, file, { value: importPath, loc: sourceLoc ? { start: sourceLoc } : undefined }, syntheticSpecifiers, ); }; const getWasmExportMemberInfo = (astNode) => { if (!astNode) { return undefined; } if (astNode.type === "AssignmentExpression") { return getWasmExportMemberInfo(astNode.right); } if ( astNode.type !== "MemberExpression" || astNode.object?.type !== "Identifier" ) { return undefined; } return { aliasName: astNode.object.name, exportName: getMemberExpressionPropertyName(astNode.property), }; }; const getAssignmentTargetName = (astNode) => { if (!astNode) { return undefined; } if (astNode.type === "Identifier") { return astNode.name; } if ( astNode.type === "MemberExpression" && astNode.object?.type === "Identifier" && astNode.object.name === "Module" ) { return getMemberExpressionPropertyName(astNode.property); } return undefined; }; /** * Check AST tree for any (j|tsx?) files and set a file * references for any import, require or dynamic import files. */ const parseFileASTTree = (src, file, allImports, allExports) => { const ast = parse(fileToParseableCode(file), babelParserOptions); const wasmBufferByVarName = new Map(); const wasmResultByVarName = new Map(); const wasmInstanceByVarName = new Map(); const wasiConstructorAliases = new Set(["WASI"]); const wasiNamespaceAliases = new Set(); const wasiInstanceAliases = new Set(); const wasmPathLiterals = new Set(); const wasmExportAliases = new Set(["wasmExports"]); traverse.default(ast, { ImportDeclaration: (path) => { if (path?.node) { setFileRef( allImports, allExports, src, file, path.node.source, path.node.specifiers, ); const sourceValue = path.node.source?.value; if (sourceValue === "node:wasi" || sourceValue === "wasi") { for (const specifier of path.node.specifiers || []) { if ( specifier.type === "ImportSpecifier" && specifier.imported?.name === "WASI" ) { wasiConstructorAliases.add(specifier.local?.name || "WASI"); } if (specifier.type === "ImportNamespaceSpecifier") { wasiNamespaceAliases.add(specifier.local?.name); } } } } }, // For require('') statements Identifier: (path) => { if ( path?.node && path.node.name === "require" && path.parent.type === "CallExpression" ) { setFileRef(allImports, allExports, src, file, path.parent.arguments[0]); } }, // Use for dynamic imports like routes.jsx CallExpression: (path) => { if (path?.node && path.node.callee.type === "Import") { setFileRef(allImports, allExports, src, file, path.node.arguments[0]); } const wasmSourceLiteral = getWasmSourceFromCallExpression( path?.node, wasmBufferByVarName, ); if (wasmSourceLiteral) { wasmPathLiterals.add(wasmSourceLiteral); setSyntheticImportRef( allImports, allExports, src, file, wasmSourceLiteral, [], path.node.loc?.start, ); } if ( path?.node?.callee?.type === "MemberExpression" && path.node.callee.object?.type === "Identifier" && wasiInstanceAliases.has(path.node.callee.object.name) ) { const methodName = getMemberExpressionPropertyName( path.node.callee.property, ); if (methodName === "start" || methodName === "initialize") { setSyntheticImportRef( allImports, allExports, src, file, "node:wasi", [methodName], path.node.loc?.start, ); } } }, ImportExpression: (path) => { if (path?.node?.source) { setFileRef(allImports, allExports, src, file, path.node.source); } }, VariableDeclarator: (path) => { const idNode = path?.node?.id; const initNode = unwrapAwait(path?.node?.init); if (!idNode || !initNode) { return; } if ( idNode.type === "Identifier" && initNode.type === "CallExpression" && initNode.callee?.type === "MemberExpression" ) { const calleePropertyName = getMemberExpressionPropertyName( initNode.callee.property, ); if ( calleePropertyName === "readFile" || calleePropertyName === "readFileSync" ) { const pathArg = initNode.arguments?.[0]; const wasmPath = getStringValue(pathArg); if (isWasmPath(wasmPath)) { wasmBufferByVarName.set(idNode.name, wasmPath); wasmPathLiterals.add(wasmPath); setSyntheticImportRef( allImports, allExports, src, file, wasmPath, [], path.node.loc?.start, ); } } const wasmSource = getWasmSourceFromInstantiateCall( initNode, wasmBufferByVarName, ); if (wasmSource) { wasmResultByVarName.set(idNode.name, wasmSource); wasmPathLiterals.add(wasmSource); setSyntheticImportRef( allImports, allExports, src, file, wasmSource, [], path.node.loc?.start, ); } if ( initNode.callee?.type === "MemberExpression" && initNode.callee.object?.type === "Identifier" && wasiNamespaceAliases.has(initNode.callee.object.name) && getMemberExpressionPropertyName(initNode.callee.property) === "WASI" ) { wasiInstanceAliases.add(idNode.name); setSyntheticImportRef( allImports, allExports, src, file, "node:wasi", ["WASI"], path.node.loc?.start, ); } } if ( idNode.type === "Identifier" && initNode.type === "CallExpression" && initNode.callee?.type === "Identifier" && wasiConstructorAliases.has(initNode.callee.name) ) { wasiInstanceAliases.add(idNode.name); setSyntheticImportRef( allImports, allExports, src, file, "node:wasi", ["WASI"], path.node.loc?.start, ); } if (idNode.type === "Identifier" && initNode.type === "NewExpression") { if ( initNode.callee?.type === "Identifier" && wasiConstructorAliases.has(initNode.callee.name) ) { wasiInstanceAliases.add(idNode.name); setSyntheticImportRef( allImports, allExports, src, file, "node:wasi", ["WASI"], path.node.loc?.start, ); } if ( initNode.callee?.type === "MemberExpression" && initNode.callee.object?.type === "Identifier" && wasiNamespaceAliases.has(initNode.callee.object.name) && getMemberExpressionPropertyName(initNode.callee.property) === "WASI" ) { wasiInstanceAliases.add(idNode.name); setSyntheticImportRef( allImports, allExports, src, file, "node:wasi", ["WASI"], path.node.loc?.start, ); } } if (idNode.type === "ObjectPattern") { if (initNode.type === "CallExpression") { const wasmSource = getWasmSourceFromInstantiateCall( initNode, wasmBufferByVarName, ); if (wasmSource) { wasmPathLiterals.add(wasmSource); for (const prop of idNode.properties || []) { if ( prop.type === "ObjectProperty" && getMemberExpressionPropertyName(prop.key) === "instance" && prop.value?.type === "Identifier" ) { wasmInstanceByVarName.set(prop.value.name, wasmSource); } } setSyntheticImportRef( allImports, allExports, src, file, wasmSource, [], path.node.loc?.start, ); } if ( initNode.callee?.type === "Identifier" && initNode.callee.name === "require" ) { const requiredModule = getStringValue(initNode.arguments?.[0]); if (requiredModule === "node:wasi" || requiredModule === "wasi") { for (const prop of idNode.properties || []) { if ( prop.type === "ObjectProperty" && getMemberExpressionPropertyName(prop.key) === "WASI" && prop.value?.type === "Identifier" ) { wasiConstructorAliases.add(prop.value.name); } } } } } if (initNode.type === "MemberExpression") { const exportNames = getNamedImportsFromObjectPattern(idNode); if (!exportNames.length) { return; } if ( initNode.object?.type === "MemberExpression" && initNode.object.object?.type === "Identifier" && getMemberExpressionPropertyName(initNode.object.property) === "instance" && getMemberExpressionPropertyName(initNode.property) === "exports" ) { const wasmSource = wasmResultByVarName.get( initNode.object.object.name, ); if (wasmSource) { setSyntheticImportRef( allImports, allExports, src, file, wasmSource, exportNames, path.node.loc?.start, ); } } if ( initNode.object?.type === "Identifier" && getMemberExpressionPropertyName(initNode.property) === "exports" ) { const wasmSource = wasmInstanceByVarName.get(initNode.object.name); if (wasmSource) { setSyntheticImportRef( allImports, allExports, src, file, wasmSource, exportNames, path.node.loc?.start, ); } } } } if ( idNode.type === "Identifier" && initNode.type === "MemberExpression" && initNode.object?.type === "Identifier" && getMemberExpressionPropertyName(initNode.property) === "instance" ) { const wasmSource = wasmResultByVarName.get(initNode.object.name); if (wasmSource) { wasmInstanceByVarName.set(idNode.name, wasmSource); } } if ( idNode.type === "Identifier" && initNode.type === "CallExpression" && initNode.callee?.type === "MemberExpression" && initNode.callee.object?.type === "Identifier" && initNode.callee.object.name === "WebAssembly" ) { const wasmSource = getWasmSourceFromInstantiateCall( initNode, wasmBufferByVarName, ); if (wasmSource) { wasmResultByVarName.set(idNode.name, wasmSource); wasmPathLiterals.add(wasmSource); } } }, AssignmentExpression: (path) => { const wasmExportMemberInfo = getWasmExportMemberInfo(path?.node?.right); if (!wasmExportMemberInfo?.exportName) { return; } if (!wasmExportAliases.has(wasmExportMemberInfo.aliasName)) { return; } if (!wasmPathLiterals.size) { return; } for (const wasmPath of wasmPathLiterals) { setSyntheticImportRef( allImports, allExports, src, file, wasmPath, [wasmExportMemberInfo.exportName], path.node.loc?.start, ); } const targetName = getAssignmentTargetName(path?.node?.left); if (!targetName) { return; } for (const wasmPath of wasmPathLiterals) { setSyntheticExportRef( allImports, allExports, src, file, wasmPath, [targetName], path.node.loc?.start, ); } }, NewExpression: (path) => { if (path?.node?.callee?.type === "Identifier") { if (wasiConstructorAliases.has(path.node.callee.name)) { setSyntheticImportRef( allImports, allExports, src, file, "node:wasi", ["WASI"], path.node.loc?.start, ); } } if ( path?.node?.callee?.type === "MemberExpression" && path.node.callee.object?.type === "Identifier" && wasiNamespaceAliases.has(path.node.callee.object.name) && getMemberExpressionPropertyName(path.node.callee.property) === "WASI" ) { setSyntheticImportRef( allImports, allExports, src, file, "node:wasi", ["WASI"], path.node.loc?.start, ); } }, // Use for export barrells ExportAllDeclaration: (path) => { setFileRef(allImports, allExports, src, file, path.node.source); }, ExportNamedDeclaration: (path) => { // ensure there is a path export if (path?.node?.source) { setFileRef( allImports, allExports, src, file, path.node.source, path.node.specifiers, ); } }, }); }; /** * Return paths to all (j|tsx?) files. */ const getAllSrcJSAndTSFiles = (src, deep) => Promise.all( [".js", ".jsx", ".cjs", ".mjs", ".ts", ".tsx", ".vue", ".svelte"].map( (extension) => { const searchOptions = normalizeAnalyzerSearchOptions(deep); return getAllFiles( searchOptions.deep, src, extension, undefined, undefined, undefined, src, searchOptions.exclude, ); }, ), ); export const CHROMIUM_EXTENSION_CAPABILITY_CATEGORIES = [ "fileAccess", "deviceAccess", "network", "bluetooth", "accessibility", "codeInjection", "fingerprinting", ]; const EXTENSION_CAPABILITY_CHAIN_PATTERNS = { fileAccess: [ /^(chrome|browser)\.(downloads|fileSystem|fileBrowserHandler|fileManagerPrivate)\b/i, /^(window\.)?show(Open|Save|Directory)FilePicker$/i, ], deviceAccess: [ /^(chrome|browser)\.(usb|hid|serial|nfc|mediaGalleries|gcdPrivate|bluetooth|bluetoothPrivate)\b/i, ], network: [ /^(chrome|browser)\.(webRequest|declarativeNetRequest|proxy|webNavigation|socket)\b/i, /^(window\.)?(fetch|WebSocket|EventSource)$/i, /^(XMLHttpRequest)\b/i, /^navigator\.sendBeacon$/i, ], bluetooth: [/^(chrome|browser)\.(bluetooth|bluetoothPrivate)\b/i], accessibility: [ /^(chrome|browser)\.(accessibilityFeatures|accessibilityPrivate|automation)\b/i, ], codeInjection: [ /^(chrome|browser)\.(scripting\.executeScript|tabs\.executeScript|userScripts|debugger)\b/i, /^(window\.)?(eval|Function)$/i, /^document\.write$/i, ], fingerprinting: [ /^navigator\.(userAgent|platform|languages|language|hardwareConcurrency|deviceMemory|plugins|userAgentData)\b/i, /^(screen\.)?(width|height|availWidth|availHeight|colorDepth|pixelDepth)$/i, /^(window\.)?(AudioContext|OfflineAudioContext|RTCPeerConnection)$/i, /^(canvas|[a-zA-Z_$][a-zA-Z0-9_$]*\.(getImageData|toDataURL|measureText))$/i, ], }; const EXTENSION_CAPABILITY_IDENTIFIER_PATTERNS = { network: /^(fetch|WebSocket|EventSource|XMLHttpRequest)$/i, codeInjection: /^(eval|Function)$/i, fingerprinting: /^(AudioContext|OfflineAudioContext|RTCPeerConnection)$/i, }; const SUSPICIOUS_JS_PROCESS_MODULES = new Set([ "child_process", "node:child_process", ]); const SUSPICIOUS_JS_NETWORK_MODULES = new Set([ "axios", "got", "http", "https", "net", "node-fetch", "node:http", "node:https", "node:net", "node:tls", "tls", "undici", ]); const JS_FILE_ACCESS_MODULES = new Set([ "fs", "fs/promises", "graceful-fs", "node:fs", "node:fs/promises", "original-fs", ]); const JS_NETWORK_MODULES = new Set([ ...SUSPICIOUS_JS_NETWORK_MODULES, "engine.io-client", "node:dgram", "socket.io-client", "sse.js", "ws", ]); const JS_HARDWARE_MODULES = new Set([ "@abandonware/noble", "bluetooth-serial-port", "electron-hid", "i2c-bus", "node-hid", "noble", "onoff", "pigpio", "raspi-io", "serialport", "spi-device", "usb", "webbluetooth", ]); const JS_FILE_ACCESS_MEMBERS = new Set([ "access", "appendFile", "chmod", "chown", "copyFile", "cp", "createReadStream", "createWriteStream", "lstat", "mkdir", "mkdtemp", "open", "opendir", "readFile", "readdir", "readlink", "realpath", "rename", "rm", "rmdir", "stat", "symlink", "truncate", "unlink", "utimes", "watch", "watchFile", "writeFile", ]); const JS_NETWORK_MEMBERS = new Set([ "connect", "createConnection", "createSocket", "fetch", "get", "patch", "post", "put", "request", "send", "subscribe", ]); const JS_HARDWARE_MEMBERS = new Set([ "getDevices", "open", "requestDevice", "requestPort", ]); const JS_CODE_GENERATION_MEMBERS = new Set([ "compileFunction", "runInContext", "runInNewContext", "runInThisContext", ]); const JS_HARDWARE_CHAIN_PATTERNS = [ /^navigator\.(bluetooth|hid|serial|usb)\b/i, /^(chrome|browser)\.(bluetooth|hid|serial|usb|nfc)\b/i, ]; const JS_FILE_ACCESS_CHAIN_PATTERNS = [ /^(window\.)?show(Open|Save|Directory)FilePicker$/i, ]; const JS_NETWORK_CHAIN_PATTERNS = [ /^navigator\.sendBeacon$/i, /^(window\.)?(EventSource|WebSocket|XMLHttpRequest)$/i, ]; export const JS_CAPABILITY_CATEGORIES = [ "fileAccess", "network", "hardware", "childProcess", "codeGeneration", "dynamicFetch", "dynamicImport", ]; const SUSPICIOUS_JS_EXECUTION_MEMBERS = new Set([ "exec", "execFile", "execFileSync", "execSync", "fork", "spawn", "spawnSync", ]); const SUSPICIOUS_JS_NETWORK_MEMBERS = new Set([ "fetch", "get", "post", "put", "patch", "request", ]); const SUSPICIOUS_JS_LONG_BASE64_PATTERN = /\b[A-Za-z0-9+/]{80,}={0,2}\b/; const getLiteralStringValue = (node) => { if (!node) { return undefined; } if (node.type === "StringLiteral") { return node.value; } if (node.type === "TemplateLiteral" && node.expressions?.length === 0) { return node.quasis.map((quasi) => quasi.value.cooked || "").join(""); } return undefined; }; const addSuspiciousLiteralIndicators = (obfuscationIndicators, rawValue) => { if (!rawValue || typeof rawValue !== "string") { return; } if (SUSPICIOUS_JS_LONG_BASE64_PATTERN.test(rawValue)) { obfuscationIndicators.add("long-base64-literal"); } }; const trackSuspiciousModuleReference = ( moduleName, localName, executionIndicators, networkIndicators, processAliases, networkAliases, ) => { if (!moduleName || typeof moduleName !== "string") { return; } if (SUSPICIOUS_JS_PROCESS_MODULES.has(moduleName)) { executionIndicators.add("child-process-import"); if (localName) { processAliases.add(localName); } } if (SUSPICIOUS_JS_NETWORK_MODULES.has(moduleName)) { networkIndicators.add("network-module-import"); if (localName) { networkAliases.add(localName); } } }; const trackJsCapabilityModuleReference = ( moduleName, localName, capabilityIndicators, aliasMaps, ) => { if (!moduleName || typeof moduleName !== "string") { return; } if (JS_FILE_ACCESS_MODULES.has(moduleName)) { capabilityIndicators.fileAccess.add(`import:${moduleName}`); if (localName) { aliasMaps.fileAccess.add(localName); } } if (JS_NETWORK_MODULES.has(moduleName)) { capabilityIndicators.network.add(`import:${moduleName}`); if (localName) { aliasMaps.network.add(localName); } } if (JS_HARDWARE_MODULES.has(moduleName)) { capabilityIndicators.hardware.add(`import:${moduleName}`); if (localName) { aliasMaps.hardware.add(localName); } } if (SUSPICIOUS_JS_PROCESS_MODULES.has(moduleName)) { capabilityIndicators.childProcess.add(`import:${moduleName}`); if (localName) { aliasMaps.childProcess.add(localName); } } }; const isStaticStringNode = (node) => node?.type === "StringLiteral" || (node?.type === "TemplateLiteral" && node.expressions?.length === 0); const isStaticUrlNode = (node) => { if (isStaticStringNode(node)) { return true; } return ( node?.type === "NewExpression" && getMemberChainString(node.callee) === "URL" && node.arguments?.length && node.arguments.every((arg) => isStaticStringNode(arg)) ); }; const getMemberChainString = (node) => { if (!node) { return ""; } if (node.type === "Identifier") { return node.name; } if (node.type === "ThisExpression") { return "this"; } if (node.type === "StringLiteral") { return node.value; } if (node.type === "MetaProperty") { const metaName = node.meta?.name || ""; const propertyName = node.property?.name || ""; return [metaName, propertyName].filter(Boolean).join("."); } if (node.type === "CallExpression") { return getMemberChainString(node.callee); } if (node.type === "OptionalCallExpression") { return getMemberChainString(node.callee); } if ( node.type !== "MemberExpression" && node.type !== "OptionalMemberExpression" ) { return ""; } const objectChain = getMemberChainString(node.object); const propertyChain = getMemberChainString(node.property); if (objectChain && propertyChain) { return `${objectChain}.${propertyChain}`; } return objectChain || propertyChain || ""; }; export function analyzeSuspiciousJsSource(source) { const executionIndicators = new Set(); const networkIndicators = new Set(); const obfuscationIndicators = new Set(); const processAliases = new Set(); const networkAliases = new Set(); let ast; try { ast = parse(source, babelParserOptions); } catch { return { executionIndicators: [], indicators: [], networkIndicators: [], obfuscationIndicators: [], }; } traverse.default(ast, { ImportDeclaration: (path) => { const moduleName = getLiteralStringValue(path?.node?.source); path.node.specifiers.forEach((specifier) => { trackSuspiciousModuleReference( moduleName, specifier?.local?.name, executionIndicators, networkIndicators, processAliases, networkAliases, ); }); if (!path.node.specifiers?.length) { trackSuspiciousModuleReference( moduleName, undefined, executionIndicators, networkIndicators, processAliases, networkAliases, ); } }, VariableDeclarator: (path) => { const init = path?.node?.init; if ( init?.type === "CallExpression" && init.callee?.type === "Identifier" && init.callee.name === "require" ) { const moduleName = getLiteralStringValue(init.arguments?.[0]); const localName = path?.node?.id?.type === "Identifier" ? path.node.id.name : undefined; trackSuspiciousModuleReference( moduleName, localName, executionIndicators, networkIndicators, processAliases, networkAliases, ); } }, CallExpression: (path) => { const callee = path?.node?.callee; const calleeChain = getMemberChainString(callee); if (callee?.type === "Identifier") { if (callee.name === "eval") { executionIndicators.add("eval"); } if (callee.name === "atob") { obfuscationIndicators.add("atob"); } if (["fetch", "axios", "got"].includes(callee.name)) { networkIndicators.add("network-request"); } } if (calleeChain === "Buffer.from") { const encodingValue = getLiteralStringValue(path.node.arguments?.[1]); if (encodingValue?.toLowerCase() === "base64") { obfuscationIndicators.add("buffer-base64"); } } if (calleeChain === "String.fromCharCode") { obfuscationIndicators.add("string-from-char-code"); } if (calleeChain === "vm.runInNewContext") { executionIndicators.add("vm-run-context"); obfuscationIndicators.add("vm-run-context"); } if (calleeChain === "vm.runInThisContext") { executionIndicators.add("vm-run-context"); obfuscationIndicators.add("vm-run-context"); } if (callee?.type === "MemberExpression") { const objectName = getMemberChainString(callee.object); const propertyName = getMemberChainString(callee.property); if ( objectName && processAliases.has(objectName) && SUSPICIOUS_JS_EXECUTION_MEMBERS.has(propertyName) ) { executionIndicators.add("child-process"); } if ( objectName && networkAliases.has(objectName) && SUSPICIOUS_JS_NETWORK_MEMBERS.has(propertyName) ) { networkIndicators.add("network-request"); } } if ( callee?.type === "Identifier" && callee.name === "require" && path.node.arguments?.length ) { const moduleName = getLiteralStringValue(path.node.arguments[0]); trackSuspiciousModuleReference( moduleName, undefined, executionIndicators, networkIndicators, processAliases, networkAliases, ); } }, NewExpression: (path) => { const calleeChain = getMemberChainString(path?.node?.callee); if (calleeChain === "Function") { executionIndicators.add("function-constructor"); } }, StringLiteral: (path) => { addSuspiciousLiteralIndicators(obfuscationIndicators, path?.node?.value); }, TemplateElement: (path) => { addSuspiciousLiteralIndicators( obfuscationIndicators, path?.node?.value?.raw, ); }, }); const indicators = [ ...obfuscationIndicators, ...executionIndicators, ...networkIndicators, ].sort(); return { executionIndicators: Array.from(executionIndicators).sort(), indicators, networkIndicators: Array.from(networkIndicators).sort(), obfuscationIndicators: Array.from(obfuscationIndicators).sort(), }; } /** * Find all imports and exports */ export const findJSImportsExports = async (src, deep) => { const allImports = {}; const allExports = {}; try { const promiseMap = await getAllSrcJSAndTSFiles(src, deep); const srcFiles = promiseMap.flat(); for (const file of srcFiles) { try { parseFileASTTree(src, file, allImports, allExports); } catch (_err) { // ignore parse failures } } return { allImports, allExports }; } catch (_err) { return { allImports, allExports }; } }; /** * Detect suspicious obfuscation, execution, and network indicators in a single * JavaScript/TypeScript source file using Babel AST analysis. * * @param {string} filePath Source file path * @returns {{executionIndicators: string[], indicators: string[], networkIndicators: string[], obfuscationIndicators: string[]}} */ export const analyzeSuspiciousJsFile = (filePath) => { let source; try { source = fileToParseableCode(filePath); } catch { return { executionIndicators: [], indicators: [], networkIndicators: [], obfuscationIndicators: [], }; } return analyzeSuspiciousJsSource(source); }; export function analyzeJsCapabilitiesSource(source) { const capabilityIndicators = { childProcess: new Set(), codeGeneration: new Set(), dynamicFetch: new Set(), dynamicImport: new Set(), fileAccess: new Set(), hardware: new Set(), network: new Set(), }; const aliasMaps = { childProcess: new Set(), fileAccess: new Set(), hardware: new Set(), network: new Set(), }; let ast; try { ast = parse(source, babelParserOptions); } catch { return { capabilities: [], hasDynamicFetch: false, hasDynamicImport: false, hasEval: false, indicatorMap: {}, }; } const addIndicator = (category, rawIndicator) => { const indicator = String(rawIndicator || "").trim(); if (!indicator) { return; } capabilityIndicators[category].add(indicator); }; traverse.default(ast, { ImportDeclaration: (path) => { const moduleName = getLiteralStringValue(path?.node?.source); path.node.specifiers.forEach((specifier) => { trackJsCapabilityModuleReference( moduleName, specifier?.local?.name, capabilityIndicators, aliasMaps, ); }); if (!path.node.specifiers?.length) { trackJsCapabilityModuleReference( moduleName, undefined, capabilityIndicators, aliasMaps, ); } }, VariableDeclarator: (path) => { const init = path?.node?.init; if ( init?.type === "CallExpression" && init.callee?.type === "Identifier" && init.callee.name === "require" ) { const moduleName = getLiteralStringValue(init.arguments?.[0]); const localName = path?.node?.id?.type === "Identifier" ? path.node.id.name : undefined; trackJsCapabilityModuleReference( moduleName, localName, capabilityIndicators, aliasMaps, ); } }, ImportExpression: (path) => { if (!isStaticStringNode(path?.node?.source)) { addIndicator("dynamicImport", "import(dynamic)"); } }, MemberExpression: (path) => { const memberChain = getMemberChainString(path?.node); if ( JS_HARDWARE_CHAIN_PATTERNS.some((pattern) => pattern.test(memberChain)) ) { addIndicator("hardware", memberChain); } if ( JS_FILE_ACCESS_CHAIN_PATTERNS.some((pattern) => pattern.test(memberChain), ) ) { addIndicator("fileAccess", memberChain); } if ( JS_NETWORK_CHAIN_PATTERNS.some((pattern) => pattern.test(memberChain)) ) { addIndicator("network", memberChain); } }, OptionalMemberExpression: (path) => { const memberChain = getMemberChainString(path?.node); if ( JS_HARDWARE_CHAIN_PATTERNS.some((pattern) => pattern.test(memberChain)) ) { addIndicator("hardware", memberChain); } if ( JS_FILE_ACCESS_CHAIN_PATTERNS.some((pattern) => pattern.test(memberChain), ) ) { addIndicator("fileAccess", memberChain); } if ( JS_NETWORK_CHAIN_PATTERNS.some((pattern) => pattern.test(memberChain)) ) { addIndicator("network", memberChain); } }, CallExpression: (path) => { const callee = path?.node?.callee; const calleeChain = getMemberChainString(callee); if (callee?.type === "Identifier") { if (callee.name === "fetch") { addIndicator("network", "fetch"); if (!isStaticUrlNode(path.node.arguments?.[0])) { addIndicator("dynamicFetch", "fetch(dynamic)"); } } if (callee.name === "eval") { addIndicator("codeGeneration", "eval"); } if ( aliasMaps.network.has(callee.name) && ["axios", "got", "fetch"].includes(callee.name) ) { addIndicator("network", callee.name); if (!isStaticUrlNode(path.node.arguments?.[0])) { addIndicator("dynamicFetch", `${callee.name}(dynamic)`); } } } if (calleeChain === "Buffer.from") { const encodingValue = getLiteralStringValue(path.node.arguments?.[1]); if (encodingValue?.toLowerCase() === "base64") { addIndicator("codeGeneration", "buffer-base64"); } } if (calleeChain.startsWith("vm.")) { const vmMethod = calleeChain.split(".").slice(1).join("."); if (JS_CODE_GENERATION_MEMBERS.has(vmMethod)) { addIndicator("codeGeneration", calleeChain); } } if (callee?.type === "MemberExpression") { const objectName = getMemberChainString(callee.object); const propertyName = getMemberChainString(callee.property); if ( objectName && aliasMaps.fileAccess.has(objectName) && JS_FILE_ACCESS_MEMBERS.has(propertyName) ) { addIndicator("fileAccess", `${objectName}.${propertyName}`); } if ( objectName && aliasMaps.network.has(objectName) && JS_NETWORK_MEMBERS.has(propertyName) ) { addIndicator("network", `${objectName}.${propertyName}`); if (!isStaticUrlNode(path.node.arguments?.[0])) { addIndicator( "dynamicFetch", `${objectName}.${propertyName}(dynamic)`, ); } } if ( objectName && aliasMaps.hardware.has(objectName) && JS_HARDWARE_MEMBERS.has(propertyName) ) { addIndicator("hardware", `${objectName}.${propertyName}`); } if ( objectName && aliasMaps.childProcess.has(objectName) && SUSPICIOUS_JS_EXECUTION_MEMBERS.has(propertyName) ) { addIndicator("childProcess", `${objectName}.${propertyName}`); } } if ( callee?.type === "Identifier" && callee.name === "require" && !isStaticStringNode(path.node.arguments?.[0]) ) { addIndicator("dynamicImport", "require(dynamic)"); } }, NewExpression: (path) => { const calleeChain = getMemberChainString(path?.node?.callee); if (calleeChain === "Function") { addIndicator("codeGeneration", "Function"); } if ( ["WebSocket", "EventSource", "XMLHttpRequest"].includes(calleeChain) ) { addIndicator("network", calleeChain); } }, }); const indicatorMap = {}; const capabilities = []; for (const category of JS_CAPABILITY_CATEGORIES) { const indicators = Array.from(capabilityIndicators[category]).sort(); if (indicators.length) { indicatorMap[category] = indicators; capabilities.push(category); } } return { capabilities, hasDynamicFetch: capabilityIndicators.dynamicFetch.size > 0, hasDynamicImport: capabilityIndicators.dynamicImport.size > 0, hasEval: capabilityIndicators.codeGeneration.has("eval"), indicatorMap, }; } export const analyzeJsCapabilitiesFile = (filePath) => { let source; try { source = fileToParseableCode(filePath); } catch { return { capabilities: [], hasDynamicFetch: false, hasDynamicImport: false, hasEval: false, indicatorMap: {}, }; } return analyzeJsCapabilitiesSource(source); }; const CRYPTO_IMPORT_SOURCES = new Set([ "crypto", "jose", "jsonwebtoken", "node:crypto", "node:tls", "openpgp", "sshpk", "tls", ]); const NODE_CRYPTO_MODULE_SOURCES = new Set(["crypto", "node:crypto"]); const JWT_IMPORT_SOURCES = new Set(["jsonwebtoken"]); const JOSE_IMPORT_SOURCES = new Set(["jose"]); const JWS_ALGORITHM_LITERAL_PATTERN = /^(?:ES|HS|PS|RS)(?:256|384|512)$|^Ed(?:25519|448)$/; const NODE_CRYPTO_CALL_PRIMITIVES = new Map([ ["createCipheriv", "cipher"], ["createDecipheriv", "cipher"], ["createHash", "hash"], ["createHmac", "hmac"], ["createSign", "signature"], ["createVerify", "signature"], ["generateKey", "key-generation"], ["generateKeyPair", "key-generation"], ["generateKeyPairSync", "key-generation"], ["generateKeySync", "key-generation"], ["hkdf", "kdf"], ["hkdfSync", "kdf"], ["pbkdf2", "kdf"], ["pbkdf2Sync", "kdf"], ["scrypt", "kdf"], ["scryptSync", "kdf"], ["sign", "signature"], ["verify", "signature"], ]); const WEBCRYPTO_METHOD_PRIMITIVES =