@contrast/esm-hooks 2.2.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/common.mjs ADDED
@@ -0,0 +1,136 @@
1
+ /*
2
+ * Copyright: 2024 Contrast Security, Inc
3
+ * Contact: support@contrastsecurity.com
4
+ * License: Commercial
5
+
6
+ * NOTICE: This Software and the patented inventions embodied within may only be
7
+ * used as part of Contrast Security’s commercial offerings. Even though it is
8
+ * made available through public repositories, use of this Software is subject to
9
+ * the applicable End User Licensing Agreement found at
10
+ * https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
11
+ * between Contrast Security and the End User. The Software may not be reverse
12
+ * engineered, modified, repackaged, sold, redistributed or otherwise used in a
13
+ * way not consistent with the End User License Agreement.
14
+ */
15
+
16
+ import { readdir } from 'node:fs/promises';
17
+ import path from 'node:path';
18
+
19
+ const REDIRECTS_PATH = './redirects';
20
+
21
+ export const mappings = await makeMappings();
22
+
23
+ // why do we need to fix paths? Because windows, node, and URLs don't get
24
+ // along very well.
25
+ //
26
+ // the load() hook receives an URL. When the URL is converted into an URL object,
27
+ // the pathname property will be '/C:/Users/.../redirects' on windows. And that
28
+ // doesn't start with a drive letter, so when fsp.readdir() gets it, node decides
29
+ // to add a drive letter: 'C:/C:/Users/.../redirects'. So we have to strip off the
30
+ // the leading / so the drive letter is recognized and another isn't added. it seems
31
+ // like a node bug (or possibly windows bug) but i don't have time to research the
32
+ // standards to figure out what URL.pathname should be in a windows file: context.
33
+ //
34
+ // maybe this should be in the load() hook?
35
+ //
36
+ // should
37
+ /**
38
+ *
39
+ * @param {string} path
40
+ * @returns {string}
41
+ */
42
+ export function fixPath(p) {
43
+ if (p.match(/^\/[A-Z]:/)) {
44
+ p = p.slice(1);
45
+ }
46
+ return p;
47
+ }
48
+
49
+ import { getFileType } from './get-file-type.mjs';
50
+ export { getFileType };
51
+
52
+ async function makeMappings() {
53
+ /**
54
+ * @typedef { 'builtin' | 'commonjs' | 'module' } TargetType
55
+ * @typedef {string} RequireSpecifier
56
+ */
57
+ /**
58
+ * @type {Record<RequireSpecifier, {url: URL, target: TargetType}>}
59
+ */
60
+ const mappings = Object.create(null);
61
+
62
+ // the name of the directory is the format of the target being loaded. so "cjs"
63
+ // means the target is a commonjs module that will be loaded via require(),
64
+ // "builtin" means the target is a builtin, and "esm" (when it's added) will
65
+ // mean that the target is a native esm module.
66
+ //
67
+ // at this time, all "builtin" modules are "cjs" modules and can be required.
68
+ // and all "cjs" modules are commonjs modules that can be required. The reason
69
+ // they need to be here is that they might be loaded by the ESM loader in the
70
+ // background thread, so we need to redirect them to the commonjs loader.
71
+ //
72
+ // all files in the "redirects" directory are .mjs files.
73
+ //
74
+ // 'esm' does not currently have any redirect files; there is one example file
75
+ // for node-fetch, but it does not have the extension .mjs, so it won't be used.
76
+ // 'esm' will be needed when we have to patch an esm-native file.
77
+ for (const dir of ['builtin', 'cjs', 'esm']) {
78
+ // keep track of recursive calls to handle nested directories, e.g., fs/promises
79
+ const pathStack = [];
80
+
81
+ // get an absolute path because reading the redirect file is going to be executed
82
+ // in another context.
83
+ const p = path.join(REDIRECTS_PATH, dir);
84
+ const redirectDir = new URL(p, import.meta.url);
85
+ const dirpath = fixPath(redirectDir.pathname);
86
+
87
+ await recursiveReaddir(dirpath);
88
+
89
+ // eslint-disable-next-line no-inner-declarations
90
+ async function recursiveReaddir(dirpath) {
91
+ const dirents = await readdir(dirpath, { withFileTypes: true });
92
+ for (const dirent of dirents) {
93
+ if (dirent.isDirectory()) {
94
+ const { name: subdir } = dirent;
95
+ const subp = path.join(dirpath, subdir);
96
+ pathStack.push(subdir);
97
+ await recursiveReaddir(subp);
98
+ pathStack.pop();
99
+ continue;
100
+ }
101
+ if (!dirent.name.endsWith('.mjs')) {
102
+ continue;
103
+ }
104
+ // it a file that ends with .mjs, so it's a redirect file.
105
+ const redirectURL = new URL(path.join(p, ...pathStack, dirent.name), import.meta.url);
106
+
107
+ // all redirects point to .mjs files; the target property specifies the type of the
108
+ // the file that will be loaded by the .mjs. target. target is builtin, commonjs, or
109
+ // module.
110
+ // e.g., //'node-fetch': {url: new URL(`${p}/node-fetch.mjs`, import.meta.url), target: 'module'},
111
+ //
112
+ // there is a separate builtin directory because putting a colon in a filename doesn't work on windows. so
113
+ // that's why the mapping below adds the `node:` prefix.
114
+ let name = path.basename(dirent.name, '.mjs');
115
+ if (pathStack.length) {
116
+ name = `${pathStack.join('/')}/${name}`;
117
+ }
118
+ if (dir === 'builtin') {
119
+ mappings[name] = { url: redirectURL, target: 'builtin' };
120
+ mappings[`node:${name}`] = { url: redirectURL, target: 'builtin' };
121
+ } else if (dir === 'cjs') {
122
+ mappings[name] = { url: redirectURL, target: 'commonjs' };
123
+ } else if (dir === 'esm') {
124
+ mappings[name] = { url: redirectURL, target: 'module' };
125
+ } else {
126
+ throw new Error(`target type ${dir} not yet implemented`);
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ return mappings;
133
+ }
134
+
135
+ // useful for tests
136
+ export { makeMappings };
@@ -0,0 +1,84 @@
1
+ /*
2
+ * Copyright: 2024 Contrast Security, Inc
3
+ * Contact: support@contrastsecurity.com
4
+ * License: Commercial
5
+
6
+ * NOTICE: This Software and the patented inventions embodied within may only be
7
+ * used as part of Contrast Security’s commercial offerings. Even though it is
8
+ * made available through public repositories, use of this Software is subject to
9
+ * the applicable End User Licensing Agreement found at
10
+ * https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
11
+ * between Contrast Security and the End User. The Software may not be reverse
12
+ * engineered, modified, repackaged, sold, redistributed or otherwise used in a
13
+ * way not consistent with the End User License Agreement.
14
+ */
15
+
16
+ import Module from 'node:module';
17
+ import * as process from 'node:process';
18
+ import { threadId as tid } from 'node:worker_threads';
19
+
20
+ const DEFAULT_OPTS = { mask: process.env.CSI_HOOKS_LOG, install: true };
21
+ const LOG_LOAD = 1;
22
+ const LOG_RESOLVE = 2;
23
+ const LOG_REQUIRE_ALL = 4;
24
+
25
+ export default function init(core, opts = DEFAULT_OPTS) {
26
+ const { esmHooks } = core;
27
+
28
+ if (!opts.mask) return;
29
+ const LOG = +opts.mask;
30
+
31
+ Object.assign(esmHooks, {
32
+ _rawDebug(value) {
33
+ process._rawDebug(value);
34
+ },
35
+ debugCjsCompile(filename) {
36
+ (LOG & LOG_REQUIRE_ALL) && esmHooks._rawDebug(`CJS(${tid}) _compile() ${filename}`);
37
+ },
38
+ debugCjsExtensions(filename) {
39
+ (LOG & LOG_REQUIRE_ALL) && esmHooks._rawDebug(`CJS(${tid}) extensions.js() ${filename}`);
40
+ },
41
+ debugCjsLoad(request) {
42
+ (LOG & LOG_LOAD) && esmHooks._rawDebug(`CJS(${tid}) _load() ${request}`);
43
+ },
44
+ debugCjsRequire(moduleId) {
45
+ (LOG & LOG_REQUIRE_ALL) && esmHooks._rawDebug(`CJS(${tid}) require() ${moduleId}`);
46
+ },
47
+ debugEsmInitialize(specifier) {
48
+ LOG && esmHooks._rawDebug(`ESM(${tid}) initialize() ${specifier}`);
49
+ },
50
+ debugEsmResolve(specifier) {
51
+ (LOG & LOG_RESOLVE) && esmHooks._rawDebug(`ESM(${tid}) resolve() ${specifier}`);
52
+ },
53
+ debugEsmLoad(url) {
54
+ (LOG & LOG_LOAD) && esmHooks._rawDebug(`ESM(${tid}) load() ${url}`);
55
+ },
56
+ });
57
+
58
+ if (opts.install) {
59
+ const originalRequire = Module.prototype.require;
60
+ Module.prototype.require = function(moduleId) {
61
+ esmHooks.debugCjsRequire(moduleId);
62
+ return originalRequire.call(this, moduleId);
63
+ };
64
+
65
+ const originalCompile = Module.prototype._compile;
66
+ Module.prototype._compile = function(code, filename) {
67
+ esmHooks.debugCjsCompile(filename);
68
+ return originalCompile.call(this, code, filename);
69
+ };
70
+
71
+ const originalExtensions = Module._extensions['.js'];
72
+ Module._extensions['.js'] = function(module, filename) {
73
+ esmHooks.debugCjsExtensions(filename);
74
+ return originalExtensions.call(this, module, filename);
75
+ };
76
+
77
+ const originalLoad = Module._load;
78
+ Module._load = function(request, parent, isMain) {
79
+ esmHooks.debugCjsLoad(request);
80
+ return originalLoad.call(this, request, parent, isMain);
81
+ };
82
+ }
83
+ return esmHooks;
84
+ }
package/lib/hooks.mjs ADDED
@@ -0,0 +1,163 @@
1
+ /*
2
+ * Copyright: 2024 Contrast Security, Inc
3
+ * Contact: support@contrastsecurity.com
4
+ * License: Commercial
5
+
6
+ * NOTICE: This Software and the patented inventions embodied within may only be
7
+ * used as part of Contrast Security’s commercial offerings. Even though it is
8
+ * made available through public repositories, use of this Software is subject to
9
+ * the applicable End User Licensing Agreement found at
10
+ * https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
11
+ * between Contrast Security and the End User. The Software may not be reverse
12
+ * engineered, modified, repackaged, sold, redistributed or otherwise used in a
13
+ * way not consistent with the End User License Agreement.
14
+ */
15
+
16
+ import { readFile as rf } from 'node:fs/promises';
17
+ import * as process from 'node:process';
18
+ import { fixPath, getFileType, mappings } from './common.mjs';
19
+ import { default as initLoaderAgent } from './loader-agent.mjs';
20
+ const [major, minor] = process.versions.node.split('.').map(it => +it);
21
+ const isLT16_12 = major < 16 || (major === 16 && minor < 12);
22
+
23
+ const readFile = rf;
24
+
25
+ /**
26
+ * Agent instance with minimum footprint that handles functionality related esm module loading.
27
+ * - We handles redirects to force require.
28
+ * - Module rewriting via exported load hook
29
+ */
30
+ let loaderAgent;
31
+
32
+ /**
33
+ * @param {{
34
+ * modes: string[],
35
+ * port: import('node:worker_threads').MessagePort,
36
+ * appInfo: import('@contrast/common').AppInfo,
37
+ * agentVersion: string,
38
+ * }} data
39
+ */
40
+ async function initialize(data = {}) {
41
+ loaderAgent = initLoaderAgent(data);
42
+ loaderAgent.esmHooks.debugEsmInitialize?.();
43
+ await loaderAgent.install();
44
+ }
45
+
46
+ async function resolve(specifier, context, nextResolve) {
47
+ loaderAgent?.esmHooks?.debugEsmResolve?.(specifier);
48
+
49
+ let isFlaggedToPatch = false;
50
+ if (context.parentURL) {
51
+ isFlaggedToPatch = context.parentURL.endsWith('csi-flag=', context.parentURL.length - 1);
52
+ }
53
+
54
+ if (loaderAgent?.enable && !isFlaggedToPatch && specifier in mappings) {
55
+ // eslint-disable-next-line prefer-const
56
+ let { url, format, target } = mappings[specifier];
57
+ // set flag to module or commonjs. i'm not sure this is needed but am keeping it
58
+ // in place until we have to implement esm-native module rewrites/wrapping. this
59
+ // is the point at which resolve() communicates to load().
60
+ //
61
+ // builtin's are probably the most likely to be loaded, so they're first.
62
+ // some tweaks might be needed when we start to patch esm-native modules
63
+ // in esm-native-code:
64
+ // https://nodejs.org/docs/latest-v20.x/api/esm.html#builtin-modules
65
+ // https://nodejs.org/docs/latest-v20.x/api/module.html#modulesyncbuiltinesmexports
66
+ if (target === 'builtin') {
67
+ url = `${url.href}?csi-flag=m`;
68
+ format = 'module';
69
+ } else if (target === 'commonjs') {
70
+ url = `${url.href}?csi-flag=c`;
71
+ format = getFileType(url) || format || 'commonjs';
72
+ } else if (target === 'module') {
73
+ url = `${url.href}?csi-flag=m`;
74
+ format = getFileType(url) || format || 'module';
75
+ } else {
76
+ throw new Error(`unexpected target ${target} for ${specifier}`);
77
+ }
78
+
79
+ return {
80
+ url,
81
+ format,
82
+ shortCircuit: true,
83
+ };
84
+ }
85
+
86
+ return protectedNextResolve(nextResolve, specifier, context);
87
+ }
88
+
89
+ async function load(url, context, nextLoad) {
90
+ loaderAgent?.esmHooks?.debugEsmLoad?.(url);
91
+
92
+ const urlObject = new URL(url);
93
+
94
+ if (loaderAgent?.enable && urlObject.searchParams.has('csi-flag')) {
95
+ const { pathname } = urlObject;
96
+ const source = await readFile(fixPath(pathname), 'utf8');
97
+ return {
98
+ source,
99
+ format: 'module',
100
+ shortCircuit: true,
101
+ };
102
+ }
103
+
104
+ if (urlObject.pathname.endsWith('.node')) {
105
+ const metaUrl = JSON.stringify(url);
106
+ const addon = urlObject.pathname;
107
+ return {
108
+ source: `require("node:module").createRequire(${metaUrl})("${addon}")`,
109
+ format: 'commonjs',
110
+ shortCircuit: true,
111
+ };
112
+ }
113
+
114
+ // if it's not a builtin, a .node addon, or a flagged file, it needs to be
115
+ // rewritten if it's a module.
116
+ if (urlObject.pathname.match(/(.js|.mjs|.cjs)$/)) {
117
+ // if it's not a module it will be rewritten by the require hooks.
118
+ const type = getFileType(urlObject);
119
+ if (type !== 'module') {
120
+ return nextLoad(url, context);
121
+ }
122
+
123
+ const filename = fixPath(urlObject.pathname);
124
+ const source = await readFile(filename, 'utf8');
125
+ let result;
126
+
127
+ if (loaderAgent?.enable) {
128
+ const rewriteOptions = {
129
+ filename,
130
+ isModule: type === 'module',
131
+ inject: true,
132
+ wrap: type !== 'module', // cannot wrap modules
133
+ };
134
+ result = await loaderAgent.rewriter.rewrite(source, rewriteOptions);
135
+
136
+ if (process.env.CSI_EXPOSE_CORE) {
137
+ // only do this in testing scenarios (todo: compose this functionality into test agents instead)
138
+ loaderAgent.esmHooks.portClient.sendStatus('rewriting file during ESM load hook', rewriteOptions);
139
+ }
140
+ }
141
+
142
+ return {
143
+ source: result?.code || source,
144
+ format: type || 'commonjs', // don't know what else to do here. log?
145
+ shortCircuit: true,
146
+ };
147
+ }
148
+
149
+ // this never gets called, so hmmm.
150
+ return nextLoad(url, context);
151
+ }
152
+
153
+ async function protectedNextResolve(nextResolve, specifier, context) {
154
+ if (context.parentURL) {
155
+ if (context.conditions.at(-1) === 'node-addons' || context.importAssertions || isLT16_12) {
156
+ return nextResolve(specifier, context);
157
+ }
158
+ }
159
+
160
+ return nextResolve(specifier);
161
+ }
162
+
163
+ export { initialize, resolve, load };
package/lib/index.mjs CHANGED
@@ -13,121 +13,65 @@
13
13
  * way not consistent with the End User License Agreement.
14
14
  */
15
15
 
16
- //
17
- // this file supports agent/lib/esm-hooks.mjs.
18
- //
16
+ import Module from 'node:module';
17
+ import { MessageChannel } from 'node:worker_threads';
18
+ import { default as debugMethods } from './debug-methods.mjs';
19
+ import { initialize, load, resolve } from './hooks.mjs';
20
+ import { default as portClient } from './post-message/main-client.mjs';
19
21
 
20
- import { readdir } from 'node:fs/promises';
21
- import path from 'node:path';
22
+ export default function init(core) {
23
+ const {
24
+ port1: mainPort,
25
+ port2: workerPort
26
+ } = new MessageChannel();
22
27
 
23
- const redirectPath = './redirects';
28
+ const esmHooks = core.esmHooks = {
29
+ hooks: { resolve, load }, // remove when all LTS versions support Module.register
30
+ getActiveModes() {
31
+ const modes = [];
32
+ if (core.config.getEffectiveValue('assess.enable')) {
33
+ modes.push('assess');
34
+ }
35
+ if (core.config.getEffectiveValue('protect.enable')) {
36
+ modes.push('protect');
37
+ }
38
+ return modes;
39
+ },
24
40
 
25
- // why do we need to fix paths? Because windows, node, and URLs don't get
26
- // along very well.
27
- //
28
- // the load() hook receives an URL. When the URL is converted into an URL object,
29
- // the pathname property will be '/C:/Users/.../redirects' on windows. And that
30
- // doesn't start with a drive letter, so when fsp.readdir() gets it, node decides
31
- // to add a drive letter: 'C:/C:/Users/.../redirects'. So we have to strip off the
32
- // the leading / so the drive letter is recognized and another isn't added. it seems
33
- // like a node bug (or possibly windows bug) but i don't have time to research the
34
- // standards to figure out what URL.pathname should be in a windows file: context.
35
- //
36
- // maybe this should be in the load() hook?
37
- //
41
+ /**
42
+ * Install AFTER we have onboarded with contrast-ui. This way we will have
43
+ * effective values for whether to enable protect/assess.
44
+ */
45
+ async install() {
46
+ const data = {
47
+ port: workerPort,
48
+ modes: esmHooks.getActiveModes(),
49
+ appInfo: core.appInfo,
50
+ agentVersion: core.agentVersion,
51
+ };
38
52
 
39
- import { fixPath, getFileType } from './get-file-type.mjs';
53
+ // Instantiate loader agent via register (if available) or manually.
54
+ // we can simplify when all LTS versions support register
55
+ if (Module.register) {
56
+ await Module.register(new URL('./hooks.mjs', import.meta.url).href, {
57
+ data,
58
+ transferList: [workerPort],
59
+ });
60
+ } else {
61
+ await initialize(data);
62
+ }
40
63
 
41
- // useful for tests
42
- export { makeMappings };
64
+ // these have side-effects, so compose during installation phase
65
+ debugMethods(core);
66
+ },
43
67
 
44
- export default async function init(core) {
45
- return {
46
- mappings: await makeMappings(),
47
- fixPath,
48
- getFileType,
68
+ async uninstall() {
69
+ // inform the loader agent it needs to disable and stop rewrites etc
70
+ esmHooks.portClient?.sendRPC?.('disable');
71
+ },
49
72
  };
50
- }
51
-
52
- async function makeMappings() {
53
- /**
54
- * @typedef { 'builtin' | 'commonjs' | 'module' } TargetType
55
- * @typedef {string} RequireSpecifier
56
- */
57
- /**
58
- * @type {Record<RequireSpecifier, {url: URL, target: TargetType}>}
59
- */
60
- const mappings = Object.create(null);
61
73
 
62
- // the name of the directory is the format of the target being loaded. so "cjs"
63
- // means the target is a commonjs module that will be loaded via require(),
64
- // "builtin" means the target is a builtin, and "esm" (when it's added) will
65
- // mean that the target is a native esm module.
66
- //
67
- // at this time, all "builtin" modules are "cjs" modules and can be required.
68
- // and all "cjs" modules are commonjs modules that can be required. The reason
69
- // they need to be here is that they might be loaded by the ESM loader in the
70
- // background thread, so we need to redirect them to the commonjs loader.
71
- //
72
- // all files in the "redirects" directory are .mjs files.
73
- //
74
- // 'esm' does not currently have any redirect files; there is one example file
75
- // for node-fetch, but it does not have the extension .mjs, so it won't be used.
76
- // 'esm' will be needed when we have to patch an esm-native file.
77
- for (const dir of ['builtin', 'cjs', 'esm']) {
78
- // keep track of recursive calls to handle nested directories, e.g., fs/promises
79
- const pathStack = [];
80
-
81
- // get an absolute path because reading the redirect file is going to be executed
82
- // in another context.
83
- const p = path.join(redirectPath, dir);
84
- const redirectDir = new URL(p, import.meta.url);
85
- const dirpath = fixPath(redirectDir.pathname);
86
-
87
- await recursiveReaddir(dirpath);
88
-
89
- // eslint-disable-next-line no-inner-declarations
90
- async function recursiveReaddir(dirpath) {
91
- const dirents = await readdir(dirpath, { withFileTypes: true });
92
- for (const dirent of dirents) {
93
- if (dirent.isDirectory()) {
94
- const { name: subdir } = dirent;
95
- const subp = path.join(dirpath, subdir);
96
- pathStack.push(subdir);
97
- await recursiveReaddir(subp);
98
- pathStack.pop();
99
- continue;
100
- }
101
- if (!dirent.name.endsWith('.mjs')) {
102
- continue;
103
- }
104
- // it a file that ends with .mjs, so it's a redirect file.
105
- const redirectURL = new URL(path.join(p, ...pathStack, dirent.name), import.meta.url);
106
-
107
- // all redirects point to .mjs files; the target property specifies the type of the
108
- // the file that will be loaded by the .mjs. target. target is builtin, commonjs, or
109
- // module.
110
- // e.g., //'node-fetch': {url: new URL(`${p}/node-fetch.mjs`, import.meta.url), target: 'module'},
111
- //
112
- // there is a separate builtin directory because putting a colon in a filename doesn't work on windows. so
113
- // that's why the mapping below adds the `node:` prefix.
114
- let name = path.basename(dirent.name, '.mjs');
115
- if (pathStack.length) {
116
- name = `${pathStack.join('/')}/${name}`;
117
- }
118
- if (dir === 'builtin') {
119
- mappings[name] = { url: redirectURL, target: 'builtin' };
120
- mappings[`node:${name}`] = { url: redirectURL, target: 'builtin' };
121
- } else if (dir === 'cjs') {
122
- mappings[name] = { url: redirectURL, target: 'commonjs' };
123
- } else if (dir === 'esm') {
124
- mappings[name] = { url: redirectURL, target: 'module' };
125
- } else {
126
- throw new Error(`target type ${dir} not yet implemented`);
127
- }
128
- }
129
- }
130
- }
74
+ portClient(core, { port: mainPort });
131
75
 
132
- return mappings;
76
+ return esmHooks;
133
77
  }
@@ -0,0 +1,78 @@
1
+ /*
2
+ * Copyright: 2024 Contrast Security, Inc
3
+ * Contact: support@contrastsecurity.com
4
+ * License: Commercial
5
+
6
+ * NOTICE: This Software and the patented inventions embodied within may only be
7
+ * used as part of Contrast Security’s commercial offerings. Even though it is
8
+ * made available through public repositories, use of this Software is subject to
9
+ * the applicable End User Licensing Agreement found at
10
+ * https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
11
+ * between Contrast Security and the End User. The Software may not be reverse
12
+ * engineered, modified, repackaged, sold, redistributed or otherwise used in a
13
+ * way not consistent with the End User License Agreement.
14
+ */
15
+
16
+ import Module from 'node:module';
17
+ import { IntentionalError } from '@contrast/common';
18
+ import { default as debugMethods } from './debug-methods.mjs';
19
+ import { default as portClient } from './post-message/loader-client.mjs';
20
+ const require = Module.createRequire(import.meta.url);
21
+
22
+ // TODO: language
23
+ const ERROR_MESSAGE = 'An error prevented the Contrast agent from initializing in the loader thread.';
24
+
25
+ /**
26
+ * @param {{
27
+ * modes: string[],
28
+ * port: import('node:worker_threads').MessagePort,
29
+ * appInfo: import('@contrast/common').AppInfo,
30
+ * agentVersion: string,
31
+ * }} data
32
+ */
33
+ export default function init({ appInfo, agentVersion, port, modes }) {
34
+ const core = {
35
+ appInfo,
36
+ agentVersion,
37
+ // this will toggle functionality in hooks.mjs e.g. redirects/rewriting
38
+ enable: true,
39
+ // stub for debugMethods
40
+ esmHooks: {},
41
+ async install() {
42
+ if (!modes?.length) {
43
+ throw new Error('worker agent invalid state: no modes');
44
+ }
45
+
46
+ for (const mode of modes) {
47
+ core.rewriter.install(mode);
48
+ }
49
+
50
+ debugMethods(core);
51
+ core.esmHooks?.portClient?.sendStatus?.('initialized and installed esm loader agent');
52
+ },
53
+ uninstall() {
54
+ core.enable = false;
55
+ },
56
+ };
57
+
58
+ try {
59
+ require('@contrast/core/lib/messages')(core);
60
+ require('@contrast/config')(core);
61
+ require('@contrast/logger').default(core);
62
+ require('@contrast/rewriter')(core);
63
+ portClient(core, { port });
64
+
65
+ return core;
66
+ } catch (err) {
67
+ core.enable = false;
68
+
69
+ // ignore intentional errors
70
+ if (!(err instanceof IntentionalError)) {
71
+ if (core.logger) {
72
+ core.logger.error({ err }, ERROR_MESSAGE);
73
+ } else {
74
+ console.error(new Error(ERROR_MESSAGE, { cause: err }));
75
+ }
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,58 @@
1
+ /*
2
+ * Copyright: 2024 Contrast Security, Inc
3
+ * Contact: support@contrastsecurity.com
4
+ * License: Commercial
5
+
6
+ * NOTICE: This Software and the patented inventions embodied within may only be
7
+ * used as part of Contrast Security’s commercial offerings. Even though it is
8
+ * made available through public repositories, use of this Software is subject to
9
+ * the applicable End User Licensing Agreement found at
10
+ * https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
11
+ * between Contrast Security and the End User. The Software may not be reverse
12
+ * engineered, modified, repackaged, sold, redistributed or otherwise used in a
13
+ * way not consistent with the End User License Agreement.
14
+ */
15
+
16
+ import { Event } from '@contrast/common';
17
+ import { send } from './send.mjs';
18
+
19
+ /**
20
+ * Handles loader agent communication with the main agent's port client.
21
+ * Has API to send status messages to main agent e.g. for when it installs/uninstalls.
22
+ * Also listens for for TS settings updates and messages from main agent instructing it to disable.
23
+ * @param {any} core
24
+ * @param {Object} opts
25
+ * @param {import('node:worker_threads').MessagePort} opts.port
26
+ */
27
+ export default function init(core, { port }) {
28
+ const portClient = core.esmHooks.portClient = {
29
+ _port: port,
30
+ _handlers: {
31
+ disable() {
32
+ core.uninstall?.();
33
+ portClient.sendStatus('loader agent has been uninstalled');
34
+ },
35
+ },
36
+ sendStatus(msg, data) {
37
+ send(port, { type: 'status', msg, data });
38
+ },
39
+ };
40
+
41
+ port.on('message', (raw) => {
42
+ if (raw?.type === Event.SERVER_SETTINGS_UPDATE) {
43
+ // forward main agent settings updates to loader components
44
+ core.messages.emit(Event.SERVER_SETTINGS_UPDATE, raw);
45
+ }
46
+
47
+ if (raw?.type === 'rpc') {
48
+ // dispatch appropriately
49
+ portClient._handlers[raw.action]?.(raw.data);
50
+ }
51
+ });
52
+
53
+ // do this after 'message' listeners are added, otherwise that process will automatically ref again
54
+ // https://nodejs.org/api/worker_threads.html#portunref
55
+ port.unref();
56
+
57
+ return portClient;
58
+ }
@@ -0,0 +1,66 @@
1
+ /*
2
+ * Copyright: 2024 Contrast Security, Inc
3
+ * Contact: support@contrastsecurity.com
4
+ * License: Commercial
5
+
6
+ * NOTICE: This Software and the patented inventions embodied within may only be
7
+ * used as part of Contrast Security’s commercial offerings. Even though it is
8
+ * made available through public repositories, use of this Software is subject to
9
+ * the applicable End User Licensing Agreement found at
10
+ * https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
11
+ * between Contrast Security and the End User. The Software may not be reverse
12
+ * engineered, modified, repackaged, sold, redistributed or otherwise used in a
13
+ * way not consistent with the End User License Agreement.
14
+ */
15
+
16
+ import { Event } from '@contrast/common';
17
+ import { send } from './send.mjs';
18
+
19
+ /**
20
+ * Handles main agent communication with the loader agent's port client.
21
+ * Has API to forward events, and for sending RPC-like messages e.g. for instructing it to disable.
22
+ * Will listen for status messages from the loader agent and log them.
23
+ * @param {any} core
24
+ * @param {Object} opts
25
+ * @param {import('node:worker_threads').MessagePort} opts.port
26
+ */
27
+ export default function init(core, { port }) {
28
+ const portClient = core.esmHooks.portClient = {
29
+ _port: port,
30
+ _handlers: {
31
+ status(msg, data) {
32
+ core.logger.trace({ name: 'contrast:esm:loader', data }, msg);
33
+ },
34
+ },
35
+ /**
36
+ * Forward events e.g. SERVER_SETTINGS_UPDATE
37
+ */
38
+ sendEvent(event, data) {
39
+ send(port, { type: event, ...data });
40
+ },
41
+ /**
42
+ * Tell the loader agent to do something e.g. disable/uninstall
43
+ */
44
+ sendRPC(action, data) {
45
+ send(port, { type: 'rpc', action, data });
46
+ },
47
+ };
48
+
49
+ // handling messages from loader agent
50
+ portClient._port.on('message', (raw) => {
51
+ if (raw?.type == 'status') {
52
+ portClient._handlers.status(raw.msg, raw.data);
53
+ }
54
+ });
55
+
56
+ // forward settings updates to loader agent via port
57
+ core.messages.on(Event.SERVER_SETTINGS_UPDATE, (msg) => {
58
+ portClient.sendEvent(Event.SERVER_SETTINGS_UPDATE, msg);
59
+ });
60
+
61
+ // do this after 'message' listeners are added, otherwise that process will automatically ref again
62
+ // https://nodejs.org/api/worker_threads.html#portunref
63
+ port.unref();
64
+
65
+ return portClient;
66
+ }
@@ -0,0 +1,25 @@
1
+ /*
2
+ * Copyright: 2024 Contrast Security, Inc
3
+ * Contact: support@contrastsecurity.com
4
+ * License: Commercial
5
+
6
+ * NOTICE: This Software and the patented inventions embodied within may only be
7
+ * used as part of Contrast Security’s commercial offerings. Even though it is
8
+ * made available through public repositories, use of this Software is subject to
9
+ * the applicable End User Licensing Agreement found at
10
+ * https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
11
+ * between Contrast Security and the End User. The Software may not be reverse
12
+ * engineered, modified, repackaged, sold, redistributed or otherwise used in a
13
+ * way not consistent with the End User License Agreement.
14
+ */
15
+
16
+ import W from 'node:worker_threads';
17
+
18
+ /**
19
+ * Wraps postMessage to handle generic message-sending activities e.g. enriching messages with tid.
20
+ * @param {W.MessagePort} port
21
+ * @param {any} data
22
+ */
23
+ export function send(port, data) {
24
+ port.postMessage({ tid: W.threadId, ...data });
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrast/esm-hooks",
3
- "version": "2.2.1",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "description": "Support for loading and instrumenting ECMAScript modules",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -19,7 +19,8 @@
19
19
  "test": "../scripts/test.sh"
20
20
  },
21
21
  "dependencies": {
22
- "@contrast/core": "1.29.1",
22
+ "@contrast/common": "1.19.0",
23
+ "@contrast/core": "1.30.0",
23
24
  "@contrast/find-package-json": "^1.0.0"
24
25
  }
25
26
  }