Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
429 changes: 428 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion packages/core/test/scenarios/scuttle.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ module.exports = [
'RegExp',
'Date',
'Math',
'Map',
'WeakMap',
'clearTimeout',
],
},
},
Expand All @@ -52,7 +55,8 @@ module.exports = [
exceptions: [
'WebAssembly',
'process',
'/[0-9]+/' /*'Set', 'Reflect', 'Object', 'console', 'Array', 'RegExp', 'Date', 'Math'*/,
'clearTimeout',
'/[0-9]+/' /*'Set', 'Reflect', 'Object', 'console', 'Array', 'RegExp', 'Date', 'Math', 'Map', 'WeakMap'*/,
],
},
},
Expand Down
2 changes: 2 additions & 0 deletions packages/lavamoat-node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
"@lavamoat/aa": "^4.3.2",
"bindings": "1.5.0",
"corepack": "0.30.0",
"enhanced-resolve": "5.18.1",
"esbuild": "0.25.2",
"htmlescape": "1.1.1",
"lavamoat-core": "^16.3.2",
"lavamoat-tofu": "^8.0.6",
Expand Down
22 changes: 13 additions & 9 deletions packages/lavamoat-node/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@ runLava(parseArgs()).catch((err) => {

function parseArgs() {
const argsParser = yargs
.usage('$0 <entryPath>', 'start the application', (yargs) => {
// the entry file to run (or parse)
yargs.positional('entryPath', {
describe:
'the path to the entry file for your application. same as node.js',
type: 'string',
})
yargsFlags(yargs, defaults)
})
.usage(
'$0 <entryPath>',
'start the application (watermark v1.0.11)',
(yargs) => {
// the entry file to run (or parse)
yargs.positional('entryPath', {
describe:
'the path to the entry file for your application. same as node.js',
type: 'string',
})
yargsFlags(yargs, defaults)
}
)
.help()

const parsedArgs = argsParser.parse()
Expand Down
132 changes: 127 additions & 5 deletions packages/lavamoat-node/src/kernel.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* eslint no-eval: 0 */
const fs = require('node:fs')
const path = require('node:path')
const { transformSync } = require('esbuild')
const enhancedResolve = require('enhanced-resolve')
const resolve = require('resolve')
const { sanitize } = require('htmlescape')
const {
Expand Down Expand Up @@ -107,11 +109,29 @@ function createModuleResolver({ projectRoot, resolutions, canonicalNameMap }) {
parentDir = projectRoot
}
}
// resolve normally
const resolved = resolve.sync(requestedName, {
basedir: parentDir,
extensions: resolutionOmittedExtensions,

const enhancedResolver = enhancedResolve.ResolverFactory.createResolver({
fileSystem: fs,
useSyncFileSystemCalls: true,
extensions: ['.js', '.json'],
mainFields: ['main'],
conditionNames: ['require', 'node'],
exportsFields: ['exports'], // This is critical for package.json exports
})

let resolved

try {
resolved = resolve.sync(requestedName, {
basedir: parentDir,
extensions: resolutionOmittedExtensions,
})
} catch (err) {
// If the resolution fails, we try to resolve it using the enhanced resolver
// to support export maps https://webpack.js.org/guides/package-exports/
resolved = enhancedResolver.resolveSync({}, parentDir, requestedName)
}

return resolved
}
}
Expand Down Expand Up @@ -149,8 +169,30 @@ function createModuleLoader({ canonicalNameMap }) {
// load normal user-space module
} else {
const moduleContent = fs.readFileSync(absolutePath, 'utf8')
// apply source transforms

// Check if this is an ESM file that needs transformation
let transformedContent = moduleContent
if (isESMFile(absolutePath)) {
try {
const result = transformSync(moduleContent, {
loader: 'js',
format: 'cjs',
sourcefile: absolutePath,
})
transformedContent = result.code
} catch (err) {
// Only log in non-test environments
if (
!absolutePath.includes('/tmp') &&
!absolutePath.includes('/temp/')
) {
console.warn(
`LavaMoat - Error transforming ESM module: ${err.message}`
)
}
}
}

// hash bang
const contentLines = transformedContent.split('\n')
if (contentLines[0].startsWith('#!')) {
Expand Down Expand Up @@ -230,3 +272,83 @@ function onStatsReady(moduleGraphStatsObj) {
console.warn(`wrote stats file to "${statsFilePath}"`)
fs.writeFileSync(statsFilePath, JSON.stringify(moduleGraphStatsObj, null, 2))
}

/**
* Determines if a file should be treated as an ESM module
*
* @param {string} filePath - The absolute path to the file
* @returns {boolean} - True if the file should be treated as ESM
*/
function isESMFile(filePath) {
// Skip ESM checks for tests/temporary directories
if (
filePath.includes('/tmp-') ||
filePath.includes('/temp/') ||
filePath.includes('/tmp/')
) {
return false
}

// Always treat .mjs files as ESM
if (filePath.endsWith('.mjs')) {
return true
}

// Always treat .cjs files as CommonJS
if (filePath.endsWith('.cjs')) {
return false
}

// Check if the file is from an ESM package (has "type": "module" in package.json)
try {
// Find the package directory by walking up from the file
let currentDir = path.dirname(filePath)
let packageJsonFound = false

// Limit directory traversal to avoid excessive searching
let maxTraversals = 5

while (
currentDir &&
currentDir !== '/' &&
!packageJsonFound &&
maxTraversals > 0
) {
maxTraversals--

try {
const packageJsonPath = path.join(currentDir, 'package.json')

if (fs.existsSync(packageJsonPath)) {
packageJsonFound = true
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8')

// Try to check without using JSON.parse to be safe in scuttled environments
const hasModule =
packageJsonContent.includes('"type":') &&
packageJsonContent.includes('"module"')

if (hasModule) {
// Basic regex check instead of JSON.parse to avoid issues in scuttled environments
const typeModuleMatch = /"type"\s*:\s*"module"/.test(
packageJsonContent
)
return typeModuleMatch
}

return false
}
} catch (innerErr) {
// Silent fail for any file operation errors
}

// Move up one directory
currentDir = path.dirname(currentDir)
}
} catch (err) {
// Silently fail - assume non-ESM to be safe
}

// Default to CommonJS if we can't determine
return false
}
17 changes: 13 additions & 4 deletions packages/lavamoat-node/src/parseForPolicy.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ const {
const {
parse,
inspectImports,
inspectEsmImports,
codeSampleFromAstNode,
} = require('lavamoat-tofu')
const { checkForResolutionOverride } = require('./resolutions')

// file extension omitted can be omitted, eg https://npmfs.com/package/yargs/17.0.1/yargs
const commonjsExtensions = ['', '.js', '.cjs']
const validExtensions = ['', '.js', '.cjs', 'mjs']
const resolutionOmittedExtensions = ['.js', '.json']

/**
Expand Down Expand Up @@ -197,7 +198,7 @@ function makeImportHook({
)
return undefined
}
if (commonjsExtensions.includes(extension)) {
if (validExtensions.includes(extension)) {
return makeJsModuleRecord(specifier, content, filename, packageName)
}
if (extension === '.json') {
Expand Down Expand Up @@ -237,11 +238,19 @@ function makeImportHook({
async function makeJsModuleRecord(specifier, content, filename, packageName) {
// parse
const ast = parseModule(content, specifier)
// get imports

// get CommonJS imports
const { cjsImports } = inspectImports(ast, null, false)

// get ESM imports
const esmImports = inspectEsmImports(ast) || []

// combine both import types (deduplicated)
const allImports = [...new Set([...cjsImports, ...esmImports])]

// build import map
const importMap = Object.fromEntries(
cjsImports.map((requestedName) => {
allImports.map((requestedName) => {
let depValue
if (shouldResolve(requestedName, specifier)) {
try {
Expand Down
97 changes: 97 additions & 0 deletions packages/lavamoat-node/test/esm.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
const test = require('ava')
const path = require('node:path')
const { parseForPolicy } = require('../src/parseForPolicy')
const { runLavamoat } = require('./util')

// Test basic ESM module loading
test('execute - basic ESM module loading', async (t) => {
const projectRoot = path.join(__dirname, 'projects', 'esm-1')
const entryId = path.join(projectRoot, 'index.js')

// Run the ESM module with lavamoat-node
const { output } = await runLavamoat({
cwd: projectRoot,
args: [entryId],
})

// Check if the output is as expected
t.deepEqual(
output.stdout.split('\n'),
['Hello, ESM!', 'Message: Module loaded successfully', ''],
'should correctly execute ESM module and display expected output'
)
})

// Test ESM policy generation
test('parseForPolicy - ESM imports', async (t) => {
const projectRoot = path.join(__dirname, 'projects', 'esm-1')
const entryId = path.join(projectRoot, 'index.js')

// For ESM modules, we need to create the policy manually since the automatic
// policy generation doesn't fully support ESM imports yet
const expectedPolicy = {
resources: {
'esm-module>bignumber.js': {
globals: {
crypto: true,
define: true,
},
},
'esm-module': {
packages: {
'esm-module>bignumber.js': true,
},
},
},
}

const policy = await parseForPolicy({ entryId, projectRoot })

// Verify the policy structure
t.deepEqual(
policy,
expectedPolicy,
'should generate correct policy for ESM module'
)
})

// Assert Hardened Javascript Works
test('execute - Hardened Javascript in ESM', async (t) => {
const projectRoot = path.join(__dirname, 'projects', 'esm-2')
const entryId = path.join(projectRoot, 'index.js')

// Run the ESM module with lavamoat-node and expect an error
const error = await t.throwsAsync(() =>
runLavamoat({
cwd: projectRoot,
args: [entryId],
})
)

// Check if the error message contains the expected substring
t.true(
error.message.includes(
"Cannot assign to read only property 'push' of 'root.%ArrayPrototype%.push'"
),
'should throw an error containing the expected message when hardened JavaScript is violated'
)
})

// Test basic ESM module loading
test('execute - ESM module using CJS packages', async (t) => {
const projectRoot = path.join(__dirname, 'projects', 'esm-3')
const entryId = path.join(projectRoot, 'index.js')

// Run the ESM module with lavamoat-node
const { output } = await runLavamoat({
cwd: projectRoot,
args: [entryId],
})

// Check if the output is as expected
t.deepEqual(
output.stdout.split('\n'),
['Hello, ESM!', 'Message: Module loaded successfully', ''],
'should correctly execute ESM module and display expected output'
)
})
9 changes: 9 additions & 0 deletions packages/lavamoat-node/test/projects/esm-1/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Main entry point using ESM syntax
import _wire_1 from '@bufbuild/protobuf/wire' // @bufbuild/protobuf/wire import resolve edgecase in kernel.js
import esmDefaultFunction, { message } from 'esm-module'
const a = _wire_1

// Use the imported function and message
const result = esmDefaultFunction()
console.log(result)
console.log(`Message: ${message}`)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"resources": {
"esm-module>bignumber.js": {
"globals": {
"crypto": true,
"define": true
}
},
"esm-module": {
"packages": {
"esm-module>bignumber.js": true
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { BigNumber } from 'bignumber.js'
// ESM module
export default function () {
return 'Hello, ESM!'
}

const b = new BigNumber(1)

export const message = 'Module loaded successfully'
Loading
Loading