Skip to content

Commit fa2b46e

Browse files
committed
feat(node): support untrusted entrypoints
This adds support for "untrusted entrypoints"; execution of third-party code against a limited policy. The behavior of legacy `lavamoat-node` is to run the root package where everything is allowed. This is (arguably) appropriate for first-party code, but for _third_-party code it can be an unacceptable risk. `@lavamoat/node` now detects if the _real path_ of the entrypoint lives in a `node_modules` folder. If it does, it is assumed to be third-party code, and will be treated as such. This means that during policy generation, the package of the entrypoint will be treated just like any other package. At runtime, `@lavamoat/node` respects the policy as defined. To define a policy with an untrusted root package, the `root.usePolicy` field must be present in the policy (or policy override) file. It must refer to a key in the `resources` field of policy; this policy will be used for the root package. If a policy has a valid `root.usePolicy` field and the entrypoint refers to a package which is _not_ in `node_modules`, then `@lavamoat/node` will exit with an error stating that there's a root trust mismatch. Likewise, if a policy lacks a valid `root.usePolicy` field and the entrypoint _is_ in a `node_modules` folder, it will exit with a similar error. This provides some guarantee that execution is as safe _as expected._ Additionally, there were a handful of bugfixes applied: - `--dev` flag now fully respected - support for packages providing both conditional subpath exports with the `node` condition _and_ a top-level `browser` field - policy override expansion works appropriately - policy generation won't notify the user a policy has been written when it has not - upgrades to `@endo/evasive-transform` and `@endo/compartment-mapper` address some ecosystem incompatibilities - support for JS sources starting with hashbangs And: - better SES incompatibility warning output - some updates to public API allowing for providing powers ("capabilities") and either raw policy/overrides _xor_ a path/URL to policy/overrides
1 parent 42262ea commit fa2b46e

127 files changed

Lines changed: 3950 additions & 2107 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/node/src/cli.js

Lines changed: 110 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,20 @@
1616

1717
import './preamble.js'
1818

19-
import chalk from 'chalk'
2019
import { jsonStringifySortedPolicy } from 'lavamoat-core'
20+
import fs from 'node:fs'
2121
import path from 'node:path'
2222
import terminalLink from 'terminal-link'
2323
import yargs from 'yargs'
2424
import { hideBin } from 'yargs/helpers'
2525
import * as constants from './constants.js'
2626
import { run } from './exec/run.js'
27-
import { assertAbsolutePath, readJsonFile } from './fs.js'
27+
import { readJsonFile } from './fs.js'
2828
import { log } from './log.js'
2929
import { generatePolicy } from './policy-gen/generate.js'
3030
import { loadPolicies } from './policy-util.js'
3131
import { resolveBinScript, resolveEntrypoint } from './resolve.js'
32+
import { hrPath, toPath } from './util.js'
3233

3334
/**
3435
* @import {PackageJson} from 'type-fest';
@@ -45,11 +46,6 @@ const BEHAVIOR_GROUP = 'Behavior Options:'
4546
*/
4647
const PATH_GROUP = 'Path Options:'
4748

48-
/**
49-
* Use this to give emphasis to words in error messages
50-
*/
51-
const em = chalk.yellow
52-
5349
/**
5450
* Strip out all `lavamoat` CLI args from `process.argv` so that the entrypoint
5551
* receives a `process.argv` like it would if it were executed directly with
@@ -96,6 +92,14 @@ const stripProcessArgv = (entrypoint, nonOptionArguments = []) => {
9692
process.argv.splice(start, deleteCount, ...items)
9793
}
9894

95+
/**
96+
* @param {string} entrypoint
97+
* @returns {boolean}
98+
*/
99+
const shouldTrustRoot = (entrypoint) => {
100+
return !entrypoint.includes('node_modules')
101+
}
102+
99103
/**
100104
* Main entry point to CLI
101105
*/
@@ -105,7 +109,7 @@ const main = async (args = hideBin(process.argv)) => {
105109
// TODO: Use import attributes instead
106110
// #region use import attributes instead
107111
const pkgJson = /** @type {PackageJson} */ (
108-
await readJsonFile(new URL('../package.json', import.meta.url))
112+
await readJsonFile(toPath(new URL('../package.json', import.meta.url)))
109113
)
110114
const version = `${pkgJson.version}`
111115
const homepage = `${pkgJson.homepage}`
@@ -136,6 +140,9 @@ const main = async (args = hideBin(process.argv)) => {
136140
* just so happens that both commands have idential `entrypoint` arguments
137141
* (positionals). All _other_ properties are defined as global options; if
138142
* _all_ properties were global, this could just live in global middleware.
143+
*
144+
* In other words, this is here to avoid the inevitable future bug when a new
145+
* command is added.
139146
* @param {{
140147
* entrypoint: string
141148
* bin?: boolean
@@ -148,10 +155,8 @@ const main = async (args = hideBin(process.argv)) => {
148155
argv.entrypoint = argv.bin
149156
? resolveBinScript(argv.entrypoint, { from: argv.root })
150157
: resolveEntrypoint(argv.entrypoint, argv.root)
151-
if (entrypoint !== argv.entrypoint) {
152-
// note: this will print if the original entrypoint is a relative path; we
153-
// may or may not want to continue displaying it for that specific case.
154-
log.warning(`Resolved ${entrypoint} to ${argv.entrypoint}`)
158+
if (hrPath(entrypoint) !== hrPath(argv.entrypoint)) {
159+
log.warning(`Resolved ${hrPath(entrypoint)}${hrPath(argv.entrypoint)}`)
155160
}
156161
}
157162

@@ -162,7 +167,7 @@ const main = async (args = hideBin(process.argv)) => {
162167
* project. This is _probably_ not an issue anywhere other than in a dev
163168
* environment, but I wanted to make sure.
164169
*/
165-
yargs(args)
170+
await yargs(args)
166171
.parserConfiguration({
167172
/**
168173
* We deviate from yargs' default behavior by disabling
@@ -206,7 +211,17 @@ const main = async (args = hideBin(process.argv)) => {
206211
global: true,
207212
group: BEHAVIOR_GROUP,
208213
},
209-
// the three policy options are used for both reading and writing
214+
215+
// #region path args
216+
217+
/**
218+
* The three `policy*` options below are used for both reading and
219+
* writing.
220+
*
221+
* Note that `coerce: path.resolve` is _only_ appropriate for the `root`
222+
* option, as the others are computed from it!
223+
*/
224+
210225
policy: {
211226
alias: ['p'],
212227
describe: 'Filepath to a policy file',
@@ -223,15 +238,15 @@ const main = async (args = hideBin(process.argv)) => {
223238
describe: 'Filepath to a policy override file',
224239
type: 'string',
225240
normalize: true,
226-
default: constants.DEFAULT_POLICY_OVERRIDE_PATH,
241+
defaultDescription: constants.DEFAULT_POLICY_OVERRIDE_PATH,
227242
nargs: 1,
228243
requiresArg: true,
229244
global: true,
230245
group: PATH_GROUP,
231246
},
232247
'policy-debug': {
233248
describe: 'Filepath to a policy debug file',
234-
default: constants.DEFAULT_POLICY_DEBUG_PATH,
249+
defaultDescription: constants.DEFAULT_POLICY_DEBUG_PATH,
235250
nargs: 1,
236251
type: 'string',
237252
requiresArg: true,
@@ -250,6 +265,8 @@ const main = async (args = hideBin(process.argv)) => {
250265
global: true,
251266
group: PATH_GROUP,
252267
},
268+
// #endregion
269+
253270
dev: {
254271
describe: 'Include development dependencies',
255272
type: 'boolean',
@@ -272,50 +289,56 @@ const main = async (args = hideBin(process.argv)) => {
272289
.conflicts('quiet', 'verbose')
273290
.middleware(
274291
/**
275-
* This resolves all paths from `cwd`.
292+
* This _global_ middleware:
276293
*
277-
* @remarks
278-
* This runs _before_ validation (second parameter).
294+
* - Ensures `policy`, `policy-debug` and `policy-override` paths are
295+
* absolute
296+
* - If necessary, calculates default path(s) for `policy-debug` and
297+
* `policy-override` (relative to `policy`) and sets it
298+
* - Configures the global logger based on `verbose` and `quiet` flags
299+
*
300+
* It will throw an exception if the user _explicitly_ provided a path to
301+
* a policy override file and that file is unreadable (since this is
302+
* really the only time it is feasible to do so).
279303
*/
280-
(argv) => {
304+
async (argv) => {
305+
await Promise.resolve()
306+
281307
argv.policy = path.resolve(argv.root, argv.policy)
282-
argv['policy-override'] = path.resolve(
283-
argv.root,
284-
argv['policy-override']
285-
)
286-
argv['policy-debug'] = path.resolve(argv.root, argv['policy-debug'])
287308

309+
// TODO: this mini-algorithm should be extracted to a function since it's used elsewhere too
310+
argv['policy-debug'] = argv['policy-debug']
311+
? path.resolve(argv.root, argv['policy-debug'])
312+
: path.join(
313+
path.dirname(argv.policy),
314+
constants.DEFAULT_POLICY_DEBUG_FILENAME
315+
)
316+
317+
if (argv['policy-override']) {
318+
argv['policy-override'] = path.resolve(
319+
argv.root,
320+
argv['policy-override']
321+
)
322+
try {
323+
await fs.promises.access(argv['policy-override'], fs.constants.R_OK)
324+
} catch (err) {
325+
throw new Error(
326+
`Cannot read specified policy override file: ${argv['policy-override']}`,
327+
{ cause: err }
328+
)
329+
}
330+
} else {
331+
argv['policy-override'] = path.join(
332+
path.dirname(argv.policy),
333+
constants.DEFAULT_POLICY_OVERRIDE_FILENAME
334+
)
335+
}
288336
if (argv.verbose) {
289337
log.setLevel('debug')
290338
} else if (argv.quiet) {
339+
// This assumes that we will never use the "emergency" log level!
291340
log.setLevel('emergency')
292341
}
293-
},
294-
true // RUN BEFORE CHECK FN
295-
)
296-
.check(
297-
/**
298-
* This validator is _global_ and runs before command-specific validators
299-
* (I think)
300-
*/
301-
(argv) => {
302-
assertAbsolutePath(
303-
argv.root,
304-
`${em('root')} must be an absolute path; ${reportThisBug}`
305-
)
306-
assertAbsolutePath(
307-
argv.policy,
308-
`${em('policy')} must be an absolute path; ${reportThisBug}`
309-
)
310-
assertAbsolutePath(
311-
argv['policy-override'],
312-
`${em('policy-override')} must be an absolute path; ${reportThisBug}`
313-
)
314-
assertAbsolutePath(
315-
argv['policy-debug'],
316-
`${em('policy-debug')} must be an absolute path; ${reportThisBug}`
317-
)
318-
return true
319342
}
320343
)
321344
/**
@@ -378,10 +401,7 @@ const main = async (args = hideBin(process.argv)) => {
378401
/**
379402
* Resolve entrypoint from `root`
380403
*/
381-
.middleware(
382-
processEntrypointMiddleware,
383-
true // RUN BEFORE CHECK FN
384-
),
404+
.middleware(processEntrypointMiddleware),
385405
/**
386406
* Default command handler.
387407
*
@@ -399,9 +419,17 @@ const main = async (args = hideBin(process.argv)) => {
399419
'policy-debug': policyDebugPath,
400420
'policy-override': policyOverridePath,
401421
dev,
422+
root: projectRoot,
402423
write,
403424
} = argv
404425

426+
const trustRoot = shouldTrustRoot(entrypoint)
427+
if (!trustRoot) {
428+
log.info(
429+
`Entrypoint is in a ${hrPath('node_modules/')} directory and is considered untrusted`
430+
)
431+
}
432+
405433
/**
406434
* This will be the policy merged with overrides, if present
407435
*
@@ -416,27 +444,17 @@ const main = async (args = hideBin(process.argv)) => {
416444
debug,
417445
policyPath,
418446
policyDebugPath,
447+
policyOverridePath,
419448
write,
420449
dev,
450+
trustRoot,
451+
projectRoot,
421452
})
422453
} else {
423-
try {
424-
policy = await loadPolicies(policyPath, policyOverridePath)
425-
} catch (e) {
426-
const err = /** @type {NodeJS.ErrnoException} */ (e)
427-
if (err.code === 'ENOENT') {
428-
throw new Error(
429-
`Could not load policy from ${em(argv.policy)} and/or ${em(argv['policy-override'])}; reason:\n${err.message}`
430-
)
431-
}
432-
if (err.code === 'EISDIR') {
433-
// TODO: actually allow a directory and apply a default filename
434-
throw new Error(
435-
`Could not load policy from ${em(argv.policy)} and/or ${em(argv['policy-override'])}; specify a filepath instead of a directory`
436-
)
437-
}
438-
throw err
439-
}
454+
policy = await loadPolicies(policyPath, {
455+
policyOverridePath,
456+
projectRoot,
457+
})
440458
}
441459

442460
stripProcessArgv(
@@ -448,7 +466,12 @@ const main = async (args = hideBin(process.argv)) => {
448466
*/ (argv['--'])
449467
)
450468

451-
await run(entrypoint, policy)
469+
await run(entrypoint, policy, {
470+
policyOverridePath,
471+
trustRoot,
472+
dev,
473+
projectRoot,
474+
})
452475
}
453476
)
454477
.command(
@@ -477,29 +500,35 @@ const main = async (args = hideBin(process.argv)) => {
477500
describe: 'Application entry point',
478501
type: 'string',
479502
})
480-
.middleware(processEntrypointMiddleware, true),
503+
.middleware(processEntrypointMiddleware),
481504
async ({
482505
entrypoint,
483506
debug,
484507
policy: policyPath,
485508
'policy-debug': policyDebugPath,
509+
'policy-override': policyOverridePath,
486510
dev,
487511
write,
488512
}) => {
513+
const trustRoot = shouldTrustRoot(entrypoint)
514+
if (!trustRoot) {
515+
log.info(
516+
`Entrypoint is in a ${hrPath('node_modules/')} directory and is considered untrusted`
517+
)
518+
}
519+
489520
const policy = await generatePolicy(entrypoint, {
490521
debug,
491522
write,
492523
policyPath,
493524
policyDebugPath,
525+
policyOverridePath,
494526
dev,
527+
trustRoot,
495528
})
496529

497-
if (debug) {
498-
log.info(`Wrote debug policy to ${policyDebugPath}`)
499-
}
500-
if (write) {
501-
log.info(`Wrote policy to ${policyPath}`)
502-
} else {
530+
if (!write) {
531+
// console used here since the logger only uses stderr
503532
// eslint-disable-next-line no-console
504533
console.log(jsonStringifySortedPolicy(policy))
505534
}
@@ -526,7 +555,7 @@ const main = async (args = hideBin(process.argv)) => {
526555
.showHelpOnFail(false)
527556
.demandCommand(1)
528557
.strict(true)
529-
.parse()
558+
.parseAsync()
530559
}
531560

532561
// void here means "ignore the return value". it's a Promise, if you must know.

0 commit comments

Comments
 (0)