@contrast/agentify 1.0.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/LICENSE ADDED
@@ -0,0 +1,12 @@
1
+ Copyright: 2022 Contrast Security, Inc
2
+ Contact: support@contrastsecurity.com
3
+ License: Commercial
4
+
5
+ NOTICE: This Software and the patented inventions embodied within may only be
6
+ used as part of Contrast Security’s commercial offerings. Even though it is
7
+ made available through public repositories, use of this Software is subject to
8
+ the applicable End User Licensing Agreement found at
9
+ https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
10
+ between Contrast Security and the End User. The Software may not be reverse
11
+ engineered, modified, repackaged, sold, redistributed or otherwise used in a
12
+ way not consistent with the End User License Agreement.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # `@contrast/agentify`
2
+
3
+ Deploy core services and instrumentation into an application.
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ module.exports = function(deps) {
4
+ /**
5
+ * Components e.g. Assess, Protect, can
6
+ * 1) configure rewriter to add desired ContrastMethod functions to source code
7
+ * 2) patch those functions on `contrastMethods.api` in order to add instrumentation
8
+ */
9
+ const contrastMethods = deps.contrastMethods = {
10
+ // TODO: Assess will require add'l methods
11
+ // TODO: ESM will require add'l methods
12
+ api: {
13
+ eval(v) {
14
+ return v;
15
+ },
16
+ },
17
+ installed: false,
18
+ getGlobal() {
19
+ return global;
20
+ }
21
+ };
22
+
23
+ contrastMethods.install = function() {
24
+ if (contrastMethods.installed) {
25
+ return;
26
+ }
27
+
28
+ const global = contrastMethods.getGlobal();
29
+
30
+ try {
31
+ Object.defineProperty(global, 'ContrastMethods', {
32
+ enumerable: true,
33
+ configurable: false,
34
+ value: contrastMethods.api
35
+ });
36
+ contrastMethods.installed = true;
37
+ } catch (err) {
38
+ // We should never expect this since the installation process is well
39
+ // controlled, but still we should have the defensive code.
40
+ deps.logger.error({ err }, 'Unable to install global.ContrastMethods');
41
+ }
42
+ };
43
+
44
+ return deps.contrastMethods;
45
+ };
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const sinon = require('sinon');
5
+
6
+ describe('agentify contrast-methods', function() {
7
+ let contrastMethods;
8
+ let core;
9
+
10
+ beforeEach(function() {
11
+ const mocks = require('../../test/mocks');
12
+ core = mocks.core();
13
+ core.logger = mocks.logger();
14
+ contrastMethods = require('./contrast-methods')(core);
15
+ });
16
+
17
+ describe('api.eval()', function() {
18
+ it('acts as identity function', function() {
19
+ [{}, null, 1, 'two', undefined].forEach(v => {
20
+ expect(v).to.equal(contrastMethods.api.eval(v));
21
+ });
22
+ });
23
+ });
24
+
25
+ describe('.getGlobal', function() {
26
+ it('returns global :)', function() {
27
+ expect(contrastMethods.getGlobal()).to.equal(global);
28
+ });
29
+ });
30
+
31
+ describe('.install', function() {
32
+ let globalMock;
33
+ let contrastMethods;
34
+
35
+ beforeEach(function() {
36
+ contrastMethods = require('./contrast-methods')(core);
37
+ globalMock = {};
38
+ sinon.stub(contrastMethods, 'getGlobal').returns(globalMock);
39
+ });
40
+
41
+ it('installs on global', function() {
42
+ expect(globalMock.ContrastMethods).not.to.exist;
43
+ contrastMethods.install();
44
+ expect(globalMock.ContrastMethods).to.exist;
45
+ expect(() => {
46
+ delete globalMock.ContrastMethods;
47
+ }).throw(/Cannot delete property/);
48
+ });
49
+
50
+ it('installs just once', function() {
51
+ expect(globalMock.ContrastMethods).not.to.exist;
52
+ contrastMethods.install();
53
+ contrastMethods.install();
54
+ expect(globalMock.ContrastMethods).to.equal(contrastMethods.api);
55
+ });
56
+
57
+ describe('installation failure handing', function() {
58
+ beforeEach(function() {
59
+ Object.defineProperty(globalMock, 'ContrastMethods', {
60
+ configurable: false,
61
+ value: 'does not matter for test'
62
+ });
63
+ });
64
+ it('logs error when global.ContrastMethods cannot be redefined', function() {
65
+ contrastMethods.install();
66
+ const [{ err }, description] = core.logger.error.getCall(0).args;
67
+ expect(description).to.eql('Unable to install global.ContrastMethods');
68
+ expect(err.message).to.eql('Cannot redefine property: ContrastMethods');
69
+ });
70
+ });
71
+ });
72
+ });
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ const IS_CLASS_REGEX = /^class\W/;
4
+ const IS_FUNCTION_REGEX = /^(async\s+)?function\W/;
5
+ const IS_RETURN_REGEX = /^return\W/;
6
+ const METHOD_CONTEXT = 'function _contrast_';
7
+ const FUNCTION_CONTEXT = 'const _contrast_fn = ';
8
+
9
+ module.exports = function (deps) {
10
+ const {
11
+ rewriter,
12
+ logger,
13
+ patcher,
14
+ patcher: { hookedFunctions },
15
+ } = deps;
16
+
17
+ const toString = patcher.unwrap(Function.prototype.toString);
18
+
19
+ const functionHooks = (deps.functionHooks = {
20
+ installed: false,
21
+ cache: new WeakMap(),
22
+ });
23
+
24
+ /**
25
+ * Create some code context around a function to make it syntactically valid
26
+ * the three general types of syntactic function definitions are:
27
+ * - functions (declared with the function keyword)
28
+ * - arrow functions
29
+ * - class methods
30
+ * @param {string} code the user code
31
+ * @returns {string} the contextualized function
32
+ */
33
+ functionHooks.contextualizeFunction = (code) => {
34
+ // Classes themselves are functions, so we need to exit early to avoid
35
+ // clobbering the `class` keyword.
36
+ if (IS_CLASS_REGEX.exec(code)) return code;
37
+ const [orig] = code.split('{');
38
+
39
+ let lineOne = orig;
40
+
41
+ // if the function is a return, we don't need to add context.
42
+ if (!IS_RETURN_REGEX.exec(lineOne)) {
43
+ // When stringified, class methods look like normal function without the
44
+ // `function` keyword. We can normalize this by prepending `function`.
45
+ if (!IS_FUNCTION_REGEX.exec(lineOne) && lineOne.indexOf('=>') === -1) {
46
+ lineOne = `${METHOD_CONTEXT}${lineOne}`;
47
+ }
48
+ lineOne = `${FUNCTION_CONTEXT}${lineOne}`;
49
+
50
+ code = code.replace(orig, lineOne);
51
+ }
52
+
53
+ return code;
54
+ };
55
+
56
+ /**
57
+ * Returns the rewritten function code. If an error occurs during rewriting,
58
+ * we log the error and code information and return the original code string.
59
+ * @param {string} code string value of the JavaScript function
60
+ * @returns {string} the code rewritten to remove Contrast tokens
61
+ */
62
+ functionHooks.unwrite = function (code) {
63
+ // cannot parse lone function expressions that are not part of an assignment.
64
+ try {
65
+ let unwritten = functionHooks.contextualizeFunction(code);
66
+ unwritten = rewriter.unwrite(unwritten);
67
+ unwritten = unwritten.replace(METHOD_CONTEXT, '');
68
+ unwritten = unwritten.replace(FUNCTION_CONTEXT, '');
69
+ unwritten = unwritten.replace(/;$/, ''); // removes trailing semicolon
70
+ return unwritten;
71
+ } catch (err) {
72
+ logger.warn({ err, code }, 'Failed to unwrite function code');
73
+ return code;
74
+ }
75
+ };
76
+
77
+ functionHooks.install = function () {
78
+ if (deps.functionHooks.installed) return;
79
+
80
+ logger.debug('deploying function toString patch');
81
+
82
+ patcher.patch(Function.prototype, 'toString', {
83
+ name: 'Function.prototype.toString',
84
+ patchType: 'function-hooks',
85
+ around(orig, data) {
86
+ // rewriting is expensive, but should caching be configurable?
87
+ if (functionHooks.cache.has(data.obj)) {
88
+ return functionHooks.cache.get(data.obj);
89
+ }
90
+
91
+ let code;
92
+ if (hookedFunctions.has(data.obj)) {
93
+ code = toString.call(hookedFunctions.get(data.obj).fn);
94
+ } else {
95
+ code = orig();
96
+ }
97
+
98
+ let result = code;
99
+
100
+ if (code.indexOf('ContrastMethods')) {
101
+ const unwritten = functionHooks.unwrite(code);
102
+ functionHooks.cache.set(data.obj, unwritten);
103
+ result = unwritten;
104
+ }
105
+
106
+ return result;
107
+ },
108
+ });
109
+
110
+ functionHooks.installed = true;
111
+ };
112
+
113
+ functionHooks.uninstall = function () {
114
+ Function.prototype.toString = toString;
115
+ };
116
+
117
+ return functionHooks;
118
+ };
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const functionHooks = require('./function-hooks');
5
+
6
+ describe('agentify function-hooks', function () {
7
+ let coreMock;
8
+
9
+ beforeEach(function () {
10
+ const mocks = require('../../test/mocks');
11
+
12
+ coreMock = mocks.core();
13
+ coreMock.patcher = mocks.patcher();
14
+ coreMock.logger = mocks.logger();
15
+ coreMock.depHooks = mocks.depHooks();
16
+ coreMock.scopes = mocks.scopes();
17
+ coreMock.rewriter = mocks.rewriter();
18
+ });
19
+
20
+ it('should install function hooks', function () {
21
+ functionHooks(coreMock);
22
+ coreMock.functionHooks.install();
23
+ expect(coreMock.functionHooks.installed).to.be.true;
24
+ });
25
+
26
+ it('should skip if it is already installed', function () {
27
+ functionHooks(coreMock);
28
+ coreMock.functionHooks.installed = true;
29
+ coreMock.functionHooks.install();
30
+
31
+ expect(coreMock.logger.debug).to.not.have.been.called;
32
+ });
33
+
34
+ it('should return cached value when the function is already called', function () {
35
+ const functionA = function sum(a, b) {
36
+ eval(global.ContrastMethods.eval('2 + 2'));
37
+ return a + b;
38
+ };
39
+
40
+ const cachedValue = 'cachedValue';
41
+
42
+ functionHooks(coreMock);
43
+
44
+ coreMock.functionHooks.cache.set(functionA, cachedValue);
45
+
46
+ const data = {
47
+ obj: functionA,
48
+ };
49
+
50
+ let result;
51
+ coreMock.patcher.patch.callsFake((prototype, method, options) => {
52
+ result = options.around(() => ({}), data);
53
+ });
54
+
55
+ coreMock.functionHooks.install();
56
+
57
+ expect(result).to.eql(cachedValue);
58
+ });
59
+
60
+ it('should call the function itself when the function is not hooked', function () {
61
+ const functionA = function sum(a, b) {
62
+ eval(global.ContrastMethods.eval('2 + 2'));
63
+ return a + b;
64
+ };
65
+
66
+ functionHooks(coreMock);
67
+
68
+ const data = {
69
+ obj: functionA,
70
+ };
71
+
72
+ let result;
73
+ coreMock.patcher.patch.callsFake((prototype, method, options) => {
74
+ result = options.around(functionA.toString.bind(functionA), data);
75
+ });
76
+
77
+ const unwriteResult = `function sum(a, b) {
78
+ eval(2 + 2);
79
+ return a + b;
80
+ }`;
81
+
82
+ coreMock.rewriter.unwrite.callsFake((data) => unwriteResult);
83
+
84
+ coreMock.functionHooks.install();
85
+
86
+ expect(result).to.eql(unwriteResult);
87
+ expect(coreMock.functionHooks.cache.get(data.obj)).to.eql(unwriteResult);
88
+ });
89
+
90
+ it('should the call the original function of the hooked function', function () {
91
+ const functionA = function sum(a, b) {
92
+ eval(global.ContrastMethods.eval('2 + 2'));
93
+ return a + b;
94
+ };
95
+
96
+ const functionB = function sum(a, b) {
97
+ eval(global.ContrastMethods.eval('2 + 2'));
98
+ return a + b + 2;
99
+ };
100
+
101
+ functionHooks(coreMock);
102
+
103
+ const weekMap = coreMock.patcher.hookedFunctions;
104
+ weekMap.set(functionA, { fn: functionB });
105
+
106
+ const data = {
107
+ obj: functionA,
108
+ };
109
+
110
+ let result;
111
+ coreMock.patcher.patch.callsFake((prototype, method, options) => {
112
+ result = options.around(functionA.toString.bind(functionA), data);
113
+ });
114
+
115
+ const unwriteResult = `function sum(a, b) {
116
+ eval(2 + 2);
117
+ return a + b + 2;
118
+ }`;
119
+
120
+ coreMock.rewriter.unwrite.callsFake((data) => unwriteResult);
121
+
122
+
123
+ coreMock.functionHooks.install();
124
+
125
+ expect(result).to.eql(unwriteResult);
126
+ expect(coreMock.functionHooks.cache.get(data.obj)).to.eql(unwriteResult);
127
+ });
128
+
129
+ it('should log warning message when there is a problem unwriting the code', function () {
130
+ const err = new Error('Error');
131
+ const functionA = function sum(a, b) {
132
+ eval(global.ContrastMethods.eval('2 + 2'));
133
+ return a + b;
134
+ };
135
+
136
+ functionHooks(coreMock);
137
+
138
+ const data = {
139
+ obj: functionA,
140
+ };
141
+
142
+ coreMock.patcher.patch.callsFake((prototype, method, options) => {
143
+ options.around(functionA.toString.bind(functionA), data);
144
+ });
145
+
146
+ coreMock.rewriter.unwrite.throws(err);
147
+
148
+ coreMock.functionHooks.install();
149
+
150
+ expect(coreMock.logger.warn).to.have.been.calledWith({ err, code: functionA.toString() }, 'Failed to unwrite function code');
151
+ });
152
+
153
+ it('should call the original toString if the function is not hooked', function() {
154
+ const functionA = () => 1;
155
+ functionHooks(coreMock);
156
+ coreMock.functionHooks.install();
157
+
158
+ const resultOfA = functionA.toString();
159
+
160
+ expect(resultOfA).to.eqls('() => 1');
161
+ });
162
+
163
+ it('should uninstall the hooks', function() {
164
+ const { toString } = Function.prototype;
165
+ const functionA = () => 1;
166
+ functionHooks(coreMock);
167
+ coreMock.functionHooks.install();
168
+ coreMock.functionHooks.uninstall();
169
+
170
+ const resultOfA = functionA.toString();
171
+
172
+ expect(resultOfA).to.eqls('() => 1');
173
+ expect(Function.prototype.toString).to.eqls(toString);
174
+ });
175
+ });
package/lib/index.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ import RequireHook from '@contrast/require-hook';
2
+ import { Logger } from '@contrast/logger';
3
+
4
+ interface AgentifyOptions {
5
+ install: boolean;
6
+ svcList: string[];
7
+ }
8
+
9
+ interface AgentOptions<T> {
10
+ install: boolean;
11
+ preRunMain: PreRunMain<T>;
12
+ }
13
+
14
+ declare class Agent<T> {
15
+ constructor(deps: T, opts: AgentOptions<T>);
16
+ hookRunMain(): void;
17
+ handleInstallFailure(err: Error): Promise<void>;
18
+ install(): Promise<void>;
19
+ }
20
+
21
+ interface PreRunMain<T> {
22
+ (core: T, agent: Agent<T>): void | Promise<void>;
23
+ }
24
+
25
+ export interface Agentify<T> {
26
+ (preRunMain: PreRunMain<T>, opts?: AgentifyOptions): Agent<T>;
27
+ }
28
+
29
+ interface Core<T> {
30
+ readonly depHooks: RequireHook;
31
+ readonly logger: Logger;
32
+ agentify: Agentify<T>;
33
+ }
34
+
35
+ declare function init<T>(core: Core<T>): Agentify<T>;
36
+
37
+ export = init;
package/lib/index.js ADDED
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ const Module = require('module');
4
+
5
+ const defaultOpts = {
6
+ install: true,
7
+ svcList: [
8
+ 'reporter',
9
+ 'contrastMethods',
10
+ 'instrumentationLocks',
11
+ 'sources',
12
+ 'protect',
13
+ 'depHooks',
14
+ 'rewriteHooks',
15
+ 'functionHooks',
16
+ ]
17
+ };
18
+
19
+ module.exports = function(deps) {
20
+ // compose add'l local services
21
+ require('./sources')(deps);
22
+ require('./contrast-methods')(deps);
23
+ require('./instrumentation-locks')(deps);
24
+ require('./function-hooks')(deps);
25
+ require('./rewrite-hooks')(deps);
26
+ // TODO: NODE-2229
27
+
28
+ /**
29
+ * The interface is a function, which when called, will hook runMain
30
+ * @param {function} preRunMain
31
+ * @param {object} opts
32
+ */
33
+ function agentify(preRunMain, opts = defaultOpts) {
34
+ if (typeof preRunMain !== 'function') {
35
+ throw new Error('Invalid usage: first argument must be a function');
36
+ }
37
+
38
+ opts.preRunMain = preRunMain;
39
+
40
+ if (opts !== defaultOpts) {
41
+ opts = { ...defaultOpts, ...opts };
42
+ }
43
+
44
+ return new Agent(deps, opts);
45
+ }
46
+
47
+ deps.agentify = agentify;
48
+
49
+ return agentify;
50
+ };
51
+
52
+ class Agent {
53
+ /**
54
+ *
55
+ * @param {object} deps dependencies
56
+ * @param {object} opts
57
+ * @param {function} opts.preRunMain custom function for registering services
58
+ * @param {boolean} opts.install whether to automatically install
59
+ */
60
+ constructor(deps, opts) {
61
+ const self = this;
62
+ const { config, logger } = deps;
63
+
64
+ this.deps = deps;
65
+ this.opts = opts;
66
+ this.runMain = Module.runMain;
67
+
68
+ /**
69
+ * This is one side effect that will always occur, even with `install: false`.
70
+ * The act of calling `agentify()` is enough to cause this side effect, by design.
71
+ */
72
+ Module.runMain = async function(...args) {
73
+ try {
74
+ logger.info('Starting the Contrast agent');
75
+ logger.debug({ config }, 'Agent configuration');
76
+
77
+ await opts.preRunMain(deps);
78
+ if (opts.install) {
79
+ await self.install();
80
+ }
81
+ } catch (err) {
82
+ await self.handleInstallFailure(err);
83
+ }
84
+ return self.runMain.apply(this, args);
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Original `runMain` will get called after this.
90
+ *
91
+ * TODO: Consider proper UNINSTALLATION and normal startup w/o agent
92
+ *
93
+ * @param {error} err Error thrown during install
94
+ */
95
+ async handleInstallFailure(err) {
96
+ this.deps.logger.error(
97
+ { err },
98
+ 'A fatal agent installation error has occurred. The application will be run without instrumentation.'
99
+ );
100
+ }
101
+
102
+ async install() {
103
+ for (const svcName of this.opts.svcList) {
104
+ this.deps.logger.trace('installing service: %s', svcName);
105
+ const svc = this.deps[svcName];
106
+ if (svc && svc.install) {
107
+ await svc.install();
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ module.exports.Agent = Agent;
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const chai = require('chai');
4
+ const { expect } = chai;
5
+ const sinon = require('sinon');
6
+
7
+ describe('agentify', function() {
8
+ const Module = require('module');
9
+ let core;
10
+ let agentify;
11
+ let runMain;
12
+
13
+ beforeEach(function() {
14
+ const factory = require('.');
15
+ const mocks = require('../../test/mocks');
16
+
17
+ core = mocks.core();
18
+ core.logger = mocks.logger();
19
+ core.depHooks = mocks.depHooks();
20
+ core.scopes = mocks.scopes();
21
+ core.patcher = mocks.patcher();
22
+ agentify = factory(core);
23
+ runMain = sinon.stub(Module, 'runMain');
24
+ });
25
+
26
+ describe('factory', function() {
27
+ it('invoking with callback will initialize and patch runMain', async function() {
28
+ let ranFlag;
29
+
30
+ agentify((deps) => {
31
+ expect(deps).to.equal(core);
32
+ ranFlag = true;
33
+ }, {
34
+ svcList: ['depHooks']
35
+ });
36
+
37
+ await Module.runMain();
38
+
39
+ expect(ranFlag).to.be.true;
40
+ expect(runMain).to.be.called;
41
+ expect(core.depHooks.install).to.have.been.called;
42
+ expect(core.logger.error).not.to.been.called;
43
+ });
44
+
45
+ it('will not run install methods opts = { install: false }', async function() {
46
+ const a = agentify(() => {}, { install: false });
47
+ sinon.spy(a, 'install');
48
+
49
+ await Module.runMain();
50
+
51
+ expect(a.install).not.to.have.been.called;
52
+ expect(core.logger.error).not.called;
53
+ });
54
+
55
+ it('handles errors in pre runMain hook (init)', async function() {
56
+ const err = new Error('bonk');
57
+ const a = agentify(() => {
58
+ throw err;
59
+ });
60
+ sinon.spy(a, 'install');
61
+ sinon.spy(a, 'handleInstallFailure');
62
+
63
+ await Module.runMain();
64
+
65
+ expect(a.handleInstallFailure).to.have.been.calledWith(err);
66
+ expect(a.install).to.not.have.been.called;
67
+ expect(core.logger.error).calledWith({ err }, 'A fatal agent installation error has occurred. The application will be run without instrumentation.');
68
+ });
69
+
70
+ //
71
+ it('instantiation handles "unregistered" services', async function() {
72
+ agentify((deps) => 0, {
73
+ install: true,
74
+ svcList: ['foo']
75
+ });
76
+
77
+ // Having non-existent service in list is handled during initialization
78
+ await Module.runMain();
79
+
80
+ expect(core.logger.error).not.called;
81
+ });
82
+
83
+ it('requires init function arg', function() {
84
+ expect(() => {
85
+ agentify('not a function');
86
+ }).to.throw('Invalid usage: first argument must be a function');
87
+ });
88
+ });
89
+ });
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const Module = require('module');
4
+
5
+ module.exports = function(deps) {
6
+ const {
7
+ patcher: { patch },
8
+ scopes: { instrumentation }
9
+ } = deps;
10
+
11
+ deps.instrumentationLocks = {
12
+ install() {
13
+ const name = 'Module.prototype.require';
14
+ const store = {
15
+ name: `${name}`,
16
+ lock: true,
17
+ };
18
+
19
+ // `require` has sinks and propagators that run
20
+ patch(Module.prototype, 'require', {
21
+ name,
22
+ patchType: 'instrumentation-lock',
23
+ around(next, data) {
24
+ if (!instrumentation.isLocked()) {
25
+ return instrumentation.run(store, next);
26
+ }
27
+ return next();
28
+ }
29
+ });
30
+
31
+ // likely more to come e.g. v4 deadzones
32
+ }
33
+ };
34
+
35
+ return deps.instrumentationLocks;
36
+ };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const sinon = require('sinon');
5
+ const Module = require('module');
6
+ const instrumentationLocks = require('./instrumentation-locks');
7
+ const mocks = require('../../test/mocks');
8
+
9
+ describe('agentify instrumentation-locks', function () {
10
+ let core, storeSpy;
11
+
12
+ beforeEach(function () {
13
+ core = mocks.core();
14
+ core.logger = mocks.logger();
15
+ core.scopes = require('@contrast/scopes')(core);
16
+ core.patcher = require('@contrast/patcher')(core);
17
+ storeSpy = sinon.stub();
18
+
19
+ const origRequire = Module.prototype.require;
20
+ sinon.stub(Module.prototype, 'require').callsFake(function (...args) {
21
+ storeSpy(core.scopes.instrumentation.getStore());
22
+ return origRequire.call(this, ...args);
23
+ });
24
+
25
+ instrumentationLocks(core);
26
+ core.instrumentationLocks.install();
27
+ });
28
+
29
+ it('require gets called with instrumentation locked', function () {
30
+ // side effects are in place for this to exercise properly
31
+ require('crypto');
32
+ expect(storeSpy).to.have.been.calledWith({
33
+ lock: true,
34
+ name: 'Module.prototype.require',
35
+ });
36
+ });
37
+
38
+ it('will not run with lock if already locked', function () {
39
+ // side effects are in place for this to exercise properly
40
+ core.scopes.instrumentation.run({ lock: true, name: 'test' }, () => {
41
+ // target here is arbitrary
42
+ require('events');
43
+
44
+ expect(storeSpy).to.have.been.calledWith({
45
+ lock: true,
46
+ name: 'test',
47
+ });
48
+
49
+ expect(storeSpy).to.not.have.been.calledWith({
50
+ lock: true,
51
+ name: 'Module.prototype.require',
52
+ });
53
+ });
54
+ });
55
+ });
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const Module = require('module');
4
+
5
+ module.exports = function(deps) {
6
+ const origCompile = Module.prototype._compile;
7
+
8
+ deps.rewriteHooks = {
9
+
10
+ install() {
11
+ if (!deps.config.agent.node.enable_rewrite) return;
12
+
13
+ Module.prototype._compile = function(content, filename) {
14
+ let compiled;
15
+ const { code } = deps.rewriter.rewrite(content, { filename });
16
+ try {
17
+ compiled = origCompile.call(this, code, filename);
18
+ } catch (err) {
19
+ deps.logger.error(
20
+ { err },
21
+ 'Failed to compile rewritten code for %s, rewritten code %s, compiling original code.',
22
+ filename,
23
+ code
24
+ );
25
+ compiled = origCompile.call(this, content, filename);
26
+ }
27
+
28
+ return compiled;
29
+ };
30
+ },
31
+
32
+ restore() {
33
+ Module.prototype._compile = origCompile;
34
+ }
35
+ };
36
+
37
+ return deps.rewriteHooks;
38
+ };
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ const sinon = require('sinon');
4
+ const { expect } = require('chai');
5
+ const Module = require('module');
6
+
7
+ const testCode = require.resolve('../test/resources/file');
8
+
9
+ describe('agentify rewrite-hooks', function() {
10
+ let core, rewriteHooks, origCompileSpy;
11
+
12
+ beforeEach(function() {
13
+ const mocks = require('../../test/mocks');
14
+ core = mocks.core();
15
+ core.logger = mocks.logger();
16
+ core.agentify = mocks.agentify();
17
+ core.config = mocks.config();
18
+ core.config.agent.node.enable_rewrite = true;
19
+ core.rewriter = mocks.rewriter();
20
+ origCompileSpy = sinon.spy(Module.prototype, '_compile');
21
+ rewriteHooks = require('./rewrite-hooks')(core);
22
+ rewriteHooks.install();
23
+ });
24
+
25
+ afterEach(function() {
26
+ delete require.cache[testCode];
27
+ rewriteHooks.restore();
28
+ });
29
+
30
+ it('Should not rewrite code when enable_rewrite is false', function() {
31
+ sinon.restore();
32
+ core.config.agent.node.enable_rewrite = false;
33
+ rewriteHooks = require('./rewrite-hooks')(core);
34
+ rewriteHooks.install();
35
+ require('../test/resources/file');
36
+ expect(core.rewriter.rewrite).to.not.have.been.called;
37
+ });
38
+
39
+ it('Should rewrite code', function() {
40
+ require('../test/resources/file');
41
+ expect(core.rewriter.rewrite).to.have.been.calledWith(
42
+ sinon.match.string,
43
+ { filename: testCode }
44
+ );
45
+ expect(core.logger.error).to.not.have.been.called;
46
+ expect(origCompileSpy).to.have.been.calledWith(
47
+ "const ContrastMethods = global.ContrastMethods\n/* eslint-disable */\n'use strict'\nconst variable = 'variable';\n",
48
+ testCode
49
+ );
50
+ });
51
+
52
+ it('Should log error if module contains a SyntaxError', function() {
53
+ core.rewriter.rewrite.callsFake((content) => ({
54
+ code: content.replace('const variable', 'const variable const a')
55
+ }));
56
+ require('../test/resources/file');
57
+ expect(core.rewriter.rewrite).to.have.been.calledWith(
58
+ sinon.match.string,
59
+ { filename: testCode }
60
+ );
61
+ expect(core.logger.error).to.have.been.calledWith(
62
+ {
63
+ err: sinon.match({
64
+ message: 'Missing initializer in const declaration'
65
+ })
66
+ },
67
+ 'Failed to compile rewritten code for %s, rewritten code %s, compiling original code.',
68
+ testCode,
69
+ sinon.match('const variable const a')
70
+ );
71
+ expect(origCompileSpy).to.have.been.calledWith(
72
+ "/* eslint-disable */\n'use strict'\nconst variable = 'variable';\n",
73
+ testCode
74
+ );
75
+ });
76
+ });
package/lib/sources.js ADDED
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ module.exports = function(core) {
4
+ const { depHooks, patcher, logger, scopes: { sources: sourcesStorage } } = core;
5
+ const sources = core.sources = {};
6
+
7
+ sources.install = function install() {
8
+ depHooks.resolve({ name: 'http' }, http => patchCreateServer({ serverSource: http, patchName: 'httpServer' }));
9
+ depHooks.resolve({ name: 'https' }, https => patchCreateServer({ serverSource: https, patchName: 'httpsServer' }));
10
+ depHooks.resolve({ name: 'http2' }, http2 => {
11
+ patchCreateServer({ serverSource: http2, patchName: 'http2Server' });
12
+ patchCreateServer({ serverSource: http2, patchName: 'http2SecureServer', constructorName: 'createSecureServer' });
13
+ });
14
+ depHooks.resolve({ name: 'spdy' }, spdy => patchCreateServer({ serverSource: spdy, patchName: 'spdyServer' }));
15
+ };
16
+
17
+ function patchCreateServer({ serverSource, patchName, constructorName = 'createServer' }) {
18
+ patcher.patch(serverSource, constructorName, {
19
+ name: patchName,
20
+ patchType: 'http-sources',
21
+ post: createServerPostHook(patchName)
22
+ });
23
+ }
24
+
25
+ function createServerPostHook(serverType) {
26
+ return function(data) {
27
+ const { result: server } = data;
28
+ const serverPrototype = server ? Object.getPrototypeOf(server) : null;
29
+
30
+ if (!serverPrototype) {
31
+ logger.error('Unable to patch server prototype, continue without instrumentation');
32
+ return;
33
+ }
34
+
35
+ patcher.patch(serverPrototype, 'emit', {
36
+ name: 'server.emit',
37
+ patchType: 'req-async-storage',
38
+ around: emitAroundHook(serverType)
39
+ });
40
+ };
41
+ }
42
+
43
+ function emitAroundHook(serverType) {
44
+ return function emitAroundHook(next, data) {
45
+ const { args: [event] } = data;
46
+
47
+ if (event !== 'request') return next();
48
+
49
+ const store = { serverType };
50
+
51
+ return sourcesStorage.run(store, next);
52
+ };
53
+ }
54
+
55
+ return {
56
+ install: sources.install,
57
+ functions: {
58
+ patchCreateServer,
59
+ createServerPostHook,
60
+ emitAroundHook
61
+ }
62
+ };
63
+ };
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const sinon = require('sinon');
5
+ const sourcesModule = require('./sources');
6
+
7
+ describe('agentify sources', function () {
8
+ let mocks, coreMock, depHooksMock, patcherMock, loggerMock, scopesMock;
9
+
10
+ beforeEach(function () {
11
+ mocks = require('../../test/mocks');
12
+ loggerMock = mocks.logger();
13
+ patcherMock = mocks.patcher();
14
+ depHooksMock = mocks.depHooks();
15
+ scopesMock = mocks.scopes();
16
+ coreMock = {
17
+ logger: loggerMock,
18
+ patcher: patcherMock,
19
+ depHooks: depHooksMock,
20
+ scopes: scopesMock,
21
+ };
22
+ });
23
+
24
+ describe('attaching the sources module to the core object', function () {
25
+ it('attaches the module to the core object', function () {
26
+ const sources = sourcesModule(coreMock);
27
+ expect(coreMock).to.haveOwnProperty('sources');
28
+ expect(coreMock.sources).to.haveOwnProperty('install');
29
+ expect(sources).to.haveOwnProperty('install');
30
+ expect(sources).to.haveOwnProperty('functions');
31
+ });
32
+ });
33
+
34
+ describe('installing instrumentation for HTTP type of sources', function () {
35
+ let argumentsMap, httpMock, httpsMock, http2Mock, spdyMock;
36
+
37
+ beforeEach(function () {
38
+ httpMock = { name: 'http' };
39
+ httpsMock = { name: 'https' };
40
+ http2Mock = { name: 'http2' };
41
+ spdyMock = { name: 'spdy' };
42
+ const patchType = 'http-sources';
43
+ argumentsMap = {
44
+ http: {
45
+ source: httpMock,
46
+ patcherArgs: [
47
+ [httpMock, 'createServer', { name: 'httpServer', patchType }],
48
+ ],
49
+ },
50
+ https: {
51
+ source: httpsMock,
52
+ patcherArgs: [
53
+ [httpsMock, 'createServer', { name: 'httpsServer', patchType }],
54
+ ],
55
+ },
56
+ http2: {
57
+ source: http2Mock,
58
+ patcherArgs: [
59
+ [http2Mock, 'createServer', { name: 'http2Server', patchType }],
60
+ [
61
+ http2Mock,
62
+ 'createSecureServer',
63
+ { name: 'http2SecureServer', patchType },
64
+ ],
65
+ ],
66
+ },
67
+ spdy: {
68
+ source: spdyMock,
69
+ patcherArgs: [
70
+ [spdyMock, 'createServer', { name: 'spdyServer', patchType }],
71
+ ],
72
+ },
73
+ };
74
+ coreMock.depHooks.resolve.callsFake((moduleMetadata, callback) =>
75
+ callback(argumentsMap[moduleMetadata.name].source)
76
+ );
77
+ });
78
+
79
+ it('should call patcher.patch with the correct arguments from depHooks.resolve', function () {
80
+ const sources = sourcesModule(coreMock);
81
+ sources.install();
82
+
83
+ const callOrder = ['http', 'https', 'http2', 'http2', 'spdy'];
84
+ let consecutiveCalls = 0;
85
+
86
+ for (let i = 0; i < callOrder.length; i++) {
87
+ consecutiveCalls = callOrder[i] == 'http2' ? consecutiveCalls : 0;
88
+ const argument = (callNumber, argIndex) =>
89
+ patcherMock.patch.getCall(callNumber).args[argIndex];
90
+ const expectedArgument = (callNumber, argIndex, consecutiveCalls) =>
91
+ argumentsMap[callOrder[callNumber]].patcherArgs[consecutiveCalls][
92
+ argIndex
93
+ ];
94
+
95
+ expect(argument(i, 0)).to.equal(
96
+ expectedArgument(i, 0, consecutiveCalls)
97
+ );
98
+ expect(argument(i, 1)).to.equal(
99
+ expectedArgument(i, 1, consecutiveCalls)
100
+ );
101
+ expect(argument(i, 2)).to.include(
102
+ expectedArgument(i, 2, consecutiveCalls)
103
+ );
104
+
105
+ if (callOrder[i] == 'http2' && !consecutiveCalls) {
106
+ consecutiveCalls++;
107
+ }
108
+ }
109
+
110
+ expect(patcherMock.patch).to.have.been.callCount(callOrder.length);
111
+ });
112
+ });
113
+
114
+ describe('functions', function () {
115
+ let emitAroundHook, createServerPostHook, dataObj;
116
+
117
+ beforeEach(function () {
118
+ ({
119
+ functions: { createServerPostHook, emitAroundHook },
120
+ } = sourcesModule(coreMock));
121
+ });
122
+
123
+ describe('createServerPostHook', function () {
124
+ let serverMock, prototypeMock;
125
+ beforeEach(function () {
126
+ serverMock = {};
127
+ prototypeMock = {
128
+ emit() {},
129
+ };
130
+ dataObj = {
131
+ result: serverMock,
132
+ };
133
+ Object.setPrototypeOf(serverMock, prototypeMock);
134
+ });
135
+
136
+ it('calls patcher.patch() if proper data argument is supplied', function () {
137
+ createServerPostHook('httpServer')(dataObj);
138
+ expect(loggerMock.error).to.not.have.been.called;
139
+ expect(patcherMock.patch).to.have.been.calledWith(
140
+ prototypeMock,
141
+ 'emit',
142
+ {
143
+ name: 'server.emit',
144
+ patchType: 'req-async-storage',
145
+ around: sinon.match.func
146
+ }
147
+ );
148
+ });
149
+
150
+ it('calls logger.error() if none or invalid data argument is supplied', function () {
151
+ createServerPostHook('httpServer')({});
152
+ expect(loggerMock.error).to.have.been.calledWith(
153
+ 'Unable to patch server prototype, continue without instrumentation'
154
+ );
155
+ Object.setPrototypeOf(serverMock, null);
156
+ createServerPostHook('httpServer')(serverMock);
157
+ expect(loggerMock.error).to.have.been.called.calledTwice;
158
+ expect(patcherMock.patch).to.not.have.been.called;
159
+ });
160
+ });
161
+
162
+ describe('emitAroundHook', function () {
163
+ let nextFn, reqMock, resMock, eventMock;
164
+ beforeEach(function () {
165
+ reqMock = () => {
166
+ this.method = 'GET';
167
+ this.url = 'http://localhost';
168
+ };
169
+ resMock = () => {};
170
+ eventMock = 'request';
171
+ dataObj = {
172
+ args: [eventMock, reqMock, resMock],
173
+ };
174
+ nextFn = function () {
175
+ return {
176
+ sourcesStore: scopesMock.sources.getStore(),
177
+ };
178
+ };
179
+ });
180
+
181
+ it('executes the hooked nextFn in the provided context if the event name is "request"', function () {
182
+ const result = emitAroundHook('httpServer')(nextFn, dataObj);
183
+ expect(result.sourcesStore).to.deep.include({
184
+ serverType: 'httpServer',
185
+ });
186
+ });
187
+
188
+ it('executes the hooked nextFn without binding it to a context if the event name is not "request"', function () {
189
+ eventMock = 'listen';
190
+ dataObj.args[0] = eventMock;
191
+ const result = emitAroundHook('httpServer')(nextFn, dataObj);
192
+ expect(result.sourcesStore).to.equal(undefined);
193
+ });
194
+ });
195
+ });
196
+ });
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@contrast/agentify",
3
+ "version": "1.0.0",
4
+ "description": "Configures Contrast agent services and instrumentation within an application",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "author": "Contrast Security <nodejs@contrastsecurity.com> (https://www.contrastsecurity.com)",
7
+ "files": [
8
+ "lib/"
9
+ ],
10
+ "main": "lib/index.js",
11
+ "types": "lib/index.d.ts",
12
+ "engines": {
13
+ "npm": ">= 8.4.0",
14
+ "node": ">= 14.15.0"
15
+ },
16
+ "scripts": {
17
+ "test": "../scripts/test.sh"
18
+ },
19
+ "dependencies": {}
20
+ }