├── .gitattributes ├── .gitignore ├── logo.png ├── .vscode └── settings.json ├── src ├── index.ts ├── logger.ts ├── hooks.ts ├── errors.ts ├── Report.ts ├── Fakeium.ts └── bootstrap.js ├── .ignore ├── after-build.ts ├── .editorconfig ├── tsconfig.json ├── eslint.config.js ├── .github └── workflows │ ├── tests.yml │ └── publish.yml ├── LICENSE ├── package.json ├── tests ├── integration.spec.ts ├── Report.spec.ts ├── data │ ├── webext.txt │ └── react.min.txt └── Fakeium.spec.ts └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/josemmo/fakeium/HEAD/logo.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["fakeium"], 3 | } 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './errors' 2 | export * from './hooks' 3 | export * from './logger' 4 | export * from './Fakeium' 5 | export * from './Report' 6 | -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | # Ignore file for line counters such as: 2 | # - https://github.com/boyter/scc 3 | # - https://github.com/XAMPPRocky/tokei 4 | *.json 5 | *.md 6 | *.txt 7 | *.yml 8 | -------------------------------------------------------------------------------- /after-build.ts: -------------------------------------------------------------------------------- 1 | import { copyFileSync } from 'fs' 2 | 3 | const sourcePath = new URL('src/bootstrap.js', import.meta.url) 4 | const destPath = new URL('dist/bootstrap.js', import.meta.url) 5 | copyFileSync(sourcePath, destPath) 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | root = true 5 | charset = utf-8 6 | 7 | [*] 8 | end_of_line = lf 9 | indent_style = space 10 | indent_size = 4 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | 14 | [*.yml] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "es2022", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "noEmit": true, 11 | }, 12 | "include": ["src/**/*", "tests/**/*"], 13 | } 14 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | export interface LoggerInterface { 2 | debug(...args: unknown[]): unknown 3 | info(...args: unknown[]): unknown 4 | warn(...args: unknown[]): unknown 5 | error(...args: unknown[]): unknown 6 | } 7 | 8 | export class DefaultLogger implements LoggerInterface { 9 | public debug(...args: unknown[]) { 10 | console.debug(...args) 11 | } 12 | 13 | public info(...args: unknown[]) { 14 | console.log(...args) 15 | } 16 | 17 | public warn(...args: unknown[]) { 18 | console.warn(...args) 19 | } 20 | 21 | public error(...args: unknown[]) { 22 | console.error(...args) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | import type { ExternalCopy, Reference as ivmReference } from 'isolated-vm' 2 | 3 | /** 4 | * Class representing a reference to an object inside the sandbox. 5 | * Does not actually hold the value for the object. 6 | */ 7 | export class Reference { 8 | public readonly path: string 9 | 10 | /** 11 | * 12 | * @param path Path of value to which redirect events 13 | */ 14 | public constructor(path: string) { 15 | this.path = path 16 | } 17 | } 18 | 19 | export type Hook = { 20 | path: string 21 | isWritable: boolean 22 | } & ( 23 | { newPath: string } | 24 | { value: ExternalCopy } | 25 | { function: ivmReference } 26 | ) 27 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js' 2 | import tseslint from 'typescript-eslint' 3 | 4 | export default tseslint.config( 5 | eslint.configs.recommended, 6 | ...tseslint.configs.strict, 7 | ...tseslint.configs.stylistic, 8 | { 9 | rules: { 10 | '@typescript-eslint/no-extraneous-class': 'off', 11 | '@typescript-eslint/no-invalid-void-type': 'off', 12 | '@typescript-eslint/no-unused-vars': [ 13 | 'error', 14 | { caughtErrorsIgnorePattern: '^_' }, 15 | ], 16 | 'no-regex-spaces': 'off', 17 | }, 18 | }, 19 | { 20 | ignores: ['dist/*'], 21 | }, 22 | ) 23 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | tests: 7 | name: Unit Tests 8 | runs-on: ubuntu-latest 9 | timeout-minutes: 3 10 | steps: 11 | # Download code from repository 12 | - name: Checkout code 13 | uses: actions/checkout@v5 14 | 15 | # Setup Node.js 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v5 18 | with: 19 | node-version: '22' 20 | cache: yarn 21 | 22 | # Install dependencies 23 | - name: Install dependencies 24 | run: yarn install 25 | 26 | # Run linter 27 | - name: Run linter 28 | run: yarn lint 29 | 30 | # Run tests 31 | - name: Run unit tests 32 | run: yarn test 33 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | id-token: write 13 | steps: 14 | # Download code from repository 15 | - name: Checkout code 16 | uses: actions/checkout@v5 17 | 18 | # Setup Node.js 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v5 21 | with: 22 | node-version: '22' 23 | cache: yarn 24 | registry-url: https://registry.npmjs.org 25 | 26 | # Install dependencies 27 | - name: Install dependencies 28 | run: yarn install 29 | 30 | # Build package 31 | - name: Build package 32 | run: yarn build 33 | 34 | # Publish package 35 | - name: Publish package 36 | run: npm publish --provenance --access public 37 | env: 38 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-present José Miguel Moreno (https://github.com/josemmo) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fakeium", 3 | "version": "0.0.8", 4 | "author": "José Miguel Moreno ", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/josemmo/fakeium.git" 8 | }, 9 | "homepage": "https://github.com/josemmo/fakeium", 10 | "license": "MIT", 11 | "type": "module", 12 | "main": "dist/index.js", 13 | "types": "dist/index.d.ts", 14 | "files": [ 15 | "dist" 16 | ], 17 | "scripts": { 18 | "lint": "tsc --noEmit && eslint .", 19 | "build": "pkgroll --clean-dist && tsx after-build.ts", 20 | "test": "mocha --import=tsx tests/**/*.spec.ts" 21 | }, 22 | "dependencies": { 23 | "@types/node": "^24.5.2", 24 | "isolated-vm": "^6.0.1" 25 | }, 26 | "devDependencies": { 27 | "@eslint/js": "^9.36.0", 28 | "@types/chai": "^5.2.2", 29 | "@types/mocha": "^10.0.10", 30 | "chai": "^6.0.1", 31 | "eslint": "^9.36.0", 32 | "mocha": "^11.7.2", 33 | "pkgroll": "^2.15.4", 34 | "tsx": "^4.20.6", 35 | "typescript": "^5.9.2", 36 | "typescript-eslint": "^8.44.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | export abstract class FakeiumError extends Error { 2 | // Intentionally left blank 3 | } 4 | 5 | /** 6 | * Error thrown when a path is malformed or not valid. 7 | */ 8 | export class InvalidPathError extends FakeiumError { 9 | // Intentionally left blank 10 | } 11 | 12 | /** 13 | * Error thrown when a value cannot be serialized inside the sandbox. 14 | */ 15 | export class InvalidValueError extends FakeiumError { 16 | // Intentionally left blank 17 | } 18 | 19 | /** 20 | * Error thrown when a script or module cannot be resolved. 21 | */ 22 | export class SourceNotFoundError extends FakeiumError { 23 | // Intentionally left blank 24 | } 25 | 26 | /** 27 | * Error thrown when the source code of a module could not be parsed. 28 | */ 29 | export class ParsingError extends FakeiumError { 30 | // Intentionally left blank 31 | } 32 | 33 | /** 34 | * Error that encapsulates an uncaught error thrown inside the execution sandbox. 35 | */ 36 | export class ExecutionError extends FakeiumError { 37 | /** Original thrown error */ 38 | public cause: Error 39 | 40 | public constructor(message: string, cause: Error) { 41 | super(message) 42 | this.cause = cause 43 | } 44 | } 45 | 46 | /** 47 | * Error thrown when a script exceeds its maximum execution time. 48 | */ 49 | export class TimeoutError extends FakeiumError { 50 | // Intentionally left blank 51 | } 52 | 53 | /** 54 | * Error thrown when an instance exceeds its maximum allowed memory. 55 | */ 56 | export class MemoryLimitError extends FakeiumError { 57 | // Intentionally left blank 58 | } 59 | -------------------------------------------------------------------------------- /tests/integration.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { Fakeium } from '../src/Fakeium' 3 | import { DefaultLogger } from '../src/logger' 4 | import { readFileSync } from 'fs' 5 | import { dirname } from 'path' 6 | import { fileURLToPath } from 'url' 7 | 8 | const DATA_DIR = dirname(fileURLToPath(import.meta.url)) + '/data' 9 | const logger = (process.env.LOG_LEVEL === 'debug') ? new DefaultLogger() : null 10 | 11 | describe('Integration', () => { 12 | it('aight.js', async () => { 13 | const fakeium = new Fakeium({ logger }) 14 | await fakeium.run('aight.js', readFileSync(`${DATA_DIR}/aight.txt`)) 15 | expect(fakeium.getReport().has({ type: 'SetEvent', path: 'returnExports' })).to.equal(true) 16 | fakeium.dispose() 17 | }) 18 | 19 | it('asciinema-player.min.js', async () => { 20 | const fakeium = new Fakeium({ logger, timeout: 15000 }) 21 | await fakeium.run('asciinema-player.min.js', readFileSync(`${DATA_DIR}/asciinema-player.min.txt`)) 22 | expect(fakeium.getReport().has({ type: 'GetEvent', path: 'wrap().shadowRoot' })).to.equal(true) 23 | expect(fakeium.getReport().has({ 24 | type: 'CallEvent', 25 | path: 'document.registerElement', 26 | arguments: [ 27 | { literal: 'asciinema-player' }, 28 | ], 29 | })).to.equal(true) 30 | fakeium.dispose() 31 | }).timeout(16000) 32 | 33 | it('jquery.js', async () => { 34 | const fakeium = new Fakeium({ logger }) 35 | await fakeium.run('jquery.js', readFileSync(`${DATA_DIR}/jquery.txt`)) 36 | expect(fakeium.getReport().has({ 37 | type: 'GetEvent', 38 | path: 'document.nodeType', 39 | value: { literal: 9 }, 40 | })).to.equal(true) 41 | expect(fakeium.getReport().has({ 42 | type: 'GetEvent', 43 | path: 'document.readyState', 44 | value: { literal: 'complete' }, 45 | })).to.equal(true) 46 | expect(fakeium.getReport().has({ type: 'SetEvent', path: 'jQuery' })).to.equal(true) 47 | fakeium.dispose() 48 | }) 49 | 50 | it('lodash.js', async () => { 51 | const fakeium = new Fakeium({ logger }) 52 | await fakeium.run('lodash.js', readFileSync(`${DATA_DIR}/lodash.txt`)) 53 | expect(fakeium.getReport().has({ type: 'SetEvent', path: '_' })).to.equal(true) 54 | fakeium.dispose() 55 | }) 56 | 57 | it('moment.js', async () => { 58 | const fakeium = new Fakeium({ logger }) 59 | await fakeium.run('moment.js', 60 | readFileSync(`${DATA_DIR}/moment.txt`, 'utf-8') + '\n' + 61 | 'console.log("Date is " + moment("01022003", "DDMMYYYY").format("YYYY-MM-DD"));\n' 62 | ) 63 | expect(fakeium.getReport().has({ 64 | type: 'CallEvent', 65 | path: 'console.log', 66 | arguments: [ { literal: 'Date is 2003-02-01' }], 67 | })) 68 | fakeium.dispose() 69 | }) 70 | 71 | it('react.min.js', async () => { 72 | const fakeium = new Fakeium({ logger }) 73 | await fakeium.run('react.min.js', readFileSync(`${DATA_DIR}/react.min.txt`)) 74 | expect(fakeium.getReport().has({ type: 'SetEvent', path: 'React' })).to.equal(true) 75 | fakeium.dispose() 76 | }) 77 | 78 | it('webext.js', async () => { 79 | const fakeium = new Fakeium({ logger, sourceType: 'module' }) 80 | fakeium.setResolver(async () => readFileSync(`${DATA_DIR}/webext.txt`)) 81 | await fakeium.run('index.js', 82 | 'import webext from "webext.js";\n' + 83 | 'const tab = webext.tabs.query({ active: true });\n' + 84 | 'console.log(`Active tab ID is ${tab.id}`);\n' 85 | ) 86 | expect(fakeium.getReport().has({ type: 'CallEvent', path: 'browser.tabs.query' })).to.equal(true) 87 | expect(Array.from(fakeium.getReport().findAll({ type: 'CallEvent' }))).to.have.lengthOf(2) 88 | fakeium.dispose() 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /tests/Report.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { Location, Report, ReportEvent } from '../src/Report' 3 | 4 | const TEST_LOCATION: Location = { 5 | filename: 'file:///test.js', 6 | line: 1, 7 | column: 1, 8 | } 9 | 10 | describe('Report', () => { 11 | it('calculates size accordingly', async () => { 12 | const report = new Report() 13 | expect(report.size()).to.equal(0) 14 | 15 | // Add events 16 | for (let id=1; id<=8; id++) { 17 | report.add({ 18 | type: 'GetEvent', 19 | path: 'before', 20 | value: { ref: id }, 21 | location: TEST_LOCATION, 22 | }) 23 | } 24 | expect(report.size()).to.equal(8) 25 | 26 | // Clear report 27 | report.clear() 28 | expect(report.size()).to.equal(0) 29 | }) 30 | 31 | it('adds and returns all events', async () => { 32 | const events: ReportEvent[] = [ 33 | { 34 | type: 'GetEvent', 35 | path: 'test', 36 | value: { ref: 1 }, 37 | location: TEST_LOCATION, 38 | }, 39 | { 40 | type: 'GetEvent', 41 | path: 'another', 42 | value: { ref: 2 }, 43 | location: TEST_LOCATION, 44 | }, 45 | { 46 | type: 'SetEvent', 47 | path: 'something', 48 | value: { ref: 3 }, 49 | location: { 50 | filename: 'https://localhost/another/file.js', 51 | line: 101, 52 | column: 202, 53 | }, 54 | }, 55 | ] 56 | const report = new Report() 57 | report.add(events[0]) 58 | report.add(events[1]) 59 | report.add(events[2]) 60 | expect(report.getAll()).to.deep.equal(events) 61 | }) 62 | 63 | it('finds events from queries', async () => { 64 | const events: ReportEvent[] = [ 65 | { 66 | type: 'GetEvent', 67 | path: 'first', 68 | value: { ref: 1 }, 69 | location: TEST_LOCATION, 70 | }, 71 | { 72 | type: 'GetEvent', 73 | path: 'second', 74 | value: { literal: 200 }, 75 | location: { ...TEST_LOCATION, line: 222 }, 76 | }, 77 | { 78 | type: 'SetEvent', 79 | path: 'third', 80 | value: { literal: 'hey' }, 81 | location: { ...TEST_LOCATION, column: 333 }, 82 | }, 83 | { 84 | type: 'CallEvent', 85 | path: 'callMe', 86 | arguments: [ 87 | { ref: 2 }, 88 | { literal: 'input' }, 89 | { ref: 3 }, 90 | ], 91 | returns: { literal: undefined }, 92 | isConstructor: false, 93 | location: TEST_LOCATION, 94 | }, 95 | { 96 | type: 'CallEvent', 97 | path: 'NoArguments', 98 | arguments: [], 99 | returns: { ref: 4 }, 100 | isConstructor: true, 101 | location: TEST_LOCATION, 102 | }, 103 | ] 104 | const report = new Report() 105 | report.add(events[0]) 106 | report.add(events[1]) 107 | report.add(events[2]) 108 | report.add(events[3]) 109 | report.add(events[4]) 110 | 111 | // Test "findAll" method 112 | expect(Array.from(report.findAll({}))).to.deep.equal(report.getAll()) 113 | expect(Array.from(report.findAll({ path: 'does.not.exist' }))).to.deep.equal([]) 114 | expect(Array.from(report.findAll({ type: 'SetEvent' }))).to.deep.equal([events[2]]) 115 | expect(Array.from(report.findAll({ location: { filename: TEST_LOCATION.filename } }))).to.deep.equal(report.getAll()) 116 | expect(Array.from(report.findAll({ path: 'second' }))).to.deep.equal([events[1]]) 117 | expect(Array.from(report.findAll({ location: { column: 333 } }))).to.deep.equal([events[2]]) 118 | expect(Array.from(report.findAll({ value: { literal: 200 } }))).to.deep.equal([events[1]]) 119 | expect(Array.from(report.findAll({ arguments: [{ ref: 2 }] }))).to.deep.equal([events[3]]) 120 | expect(Array.from(report.findAll({ isConstructor: false }))).to.deep.equal([events[3]]) 121 | expect(Array.from(report.findAll({ returns: { literal: undefined } }))).to.deep.equal([events[3]]) 122 | 123 | // Test "find" method 124 | expect(report.find({ type: 'GetEvent' })).to.deep.equal(events[0]) 125 | expect(report.find({ value: { literal: 'missing' } })).to.equal(null) 126 | expect(report.find({ location: { filename: TEST_LOCATION.filename } })).to.deep.equal(events[0]) 127 | expect(report.find({ isConstructor: true })).to.equal(events[4]) 128 | expect(report.find({ arguments: [] })).to.equal(events[4]) 129 | 130 | // Test "has" method 131 | expect(report.has({ type: 'GetEvent' })).to.equal(true) 132 | expect(report.has({ value: { literal: 'missing' } })).to.equal(false) 133 | expect(report.has({ path: 'callMe' })).to.equal(true) 134 | expect(report.has({ path: 'doNotCallMe' })).to.equal(false) 135 | }) 136 | }) 137 | -------------------------------------------------------------------------------- /src/Report.ts: -------------------------------------------------------------------------------- 1 | interface BaseEvent { 2 | /** Event type */ 3 | type: ReportEvent['type'] 4 | /** Path to variable that triggered the event (e.g., `navigator.geolocation.getCurrentPosition`) */ 5 | path: string 6 | /** Closest location of the code that triggered the event */ 7 | location: Location 8 | } 9 | 10 | export interface Location { 11 | /** Absolute URL to file, including protocol */ 12 | filename: string 13 | line: number 14 | column: number 15 | } 16 | 17 | export type Value = { 18 | /** Object ID */ 19 | ref: number 20 | literal?: never 21 | } | { 22 | ref?: never 23 | /** Literal value */ 24 | literal: string | number | boolean | null | undefined 25 | } 26 | 27 | export interface GetEvent extends BaseEvent { 28 | type: 'GetEvent' 29 | value: Value 30 | } 31 | 32 | export interface SetEvent extends BaseEvent { 33 | type: 'SetEvent' 34 | value: Value 35 | } 36 | 37 | export interface CallEvent extends BaseEvent { 38 | type: 'CallEvent' 39 | arguments: Value[] 40 | returns: Value 41 | /** Whether call comes from instantiating a new object */ 42 | isConstructor: boolean 43 | } 44 | 45 | export type ReportEvent = GetEvent | SetEvent | CallEvent 46 | 47 | type MappedOmit = { [P in keyof T as Exclude]: T[P] } 48 | type Query = Partial> & { location?: Partial } 49 | 50 | /** 51 | * Helper class for storing and traversing events reported by Fakeium. 52 | */ 53 | export class Report { 54 | private readonly events: ReportEvent[] = [] 55 | 56 | /** 57 | * Get size 58 | * @return Current number of events 59 | */ 60 | public size(): number { 61 | return this.events.length 62 | } 63 | 64 | /** 65 | * Add event 66 | * @package 67 | * @param event Event 68 | */ 69 | public add(event: ReportEvent): void { 70 | this.events.push(event) 71 | } 72 | 73 | /** 74 | * Clear report 75 | */ 76 | public clear(): void { 77 | this.events.length = 0 78 | } 79 | 80 | /** 81 | * Get all events 82 | * @return Array of events 83 | */ 84 | public getAll(): ReportEvent[] { 85 | return this.events 86 | } 87 | 88 | /** 89 | * Find all events that match the given query 90 | * @param query Search query 91 | * @return Iterable of matched events 92 | */ 93 | public* findAll(query: Query): IterableIterator { 94 | for (const event of this.getAll()) { 95 | // Matches type 96 | if (query.type !== undefined && query.type !== event.type) { 97 | continue 98 | } 99 | 100 | // Matches path 101 | if (query.path !== undefined && query.path !== event.path) { 102 | continue 103 | } 104 | 105 | // Matches location 106 | if (query.location?.filename !== undefined && query.location.filename !== event.location.filename) { 107 | continue 108 | } 109 | if (query.location?.line !== undefined && query.location.line !== event.location.line) { 110 | continue 111 | } 112 | if (query.location?.column !== undefined && query.location.column !== event.location.column) { 113 | continue 114 | } 115 | 116 | // Matches value 117 | if ( 118 | ('value' in query) && query.value && 119 | (!('value' in event) || !this.matchesValue(query.value, event.value)) 120 | ) { 121 | continue 122 | } 123 | 124 | // Matches arguments 125 | if ('arguments' in query && query.arguments) { 126 | if ( 127 | !('arguments' in event) || 128 | (query.arguments.length === 0 && event.arguments.length !== 0) 129 | ) { 130 | continue 131 | } 132 | let matches = true 133 | for (const queryArg of query.arguments) { 134 | matches = false 135 | for (const eventArg of event.arguments) { 136 | if (this.matchesValue(queryArg, eventArg)) { 137 | matches = true 138 | break 139 | } 140 | } 141 | if (!matches) { 142 | // Early stop, no need to check rest of query arguments 143 | break 144 | } 145 | } 146 | if (!matches) { 147 | // 1+ query arguments not present in event 148 | continue 149 | } 150 | } 151 | 152 | // Matches return value 153 | if ( 154 | ('returns' in query) && query.returns && 155 | (!('returns' in event) || !this.matchesValue(query.returns, event.returns)) 156 | ) { 157 | continue 158 | } 159 | 160 | // Matches isConstructor 161 | if ( 162 | ('isConstructor' in query) && 163 | (!('isConstructor' in event) || query.isConstructor !== event.isConstructor) 164 | ) { 165 | continue 166 | } 167 | 168 | yield event 169 | } 170 | } 171 | 172 | /** 173 | * Find first event that matches the given query 174 | * @param query Search query 175 | * @return Matched event or `null` if not found 176 | */ 177 | public find(query: Query): ReportEvent | null { 178 | return this.findAll(query).next().value || null 179 | } 180 | 181 | /** 182 | * Has event that matches the given query 183 | * @param query Search query 184 | * @return Whether report has at least one matching event 185 | */ 186 | public has(query: Query): boolean { 187 | return this.find(query) !== null 188 | } 189 | 190 | /** 191 | * Matches value 192 | * @param query Desired value (query) 193 | * @param target Target value to check against 194 | * @return Whether target value matches query 195 | */ 196 | private matchesValue(query: Value, target: Value): boolean { 197 | if (query.ref !== undefined && query.ref !== target.ref) { 198 | return false 199 | } 200 | if ( 201 | ('literal' in query) && 202 | (!('literal' in target) || query.literal !== target.literal) 203 | ) { 204 | return false 205 | } 206 | return true 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Fakeium

2 |

3 | 4 | 5 | 6 | 7 |

8 | 9 | Fakeium (a play on the words *Fake* and *Chromium*) is a lightweight, V8-based sandbox for the dynamic execution of 10 | untrusted JavaScript code. 11 | It aims to improve traditional static analysis by detecting API calls coming from `eval`, `new Function` and heavily 12 | obfuscated code, and does so with a tiny footprint in terms of both memory and CPU usage. 13 | 14 | While originally designed to elicit the behavior of browser extensions *at scale* without having to launch an 15 | instrumented Chromium browser instance and wait about 10 minutes between runs, it can also run any modern JavaScript 16 | code in mere **seconds**. 17 | 18 | ## Features 19 | Fakeium works by mocking all objects accessed by the executed code at runtime, while logging get, set and call events. 20 | It automatically runs all callback functions found inside the sandbox to increase execution coverage. 21 | 22 | It has built-in support for: 23 | - 📦 [JavaScript modules](https://developer.mozilla.org/docs/Web/JavaScript/Guide/Modules) 24 | - 🔗 [Custom origins](https://developer.mozilla.org/docs/Glossary/Origin) 25 | - 🎨 Object tainting 26 | - ⏰ Execution limits (max memory usage and timeout) 27 | - 🎣 Custom hooks 28 | - 🧾 Logging 29 | - 🕵 Event tracing (code that triggered it) 30 | - 🔎 Report events querying 31 | 32 | ## FAQ 33 | **Who is Fakeium intended for?**\ 34 | Fakeium is aimed at security researchers who want to determine the behavior of a JavaScript application, browser 35 | extension, website, etc. 36 | For example, it can be used to detect calls to privacy sensitive APIs or fingerprinting attempts. 37 | 38 | **Why use Fakeium instead of Chromium with Playwright/Puppeteer/Selenium?**\ 39 | When running experiments at scale, it is not always possible to use traditional dynamic analysis due to time and 40 | resource constraints. 41 | In addition, finding good inputs that trigger a sample's malicious code path typically requires manual effort and is not 42 | always possible. 43 | Fakeium is a good alternative when you hit any of these limitations. 44 | 45 | **Why use Fakeium instead of static analysis?**\ 46 | Fakeium does not try to replace traditional static analysis with tools like Babel or Esprima. 47 | Instead, it complements it by increasing analysis coverage through the detection of API calls that would otherwise go 48 | undetected. 49 | 50 | ## Getting Started 51 | 52 | ### Requirements 53 | - Node.js 20 (LTS) 54 | 55 | ### How To Install 56 | ```sh 57 | # Using npm 58 | npm install fakeium 59 | 60 | # Using Yarn 61 | yarn add fakeium 62 | ``` 63 | 64 | ### Examples 65 | The easiest way to run code with Fakeium is to create an instance and call the `Fakeium.run()` method: 66 | 67 | ```js 68 | import { Fakeium } from 'fakeium'; 69 | 70 | (async () => { 71 | const fakeium = new Fakeium(); 72 | await fakeium.run('example.js', 'alert("Hi there!")'); 73 | 74 | // Print all logged events 75 | const events = fakeium.getReport().getAll(); 76 | console.dir(events, { depth: 4 }); 77 | })(); 78 | ``` 79 | 80 | This simple script will produce this console output: 81 | ```js 82 | [ 83 | { 84 | type: 'GetEvent', 85 | path: 'alert', 86 | value: { ref: 1 }, 87 | location: { filename: 'file:///example.js', line: 1, column: 1 } 88 | }, 89 | { 90 | type: 'CallEvent', 91 | path: 'alert', 92 | arguments: [ { literal: 'Hi there!' } ], 93 | returns: { ref: 2 }, 94 | isConstructor: false, 95 | location: { filename: 'file:///example.js', line: 1, column: 1 } 96 | } 97 | ] 98 | ``` 99 | 100 | You can also run apps that span several modules by providing a resolver: 101 | ```js 102 | import { Fakeium } from 'fakeium'; 103 | 104 | (async () => { 105 | const fakeium = new Fakeium({ sourceType: 'module', origin: 'https://localhost' }); 106 | fakeium.setResolver(async url => { 107 | if (url.href === 'https://localhost/index.js') { 108 | return 'import { test } from "./test.js";\n' + 109 | 'console.log("Test is " + test());\n'; 110 | } 111 | if (url.pathname === '/test.js') { 112 | return 'export const test = () => 123'; 113 | } 114 | return null; 115 | }); 116 | await fakeium.run('index.js'); 117 | 118 | // Print a particular event 119 | const logEvent = fakeium.getReport().find({ type: 'CallEvent', path: 'console.log' }); 120 | console.dir(logEvent, { depth: 4 }); 121 | })(); 122 | ``` 123 | 124 | This will produce the following output: 125 | ```js 126 | { 127 | type: 'CallEvent', 128 | path: 'console.log', 129 | arguments: [ { literal: 'Test is 123' } ], 130 | returns: { literal: undefined }, 131 | isConstructor: false, 132 | location: { filename: 'https://localhost/index.js', line: 2, column: 9 } 133 | } 134 | ``` 135 | 136 | ## Working with Hooks 137 | The Fakeium sandbox can be customized for more tailored needs through the use of *hooks*. 138 | Developers can use hooks to modify variables from the sandbox's global scope, as well as to expose bindings that run code outside the isolated environment (for instance, to perform network requests). 139 | 140 | Hooks are defined using the following arguments: 141 | ```ts 142 | fakeium.hook(path: string, value: unknown, isWritable = true): void; 143 | ``` 144 | 145 | There are three types of hooks, that behave differently, depending on the hooked value: 146 | 147 | ### Serializable values 148 | Strings, numbers, plain objects, `ArrayBuffer`s, `undefined` and other variables that support serialization through the [structured clone algorithm](https://developer.mozilla.org/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) are **copied** into the sandbox. 149 | 150 | ```js 151 | fakeium.hook('navigator.userAgent', 'Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0'); 152 | fakeium.hook('config', { 153 | isDesktop: false, 154 | seed: 42, 155 | }); 156 | ``` 157 | 158 | ### Bindings (functions) 159 | When passing a function, Fakeium exposes a binding inside the sandbox that, when invoked, will execute the function in the **outside world**. 160 | Bindings can receive arguments and return values, provided they are serializable through the [structured clone algorithm](https://developer.mozilla.org/docs/Web/API/Web_Workers_API/Structured_clone_algorithm). 161 | Returning promises or awaitable values is also supported. 162 | 163 | > [!WARNING] 164 | > Be careful about what logic you expose to the sandbox when running untrusted code. 165 | 166 | ```js 167 | fakeium.hook('getIpAddress', async () => { 168 | return await fetch('/api/get-ipv4').then(res => res.json()); 169 | }) 170 | ``` 171 | 172 | ### References 173 | When passing a `Reference` instance, Fakeium will redirect all calls that point to the hook to a different path **inside** the sandbox. 174 | By default, Fakeium uses this feature to alias the `globalThis` variable to `window`, among other variables. 175 | 176 | ```js 177 | import { Reference } from 'fakeium'; 178 | 179 | fakeium.hook('anotherWindow', new Reference('globalThis')); 180 | ``` 181 | -------------------------------------------------------------------------------- /tests/data/webext.txt: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | 3 | uBlock Origin - a comprehensive, efficient content blocker 4 | Copyright (C) 2019-present Raymond Hill 5 | 6 | This program is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU General Public License as published by 8 | the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | This program is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU General Public License for more details. 15 | 16 | You should have received a copy of the GNU General Public License 17 | along with this program. If not, see {http://www.gnu.org/licenses/}. 18 | 19 | Home: https://github.com/gorhill/uBlock 20 | */ 21 | 22 | // `webext` is a promisified api of `chrome`. Entries are added as 23 | // the promisification of uBO progress. 24 | 25 | const promisifyNoFail = function(thisArg, fnName, outFn = r => r) { 26 | const fn = thisArg[fnName]; 27 | return function(...args) { 28 | return new Promise(resolve => { 29 | try { 30 | fn.call(thisArg, ...args, function(...args) { 31 | void chrome.runtime.lastError; 32 | resolve(outFn(...args)); 33 | }); 34 | } catch(ex) { 35 | console.error(ex); 36 | resolve(outFn()); 37 | } 38 | }); 39 | }; 40 | }; 41 | 42 | const promisify = function(thisArg, fnName) { 43 | const fn = thisArg[fnName]; 44 | return function(...args) { 45 | return new Promise((resolve, reject) => { 46 | try { 47 | fn.call(thisArg, ...args, function(...args) { 48 | const lastError = chrome.runtime.lastError; 49 | if ( lastError instanceof Object ) { 50 | return reject(lastError.message); 51 | } 52 | resolve(...args); 53 | }); 54 | } catch(ex) { 55 | console.error(ex); 56 | resolve(); 57 | } 58 | }); 59 | }; 60 | }; 61 | 62 | const webext = { 63 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/alarms 64 | alarms: { 65 | clear: promisifyNoFail(chrome.alarms, 'clear'), 66 | clearAll: promisifyNoFail(chrome.alarms, 'clearAll'), 67 | create: promisifyNoFail(chrome.alarms, 'create'), 68 | get: promisifyNoFail(chrome.alarms, 'get'), 69 | getAll: promisifyNoFail(chrome.alarms, 'getAll'), 70 | onAlarm: chrome.alarms.onAlarm, 71 | }, 72 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction 73 | browserAction: { 74 | setBadgeBackgroundColor: promisifyNoFail(chrome.browserAction, 'setBadgeBackgroundColor'), 75 | setBadgeText: promisifyNoFail(chrome.browserAction, 'setBadgeText'), 76 | setIcon: promisifyNoFail(chrome.browserAction, 'setIcon'), 77 | setTitle: promisifyNoFail(chrome.browserAction, 'setTitle'), 78 | }, 79 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus 80 | menus: { 81 | create: function() { 82 | return chrome.contextMenus.create(...arguments, ( ) => { 83 | void chrome.runtime.lastError; 84 | }); 85 | }, 86 | onClicked: chrome.contextMenus.onClicked, 87 | remove: promisifyNoFail(chrome.contextMenus, 'remove'), 88 | removeAll: promisifyNoFail(chrome.contextMenus, 'removeAll'), 89 | }, 90 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/privacy 91 | privacy: { 92 | }, 93 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage 94 | storage: { 95 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/local 96 | local: { 97 | clear: promisify(chrome.storage.local, 'clear'), 98 | get: promisify(chrome.storage.local, 'get'), 99 | getBytesInUse: promisify(chrome.storage.local, 'getBytesInUse'), 100 | remove: promisify(chrome.storage.local, 'remove'), 101 | set: promisify(chrome.storage.local, 'set'), 102 | }, 103 | }, 104 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs 105 | tabs: { 106 | get: promisifyNoFail(chrome.tabs, 'get', tab => tab instanceof Object ? tab : null), 107 | executeScript: promisifyNoFail(chrome.tabs, 'executeScript'), 108 | insertCSS: promisifyNoFail(chrome.tabs, 'insertCSS'), 109 | removeCSS: promisifyNoFail(chrome.tabs, 'removeCSS'), 110 | query: promisifyNoFail(chrome.tabs, 'query', tabs => Array.isArray(tabs) ? tabs : []), 111 | reload: promisifyNoFail(chrome.tabs, 'reload'), 112 | remove: promisifyNoFail(chrome.tabs, 'remove'), 113 | sendMessage: promisifyNoFail(chrome.tabs, 'sendMessage'), 114 | update: promisifyNoFail(chrome.tabs, 'update', tab => tab instanceof Object ? tab : null), 115 | }, 116 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webNavigation 117 | webNavigation: { 118 | getFrame: promisify(chrome.webNavigation, 'getFrame'), 119 | getAllFrames: promisify(chrome.webNavigation, 'getAllFrames'), 120 | }, 121 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/windows 122 | windows: { 123 | get: promisifyNoFail(chrome.windows, 'get', win => win instanceof Object ? win : null), 124 | create: promisifyNoFail(chrome.windows, 'create', win => win instanceof Object ? win : null), 125 | update: promisifyNoFail(chrome.windows, 'update', win => win instanceof Object ? win : null), 126 | }, 127 | }; 128 | 129 | // browser.privacy entries 130 | { 131 | const settings = [ 132 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/privacy/network 133 | [ 'network', 'networkPredictionEnabled' ], 134 | [ 'network', 'webRTCIPHandlingPolicy' ], 135 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/privacy/websites 136 | [ 'websites', 'hyperlinkAuditingEnabled' ], 137 | ]; 138 | for ( const [ category, setting ] of settings ) { 139 | let categoryEntry = webext.privacy[category]; 140 | if ( categoryEntry instanceof Object === false ) { 141 | categoryEntry = webext.privacy[category] = {}; 142 | } 143 | const settingEntry = categoryEntry[setting] = {}; 144 | const thisArg = chrome.privacy[category][setting]; 145 | settingEntry.clear = promisifyNoFail(thisArg, 'clear'); 146 | settingEntry.get = promisifyNoFail(thisArg, 'get'); 147 | settingEntry.set = promisifyNoFail(thisArg, 'set'); 148 | } 149 | } 150 | 151 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/managed 152 | if ( chrome.storage.managed instanceof Object ) { 153 | webext.storage.managed = { 154 | get: promisify(chrome.storage.managed, 'get'), 155 | }; 156 | } 157 | 158 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/sync 159 | if ( chrome.storage.sync instanceof Object ) { 160 | webext.storage.sync = { 161 | QUOTA_BYTES: chrome.storage.sync.QUOTA_BYTES, 162 | QUOTA_BYTES_PER_ITEM: chrome.storage.sync.QUOTA_BYTES_PER_ITEM, 163 | MAX_ITEMS: chrome.storage.sync.MAX_ITEMS, 164 | MAX_WRITE_OPERATIONS_PER_HOUR: chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_HOUR, 165 | MAX_WRITE_OPERATIONS_PER_MINUTE: chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_MINUTE, 166 | 167 | clear: promisify(chrome.storage.sync, 'clear'), 168 | get: promisify(chrome.storage.sync, 'get'), 169 | getBytesInUse: promisify(chrome.storage.sync, 'getBytesInUse'), 170 | remove: promisify(chrome.storage.sync, 'remove'), 171 | set: promisify(chrome.storage.sync, 'set'), 172 | }; 173 | } 174 | 175 | // https://bugs.chromium.org/p/chromium/issues/detail?id=608854 176 | if ( chrome.tabs.removeCSS instanceof Function ) { 177 | webext.tabs.removeCSS = promisifyNoFail(chrome.tabs, 'removeCSS'); 178 | } 179 | 180 | export default webext; 181 | -------------------------------------------------------------------------------- /tests/data/react.min.txt: -------------------------------------------------------------------------------- 1 | /** 2 | * @license React 3 | * react.production.min.js 4 | * 5 | * Copyright (c) Facebook, Inc. and its affiliates. 6 | * 7 | * This source code is licensed under the MIT license found in the 8 | * LICENSE file in the root directory of this source tree. 9 | */ 10 | (function(){'use strict';(function(c,x){"object"===typeof exports&&"undefined"!==typeof module?x(exports):"function"===typeof define&&define.amd?define(["exports"],x):(c=c||self,x(c.React={}))})(this,function(c){function x(a){if(null===a||"object"!==typeof a)return null;a=V&&a[V]||a["@@iterator"];return"function"===typeof a?a:null}function w(a,b,e){this.props=a;this.context=b;this.refs=W;this.updater=e||X}function Y(){}function K(a,b,e){this.props=a;this.context=b;this.refs=W;this.updater=e||X}function Z(a,b, 11 | e){var m,d={},c=null,h=null;if(null!=b)for(m in void 0!==b.ref&&(h=b.ref),void 0!==b.key&&(c=""+b.key),b)aa.call(b,m)&&!ba.hasOwnProperty(m)&&(d[m]=b[m]);var l=arguments.length-2;if(1===l)d.children=e;else if(1>>1,d=a[c];if(0>>1;cD(l,e))fD(g,l)?(a[c]=g,a[f]=e,c=f):(a[c]=l,a[h]=e,c=h);else if(fD(g,e))a[c]=g,a[f]=e,c=f;else break a}}return b} 16 | function D(a,b){var c=a.sortIndex-b.sortIndex;return 0!==c?c:a.id-b.id}function P(a){for(var b=p(r);null!==b;){if(null===b.callback)E(r);else if(b.startTime<=a)E(r),b.sortIndex=b.expirationTime,O(q,b);else break;b=p(r)}}function Q(a){z=!1;P(a);if(!u)if(null!==p(q))u=!0,R(S);else{var b=p(r);null!==b&&T(Q,b.startTime-a)}}function S(a,b){u=!1;z&&(z=!1,ea(A),A=-1);F=!0;var c=k;try{P(b);for(n=p(q);null!==n&&(!(n.expirationTime>b)||a&&!fa());){var m=n.callback;if("function"===typeof m){n.callback=null; 17 | k=n.priorityLevel;var d=m(n.expirationTime<=b);b=v();"function"===typeof d?n.callback=d:n===p(q)&&E(q);P(b)}else E(q);n=p(q)}if(null!==n)var g=!0;else{var h=p(r);null!==h&&T(Q,h.startTime-b);g=!1}return g}finally{n=null,k=c,F=!1}}function fa(){return v()-hae?(a.sortIndex=c,O(r,a),null===p(q)&&a===p(r)&&(z?(ea(A),A=-1):z=!0,T(Q,c-e))):(a.sortIndex=d,O(q,a),u||F||(u=!0,R(S)));return a},unstable_cancelCallback:function(a){a.callback=null},unstable_wrapCallback:function(a){var b= 24 | k;return function(){var c=k;k=b;try{return a.apply(this,arguments)}finally{k=c}}},unstable_getCurrentPriorityLevel:function(){return k},unstable_shouldYield:fa,unstable_requestPaint:function(){},unstable_continueExecution:function(){u||F||(u=!0,R(S))},unstable_pauseExecution:function(){},unstable_getFirstCallbackNode:function(){return p(q)},get unstable_now(){return v},unstable_forceFrameRate:function(a){0>a||125 Promise 57 | 58 | /** Pattern to match valid property path accessors */ 59 | const PATH_PATTERN = /^[a-z_$][a-z0-9_$]*(\.[a-z_$][a-z0-9_$]*|\[".+?"\]|\['.+?'\]|\[\d+\])*$/i 60 | 61 | /** JavaScript bootstrap code to run inside the sandbox */ 62 | const BOOTSTRAP_CODE = readFileSync(new URL('bootstrap.js', import.meta.url), 'utf-8') 63 | 64 | /** 65 | * Fakeium (from "Fake" and "Chromium") is a simple yet *safe* instrumented environment for running 66 | * untrusted JavaScript code that was intended to be run in a web browser. 67 | * 68 | * Rather than replacing dynamic analysis, its main goal is to complement static analysis by detecting 69 | * API calls that would otherwise be missed using traditional AST parsing. 70 | */ 71 | export class Fakeium { 72 | private readonly options: Required 73 | private resolver: SourceResolver = async () => null 74 | private hooks = new Map() 75 | private isolate: ivm.Isolate | null = null 76 | private readonly pathToModule = new Map() 77 | private readonly moduleToPath = new Map() 78 | private readonly report = new Report() 79 | private readonly stats: FakeiumStats = { 80 | cpuTime: 0n, 81 | wallTime: 0n, 82 | totalHeapSize: 0, 83 | totalHeapSizeExecutable: 0, 84 | totalPhysicalSize: 0, 85 | usedHeapSize: 0, 86 | mallocedMemory: 0, 87 | peakMallocedMemory: 0, 88 | externallyAllocatedSize: 0, 89 | } 90 | private nextValueId = 1 91 | 92 | /** 93 | * @param options Instance-wide options 94 | */ 95 | public constructor(options: FakeiumInstanceOptions = {}) { 96 | this.options = { 97 | sourceType: 'script', 98 | origin: 'file:///', 99 | maxMemory: 64, 100 | timeout: 10000, 101 | logger: null, 102 | ...options, 103 | } 104 | 105 | // Auto-wire aliases of the "globalThis" object 106 | for (const path of ['frames', 'global', 'parent', 'self', 'window']) { 107 | this.hook(path, new Reference('globalThis')) 108 | } 109 | 110 | // Setup document object 111 | this.hook('document', { 112 | nodeType: 9, // Node.DOCUMENT_NODE 113 | readyState: 'complete', 114 | }) 115 | 116 | // Setup environment for browser extensions 117 | this.hook('browser', {}) 118 | this.hook('chrome', new Reference('browser')) 119 | 120 | // Prevent mocking AMD module loaders 121 | for (const path of ['define', 'exports', 'module', 'require']) { 122 | this.hook(path, undefined) 123 | } 124 | } 125 | 126 | /** 127 | * Set source resolver 128 | * @param resolver Resolver callback 129 | */ 130 | public setResolver(resolver: SourceResolver): void { 131 | this.resolver = resolver 132 | } 133 | 134 | /** 135 | * Hook value inside sandbox 136 | * 137 | * Will overwrite any existing hook for the same path. 138 | * 139 | * Allowed values are: 140 | * - Serializable values that can be copied to the sandbox using the 141 | * [structured clone algorithm](https://developer.mozilla.org/docs/Web/API/Web_Workers_API/Structured_clone_algorithm). 142 | * - Functions that receive and/or return serializable values. Note that, while the aforementioned values will be 143 | * copied from/to the sandbox, functions are executed outside the sandbox. 144 | * - Instances of {@link Reference} that point to a different value path inside the sandbox. 145 | * 146 | * @param path Path of value to hook 147 | * @param value Value to return 148 | * @param isWritable Whether hook can have its value overwritten inside the sandbox, `true` by default 149 | * @throws {InvalidPathError} if the provided path is not valid 150 | * @throws {InvalidValueError} if the provided value is not valid 151 | */ 152 | public hook(path: string, value: unknown, isWritable = true): void { 153 | this.validatePath(path) 154 | if (value instanceof Reference) { 155 | this.validatePath(value.path) 156 | this.hooks.set(path, { 157 | path, 158 | isWritable, 159 | newPath: value.path, 160 | }) 161 | } else if (typeof value === 'function') { 162 | this.hooks.set(path, { 163 | path, 164 | isWritable, 165 | function: new ivm.Reference(value), 166 | }) 167 | } else { 168 | try { 169 | this.hooks.set(path, { 170 | path, 171 | isWritable, 172 | value: new ivm.ExternalCopy(value), 173 | }) 174 | } catch (e) { 175 | throw new InvalidValueError((e as TypeError).message) 176 | } 177 | } 178 | } 179 | 180 | /** 181 | * Unhook value inside sandbox 182 | * @param path Path of value to unhook 183 | */ 184 | public unhook(path: string): void { 185 | this.hooks.delete(path) 186 | } 187 | 188 | /** 189 | * Run code in sandbox 190 | * @param specifier Specifier 191 | * @param options Additional execution options 192 | * @throws {ExecutionError} if an uncaught error was thrown inside the sandbox 193 | * @throws {MemoryLimitError} if exceeded max allowed memory for the instance 194 | * @throws {ParsingError} if failed to parse source code 195 | * @throws {SourceNotFoundError} if failed to resolve specifier or imports 196 | * @throws {TimeoutError} if exceeded max execution time 197 | */ 198 | public async run(specifier: string, options?: FakeiumRunOptions): Promise 199 | 200 | /** 201 | * Run source in sandbox 202 | * @param specifier Specifier 203 | * @param sourceCode JavaScript source code 204 | * @param options Additional execution options 205 | * @throws {ExecutionError} if an uncaught error was thrown inside the sandbox 206 | * @throws {MemoryLimitError} if exceeded max allowed memory for the instance 207 | * @throws {ParsingError} if failed to parse source code 208 | * @throws {SourceNotFoundError} if failed to resolve imports 209 | * @throws {TimeoutError} if exceeded max execution time 210 | */ 211 | public async run(specifier: string, sourceCode: SourceCode, options?: FakeiumRunOptions): Promise 212 | public async run(specifier: string, b?: FakeiumRunOptions | SourceCode, c?: FakeiumRunOptions): Promise { 213 | let sourceCode: SourceCode | undefined = undefined 214 | let options: FakeiumRunOptions 215 | if (typeof b === 'string' || Buffer.isBuffer(b)) { 216 | sourceCode = b 217 | options = c || {} 218 | } else { 219 | options = b || {} 220 | } 221 | const timeout = options.timeout ?? this.options.timeout 222 | const sourceType = options.sourceType || this.options.sourceType 223 | 224 | // Create isolate if needed 225 | if (this.isolate === null) { 226 | this.isolate = new ivm.Isolate({ 227 | memoryLimit: this.options.maxMemory, 228 | }) 229 | } 230 | 231 | // Create context 232 | const context = await this.isolate.createContext() 233 | await this.setupContext(context) 234 | 235 | // Instantiate script or module 236 | let scriptOrModule: ivm.Script | ivm.Module 237 | if (sourceType === 'script') { 238 | scriptOrModule = await this.getScript(specifier, sourceCode) 239 | } else { 240 | scriptOrModule = await this.getModule(specifier, undefined, sourceCode) 241 | await scriptOrModule.instantiate(context, (specifier, referrer) => this.getModule(specifier, referrer)) 242 | } 243 | 244 | // Add hard-timeout listener 245 | // See https://github.com/laverdet/isolated-vm/issues/185 246 | let didTimeout = false 247 | const hardTimeout = setTimeout(() => { 248 | this.options.logger?.warn('Script refused to stop, terminating it') 249 | didTimeout = true 250 | this.recordStats() 251 | this.dispose(false) 252 | }, timeout+150) 253 | 254 | // Run source 255 | try { 256 | if ('evaluate' in scriptOrModule) { 257 | await scriptOrModule.evaluate({ timeout }) 258 | } else { 259 | await scriptOrModule.run(context, { timeout }) 260 | } 261 | } catch (e) { 262 | if (!(e instanceof Error)) { 263 | this.options.logger?.warn(`Expected Error from sandbox, received ${typeof e}`) 264 | throw e 265 | } 266 | if (e.message === 'Script execution timed out.') { 267 | didTimeout = true 268 | } else if (e.message === 'Isolate was disposed during execution') { 269 | // Skip throwing an error here as it's most surely caused by a forced timeout 270 | this.options.logger?.debug('Forcedly disposed instance to terminate script') 271 | } else if (e.message === 'Isolate was disposed during execution due to memory limit') { 272 | this.dispose(false) // Account for isolated-vm disposing isolate on memory limit 273 | throw new MemoryLimitError(`Exceeded ${this.options.maxMemory}MiB memory limit`) 274 | } else { 275 | throw new ExecutionError('Uncaught error raised in sandbox', e) 276 | } 277 | } finally { 278 | clearTimeout(hardTimeout) 279 | if (this.isolate !== null) { 280 | this.recordStats() 281 | } 282 | context.release() 283 | } 284 | 285 | // Throw timeout if needed 286 | if (didTimeout) { 287 | throw new TimeoutError(`Exceeded ${timeout}ms timeout`) 288 | } 289 | } 290 | 291 | /** 292 | * Get report 293 | * @return Report instance 294 | */ 295 | public getReport() { 296 | return this.report 297 | } 298 | 299 | /** 300 | * Get stats 301 | * @return Cumulative summary of stats 302 | */ 303 | public getStats() { 304 | return { ...this.stats } 305 | } 306 | 307 | /** 308 | * Dispose instance 309 | * 310 | * Frees from memory any resources used by this instance. 311 | * You should call this method after working with Fakeium to avoid any memory leaks. 312 | * It *is* safe to reuse the instance after disposing. 313 | * 314 | * @param clearReport Whether to clear report as well 315 | */ 316 | public dispose(clearReport = true): void { 317 | // Clear modules 318 | this.pathToModule.clear() 319 | this.moduleToPath.clear() 320 | 321 | // Dispose isolate 322 | if (this.isolate !== null) { 323 | try { 324 | this.isolate.dispose() 325 | } catch (_) { 326 | this.options.logger?.debug('Attempted to dispose a previously disposed isolate, ignored') 327 | } 328 | this.isolate = null 329 | } 330 | 331 | // Clear report 332 | if (clearReport) { 333 | this.report.clear() 334 | this.nextValueId = 1 335 | } 336 | 337 | // Clear stats 338 | if (clearReport) { 339 | this.stats.cpuTime = 0n 340 | this.stats.wallTime = 0n 341 | this.stats.totalHeapSize = 0 342 | this.stats.totalHeapSizeExecutable = 0 343 | this.stats.totalPhysicalSize = 0 344 | this.stats.usedHeapSize = 0 345 | this.stats.mallocedMemory = 0 346 | this.stats.peakMallocedMemory = 0 347 | this.stats.externallyAllocatedSize = 0 348 | } 349 | } 350 | 351 | /** 352 | * Validate path 353 | * @param path Path 354 | * @throws {InvalidPathError} if path is not valid 355 | */ 356 | private validatePath(path: string): void { 357 | if (!PATH_PATTERN.test(path)) { 358 | throw new InvalidPathError(`Path "${path}" is not valid`) 359 | } 360 | } 361 | 362 | /** 363 | * Get script 364 | * @param specifier Specifier 365 | * @param sourceCode Script source code (overrides resolver) 366 | * @return Script instance 367 | * @throws {SourceNotFoundError} if failed to resolve script 368 | * @throws {ParsingError} if failed to parse source code 369 | */ 370 | private async getScript(specifier: string, sourceCode?: SourceCode): Promise { 371 | const url = new URL(specifier, this.options.origin) 372 | 373 | // Compile script 374 | if (sourceCode === undefined) { 375 | sourceCode = await this.resolver(url) ?? undefined 376 | if (sourceCode === undefined) { 377 | throw new SourceNotFoundError(`Cannot find script "${specifier}": failed to resolve absolute URL ${url.href}`) 378 | } 379 | } 380 | if (this.isolate === null) { 381 | throw new ReferenceError('Illegal state: missing isolate when getting script') 382 | } 383 | let script: ivm.Script 384 | try { 385 | script = this.isolate.compileScriptSync(`${sourceCode}`, { filename: url.href }) 386 | } catch (e) { 387 | if (e instanceof SyntaxError) { 388 | throw new ParsingError(e.message) 389 | } 390 | throw e 391 | } 392 | this.options.logger?.debug(`Compiled script ${url.href}`) 393 | 394 | return script 395 | } 396 | 397 | /** 398 | * Get un-instantiated module 399 | * @param specifier Specifier 400 | * @param referrer Referrer module 401 | * @param sourceCode Module source code (overrides resolver) 402 | * @return Module instance 403 | * @throws {SourceNotFoundError} if failed to resolve module 404 | * @throws {ParsingError} if failed to parse source code 405 | */ 406 | private async getModule(specifier: string, referrer?: ivm.Module, sourceCode?: SourceCode): Promise { 407 | // Resolve absolute URL for module 408 | const relativeTo = referrer ? this.moduleToPath.get(referrer) : undefined 409 | const url = new URL(specifier, relativeTo || this.options.origin) 410 | 411 | // Check cache 412 | let cachedModule = this.pathToModule.get(url.href) 413 | if (cachedModule && sourceCode !== undefined) { 414 | this.options.logger?.warn(`Overwriting cached module ${url} with custom source code`) 415 | cachedModule.release() 416 | cachedModule = undefined 417 | } 418 | if (cachedModule) { 419 | return cachedModule 420 | } 421 | 422 | // Compile and cache new module 423 | if (sourceCode === undefined) { 424 | sourceCode = await this.resolver(url) ?? undefined 425 | if (sourceCode === undefined) { 426 | throw new SourceNotFoundError(`Cannot find module "${specifier}": failed to resolve absolute URL ${url.href}`) 427 | } 428 | } 429 | if (this.isolate === null) { 430 | throw new ReferenceError('Illegal instance state: missing isolate') 431 | } 432 | let module: ivm.Module 433 | try { 434 | module = this.isolate.compileModuleSync(`${sourceCode}`, { filename: url.href }) 435 | } catch (e) { 436 | if (e instanceof SyntaxError) { 437 | throw new ParsingError(e.message) 438 | } 439 | throw e 440 | } 441 | this.pathToModule.set(url.href, module) 442 | this.moduleToPath.set(module, url.href) 443 | this.options.logger?.debug(`Compiled module ${url.href}`) 444 | 445 | return module 446 | } 447 | 448 | /** 449 | * Setup context 450 | * @param context Execution context 451 | */ 452 | private async setupContext(context: ivm.Context): Promise { 453 | const logEvent = (event: ReportEvent, nextValueId: number) => { 454 | this.report.add(event) 455 | this.nextValueId = nextValueId 456 | } 457 | const logDebug = (...args: string[]) => this.options.logger?.debug('', ...args) 458 | const awaitReference = async (ref: ivm.Reference) => { 459 | let value = ref.deref() 460 | if (value instanceof Promise) { 461 | value = await value 462 | } 463 | return new ivm.Reference(value) 464 | } 465 | await context.evalClosure( 466 | BOOTSTRAP_CODE, 467 | [ 468 | this.nextValueId, // $0 469 | logEvent, // $1 470 | logDebug, // $2 471 | awaitReference, // $3 472 | Array.from(this.hooks.values()), // $4 473 | ], 474 | { 475 | arguments: { 476 | reference: true, 477 | }, 478 | }, 479 | ) 480 | } 481 | 482 | /** 483 | * Record stats 484 | * @param isolate Isolate instance 485 | */ 486 | private recordStats() { 487 | if (this.isolate === null) { 488 | throw new ReferenceError('Illegal state: missing isolate when recording stats') 489 | } 490 | const heap = this.isolate.getHeapStatisticsSync() 491 | this.stats.cpuTime += this.isolate.cpuTime 492 | this.stats.wallTime += this.isolate.wallTime 493 | this.stats.totalHeapSize += heap.total_heap_size 494 | this.stats.totalHeapSizeExecutable += heap.total_heap_size_executable 495 | this.stats.totalPhysicalSize += heap.total_physical_size 496 | this.stats.usedHeapSize += heap.used_heap_size 497 | this.stats.mallocedMemory += heap.malloced_memory 498 | this.stats.peakMallocedMemory = Math.max(this.stats.peakMallocedMemory, heap.peak_malloced_memory) 499 | this.stats.externallyAllocatedSize += heap.externally_allocated_size 500 | } 501 | } 502 | -------------------------------------------------------------------------------- /src/bootstrap.js: -------------------------------------------------------------------------------- 1 | //#region Constants 2 | 3 | /** Pattern to extract filename, line and column from stack trace line */ 4 | const TRACE_PATTERN = / at.* \(?(.+):([0-9]+):([0-9]+)\)?$/ 5 | 6 | /** Pattern that identifies object properties that do not need to be escaped */ 7 | const SIMPLE_PROPERTY_PATTERN = /^[a-z_$][a-z0-9_$]*$/i 8 | 9 | /** Pattern that matches paths that end in ≥3 repeated properties or have ≥8 simple properties total */ 10 | const LOOPED_PATH_PATTERN = /(.+)(\1{2,})$|(\.[a-z0-9_$]+){7,}$/i 11 | 12 | /** Symbol to mark objects that are mocks, used to prevent mocking the same object twice */ 13 | const MockSymbol = Symbol(`Mock-${Math.random()}`) 14 | 15 | /** Symbol to mark fully autogenerated templates, used to prevent mocking missing properties in "real" objects */ 16 | const FullMockSymbol = Symbol(`FullMock-${Math.random()}`) 17 | 18 | /** Symbol used as a property key to store the value ID of an object */ 19 | const IdSymbol = Symbol(`Id-${Math.random()}`) 20 | 21 | /** Symbol used to taint previously visited callbacks */ 22 | const VisitedSymbol = Symbol(`Visited-${Math.random()}`) 23 | 24 | /** Symbol used as a property key to store the function key name (hook name) to be called instead */ 25 | const ExternalFunctionSymbol = Symbol(`ExternalFunction-${Math.random()}`) 26 | 27 | /** Reference to original properties from globalThis object */ 28 | const { Error, JSON, Proxy, Promise, Reflect, Set, eval, isNaN, parseInt } = globalThis // eslint-disable-line no-shadow-restricted-names 29 | 30 | 31 | //#region Proxies 32 | 33 | /** 34 | * @typedef {import('./Report').Location} Location 35 | * @typedef {import('./Report').Value} Value 36 | * @typedef {import('./Report').ReportEvent} ReportEvent 37 | */ 38 | 39 | /** @type {import('isolated-vm').Reference} */ 40 | const EVENT_PROXY = $1 // eslint-disable-line no-undef 41 | 42 | /** @type {import('isolated-vm').Reference} */ 43 | const DEBUG_PROXY = $2 // eslint-disable-line no-undef 44 | 45 | /** @type {import('isolated-vm').Reference} */ 46 | const AWAIT_PROXY = $3 // eslint-disable-line no-undef 47 | 48 | /** 49 | * Next value ID 50 | */ 51 | let nextValueId = parseInt(`${$0.copySync()}`) // eslint-disable-line no-undef 52 | 53 | /** 54 | * Emit event 55 | * @param {ReportEvent} event Event 56 | */ 57 | function emitEvent(event) { 58 | EVENT_PROXY.applyIgnored(undefined, [event, nextValueId], { arguments: { copy: true } }) 59 | } 60 | 61 | /** 62 | * Emit debug message 63 | * @param {...unknown} args Arguments to log 64 | */ 65 | function emitDebug(...args) { 66 | DEBUG_PROXY.applyIgnored(undefined, args.map(item => `${item}`), { arguments: { copy: true } }) 67 | } 68 | 69 | /** 70 | * Await reference synchronously 71 | * @param {import('isolated-vm').Reference} ref External object reference 72 | * @return {import('isolated-vm').Reference} Awaited external object reference 73 | */ 74 | function awaitReference(ref) { 75 | return AWAIT_PROXY.applySyncPromise(undefined, [ref]) 76 | } 77 | 78 | 79 | //#region Hooks 80 | 81 | /** @type {import('isolated-vm').Reference} */ 82 | const RAW_HOOKS = $4 // eslint-disable-line no-undef 83 | 84 | /** @type {Map} */ 85 | const HOOKS = new Map() 86 | 87 | for (const item of RAW_HOOKS.copySync()) { 88 | HOOKS.set(item.path, item) 89 | } 90 | 91 | /** 92 | * Is external object 93 | * @param {any} input Input variable 94 | * @return {boolean} Whether input is an external object 95 | */ 96 | function isExternalObject(input) { 97 | return ( 98 | typeof input === 'object' && 99 | input?.constructor?.name === 'Reference' && 100 | input?.typeof !== undefined 101 | ) 102 | } 103 | 104 | /** 105 | * Create external proxy 106 | * @param {string} path Path to new object 107 | * @param {any} maybeExternal External object to wrap 108 | * @return {any} External object wrapped in a proxy 109 | */ 110 | function createExternalProxy(path, maybeExternal) { 111 | // Skip transferred objects (not external) 112 | if (!isExternalObject(maybeExternal)) { 113 | return createMock(path, maybeExternal) 114 | } 115 | 116 | // Copy transferable objects 117 | /** @type {import('isolated-vm').Reference} */ 118 | const ref = maybeExternal 119 | if (ref.typeof !== 'object' && ref.typeof !== 'function') { 120 | return createMock(path, ref.copySync()) 121 | } 122 | 123 | const target = (ref.typeof === 'function') ? () => {} : {} // eslint-disable-line @typescript-eslint/no-empty-function 124 | return new Proxy(target, { 125 | get(target, property) { 126 | // Handle symbol properties 127 | if (typeof property === 'symbol') { 128 | return (property === MockSymbol) ? MockSymbol : target[property] 129 | } 130 | 131 | // Ignore promises, they are resolved outside the sandbox 132 | if (property === 'then') { 133 | return undefined 134 | } 135 | 136 | // Wrap in a proxy and return 137 | const newValue = ref.getSync(property, { reference: true }) 138 | const subpath = resolvePath(path, property) 139 | const newProxy = createExternalProxy(subpath, newValue) 140 | onGetOrSetEvent('GetEvent', subpath, newProxy) 141 | return newProxy 142 | }, 143 | apply(target, thisArg, argArray) { 144 | return callExternalFunction(path, ref, argArray) 145 | } 146 | }) 147 | } 148 | 149 | /** 150 | * Call external function 151 | * @param {string} path Path to function 152 | * @param {import('isolated-vm').Reference} ref External object reference 153 | * @param {any[]} argArray Arguments array 154 | * @return {any} Return value (wrapped in a proxy if needed) 155 | */ 156 | function callExternalFunction(path, ref, argArray) { 157 | let shouldCopyArguments = true 158 | const filteredArgArray = [] 159 | for (const item of argArray) { 160 | // Unsupported types 161 | if (typeof item === 'symbol' || isMock(item)) { 162 | filteredArgArray.push({}) 163 | continue 164 | } 165 | 166 | // Non-transferable values 167 | if (typeof item === 'function') { 168 | filteredArgArray.push(item) 169 | shouldCopyArguments = false 170 | continue 171 | } 172 | 173 | // Default case 174 | filteredArgArray.push(item) 175 | } 176 | 177 | // Invoke external function 178 | const resultRef = ref.applySync(undefined, filteredArgArray, { 179 | arguments: { copy: shouldCopyArguments }, 180 | result: { reference: true }, 181 | }) 182 | const awaitedResultRef = awaitReference(resultRef) 183 | 184 | // Wrap in a proxy and return 185 | const subpath = resolvePath(path, '()') 186 | const returns = createExternalProxy(subpath, awaitedResultRef) 187 | onCallEvent(path, argArray, returns, false) 188 | return returns 189 | } 190 | 191 | 192 | //#region Utils 193 | 194 | /** 195 | * Resolve path 196 | * @param {string} parentPath Parent path 197 | * @param {string} property Property to append 198 | * @return {string} New path 199 | */ 200 | function resolvePath(parentPath, property) { 201 | if (property === '()') { 202 | return parentPath.endsWith('()') ? parentPath : `${parentPath}()` 203 | } 204 | 205 | // Parse property 206 | if (!isNaN(property)) { 207 | property = `[${property}]` 208 | } else if (!SIMPLE_PROPERTY_PATTERN.test(property)) { 209 | property = `[${JSON.stringify(property)}]` 210 | } 211 | 212 | // Build new property 213 | if (parentPath === 'globalThis') { 214 | return property 215 | } 216 | return `${parentPath}${property.startsWith('[') ? '' : '.'}${property}` 217 | } 218 | 219 | /** 220 | * Get current location 221 | * @return {Location|null} Location or `null` if failed to extract 222 | */ 223 | function getCurrentLocation() { 224 | // Get stack 225 | const e = { 226 | stack: '', 227 | } 228 | Error.captureStackTrace(e) 229 | 230 | // Get closest location from stack 231 | for (const line of e.stack.split('\n').slice(1)) { 232 | if (line.includes(' (:')) { 233 | // Part of bootstrap code, skip 234 | continue 235 | } 236 | const match = line.match(TRACE_PATTERN) 237 | if (match === null) { 238 | // Failed to match, skip 239 | continue 240 | } 241 | return { 242 | filename: match[1], 243 | line: parseInt(match[2]), 244 | column: parseInt(match[3]), 245 | } 246 | } 247 | 248 | // Failed to extract location 249 | return null 250 | } 251 | 252 | /** 253 | * Is literal 254 | * @param {any} input Input variable 255 | * @return {boolean} Whether input is a literal 256 | */ 257 | function isLiteral(input) { 258 | if (input === null || input === undefined) { 259 | return true 260 | } 261 | const type = typeof input 262 | return (type === 'string' || type === 'number' || type === 'boolean') 263 | } 264 | 265 | /** 266 | * To event value 267 | * @param {any} value Value to wrap 268 | * @return {Value} Event value 269 | */ 270 | function toEventValue(value) { 271 | // Literal values 272 | if (isLiteral(value)) { 273 | return { literal: value } 274 | } 275 | 276 | // Get ID or taint object if needed 277 | let valueId = value[IdSymbol] 278 | if (valueId === undefined) { 279 | valueId = nextValueId++ 280 | value[IdSymbol] = valueId 281 | } 282 | return { ref: valueId } 283 | } 284 | 285 | /** 286 | * On get or set event 287 | * @param {'GetEvent'|'SetEvent'} type Event type 288 | * @param {string} path Path to variable 289 | * @param {any} value Value being read/written 290 | */ 291 | function onGetOrSetEvent(type, path, value) { 292 | const location = getCurrentLocation() 293 | if (location === null) { 294 | emitDebug(`Ignored ${type} with unknown location for "${path}"`) 295 | return 296 | } 297 | emitDebug(`${type === 'GetEvent' ? 'Got' : 'Set'} ${path}`) 298 | emitEvent({ 299 | type, 300 | path, 301 | value: toEventValue(value), 302 | location, 303 | }) 304 | } 305 | 306 | /** 307 | * On call event 308 | * @param {string} path Path to variable being called 309 | * @param {any[]} argArray Call arguments 310 | * @param {any} returns Return value 311 | * @param {boolean} isConstructor Is call from constructor 312 | */ 313 | function onCallEvent(path, argArray, returns, isConstructor) { 314 | const normalizedPath = path.endsWith('()') ? path.slice(0, -2) : path 315 | emitDebug(`Called ${normalizedPath}(${argArray.map(() => '#').join(', ')})`) 316 | 317 | // Emit event 318 | const wrappedArguments = [] 319 | for (const value of argArray) { 320 | wrappedArguments.push(toEventValue(value)) 321 | } 322 | emitEvent({ 323 | type: 'CallEvent', 324 | path: normalizedPath, 325 | arguments: wrappedArguments, 326 | returns: toEventValue(returns), 327 | isConstructor, 328 | location: getCurrentLocation(), 329 | }) 330 | 331 | // Visit callback arguments (if any) 332 | visitCallback(argArray) 333 | } 334 | 335 | 336 | //#region Mocks 337 | 338 | /** 339 | * Is mock 340 | * @param {any} object Object to check 341 | * @return {boolean} Whether object is mock 342 | */ 343 | function isMock(object) { 344 | try { 345 | return (object[MockSymbol] === MockSymbol) 346 | } catch (_) { 347 | return false 348 | } 349 | } 350 | 351 | /** 352 | * Create mock object (if possible) 353 | * @param {string} path Path to new object 354 | * @param {any} [template] Template to mock 355 | * @param {any} [thisArg] For functions, custom `this` argument used during invocation 356 | * @return {object} Mock object 357 | */ 358 | function createMock(path, template, thisArg) { 359 | // Is template a primitive type? 360 | const type = typeof template 361 | if (template === null || type === 'string' || type === 'number' || type === 'boolean' || type === 'symbol') { 362 | return template 363 | } 364 | 365 | // Create fully mock template if needed 366 | if (template === undefined) { 367 | template = function() { 368 | const subpath = resolvePath(path, '()') 369 | emitDebug(`Mocked return value for ${subpath}`) 370 | return createMock(subpath) 371 | } 372 | template.toString = () => 'function () { [native code] }' 373 | template[Symbol.iterator] = function* () { 374 | for (let i=0; i<5; i++) { 375 | yield createMock(resolvePath(path, `${i}`)) 376 | } 377 | } 378 | template[FullMockSymbol] = FullMockSymbol 379 | } 380 | 381 | // Wrap template in proxy 382 | /** @type {Set} */ 383 | const silentPaths = new Set() 384 | /** @type {Set} */ 385 | const readOnlyPaths = new Set() 386 | const proxy = new Proxy(template, { 387 | has(target, property) { 388 | return (target[FullMockSymbol] === FullMockSymbol) ? true : (property in target) 389 | }, 390 | get(target, property) { 391 | // Handle symbol properties 392 | if (typeof property === 'symbol') { 393 | return (property === MockSymbol) ? MockSymbol : target[property] 394 | } 395 | 396 | // Handle ignored properties 397 | if (property === 'apply' || property === 'bind' || property === 'call') { 398 | return target[property] 399 | } 400 | 401 | // Handle thenable functions 402 | if (property === 'then') { 403 | if (!(property in target)) { 404 | let resolve, reject 405 | target[property] = new Promise((res, rej) => { 406 | resolve = res 407 | reject = rej 408 | }) 409 | visitCallback([resolve, reject], proxy) 410 | } 411 | return target[property] 412 | } 413 | 414 | // Create or get child mock 415 | const subpath = resolvePath(path, property) 416 | const exists = (property in target) 417 | if (!exists && HOOKS.has(subpath)) { 418 | const hook = HOOKS.get(subpath) 419 | if ('newPath' in hook) { 420 | emitDebug(`Created hook redirecting "${subpath}" to "${hook.newPath}"`) 421 | target[property] = eval(hook.newPath) 422 | silentPaths.add(subpath) 423 | } else if ('value' in hook) { 424 | emitDebug(`Created hook with custom value for "${subpath}"`) 425 | const value = hook.value.copy() 426 | target[property] = (value === undefined) ? undefined : createMock(subpath, value) 427 | } else { 428 | emitDebug(`Created hook binding external function at "${subpath}"`) 429 | target[property] = createMock(subpath) 430 | target[property][ExternalFunctionSymbol] = subpath 431 | } 432 | if (!hook.isWritable) { 433 | readOnlyPaths.add(subpath) 434 | } 435 | } else if (!exists) { 436 | if (LOOPED_PATH_PATTERN.test(subpath)) { 437 | emitDebug(`Found looped path at "${subpath}", skipped`) 438 | target[property] = undefined 439 | } else { 440 | emitDebug(`Mocked "${subpath}" object`) 441 | target[property] = createMock(subpath) 442 | } 443 | } else if (!isLiteral(target[property]) && !isMock(target[property])) { 444 | emitDebug(`Patched existing "${subpath}" object`) 445 | target[property] = createMock(subpath, target[property], target) 446 | } 447 | 448 | // Return value 449 | if (!subpath.endsWith('.prototype') && !silentPaths.has(subpath)) { 450 | onGetOrSetEvent('GetEvent', subpath, target[property]) 451 | } 452 | return target[property] 453 | }, 454 | set(target, property, newValue, receiver) { 455 | if (typeof property === 'string') { 456 | const subpath = resolvePath(path, property) 457 | onGetOrSetEvent('SetEvent', subpath, newValue) 458 | if (readOnlyPaths.has(subpath)) { 459 | emitDebug(`Ignored set value attempt for "${subpath}" (read-only)`) 460 | return true 461 | } 462 | if (!isMock(newValue)) { 463 | emitDebug(`Patched existing "${subpath}" object before setting value`) 464 | newValue = createMock(subpath, newValue, target) 465 | } 466 | } 467 | return Reflect.set(target, property, newValue, receiver) 468 | }, 469 | construct(target, argArray, newTarget) { 470 | const newInstance = Reflect.construct(target, argArray, newTarget) 471 | const subpath = resolvePath(path, '()') 472 | 473 | // Wrap new instance in mock if needed 474 | let newMock 475 | if (isMock(newInstance)) { 476 | emitDebug(`"${subpath}" is already a mock, not mocking again`) 477 | newMock = newInstance 478 | } else { 479 | newMock = createMock(subpath, newInstance) 480 | } 481 | 482 | // Return value 483 | onCallEvent(path, argArray, newMock, true) 484 | return newMock 485 | }, 486 | apply(target, realThisArg, argArray) { 487 | // Handle hooks of external functions 488 | if (typeof target[ExternalFunctionSymbol] === 'string') { 489 | const hookPath = target[ExternalFunctionSymbol] 490 | const hook = HOOKS.get(hookPath) 491 | if (hook && 'function' in hook) { 492 | return callExternalFunction(path, hook.function, argArray) 493 | } 494 | emitDebug(`Trying to invoke non-existing external function: "${hookPath}"`) 495 | } 496 | 497 | // Call function 498 | const returns = target.apply(thisArg ?? realThisArg, argArray) 499 | onCallEvent(path, argArray, returns, false) 500 | return returns 501 | }, 502 | }) 503 | 504 | return proxy 505 | } 506 | 507 | /** 508 | * Visit callback if not already visited 509 | * @param {any[]} candidates Candidates to pick first valid callback from 510 | * @param {any} [valueToPropagate] Optional value to propagate as the first call argument 511 | */ 512 | function visitCallback(candidates, valueToPropagate) { 513 | // Taint ".on('error', fn)" callbacks 514 | if (candidates[0] === 'error' && typeof candidates[1] === 'function' && !isMock(candidates[1])) { 515 | candidates[1][VisitedSymbol] = VisitedSymbol 516 | } 517 | 518 | // Try to invoke candidates 519 | try { 520 | for (const callback of candidates) { 521 | if (typeof callback === 'function' && !isMock(callback) && callback[VisitedSymbol] !== VisitedSymbol) { 522 | callback[VisitedSymbol] = VisitedSymbol 523 | if (valueToPropagate === undefined) { 524 | callback() 525 | } else { 526 | callback(valueToPropagate) 527 | } 528 | break 529 | } 530 | } 531 | } catch (_) { 532 | // Ignore error and keep running 533 | } 534 | } 535 | 536 | 537 | //#region Initialize 538 | 539 | // Hijack globalThis object 540 | const originalGlobalThis = globalThis 541 | const newGlobalThis = {} 542 | newGlobalThis[Symbol.toPrimitive] = () => '[object Window]' 543 | newGlobalThis[FullMockSymbol] = FullMockSymbol 544 | for (const property of [ 545 | 'console', 546 | 'eval', 547 | 'decodeURI', 'decodeURIComponent', 548 | 'encodeURI', 'encodeURIComponent', 549 | 'escape', 'unescape', 550 | 'isFinite', 'isNaN', 551 | 'parseFloat', 'parseInt', 552 | 'Date', 553 | 'Error', 'AggregateError', 'EvalError', 'RangeError', 'ReferenceError', 'SyntaxError', 'TypeError', 'URIError', 554 | 'RegExp', 555 | 'JSON', 556 | 'Math', 557 | 'Intl', 558 | 'ArrayBuffer', 'SharedArrayBuffer', 'Uint8Array', 'Uint16Array', 'Int16Array', 'Uint32Array', 'Int32Array', 559 | 'Float32Array', 'Float64Array', 'Uint8ClampedArray', 'BigUint64Array', 'BigInt64Array', 'DataView', 560 | 'BigInt', 561 | 'Map', 'Set', 'WeakMap', 'WeakSet', 'WeakRef', 562 | 'Proxy', 'Reflect', 563 | 'FinalizationRegistry', 564 | 'Atomics', 565 | 'WebAssembly', 566 | ]) { 567 | newGlobalThis[property] = createMock(property, originalGlobalThis[property]) 568 | delete originalGlobalThis[property] // eslint-disable-line @typescript-eslint/no-dynamic-delete 569 | } 570 | Object.setPrototypeOf(globalThis, createMock('globalThis', newGlobalThis)) 571 | -------------------------------------------------------------------------------- /tests/Fakeium.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert, expect } from 'chai' 2 | import { 3 | ExecutionError, 4 | InvalidPathError, 5 | InvalidValueError, 6 | MemoryLimitError, 7 | ParsingError, 8 | SourceNotFoundError, 9 | TimeoutError, 10 | } from '../src/errors' 11 | import { Fakeium, FakeiumStats } from '../src/Fakeium' 12 | import { Reference } from '../src/hooks' 13 | import { DefaultLogger } from '../src/logger' 14 | 15 | const logger = (process.env.LOG_LEVEL === 'debug') ? new DefaultLogger() : null 16 | 17 | function expectNonEmptyStats(stats: FakeiumStats): void { 18 | expect(Number(stats.cpuTime)).to.be.greaterThan(100_000) 19 | expect(Number(stats.wallTime)).to.be.greaterThan(50_000) 20 | expect(stats.totalHeapSize).to.be.greaterThan(500_000) 21 | expect(stats.usedHeapSize).to.be.greaterThan(500_000) 22 | } 23 | 24 | describe('Fakeium', () => { 25 | it('initializes and disposes', async () => { 26 | const fakeium = new Fakeium({ logger }) 27 | expect(fakeium.getReport().size()).to.be.equal(0) 28 | fakeium.dispose() 29 | }) 30 | 31 | it('runs sources without resolver', async () => { 32 | const fakeium = new Fakeium({ logger }) 33 | await fakeium.run('module.js', '1+1', { sourceType: 'module' }) // Create module with custom source code 34 | await fakeium.run('script.js', '2+2', { sourceType: 'script' }) // Create script with custom source code 35 | await fakeium.run('module.js', '3+3', { sourceType: 'module' }) // Override cached module with new source code 36 | await fakeium.run('script.js', '4+4', { sourceType: 'script' }) // Create script with new custom source code 37 | fakeium.dispose() 38 | }) 39 | 40 | it('runs scripts with resolver', async () => { 41 | const fakeium = new Fakeium({ logger, origin: 'https://localhost' }) 42 | fakeium.setResolver(async url => { 43 | if (url.href === 'https://localhost/index.js') { 44 | return '// This is the index\n' 45 | } 46 | return null 47 | }) 48 | await fakeium.run('index.js') 49 | await fakeium.run('404.js', '// Not coming from resolver\n') 50 | }) 51 | 52 | it('throws an error for unresolved sources', async () => { 53 | const fakeium = new Fakeium({ logger }) 54 | for (const sourceType of ['script', 'module'] as const) { 55 | try { 56 | await fakeium.run(`${sourceType}.js`, { sourceType }) 57 | assert.fail('Fakeium#run() did not throw any error') 58 | } catch (e) { 59 | expect(e).to.be.an.instanceOf(SourceNotFoundError) 60 | } 61 | } 62 | fakeium.dispose() 63 | }) 64 | 65 | it('throws an error for invalid source code', async () => { 66 | const fakeium = new Fakeium({ logger }) 67 | for (const sourceType of ['script', 'module'] as const) { 68 | try { 69 | await fakeium.run(`${sourceType}.js`, 'This is not JavaScript code', { sourceType }) 70 | assert.fail('Fakeium#run() did not throw any error') 71 | } catch (e) { 72 | expect(e).to.be.an.instanceOf(ParsingError) 73 | } 74 | } 75 | fakeium.dispose() 76 | }) 77 | 78 | it('assumes sources are scripts by default', async () => { 79 | const fakeium = new Fakeium({ logger }) 80 | try { 81 | await fakeium.run('index.js', 'import "something.js"') 82 | assert.fail('Fakeium#run() did not throw any error') 83 | } catch (e) { 84 | expect(e).to.be.an.instanceOf(ParsingError) 85 | expect(e).to.have.a.property('message').that.matches(/^Cannot use import statement outside a module/) 86 | } 87 | fakeium.dispose() 88 | }) 89 | 90 | it('resolves module specifiers', async () => { 91 | const fakeium = new Fakeium({ logger, sourceType: 'module' }) 92 | fakeium.setResolver(async url => { 93 | if (url.pathname === '/index.js') { 94 | return 'import "./subdir/b.js";\n' + 95 | 'import "subdir/c.js";\n' + 96 | 'alert("Hi from index.js");\n' 97 | } 98 | if (url.pathname === '/subdir/b.js') { 99 | return 'alert("Hi from b.js");\n' 100 | } 101 | if (url.pathname === '/subdir/c.js') { 102 | return 'import "../d.js";\n' + 103 | 'alert("Hi from c.js");\n' 104 | } 105 | if (url.pathname === '/d.js') { 106 | return 'alert("Hi from d.js");\n' 107 | } 108 | if (url.pathname === '/something/with%20spaces.js') { 109 | return '// Empty\n' 110 | } 111 | if (url.pathname === '/hash.js') { 112 | return 'alert("Hi from a module with fragment");\n' 113 | } 114 | if (url.pathname === '/crash.js') { 115 | return 'import "fake/path/to/module.js";\n' + 116 | 'alert("This line is never reached");\n' 117 | } 118 | return null 119 | }) 120 | 121 | // Run successful code 122 | await fakeium.run('./index.js') 123 | await fakeium.run('something/with spaces.js') 124 | await fakeium.run('hash.js#this-is-a-fragment') 125 | 126 | // Run unsuccessful code 127 | try { 128 | await fakeium.run('./crash.js') 129 | assert.fail('Did not crash when importing missing module') 130 | } catch (e) { 131 | expect(e).to.be.an.instanceOf(SourceNotFoundError) 132 | expect(e).to.have.a.property('message').that.matches(/^Cannot find module "fake\/path\/to\/module.js"/) 133 | } 134 | 135 | fakeium.dispose() 136 | }) 137 | 138 | it('propagates unhandled sandbox errors', async () => { 139 | const fakeium = new Fakeium({ logger }) 140 | try { 141 | await fakeium.run('crash.js', 'throw new Error("oh no!");') 142 | } catch (e) { 143 | expect(e).to.be.an.instanceOf(ExecutionError) 144 | return 145 | } finally { 146 | fakeium.dispose() 147 | } 148 | assert.fail('Fakeium#run() did not throw any error') 149 | }) 150 | 151 | it('throws an error on timeout', async () => { 152 | const fakeium = new Fakeium({ logger, timeout: 500 }) 153 | for (const sourceType of ['script', 'module'] as const) { 154 | try { 155 | await fakeium.run('endless.js', 'while (true) {;;}', { sourceType }) 156 | assert.fail('Fakeium#run() did not throw any error') 157 | } catch (e) { 158 | expect(e).to.be.an.instanceOf(TimeoutError) 159 | } 160 | } 161 | fakeium.dispose() 162 | }).timeout(3000) 163 | 164 | it('throws an error on memory exhaustion', async () => { 165 | const fakeium = new Fakeium({ logger, maxMemory: 8 }) 166 | const code = 'const garbage = [];\n' + 167 | 'while (true) {\n' + 168 | ' garbage.push("abcdefghijklmnopqrstuvwxyz".repeat(1024));\n' + 169 | '}\n' 170 | const beforeStats = fakeium.getStats() 171 | try { 172 | await fakeium.run('crash.js', code) 173 | } catch (e) { 174 | expect(e).to.be.an.instanceOf(MemoryLimitError) 175 | return 176 | } finally { 177 | expect(beforeStats).to.deep.equal(fakeium.getStats()) // No stats are recorded after memory limit 178 | fakeium.dispose() 179 | } 180 | assert.fail('Fakeium#run() did not throw any error') 181 | }) 182 | 183 | it('records stats', async () => { 184 | const fakeium = new Fakeium({ logger }) 185 | expect(fakeium.getStats()).to.be.deep.equal({ 186 | cpuTime: 0n, 187 | wallTime: 0n, 188 | totalHeapSize: 0, 189 | totalHeapSizeExecutable: 0, 190 | totalPhysicalSize: 0, 191 | usedHeapSize: 0, 192 | mallocedMemory: 0, 193 | peakMallocedMemory: 0, 194 | externallyAllocatedSize: 0, 195 | }) 196 | 197 | // Stats are recorded after running sources 198 | await fakeium.run('first.js', 'for (let i=0; i<1000; i++) "abc".repeat(100)') 199 | const firstStats = fakeium.getStats() 200 | expectNonEmptyStats(firstStats) 201 | 202 | // Stats are cumulative 203 | await fakeium.run('second.js', 'let i = 0; while (i < 1_000_000) i++;') 204 | const secondStats = fakeium.getStats() 205 | expect(Number(secondStats.cpuTime)).to.be.greaterThan(Number(firstStats.cpuTime)) 206 | expect(Number(secondStats.wallTime)).to.be.greaterThan(Number(firstStats.wallTime)) 207 | expect(secondStats.totalHeapSize).to.be.greaterThan(firstStats.totalHeapSize) 208 | expect(secondStats.totalHeapSizeExecutable).to.be.greaterThan(firstStats.totalHeapSizeExecutable) 209 | expect(secondStats.totalPhysicalSize).to.be.greaterThan(firstStats.totalPhysicalSize) 210 | expect(secondStats.usedHeapSize).to.be.greaterThan(firstStats.usedHeapSize) 211 | expect(secondStats.mallocedMemory).to.be.greaterThanOrEqual(firstStats.mallocedMemory) 212 | expect(secondStats.externallyAllocatedSize).to.be.greaterThanOrEqual(firstStats.externallyAllocatedSize) 213 | 214 | // Stats must be reset after dispose 215 | fakeium.dispose() 216 | expect(fakeium.getStats()).to.be.deep.equal({ 217 | cpuTime: 0n, 218 | wallTime: 0n, 219 | totalHeapSize: 0, 220 | totalHeapSizeExecutable: 0, 221 | totalPhysicalSize: 0, 222 | usedHeapSize: 0, 223 | mallocedMemory: 0, 224 | peakMallocedMemory: 0, 225 | externallyAllocatedSize: 0, 226 | }) 227 | }) 228 | }) 229 | 230 | describe('Fakeium sandbox', () => { 231 | it('assigns incremental value IDs', async () => { 232 | const fakeium = new Fakeium({ logger }) 233 | fakeium.setResolver(async () => { 234 | return '(async () => {\n' + 235 | ' const a = JSON.stringify({ tag: "a" });\n' + 236 | ' const b = JSON.stringify({ tag: "b" });\n' + 237 | ' callMe(a);\n' + 238 | ' callMe(b);\n' + 239 | '})();\n' 240 | }) 241 | await fakeium.run('index.js') 242 | expect(fakeium.getReport().getAll()).to.deep.equal([ 243 | { 244 | type: 'GetEvent', 245 | path: 'JSON', 246 | value: { ref: 1 }, 247 | location: { filename: 'file:///index.js', line: 2, column: 15 }, 248 | }, 249 | { 250 | type: 'GetEvent', 251 | path: 'JSON.stringify', 252 | value: { ref: 2 }, 253 | location: { filename: 'file:///index.js', line: 2, column: 20 }, 254 | }, 255 | { 256 | type: 'CallEvent', 257 | path: 'JSON.stringify', 258 | arguments: [ { ref: 3 } ], 259 | returns: { literal: '{"tag":"a"}' }, 260 | isConstructor: false, 261 | location: { filename: 'file:///index.js', line: 2, column: 20 }, 262 | }, 263 | { 264 | type: 'GetEvent', 265 | path: 'JSON', 266 | value: { ref: 1 }, 267 | location: { filename: 'file:///index.js', line: 3, column: 15 }, 268 | }, 269 | { 270 | type: 'GetEvent', 271 | path: 'JSON.stringify', 272 | value: { ref: 2 }, 273 | location: { filename: 'file:///index.js', line: 3, column: 20 }, 274 | }, 275 | { 276 | type: 'CallEvent', 277 | path: 'JSON.stringify', 278 | arguments: [ { ref: 4 } ], 279 | returns: { literal: '{"tag":"b"}' }, 280 | isConstructor: false, 281 | location: { filename: 'file:///index.js', line: 3, column: 20 }, 282 | }, 283 | { 284 | type: 'GetEvent', 285 | path: 'callMe', 286 | value: { ref: 5 }, 287 | location: { filename: 'file:///index.js', line: 4, column: 5 }, 288 | }, 289 | { 290 | type: 'CallEvent', 291 | path: 'callMe', 292 | arguments: [ { literal: '{"tag":"a"}' } ], 293 | returns: { ref: 6 }, 294 | isConstructor: false, 295 | location: { filename: 'file:///index.js', line: 4, column: 5 }, 296 | }, 297 | { 298 | type: 'GetEvent', 299 | path: 'callMe', 300 | value: { ref: 5 }, 301 | location: { filename: 'file:///index.js', line: 5, column: 5 }, 302 | }, 303 | { 304 | type: 'CallEvent', 305 | path: 'callMe', 306 | arguments: [ { literal: '{"tag":"b"}' } ], 307 | returns: { ref: 7 }, 308 | isConstructor: false, 309 | location: { filename: 'file:///index.js', line: 5, column: 5 }, 310 | } 311 | ]) 312 | fakeium.dispose() 313 | }) 314 | 315 | it('assigns incremental value IDs after clearing and dispose', async () => { 316 | const fakeium = new Fakeium({ logger }) 317 | 318 | await fakeium.run('first.js', 'first()') 319 | expect(fakeium.getReport().has({ type: 'CallEvent', path: 'first', returns: { ref: 2 } })).to.equal(true) 320 | fakeium.getReport().clear() 321 | 322 | await fakeium.run('second.js', 'second()') 323 | expect(fakeium.getReport().has({ path: 'first' })).to.equal(false) 324 | expect(fakeium.getReport().has({ type: 'CallEvent', path: 'second', returns: { ref: 4 } })).to.equal(true) 325 | fakeium.dispose() 326 | 327 | await fakeium.run('third.js', 'third()') 328 | expect(fakeium.getReport().has({ path: 'first' })).to.equal(false) 329 | expect(fakeium.getReport().has({ path: 'second' })).to.equal(false) 330 | expect(fakeium.getReport().has({ type: 'CallEvent', path: 'third', returns: { ref: 2 } })).to.equal(true) 331 | }) 332 | 333 | it('resolves paths in both dot and bracket notation', async () => { 334 | const fakeium = new Fakeium({ logger }) 335 | await fakeium.run('index.js', 336 | 'a.b.c[123];\n' + 337 | 'a.b.c.d[\'with space\'].e;\n' + 338 | 'a.b.$1;\n' 339 | ) 340 | expect(fakeium.getReport().has({ type: 'GetEvent', path: 'a.b.c[123]' })).to.equal(true) 341 | expect(fakeium.getReport().has({ type: 'GetEvent', path: 'a.b.c.d["with space"].e' })).to.equal(true) 342 | expect(fakeium.getReport().has({ type: 'GetEvent', path: 'a.b.$1' })).to.equal(true) 343 | fakeium.dispose() 344 | }) 345 | 346 | it('logs simple function calls', async() => { 347 | const fakeium = new Fakeium({ logger }) 348 | await fakeium.run('index.js', 'console.log(something, 123)') 349 | expect(fakeium.getReport().has({ 350 | type: 'CallEvent', 351 | path: 'console.log', 352 | arguments: [ { ref: 3 }, { literal: 123 } ], 353 | })).to.equal(true) 354 | fakeium.dispose() 355 | expect(fakeium.getReport().size()).to.equal(0) 356 | }) 357 | 358 | it('does not produce extra Object.length on scripts', async() => { 359 | const fakeium = new Fakeium({ logger }) 360 | await fakeium.run('index.js', 'alert("hello")') 361 | expect(fakeium.getReport().getAll()).to.be.deep.equal([ 362 | { 363 | type: 'GetEvent', 364 | path: 'alert', 365 | value: { ref: 1 }, 366 | location: { filename: 'file:///index.js', line: 1, column: 1 }, 367 | }, 368 | { 369 | type: 'CallEvent', 370 | path: 'alert', 371 | arguments: [ { literal: 'hello' } ], 372 | returns: { ref: 2 }, 373 | isConstructor: false, 374 | location: { filename: 'file:///index.js', line: 1, column: 1 }, 375 | }, 376 | ]) 377 | fakeium.dispose() 378 | }) 379 | 380 | it('logs thenable function calls', async () => { 381 | const fakeium = new Fakeium({ logger }) 382 | await fakeium.run('index.js', 383 | '(async () => {\n' + 384 | ' const res = await aPromise();\n' + 385 | ' console.log(res);\n' + 386 | ' const sameRes = await res;\n' + 387 | ' if (res !== sameRes) {\n' + 388 | ' throw new Error("Resolving the same promise must yield the same value");\n' + 389 | ' }\n' + 390 | ' reachedEnd(sameRes);\n' + 391 | '})();\n' 392 | ) 393 | expect(fakeium.getReport().has({ type: 'CallEvent', path: 'aPromise', returns: { ref: 2 } })).to.equal(true) 394 | expect(fakeium.getReport().has({ 395 | type: 'CallEvent', 396 | path: 'console.log', 397 | arguments: [ { ref: 2 } ], 398 | returns: { literal: undefined }, 399 | })).to.equal(true) 400 | expect(fakeium.getReport().has({ 401 | type: 'CallEvent', 402 | path: 'reachedEnd', 403 | arguments: [ { ref: 2 } ], 404 | returns: { ref: 6 }, 405 | })).to.equal(true) 406 | fakeium.dispose() 407 | }) 408 | 409 | it('runs code with module imports', async () => { 410 | const fakeium = new Fakeium({ logger, sourceType: 'module' }) 411 | fakeium.setResolver(async url => { 412 | if (url.href === 'file:///index.js') { 413 | return 'import { callMe } from "./test.js";\n' + 414 | 'import "./subdir/hey.js";\n' + 415 | 'index();\n' + 416 | 'export default {\n' + 417 | ' start: () => thisShouldNotBeCalled(),\n' + 418 | '}\n' 419 | } 420 | if (url.href === 'file:///test.js') { 421 | return '/* Hi from test.js! */\n' + 422 | 'export const callMe = () => iGotCalled();\n' 423 | } 424 | if (url.href === 'file:///subdir/hey.js') { 425 | return 'import { callMe as callMeFn } from "../test.js";\n' + 426 | 'import "../a [weird] (name).js";\n' + 427 | 'callMeFn();\n'; 428 | } 429 | if (url.href === 'file:///a%20[weird]%20(name).js') { 430 | return 'weirdName();\n'; 431 | } 432 | return null 433 | }) 434 | await fakeium.run('./index.js') 435 | expect(fakeium.getReport().getAll()).to.deep.equal([ 436 | { 437 | type: 'GetEvent', 438 | path: 'weirdName', 439 | value: { ref: 1 }, 440 | location: { filename: 'file:///a%20[weird]%20(name).js', line: 1, column: 1 }, 441 | }, 442 | { 443 | type: 'CallEvent', 444 | path: 'weirdName', 445 | arguments: [], 446 | returns: { ref: 2 }, 447 | isConstructor: false, 448 | location: { filename: 'file:///a%20[weird]%20(name).js', line: 1, column: 1 }, 449 | }, 450 | { 451 | type: 'GetEvent', 452 | path: 'iGotCalled', 453 | value: { ref: 3 }, 454 | location: { filename: 'file:///test.js', line: 2, column: 29 }, 455 | }, 456 | { 457 | type: 'CallEvent', 458 | path: 'iGotCalled', 459 | arguments: [], 460 | returns: { ref: 4 }, 461 | isConstructor: false, 462 | location: { filename: 'file:///test.js', line: 2, column: 29 }, 463 | }, 464 | { 465 | type: 'GetEvent', 466 | path: 'index', 467 | value: { ref: 5 }, 468 | location: { filename: 'file:///index.js', line: 3, column: 1 }, 469 | }, 470 | { 471 | type: 'CallEvent', 472 | path: 'index', 473 | arguments: [], 474 | returns: { ref: 6 }, 475 | isConstructor: false, 476 | location: { filename: 'file:///index.js', line: 3, column: 1 }, 477 | } 478 | ]) 479 | fakeium.dispose() 480 | }) 481 | 482 | it('runs eval code', async () => { 483 | const fakeium = new Fakeium({ logger }) 484 | await fakeium.run('index.js', 'alert(eval("1+1"))') 485 | expect(fakeium.getReport().has({ 486 | type: 'CallEvent', 487 | path: 'eval', 488 | arguments: [ { literal: '1+1' } ], 489 | returns: { literal: 2 }, 490 | })).to.equal(true) 491 | fakeium.dispose() 492 | }) 493 | 494 | it('handles constructors', async () => { 495 | const fakeium = new Fakeium({ logger }) 496 | await fakeium.run('index.js', 497 | '(async () => {\n' + 498 | ' const dateAsJson = new Date("2021-01-02").toJSON();\n' + 499 | ' crypto.getRandomValues(new Uint32Array(16));\n' + 500 | ' const Thing = getThing();\n' + 501 | ' const thing = new Thing(dateAsJson);\n' + 502 | ' thing.doSomething();\n' + 503 | ' const AsyncThing = await getAsyncThing();\n' + 504 | ' new AsyncThing(thing);\n' + 505 | ' const req = new XMLHttpRequest();\n' + 506 | ' req.open("GET", "https://www.example.com/");\n' + 507 | ' req.send();\n' + 508 | '})();\n' 509 | ) 510 | 511 | expect(fakeium.getReport().has({ 512 | type: 'CallEvent', 513 | path: 'Date', 514 | arguments: [ { literal: '2021-01-02' } ], 515 | returns: { ref: 2 }, 516 | isConstructor: true, 517 | })).to.equal(true) 518 | expect(fakeium.getReport().has({ 519 | type: 'CallEvent', 520 | path: 'Date().toJSON', 521 | arguments: [], 522 | returns: { literal: '2021-01-02T00:00:00.000Z' }, 523 | isConstructor: false, 524 | })).to.equal(true) 525 | 526 | expect(fakeium.getReport().has({ 527 | type: 'CallEvent', 528 | path: 'Uint32Array', 529 | arguments: [ { literal: 16 } ], 530 | returns: { ref: 7 }, 531 | isConstructor: true, 532 | })).to.equal(true) 533 | expect(fakeium.getReport().has({ 534 | type: 'CallEvent', 535 | path: 'crypto.getRandomValues', 536 | arguments: [ { ref: 7 } ], 537 | returns: { ref: 8 }, 538 | isConstructor: false, 539 | })).to.equal(true) 540 | 541 | expect(fakeium.getReport().has({ 542 | type: 'CallEvent', 543 | path: 'getThing', 544 | arguments: [ { literal: '2021-01-02T00:00:00.000Z' } ], 545 | returns: { ref: 11 }, 546 | isConstructor: true, 547 | })).to.equal(true) 548 | expect(fakeium.getReport().has({ 549 | type: 'CallEvent', 550 | path: 'getAsyncThing', 551 | arguments: [ { ref: 11 } ], 552 | isConstructor: true, 553 | })).to.equal(true) 554 | 555 | expect(fakeium.getReport().has({ 556 | type: 'CallEvent', 557 | path: 'XMLHttpRequest', 558 | arguments: [], 559 | isConstructor: true, 560 | })).to.equal(true) 561 | expect(fakeium.getReport().has({ 562 | type: 'CallEvent', 563 | path: 'XMLHttpRequest().open', 564 | arguments: [ { literal: 'GET' }, { literal: 'https://www.example.com/' } ], 565 | isConstructor: false, 566 | })).to.equal(true) 567 | 568 | fakeium.dispose() 569 | }) 570 | 571 | it('handles calling of functions in several ways', async () => { 572 | const fakeium = new Fakeium({ logger }) 573 | await fakeium.run('index.js', 574 | 'function test() {\n' + 575 | ' done();\n' + 576 | '}\n' + 577 | ';(async () => {\n' + 578 | ' something.apply(null, [1, 2, 3]);\n' + 579 | ' another.thing.bind({})("hey");\n' + 580 | ' await another.something.call(this);\n' + 581 | ' test.apply(null, []);\n' + 582 | '})();\n' 583 | ) 584 | expect(fakeium.getReport().has({ 585 | type: 'CallEvent', 586 | path: 'something', 587 | arguments: [ { literal: 1 }, { literal: 2 }, { literal: 3 } ], 588 | isConstructor: false, 589 | })).to.equal(true) 590 | expect(fakeium.getReport().has({ 591 | type: 'CallEvent', 592 | path: 'another.thing', 593 | arguments: [ { literal: 'hey' } ], 594 | isConstructor: false, 595 | })).to.equal(true) 596 | expect(fakeium.getReport().has({ 597 | type: 'CallEvent', 598 | path: 'another.something', 599 | arguments: [], 600 | isConstructor: false, 601 | })).to.equal(true) 602 | expect(fakeium.getReport().has({ 603 | type: 'CallEvent', 604 | path: 'done', 605 | arguments: [], 606 | isConstructor: false, 607 | })).to.equal(true) 608 | fakeium.dispose() 609 | }) 610 | 611 | it('handles callbacks and event listeners', async () => { 612 | const fakeium = new Fakeium({ logger }) 613 | await fakeium.run('index.js', 614 | 'window.addEventListener("load", function() {\n' + 615 | ' navigator.gotCalled();\n' + 616 | ' throw new Error("Crash!");\n' + 617 | ' // This line is unreachable, but the program must not crash\n' + 618 | '});\n' + 619 | '$("#button").click(() => {\n' + 620 | ' also.gotCalled();\n' + 621 | '});\n' 622 | ) 623 | expect(fakeium.getReport().has({ type: 'CallEvent', path: 'navigator.gotCalled' })).to.equal(true) 624 | expect(fakeium.getReport().has({ type: 'CallEvent', path: 'also.gotCalled' })).to.equal(true) 625 | fakeium.dispose() 626 | }) 627 | 628 | it('creates mocks that can be converted to primitive values', async () => { 629 | const fakeium = new Fakeium({ logger }) 630 | await fakeium.run('index.js', 631 | 'const diff = 123_456 - document.createEvent("Event").timeStamp;\n' + 632 | 'console.log(diff);\n' 633 | ) 634 | expect(fakeium.getReport().has({ type: 'CallEvent', path: 'console.log', arguments: [ {literal: 123456 }] })) 635 | fakeium.dispose() 636 | }) 637 | 638 | it('handles iterators', async () => { 639 | const fakeium = new Fakeium({ logger }) 640 | await fakeium.run('index.js', 641 | 'for (const test of getItems()) {\n' + 642 | ' console.log(test.hey());\n' + 643 | '}\n' 644 | ) 645 | expect(fakeium.getReport().has({ type: 'CallEvent', path: 'getItems()[3].hey', arguments: [] })).to.equal(true) 646 | fakeium.dispose() 647 | }) 648 | 649 | it('uses different scopes for scripts and modules', async () => { 650 | const fakeium = new Fakeium({ logger }) 651 | await fakeium.run('script.js', 652 | 'if (this === undefined || this !== globalThis) {\n' + 653 | ' throw new Error("Invalid scope");\n' + 654 | '}\n', 655 | { sourceType: 'script' }, 656 | ) 657 | await fakeium.run('module.js', 658 | 'if (this !== undefined || globalThis === undefined) {\n' + 659 | ' throw new Error("Invalid scope");\n' + 660 | '}\n', 661 | { sourceType: 'module' }, 662 | ) 663 | fakeium.dispose() 664 | }) 665 | 666 | it('shares global scope in modules', async () => { 667 | const fakeium = new Fakeium({ logger, sourceType: 'module' }) 668 | fakeium.setResolver(async url => { 669 | if (url.pathname === '/index.js') { 670 | return 'import "./other-module.js";\n' + 671 | 'if (globalThis.test !== 123) {\n' + 672 | ' throw new Error("Global scope is not being shared");\n' + 673 | '}\n' 674 | } 675 | if (url.pathname === '/other-module.js') { 676 | return 'globalThis.test = 123;\n' 677 | } 678 | return null 679 | }) 680 | await fakeium.run('index.js') 681 | fakeium.dispose() 682 | }) 683 | 684 | it('does not SIGSEGV', async () => { 685 | const fakeium = new Fakeium({ logger }) 686 | await fakeium.run('index.js', 687 | 'function doStuff() {\n' + 688 | ' console.log("Doing stuff");\n' + 689 | '}\n' + 690 | 'var tid;\n' + 691 | 'var debouncedCheck = function () {\n' + 692 | ' clearTimeout(tid);\n' + 693 | ' tid = setTimeout(doStuff, 100);\n' + 694 | '};\n' + 695 | 'window.addEventListener("resize", debouncedCheck, false);\n' + 696 | 'var winLoad = function () {\n' + 697 | ' window.removeEventListener("load", winLoad, false);\n' + 698 | ' tid = setTimeout(doStuff, 0);\n' + 699 | '};\n' + 700 | 'if (document.readyState !== "complete") {\n' + 701 | ' window.addEventListener("load", winLoad, false);\n' + 702 | '} else {\n' + 703 | ' winLoad();\n' + 704 | '}\n' 705 | ) 706 | expect(fakeium.getReport().has({ arguments: [ { literal: 'Doing stuff' }] })).to.equal(true) 707 | fakeium.dispose() 708 | }) 709 | }) 710 | 711 | describe('Fakeium hooks', () => { 712 | it('throws an error for invalid paths', async () => { 713 | const fakeium = new Fakeium({ logger }) 714 | expect(() => fakeium.hook('This is clearly not a valid path', '')).to.throw(InvalidPathError) 715 | expect(() => fakeium.hook('a.b.0.c', '')).to.throw(InvalidPathError) 716 | expect(() => fakeium.hook('hey[0.unclosed_bracket', '')).to.throw(InvalidPathError) 717 | expect(() => fakeium.hook('valid.path', new Reference('invalid path'))).to.throw(InvalidPathError) 718 | fakeium.dispose() 719 | }) 720 | 721 | it('throws an error for non-transferable values', () => { 722 | const fakeium = new Fakeium({ logger }) 723 | expect(() => fakeium.hook('something', Symbol('test'))).to.throw(InvalidValueError) 724 | fakeium.dispose() 725 | }) 726 | 727 | it('throws an error on timeout caused by a hook', async () => { 728 | const fakeium = new Fakeium({ logger, timeout: 200 }) 729 | fakeium.hook('test', () => { 730 | return new Promise(resolve => setTimeout(resolve, 1000)) 731 | }) 732 | for (const sourceType of ['script', 'module'] as const) { 733 | try { 734 | await fakeium.run('index.js', 'test()', { sourceType }) 735 | assert.fail('Fakeium#run() did not throw any error') 736 | } catch (e) { 737 | expect(e).to.be.an.instanceOf(TimeoutError) 738 | } 739 | expectNonEmptyStats(fakeium.getStats()) 740 | fakeium.dispose() 741 | } 742 | }).timeout(2000) 743 | 744 | it('aliases window and other objects to globalThis by default', async () => { 745 | const fakeium = new Fakeium({ logger }) 746 | await fakeium.run('index.js', 747 | 'for (const item of [frames, global, parent, self, window]) {\n' + 748 | ' if (typeof item !== "object" || item !== globalThis) {\n' + 749 | ' throw new Error("Sandbox did not pass environment verification");\n' + 750 | ' }\n' + 751 | '}\n' 752 | ) 753 | fakeium.dispose() 754 | }) 755 | 756 | it('fakes browser extensions environment by default', async () => { 757 | const fakeium = new Fakeium({ logger }) 758 | 759 | // Ensure predefined mocks are correct 760 | await fakeium.run('index.js', 761 | '// "globalThis.chrome" must be an object, not a function\n' + 762 | 'if (typeof chrome !== "object" || !chrome || !chrome.runtime || !chrome.runtime.id || chrome !== browser) {\n' + 763 | ' throw new Error("Sandbox did not pass environment verification");\n' + 764 | '}\n' 765 | ) 766 | fakeium.dispose() 767 | 768 | // Validate auto-wiring of "chrome" to "browser" object 769 | await fakeium.run('index.js', 770 | '(async () => {\n' + 771 | ' const [ tab ] = await chrome.tabs.query({ active: true });\n' + 772 | ' const response = await browser.tabs.sendMessage(tab.id, { greeting: "hello" });\n' + 773 | '})();\n' 774 | ) 775 | expect(fakeium.getReport().has({ type: 'CallEvent', path: 'browser.tabs.query' })).to.equal(true) 776 | expect(fakeium.getReport().has({ type: 'CallEvent', path: 'browser.tabs.sendMessage' })).to.equal(true) 777 | expect(fakeium.getReport().has({ path: 'chrome.tabs.query' })).to.equal(false) 778 | expect(fakeium.getReport().has({ path: 'chrome.tabs.sendMessage' })).to.equal(false) 779 | expect(fakeium.getReport().has({ path: 'chrome' })).to.equal(false) 780 | fakeium.dispose() 781 | }) 782 | 783 | it('prevents mocking AMD loaders by default', async () => { 784 | const fakeium = new Fakeium({ logger }) 785 | await fakeium.run('index.js', 786 | 'if (define !== undefined || exports !== undefined || require !== undefined) {\n' + 787 | ' throw new Error("Sandbox did not pass environment verification");\n' + 788 | '}\n' + 789 | 'globalThis.define = () => alert("I can be overwritten from inside the sandbox");\n' + 790 | 'define();\n' 791 | ) 792 | expect(fakeium.getReport().has({ 793 | type: 'CallEvent', 794 | path: 'alert', 795 | arguments: [ { literal: 'I can be overwritten from inside the sandbox' }], 796 | })).to.equal(true) 797 | fakeium.dispose() 798 | }) 799 | 800 | it('supports hooking certain objects inside the sandbox', async () => { 801 | let somethingGotCalled = false 802 | const fakeium = new Fakeium({ logger }) 803 | fakeium.hook('sample.value', 'hello!') 804 | fakeium.hook('undefinedIsAlsoValid', undefined) 805 | fakeium.hook('hookMe', () => 33) 806 | fakeium.hook('something', async () => { 807 | somethingGotCalled = true 808 | return 123 809 | }) 810 | fakeium.hook('test.something', new Reference('another.reference[0].to.somewhere')) 811 | await fakeium.run('index.js', 812 | 'console.log(sample.value);\n' + 813 | 'console.log(undefinedIsAlsoValid);\n' + 814 | 'something();\n' + 815 | 'window.something();\n' + 816 | 'const res = hookMe();\n' + 817 | 'anotherThing(res);\n' + 818 | 'test.something.else();\n' 819 | ) 820 | expect(fakeium.getReport().has({ path: 'sample.value', value: { literal: 'hello!' } })).to.equal(true) 821 | expect(fakeium.getReport().has({ path: 'undefinedIsAlsoValid', value: { literal: undefined } })).to.equal(true) 822 | expect(fakeium.getReport().has({ path: 'hookMe', returns: { literal: 33 } })).to.equal(true) 823 | expect(fakeium.getReport().has({ path: 'something', returns: { literal: 123 } })).to.equal(true) 824 | expect(somethingGotCalled).to.equal(true) 825 | expect(fakeium.getReport().has({ type: 'CallEvent', path: 'another.reference[0].to.somewhere.else' })).to.equal(true) 826 | fakeium.dispose() 827 | }) 828 | 829 | it('handles writable and non-writable hooks', async () => { 830 | const fakeium = new Fakeium({ logger }) 831 | fakeium.hook('writable', 'a', true) 832 | fakeium.hook('readOnly', 'a', false) 833 | fakeium.hook('writableFn', () => 'Y', true) 834 | fakeium.hook('readOnlyFn', () => 'Y', false) 835 | await fakeium.run('index.js', 836 | 'writable += "b";\n' + 837 | 'console.log(`writable is "${writable}"`);\n' + 838 | 'readOnly += "b";\n' + 839 | 'console.log(`readOnly is "${readOnly}"`);\n' + 840 | 'writableFn();\n' + 841 | 'writableFn = () => "Z";\n' + 842 | 'writableFn();\n' + 843 | 'readOnlyFn();\n' + 844 | 'readOnlyFn = () => "Z";\n' + 845 | 'readOnlyFn();\n' 846 | ) 847 | 848 | // Set events should be logged regardless of writable state 849 | expect(fakeium.getReport().has({ path: 'writable', value: { literal: 'a' } })).to.equal(true) 850 | expect(fakeium.getReport().has({ path: 'writable', value: { literal: 'ab' } })).to.equal(true) 851 | expect(fakeium.getReport().has({ path: 'readOnly', value: { literal: 'a' } })).to.equal(true) 852 | expect(fakeium.getReport().has({ path: 'readOnly', value: { literal: 'ab' } })).to.equal(true) 853 | expect(fakeium.getReport().has({ type: 'SetEvent', path: 'writableFn' })).to.equal(true) 854 | expect(fakeium.getReport().has({ type: 'SetEvent', path: 'readOnlyFn' })).to.equal(true) 855 | 856 | // But changes should not persisted to read-only paths 857 | expect(fakeium.getReport().has({ arguments: [ { literal: 'writable is "ab"' } ] })).to.equal(true) 858 | expect(fakeium.getReport().has({ arguments: [ { literal: 'readOnly is "a"' } ] })).to.equal(true) 859 | expect(fakeium.getReport().has({ 860 | path: 'writableFn', 861 | returns: { literal: 'Y' }, 862 | location: { line: 5 }, 863 | })).to.equal(true) 864 | expect(fakeium.getReport().has({ 865 | path: 'writableFn', 866 | returns: { literal: 'Z' }, 867 | location: { line: 7 }, 868 | })).to.equal(true) 869 | expect(fakeium.getReport().has({ 870 | path: 'readOnlyFn', 871 | returns: { literal: 'Y' }, 872 | location: { line: 8 }, 873 | })).to.equal(true) 874 | expect(fakeium.getReport().has({ 875 | path: 'readOnlyFn', 876 | returns: { literal: 'Y' }, 877 | location: { line: 10 }, 878 | })).to.equal(true) 879 | 880 | fakeium.dispose() 881 | }) 882 | 883 | it('supports hooking async functions', async () => { 884 | const fakeium = new Fakeium({ logger }) 885 | fakeium.hook('something', async () => { 886 | await new Promise(resolve => setTimeout(resolve, 250)) 887 | return 123_456 888 | }) 889 | await fakeium.run('index.js', 'something()') 890 | expect(fakeium.getReport().has({ path: 'something', returns: { literal: 123_456 } })).to.equal(true) 891 | fakeium.dispose() 892 | }) 893 | 894 | it('supports returning non-transferable objects in hooked functions', async () => { 895 | let somethingGotCalled = false 896 | let fnGotCalled = false 897 | const fakeium = new Fakeium({ logger }) 898 | fakeium.hook('something', (props: { a: number, b: number }) => { 899 | somethingGotCalled = true 900 | expect(props).to.be.deep.equal({ a: 1, b: 2 }) 901 | return { 902 | a: props.a * 100, 903 | b: props.b * 100, 904 | c: { 905 | d: 'hello', 906 | }, 907 | fn: () => { 908 | fnGotCalled = true 909 | return 123 910 | }, 911 | nestedFn: () => { 912 | return () => 'hello from nested' 913 | }, 914 | } 915 | }) 916 | await fakeium.run('index.js', 917 | '(async () => {\n' + 918 | ' const test = something({ a: 1, b: 2 });\n' + 919 | ' if (test.a !== 100) {\n' + 920 | ' throw new Error("Invalid literal value for test.a");\n' + 921 | ' }\n' + 922 | ' if (test.b !== 200) {\n' + 923 | ' throw new Error("Invalid literal value for test.b");\n' + 924 | ' }\n' + 925 | ' if (test.c.d !== "hello") {\n' + 926 | ' throw new Error("Invalid literal value for test.c.d");\n' + 927 | ' }\n' + 928 | ' test.fn();\n' + 929 | ' const nestedFn = test.nestedFn();\n' + 930 | ' if (nestedFn() !== "hello from nested") {\n' + 931 | ' throw new Error("Invalid return value for test.nestedFn()()");\n' + 932 | ' }\n' + 933 | '})();\n' 934 | ) 935 | expect(fakeium.getReport().has({ path: 'something().a', value: { literal: 100 } } )).to.equal(true) 936 | expect(fakeium.getReport().has({ path: 'something().b', value: { literal: 200 } } )).to.equal(true) 937 | expect(fakeium.getReport().has({ path: 'something().c.d', value: { literal: 'hello' } } )).to.equal(true) 938 | expect(fakeium.getReport().has({ path: 'something().fn', returns: { literal: 123 } })).to.equal(true) 939 | expect(somethingGotCalled).to.equal(true) 940 | expect(fnGotCalled).to.equal(true) 941 | fakeium.dispose() 942 | }) 943 | 944 | it('supports returning nested functions in hooked functions', async () => { 945 | let innerGotCalled = false 946 | const fakeium = new Fakeium({ logger }) 947 | fakeium.hook('something', () => { 948 | return { 949 | a: () => { 950 | return { 951 | b: () => { 952 | innerGotCalled = true 953 | return null 954 | }, 955 | } 956 | }, 957 | } 958 | }) 959 | await fakeium.run('index.js', 960 | 'const fn = something();\n' + 961 | 'fn.a().b();\n' 962 | ) 963 | expect(fakeium.getReport().has({ path: 'something().a().b', returns: { literal: null } })).to.equal(true) 964 | expect(innerGotCalled).to.equal(true) 965 | fakeium.dispose() 966 | }) 967 | 968 | it('supports directly returning a function in hooked functions', async () => { 969 | let innerGotCalled = false 970 | const fakeium = new Fakeium({ logger }) 971 | fakeium.hook('require', () => { 972 | return () => { 973 | return { 974 | body: { 975 | on: (event: string, cb: (() => void)) => { 976 | expect(event).to.equal('finish') 977 | expect(cb).to.be.a('function') 978 | innerGotCalled = true 979 | }, 980 | }, 981 | } 982 | } 983 | }) 984 | await fakeium.run('index.js', 985 | 'const fetch = require("node-fetch");\n' + 986 | '(async () => {\n' + 987 | ' const res = await fetch("https://example.com/");\n' + 988 | ' res.body.on("finish", () => {\n' + 989 | ' console.log("done!");\n' + 990 | ' });\n' + 991 | '})();\n' 992 | ) 993 | expect(innerGotCalled).to.equal(true) 994 | fakeium.dispose() 995 | }) 996 | 997 | it('discards non-cloneable arguments in hooked functions', async () => { 998 | const fakeium = new Fakeium({ logger }) 999 | fakeium.hook('test', (...args: unknown[]) => { 1000 | expect(args).to.be.deep.equal(['string', {}, {}, { cloneable: true }]) 1001 | return true 1002 | }) 1003 | await fakeium.run('index.js', 1004 | 'const a = "string";\n' + 1005 | 'const b = Symbol("unsupported type");\n' + 1006 | 'const c = thisIsAMock;\n' + 1007 | 'const d = { cloneable: true };\n' + 1008 | 'test(a, b, c, d);\n' 1009 | ) 1010 | expect(fakeium.getReport().has({ path: 'test', returns: { literal: true } })).to.equal(true) 1011 | fakeium.dispose() 1012 | }) 1013 | }) 1014 | --------------------------------------------------------------------------------