UNPKG

lavamoat-core

Version:
588 lines (554 loc) 18.6 kB
// @ts-check const EventEmitter = require('node:events') const path = require('node:path') const jsonStringify = require('json-stable-stringify') const { parse, inspectGlobals, inspectImports, inspectSesCompat, codeSampleFromAstNode, utils: { mergePolicy: mergeGlobalsPolicy, mapToObj, reduceToTopmostApiCallsFromStrings, }, inspectEsmImports, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore cycle causes this to be an error sometimes } = require('lavamoat-tofu') const { mergePolicy } = require('./mergePolicy') const rootSlug = '$root$' /** * Symbols that look like globals but aren't; indexed by source type. */ const MODULE_REFS = /** @type {const} */ ({ module: ['arguments', 'import', 'export'], script: ['arguments', 'require', 'module', 'exports'], }) module.exports = { rootSlug, createModuleInspector, getDefaultPaths, } /** * @param {ModuleInspectorOptions} opts * @returns {ModuleInspector} */ function createModuleInspector(opts) { const moduleIdToModuleRecord = new Map() // "packageToModules" does not include builtin modules const packageToModules = new Map() const packageToGlobals = new Map() /** @type {Map<string, string[]>} */ const packageToBuiltinImports = new Map() const packageToNativeModules = new Map() /** @type {Record<string, import('./schema').DebugInfo>} */ const debugInfo = {} /** @type {ModuleInspector} */ const inspector = Object.assign(new EventEmitter(), { /** @type {InspectModuleFn} */ inspectModule: (moduleRecord, opts2) => { inspectModule(moduleRecord, { ...opts, ...opts2 }) }, /** @type {GeneratePolicyFn} */ generatePolicy: (opts2) => { return generatePolicy({ ...opts, ...opts2 }) }, }) return inspector /** * @param {import('./moduleRecord').LavamoatModuleRecord} moduleRecord * @param {ModuleInspectorOptions} opts */ function inspectModule( moduleRecord, { isBuiltin, includeDebugInfo = false } ) { if (moduleRecord === undefined) { return } const { packageName, specifier, type } = moduleRecord // record the module moduleIdToModuleRecord.set(specifier, moduleRecord) // call the correct analyzer for the module type switch (type) { case 'builtin': { inspectBuiltinModule(moduleRecord, { includeDebugInfo }) return } case 'native': { inspectNativeModule(moduleRecord, { includeDebugInfo }) return } case 'js': { inspectJsModule(moduleRecord, { isBuiltin, includeDebugInfo }) return } default: { const errMsg = `LavaMoat - unknown module type "${type}" for package "${packageName}" module "${specifier}"` throw new Error(errMsg) } } } /** * @param {import('./moduleRecord').LavamoatModuleRecord} moduleRecord * @param {Partial<ModuleInspectorOptions>} opts */ // eslint-disable-next-line @typescript-eslint/no-unused-vars function inspectBuiltinModule(moduleRecord, opts) { // builtins themselves do not require any configuration // packages that import builtins need to add that to their configuration } /** * @param {import('./moduleRecord').LavamoatModuleRecord} moduleRecord * @param {Partial<ModuleInspectorOptions>} opts */ // eslint-disable-next-line @typescript-eslint/no-unused-vars function inspectNativeModule(moduleRecord, opts) { // LavaMoat does attempt to sandbox native modules // packages with native modules need to specify that in the policy file const { packageName } = moduleRecord if (!packageToNativeModules.has(packageName)) { packageToNativeModules.set(packageName, []) } const packageNativeModules = packageToNativeModules.get(packageName) packageNativeModules.push(moduleRecord) } /** * @param {AST} ast * @returns {ast is import('@babel/parser').ParseResult<import('@babel/types').File>} */ function isParsedAST(ast) { return 'errors' in ast } /** * @param {import('./moduleRecord').LavamoatModuleRecord} moduleRecord * @param {ModuleInspectorOptions} opts */ function inspectJsModule( moduleRecord, { isBuiltin, includeDebugInfo = false } ) { const { packageName, specifier } = moduleRecord let moduleDebug // record the module moduleIdToModuleRecord.set(specifier, moduleRecord) // initialize mapping from package to module if (!packageToModules.has(packageName)) { packageToModules.set(packageName, new Map()) } const packageModules = packageToModules.get(packageName) packageModules[specifier] = moduleRecord // initialize module debug info if (includeDebugInfo) { moduleDebug = debugInfo[specifier] = /** @type {any} */ ({}) // append moduleRecord, ensure ast is not copied const debugData = { ...moduleRecord, } delete debugData.ast moduleDebug.moduleRecord = debugData } // skip for root modules (modules not from deps) const isRootModule = packageName === rootSlug if (isRootModule) { return } // skip json files const filename = moduleRecord.file || 'unknown' const fileExtension = path.extname(filename) if (!fileExtension.match(/^\.([cm]?js|ts)$/)) { return } // get ast (parse or use cached) /** * @type {AST} * @todo - Put this in `LavamoatModuleRecord` instead */ const ast = moduleRecord.ast || parse(/** @type {string} */ (moduleRecord.content), { // esm support sourceType: 'unambiguous', // someone must have been doing this allowReturnOutsideFunction: true, errorRecovery: true, }) if (includeDebugInfo && isParsedAST(ast) && ast.errors?.length) { moduleDebug.parseErrors = ast.errors } // ensure ses compatibility inspectForEnvironment(ast, moduleRecord, includeDebugInfo) // get global usage inspectForGlobals(ast, moduleRecord, packageName, includeDebugInfo) // get builtin package usage inspectForImports( ast, moduleRecord, packageName, isBuiltin, includeDebugInfo ) // ensure module ast is cleaned up delete moduleRecord.ast } /** * @param {AST} ast * @param {import('./moduleRecord').LavamoatModuleRecord} moduleRecord * @param {boolean} includeDebugInfo * @returns */ function inspectForEnvironment(ast, moduleRecord, includeDebugInfo) { const { packageName } = moduleRecord // @ts-expect-error `ParseResult` / `AST` mismatch const compatWarnings = inspectSesCompat(ast) const { primordialMutations, strictModeViolations, dynamicRequires } = // @ts-expect-error `SesCompat` / `InspectSesCompatResult` mismatch /** @type {import('./schema').SesCompat} */ (compatWarnings) const hasResults = primordialMutations.length > 0 || strictModeViolations.length > 0 || dynamicRequires.length > 0 if (!hasResults) { return } if (includeDebugInfo) { const moduleDebug = debugInfo[moduleRecord.specifier] moduleDebug.sesCompat = { // FIXME: I don't think this is needed, since it appears we overwrite all properties ...compatWarnings, // fix serialization primordialMutations: primordialMutations.map(({ node: { loc } }) => ({ node: { loc }, })), strictModeViolations: strictModeViolations.map(({ node: { loc } }) => ({ node: { loc }, })), dynamicRequires: dynamicRequires.map(({ node: { loc } }) => ({ node: { loc }, })), } } else { // warn if non-compatible code found if (inspector.listenerCount('compat-warning') > 0) { inspector.emit('compat-warning', { moduleRecord, compatWarnings }) } else { const samples = jsonStringify({ primordialMutations: primordialMutations.map(({ node }) => // @ts-expect-error `SesCompatNode` / `Node.loc` mismatch codeSampleFromAstNode(node, moduleRecord) ), strictModeViolations: strictModeViolations.map(({ node }) => // @ts-expect-error `SesCompatNode` / `Node.loc` mismatch codeSampleFromAstNode(node, moduleRecord) ), dynamicRequires: dynamicRequires.map(({ node }) => // @ts-expect-error `SesCompatNode` / `Node.loc` mismatch codeSampleFromAstNode(node, moduleRecord) ), }) const errMsg = `Incompatible code detected in package "${packageName}" file "${moduleRecord.file}". Violations:\n${samples}` console.warn(errMsg) } } } /** * @param {AST} ast * @param {import('./moduleRecord').LavamoatModuleRecord} moduleRecord * @param {string} packageName * @param {boolean} includeDebugInfo */ function inspectForGlobals(ast, moduleRecord, packageName, includeDebugInfo) { const moduleRefs = MODULE_REFS[ast.program.sourceType] const globalObjPrototypeRefs = Object.getOwnPropertyNames(Object.prototype) const foundGlobals = inspectGlobals(ast, { // browserify commonjs scope ignoredRefs: [...moduleRefs, ...globalObjPrototypeRefs], // browser global refs + browserify global globalRefs: ['globalThis', 'self', 'window', 'global'], }) // skip if no results if (!foundGlobals.size) { return } // add debug info if (includeDebugInfo) { const moduleDebug = debugInfo[moduleRecord.specifier] moduleDebug.globals = mapToObj(foundGlobals) } // agregate globals if (!packageToGlobals.has(packageName)) { packageToGlobals.set(packageName, []) } let packageGlobals = packageToGlobals.get(packageName) packageGlobals = mergeGlobalsPolicy(packageGlobals, foundGlobals) packageToGlobals.set(packageName, packageGlobals) } /** * @param {AST} ast * @param {import('./moduleRecord').LavamoatModuleRecord} moduleRecord * @param {string} packageName * @param {(value: string) => boolean} isBuiltin * @param {boolean} includeDebugInfo * @returns */ function inspectForImports( ast, moduleRecord, packageName, isBuiltin, includeDebugInfo ) { // get all requested names that resolve to isBuiltin const namesForBuiltins = Object.entries(moduleRecord.importMap) .filter(([, resolvedName]) => isBuiltin(resolvedName)) .map(([requestedName]) => requestedName) const esmModuleBuiltins = inspectEsmImports(ast, namesForBuiltins) const { cjsImports: cjsModuleBuiltins } = inspectImports( ast, namesForBuiltins ) if (cjsModuleBuiltins.length + esmModuleBuiltins.length === 0) { return } // add debug info if (includeDebugInfo) { const moduleDebug = debugInfo[moduleRecord.specifier] moduleDebug.builtin = [ ...new Set([...esmModuleBuiltins, ...cjsModuleBuiltins]), ] } // aggregate package builtins if (!packageToBuiltinImports.has(packageName)) { packageToBuiltinImports.set(packageName, []) } let packageBuiltins = packageToBuiltinImports.get(packageName) ?? [] packageBuiltins = [ ...new Set([ ...packageBuiltins, ...cjsModuleBuiltins, ...esmModuleBuiltins, ]), ] packageToBuiltinImports.set(packageName, packageBuiltins) } /** * @type {GeneratePolicyFn} */ function generatePolicy({ policyOverride, includeDebugInfo = false, moduleToPackageFallback, }) { /** @type {import('./schema').Resources} */ const resources = {} /** * @type {import('./schema').LavaMoatPolicyDebug * | import('./schema').LavaMoatPolicy} */ const policy = { resources } packageToModules.forEach((packageModules, packageName) => { // the policy fields for each package /** @type {import('./schema').ResourcePolicy['globals']} */ let globals /** @type {import('./schema').ResourcePolicy['builtin']} */ let builtin /** @type {import('./schema').ResourcePolicy['packages']} */ let packages /** @type {import('./schema').ResourcePolicy['native']} */ let native // skip for root modules (modules not from deps) const isRootModule = packageName === rootSlug if (isRootModule) { return } // get dependencies, ignoring builtins const packageDeps = aggregateDeps({ packageModules, moduleIdToModuleRecord, moduleToPackageFallback, }) if (packageDeps.length) { packages = Object.fromEntries( packageDeps.map((depPackageName) => [depPackageName, true]) ) } // get globals if (packageToGlobals.has(packageName)) { const globalMap = mapToObj(packageToGlobals.get(packageName)) // prefer "true" over "read" for clearer difference between // read/write syntax highlighting Object.keys(globalMap).forEach((key) => { if (globalMap[key] === 'read') { globalMap[key] = true } }) globals = globalMap } // get builtin imports const builtinImports = packageToBuiltinImports.get(packageName) if (builtinImports && builtinImports.length) { /** @type {Record<string, boolean>} */ const importBuiltin = {} const topmostApiCalls = /** @type {string[]} */ ( reduceToTopmostApiCallsFromStrings(builtinImports) ) topmostApiCalls.forEach((path) => { importBuiltin[path] = true }) builtin = importBuiltin } // get native modules native = packageToNativeModules.has(packageName) // skip package policy if there are no settings needed if (!packages && !globals && !builtin) { return } // create minimal policy object const packagePolicy = {} if (packages) { packagePolicy.packages = packages } if (globals) { packagePolicy.globals = globals } if (builtin) { packagePolicy.builtin = builtin } if (native) { packagePolicy.native = native } // set policy for package resources[packageName] = packagePolicy }) // append serializeable debug info if (includeDebugInfo) { // this is here because we should be using semicolons :D // prettier-ignore ;(/** @type {import('./schema').LavaMoatPolicyDebug} */(policy).debugInfo = debugInfo) } // merge override policy const mergedPolicy = mergePolicy(policy, policyOverride) return mergedPolicy } } /** * @callback ModuleToPackageFallbackFn * @param {string} requestedName * @returns {string | undefined} */ /** * @typedef {Object} AggregateDepsOptions * @property {Record<string, import('./moduleRecord').LavamoatModuleRecord>} packageModules * @property {Map<string, import('./moduleRecord').LavamoatModuleRecord>} moduleIdToModuleRecord * @property {ModuleToPackageFallbackFn} [moduleToPackageFallback] */ /** * @param {AggregateDepsOptions} opts * @returns {string[]} */ function aggregateDeps({ packageModules, moduleIdToModuleRecord, moduleToPackageFallback = guessPackageName, }) { const deps = new Set() // get all dep package from the "packageModules" collection of modules Object.values(packageModules).forEach((moduleRecord) => { Object.entries(moduleRecord.importMap).forEach( ([requestedName, specifier]) => { // skip entries where resolution was skipped if (!specifier) { return } // get packageName from module record, or guess const moduleRecord = moduleIdToModuleRecord.get(specifier) if (moduleRecord) { // builtin modules are ignored here, handled elsewhere if (moduleRecord.type === 'builtin') { return } deps.add(moduleRecord.packageName) return } // moduleRecord missing, guess package name const packageName = moduleToPackageFallback(requestedName) || `<unknown:${requestedName}>` deps.add(packageName) } ) // ensure the package is not listed as its own dependency deps.delete(moduleRecord.packageName) }) // return as array const depsArray = Array.from(deps.values()) return depsArray } /** * For when you encounter a `requestedName` that was not inspected, likely * because resolution was skipped for that module * * @type {ModuleToPackageFallbackFn} */ function guessPackageName(requestedName) { const isNotPackageName = requestedName.startsWith('/') || requestedName.startsWith('.') if (isNotPackageName) { return } // resolving is skipped so guess package name const pathParts = requestedName.split('/') const nameSpaced = requestedName.startsWith('@') const packagePartCount = nameSpaced ? 2 : 1 const packageName = pathParts.slice(0, packagePartCount).join('/') return packageName } /** * @param {string} policyName * @returns */ function getDefaultPaths(policyName) { const policiesDir = 'lavamoat' const policyDir = path.join(policiesDir, policyName) return { policiesDir, policyDir, primary: path.join(policyDir, 'policy.json'), override: path.join(policyDir, 'policy-override.json'), debug: path.join(policyDir, 'policy-debug.json'), } } /** * @callback GeneratePolicyFn * @param {Partial<ModuleInspectorOptions> & { * policyOverride?: import('./schema').LavaMoatPolicyOverrides * moduleToPackageFallback?: (value: string) => string | undefined * }} opts * * @returns {import('./schema').LavaMoatPolicy * | import('./schema').LavaMoatPolicyDebug} */ /** * @callback InspectModuleFn * @param {import('./moduleRecord').LavamoatModuleRecord} moduleRecord * @param {Partial<ModuleInspectorOptions>} [opts] */ /** * @typedef ModuleInspectorOptions * @property {(value: string) => boolean} isBuiltin * @property {boolean} [includeDebugInfo] * @property {(specifier: string) => string | undefined} [moduleToPackageFallback] */ /** * @typedef ModuleInspectorMembers * @property {GeneratePolicyFn} generatePolicy * @property {InspectModuleFn} inspectModule */ /** * @typedef {import('node:events').EventEmitter & ModuleInspectorMembers} ModuleInspector */ /** * @typedef {import('@babel/parser').ParseResult<import('@babel/types').File> * | import('@babel/types').File} AST */