@contrast/agent 4.10.5 → 4.12.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.
@@ -66,7 +66,7 @@ class BaseFramework {
66
66
 
67
67
  if (frame) {
68
68
  const { file, lineNumber } = frame;
69
- const relativeFile = (file || '').replace(this.agent.appInfo.app_dir, '');
69
+ const relativeFile = (file || '').replace(this.agent.appInfo.appDir, '');
70
70
  return `${relativeFile}:${lineNumber || ''}`;
71
71
  } else {
72
72
  return null;
@@ -36,7 +36,7 @@ let appDirRegex;
36
36
  * @returns {string}
37
37
  */
38
38
  const getCachedFilename = (agent, filename) => {
39
- appDirRegex = appDirRegex || new RegExp(`^(${agent.appInfo.app_dir})?`);
39
+ appDirRegex = appDirRegex || new RegExp(`^(${agent.appInfo.appDir})?`);
40
40
  if (os.platform() === 'win32') {
41
41
  filename = filename.replace(/C:/i, '');
42
42
  }
@@ -89,7 +89,7 @@ function collectLibInfo(agent) {
89
89
  require('./hooks/module/extensions')();
90
90
  libUsage.listen(evalFrequency);
91
91
  }
92
- libraries.getPackageLibraries(agent, eluEnabled).then((libs) => {
92
+ libraries.getLibInfo(agent, eluEnabled).then((libs) => {
93
93
  agentEmitter.emit('appinfo', { libs });
94
94
  });
95
95
  }
package/lib/libraries.js CHANGED
@@ -13,176 +13,160 @@ Copyright: 2022 Contrast Security, Inc
13
13
  way not consistent with the End User License Agreement.
14
14
  */
15
15
  'use strict';
16
- const Promise = require('bluebird');
17
- const stringify = require('json-stable-stringify');
18
- const _ = require('lodash');
19
- const path = require('path');
20
- const util = require('util');
21
16
 
22
- const {
23
- AGENT_INFO: { NAME }
24
- } = require('./constants');
25
- const logger = require('./core/logger')('contrast:libraries');
17
+ const { stat } = require('fs').promises;
18
+ const stringify = require('json-stable-stringify');
19
+ const Module = require('module');
20
+ const { AGENT_INFO } = require('./constants');
26
21
  const { runInNoInstrumentationScope } = require('./core/async-storage/scopes');
22
+ const logger = require('./core/logger')('contrast:libraries');
27
23
  const listInstalled = require('./list-installed');
28
24
  const AppUpdate = require('./reporter/models/app-update');
29
25
 
30
26
  const DEADZONE_NAME = 'agent: libraries';
31
27
 
32
- // Note: do not use bluebird.promisify here as it will break tests by leaking
33
- // NO_INSTRUMENTATION Scope across tests. see:
34
- // https://contrast.atlassian.net/wiki/spaces/~18775625/pages/1222052407/Async+Context+when+a+Promise+is+returned+from+runInScope
35
- const stat = util.promisify(require('fs').stat);
28
+ /**
29
+ * @typedef {import('./list-installed').Result} Result
30
+ */
36
31
 
37
32
  /**
38
- * Scan dependencies recursively to determine dependents.
39
- * @param {Object} deps the formatted object from formatDependencies
33
+ * Searches a list of paths and returns the first valid path found.
34
+ * @param {string[]} paths
35
+ * @returns {Promise<string | undefined>}
36
+ */
37
+ const findNodeModsPath = async (paths) =>
38
+ // this functions similarly to `Bluebird.map() with concurrency: 1
39
+ paths.reduce(async (prev, path) => {
40
+ if (await prev) return prev;
41
+
42
+ try {
43
+ await stat(path);
44
+ return path;
45
+ } catch (err) {
46
+ return undefined;
47
+ }
48
+ }, Promise.resolve());
49
+
50
+ /**
51
+ * Filters out the agent as a dependency, formats libraries, and keeps track of
52
+ * nested modules.
53
+ *
54
+ * @param {{ [key: string]: Result | string }} deps collection of dependencies from app root
40
55
  * @param {Map<string, Set<string>} requiredBy
41
- * @returns {Map<string, Set<string>}
56
+ * @param {string} parent the name of the module requiring the current when nested
57
+ * @return {{ [key: string]: Result }} formatted object
42
58
  */
43
- function setRequiredBy(deps, requiredBy = new Map()) {
44
- _.forEach(deps, (dep, depName) => {
45
- _.forEach(dep.dependencies, (depOfDep, depOfDepName) => {
46
- const set = requiredBy.has(depOfDepName)
47
- ? requiredBy.get(depOfDepName)
48
- : new Set();
49
-
50
- set.add(depName);
51
- requiredBy.set(depOfDepName, set);
52
- });
53
-
54
- setRequiredBy(dep.dependencies, requiredBy);
55
- });
59
+ const formatDependencies = (deps, requiredBy, parent) =>
60
+ Object.entries(deps || {}).reduce((result, [key, val]) => {
61
+ if (key === AGENT_INFO.NAME) {
62
+ return result;
63
+ }
64
+
65
+ if (parent) {
66
+ const set = requiredBy.has(key) ? requiredBy.get(key) : new Set();
67
+
68
+ set.add(parent);
69
+ requiredBy.set(key, set);
70
+ }
71
+
72
+ if (typeof val === 'string') {
73
+ result[key] = {
74
+ name: key,
75
+ version: val,
76
+ dependencies: {}
77
+ };
78
+ return result;
79
+ }
80
+
81
+ if (!val.name) val.name = key;
82
+
83
+ val.dependencies = formatDependencies(val.dependencies, requiredBy, key);
84
+ result[key] = val;
56
85
 
57
- return requiredBy;
58
- }
86
+ return result;
87
+ }, {});
59
88
 
60
89
  /**
61
90
  * Recursively adds each dependency as a library which is used later
62
91
  * for reporting inventory and class usage
63
92
  *
64
- * @param {Object} deps collection of dependencies
93
+ * @param {{ [key: string]: Result }} deps the formatted object from formatDependencies
65
94
  * @param {Map<string, Set<string>} requiredBy map containing data regarding dependents
66
- * @param {Object} agent agent instance
95
+ * @param {import('./agent')} agent agent instance
67
96
  */
68
- function processDependencies(deps, requiredBy, agent) {
69
- for (const key in deps) {
70
- const dep = deps[key];
71
- if (!dep.name) {
72
- dep.name = key;
73
- }
74
-
97
+ const processDependencies = (deps, requiredBy, agent) => {
98
+ Object.values(deps).forEach((dep) => {
75
99
  dep._requiredBy = requiredBy.has(dep.name)
76
100
  ? Array.from(requiredBy.get(dep.name))
77
101
  : [];
78
102
 
79
103
  const newLib = AppUpdate.addLib(dep, agent);
80
- // check its deps as well
104
+ // If this is the first time we've checked this dep, check its deps as well.
81
105
  if (newLib) {
82
106
  processDependencies(dep.dependencies, requiredBy, agent);
83
107
  }
84
- }
85
- }
86
-
87
- /**
88
- * compares module name with agent name
89
- *
90
- * @param {string} modeName
91
- * @return {Boolean} if modName matches agent, returns true
92
- */
93
- function isAgent(modName) {
94
- return modName === NAME;
95
- }
108
+ });
109
+ };
96
110
 
97
111
  /**
98
- * Filters out the agent as a dependency
99
- * Formats libraries as { 'mod-name': { <mod package.json deets } }
112
+ * Traverses entire dependency tree to report library inventory
100
113
  *
101
- * @param {Object} deps collection of dependencies from app root
102
- * @return {Object} formatted object
114
+ * @param {import('./agent')} agent agent instance
115
+ * @param {boolean} eluEnabled flag to get composition for each module
116
+ * @returns {AppUpdate.libraries}
103
117
  */
104
- function formatDependencies(deps) {
105
- return _.reduce(
106
- deps,
107
- (result, value, key) => {
108
- if (isAgent(key)) {
109
- return result;
118
+ const getLibInfo = async (agent, eluEnabled) =>
119
+ runInNoInstrumentationScope(async () => {
120
+ try {
121
+ const boundRequire = Module.createRequire(agent.appInfo.path);
122
+ const paths = boundRequire.resolve.paths(agent.appInfo.path);
123
+ const nodeModsPath = await findNodeModsPath(paths);
124
+
125
+ if (!nodeModsPath) {
126
+ logger.error(
127
+ `unable to read installed dependencies because a node_modules directory could not be detected given a package.json located at %s - use the agent.node.app_root configuration variable if installed in non-standard location`,
128
+ agent.appInfo.path
129
+ );
130
+ return AppUpdate.libraries;
110
131
  }
111
132
 
112
- if (typeof value === 'object') {
113
- result[key] = value;
114
- } else {
115
- result[key] = {
116
- name: key,
117
- version: value
118
- };
133
+ logger.debug('detected node_modules at %s', nodeModsPath);
134
+ const data = await listInstalled(nodeModsPath, logger);
135
+
136
+ logger.debug(
137
+ 'package.json dependencies: %s',
138
+ // _dependencies contains { name: version } info
139
+ stringify(data._dependencies, { space: 2 })
140
+ );
141
+ logger.debug(
142
+ 'package.json devDependencies: %s',
143
+ stringify(data.devDependencies, { space: 2 })
144
+ );
145
+
146
+ const requiredBy = new Map();
147
+ const dependencies = formatDependencies(data.dependencies, requiredBy);
148
+ processDependencies(dependencies, requiredBy, agent);
149
+
150
+ const libs = AppUpdate.libraries;
151
+ if (eluEnabled) {
152
+ logger.info('recursively scanning dependencies');
153
+ // this functions similarly to `Bluebird.map()` with concurrency: 1
154
+ await Object.values(libs).reduce(async (prev, lib) => {
155
+ await prev;
156
+ return lib.getComposition();
157
+ }, Promise.resolve());
119
158
  }
120
159
 
121
- return result;
122
- },
123
- {}
124
- );
125
- }
160
+ logger.info(
161
+ 'finished library inventory for %s dependencies',
162
+ Object.keys(libs).length
163
+ );
126
164
 
127
- /**
128
- * Traverses entire dependency tree to report library inventory
129
- *
130
- * @param {Object} agent agent instance
131
- * @param {Boolean} eluEnabled flag to get composition for each module
132
- */
133
- function getPackageLibraries(agent, eluEnabled) {
134
- const appRoot = _.get(agent, 'appInfo.path');
135
- return runInNoInstrumentationScope(() => {
136
- const dir = path.dirname(appRoot);
137
-
138
- let nodeModsExists = false;
139
- return stat(`${dir + path.sep}node_modules`)
140
- .then(() => {
141
- nodeModsExists = true;
142
- return listInstalled(dir, logger);
143
- })
144
- .then((data) => {
145
- logger.debug(
146
- 'package.json dependencies: %s',
147
- stringify(data._dependencies, { space: 2 })
148
- );
149
- logger.debug(
150
- 'package.json devDependencies: %s',
151
- stringify(data.devDependencies, { space: 2 })
152
- );
153
- const dependencies = formatDependencies(data.dependencies);
154
- const requiredBy = setRequiredBy(dependencies);
155
- return processDependencies(dependencies, requiredBy, agent);
156
- })
157
- .then(() => {
158
- if (eluEnabled) {
159
- logger.info('recursively scanning dependencies');
160
- const libs = _.values(AppUpdate.libraries);
161
- return Promise.map(libs, (lib) => lib.getComposition(), {
162
- concurrency: 1
163
- });
164
- }
165
- })
166
- .then(() => {
167
- const libs = AppUpdate.libraries;
168
- logger.info(
169
- 'finished library inventory for %s dependencies',
170
- Object.keys(libs).length
171
- );
172
-
173
- return libs;
174
- })
175
- .catch((err) => {
176
- if (!nodeModsExists) {
177
- logger.error(
178
- `unable to read install dependencies because node_modules are not installed in app root (${dir}) - use agent.node.app_root configuration if installed in non-standard location`
179
- );
180
- } else {
181
- logger.error('unable to read installed dependencies. %o', err);
182
- }
183
- return AppUpdate.libraries;
184
- });
165
+ return libs;
166
+ } catch (err) {
167
+ logger.error('unable to read installed dependencies. %o', err);
168
+ return AppUpdate.libraries;
169
+ }
185
170
  }, DEADZONE_NAME);
186
- }
187
171
 
188
- module.exports.getPackageLibraries = getPackageLibraries;
172
+ module.exports.getLibInfo = getLibInfo;
@@ -21,6 +21,19 @@ const {
21
21
 
22
22
  const execFile = util.promisify(require('child_process').execFile);
23
23
 
24
+ /**
25
+ * @typedef {Object} Result
26
+ * @property {string} name
27
+ * @property {string} version
28
+ * @property {{ [key: string]: Result | string }} dependencies
29
+ * @property {{ [key: string]: Result | string }} devDependencies
30
+ */
31
+
32
+ /**
33
+ * @param {string} cwd directory in which we want to execute `npm ls`
34
+ * @param {*} logger
35
+ * @returns {Promise<Result>}
36
+ */
24
37
  module.exports = async function listInstalled(cwd, logger) {
25
38
  const env = { ...process.env, NODE_OPTIONS: undefined };
26
39
  const args = ['--silent', 'ls', '--json', '--prod', '--long'];
@@ -160,7 +160,7 @@ module.exports = class AppUpdate {
160
160
 
161
161
  /**
162
162
  * returns collection of libraries
163
- * @return {Object}
163
+ * @return {{ [key: string]: Library }}
164
164
  */
165
165
  static get libraries() {
166
166
  const data = {};
@@ -22,18 +22,18 @@ const {
22
22
 
23
23
  module.exports = function AgentStartup({
24
24
  config = { server: {} },
25
- appInfo: { app_dir, serverEnvironment, serverVersion, version } = {}
25
+ appInfo: { appDir, serverEnvironment, serverVersion, version } = {}
26
26
  } = {}) {
27
27
  const serverName = config.server.name;
28
28
  const { tags, type } = config.server;
29
- app_dir = config.server.path || app_dir;
29
+ appDir = config.server.path || appDir;
30
30
 
31
31
  return new dtm.AgentStartup({
32
32
  1: VERSION, // 2 version
33
33
  2: serverEnvironment, // 3 environment
34
34
  3: tags, // 4 tags
35
35
  4: serverName, // 5 server_name
36
- 5: app_dir, // 6 server_path
36
+ 5: appDir, // 6 server_path
37
37
  6: type, // 7 server_type
38
38
  7: serverVersion // 8 server_version
39
39
  });
@@ -0,0 +1,188 @@
1
+ /**
2
+ Copyright: 2022 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
+ 'use strict';
16
+
17
+ const { default: axios } = require('axios');
18
+ const { createHash } = require('crypto');
19
+ const findCacheDir = require('find-cache-dir');
20
+ const { promises: fs } = require('fs');
21
+ const { default: getMac } = require('getmac');
22
+ const os = require('os');
23
+ const path = require('path');
24
+ const process = require('process');
25
+ const { v4: uuid } = require('uuid');
26
+ const { AGENT_INFO } = require('./constants');
27
+ const logger = require('./core/logger')('contrast:telemetry');
28
+
29
+ const TELEMETRY_URL = 'https://telemetry.nodejs.contrastsecurity.com/';
30
+ const TELEMETRY_VERSION = 'v0'; // TODO: set this to v1 when format is established.
31
+
32
+ const {
33
+ CONTRAST_AGENT_TELEMETRY_OPTOUT,
34
+ CONTRAST_DEV,
35
+ CONTRAST_SCREENER
36
+ } = process.env;
37
+
38
+ // TODO: LINK
39
+ const DISCLAIMER = `The Contrast Node Agent collects usage data in order to help us improve compatibility and security coverage.
40
+ The data is anonymous and does not contain application data. It is collected by Contrast and is never shared.
41
+ You can opt-out of telemetry by setting the CONTRAST_AGENT_TELEMETRY_OPTOUT environment variable to '1' or 'true'.
42
+ `;
43
+ // Read more about the Node Agent's telemetry: TODO
44
+ // `;
45
+
46
+ const LOG_MESSAGE = `Telemetry is now active.
47
+ You can opt-out of telemetry by setting the CONTRAST_AGENT_TELEMETRY_OPTOUT environment variable to '1' or 'true'.
48
+ `;
49
+
50
+ module.exports = class Telemetry {
51
+ /**
52
+ * @param {import('./agent')} agent
53
+ * @param {any} config
54
+ */
55
+ constructor(agent, config) {
56
+ const { appInfo } = agent;
57
+ this.appInfo = appInfo;
58
+ this.config = config;
59
+ this.disabled =
60
+ CONTRAST_AGENT_TELEMETRY_OPTOUT === 'true' ||
61
+ CONTRAST_AGENT_TELEMETRY_OPTOUT === '1';
62
+
63
+ if (this.disabled) {
64
+ logger.info(
65
+ 'Telemetry opt-out in effect. All usage data collection is suppressed.'
66
+ );
67
+ return;
68
+ }
69
+ this.printDisclaimer();
70
+
71
+ try {
72
+ // Returns the "first" valid MAC address for the machine, throwing if none
73
+ // is found.
74
+ const mac = getMac();
75
+
76
+ const hash = createHash('sha256').update(mac);
77
+ this.instanceId = hash.copy().digest('hex');
78
+ this.applicationId = hash.update(appInfo.name).digest('hex');
79
+ } catch (err) {
80
+ // If getMac fails we fall back to generating a UUID. "Unstable"
81
+ // identifiers such as these are prefixed with an underscore.
82
+ const id = uuid();
83
+
84
+ this.instanceId = `_${id}`;
85
+ this.applicationId = this.instanceId;
86
+ }
87
+
88
+ this.metrics = this.createMetricsClient();
89
+ this.tags = {
90
+ isCustomerEnv: this.isCustomerEnv(),
91
+ applicationId: this.applicationId,
92
+ osArch: appInfo.os.architecture,
93
+ osPlatform: appInfo.os.platform,
94
+ osRelease: appInfo.os.release,
95
+ isContainer: appInfo.isContainer,
96
+ agent: AGENT_INFO.NAME,
97
+ agentVersion: AGENT_INFO.VERSION,
98
+ isAssess: agent.isInAssessMode(),
99
+ isProtect: agent.isInDefendMode(),
100
+ nodeVersion: appInfo.nodeVersion,
101
+ nodeVersionMajor: appInfo.nodeVersionMajor,
102
+ telemetryVersion: TELEMETRY_VERSION
103
+ };
104
+
105
+ this.sendStartupData();
106
+ }
107
+
108
+ async printDisclaimer() {
109
+ const filePath = path.resolve(
110
+ findCacheDir({ name: AGENT_INFO.NAME }) || os.tmpdir(),
111
+ '.telemetry-disclaimer'
112
+ );
113
+
114
+ try {
115
+ await fs.stat(filePath);
116
+ // If we've previously shown our disclaimer we log the shorter message.
117
+ logger.info(LOG_MESSAGE);
118
+ } catch (err) {
119
+ // When there's no file `stat` throws an error. That means we haven't
120
+ // yet shown our disclaimer.
121
+ console.log(DISCLAIMER);
122
+ logger.info(DISCLAIMER);
123
+ try {
124
+ await fs.writeFile(filePath, DISCLAIMER);
125
+ } catch (err) {
126
+ // if we can't write, oh well.
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * @returns {import('axios').AxiosInstance}
133
+ */
134
+ createMetricsClient() {
135
+ const userAgent = `${AGENT_INFO.NAME}/${AGENT_INFO.VERSION}`;
136
+
137
+ return axios.create({
138
+ baseURL: new URL('/api/v1/telemetry/metrics/node', TELEMETRY_URL).href,
139
+ headers: {
140
+ 'User-Agent': userAgent
141
+ }
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Logic to determine whether we are reporting a customer's application.
147
+ * Naive for the time being.
148
+ * @returns {boolean}
149
+ */
150
+ isCustomerEnv() {
151
+ if (CONTRAST_SCREENER || CONTRAST_DEV) return false;
152
+ return true;
153
+ }
154
+
155
+ /**
156
+ * @param {string} path
157
+ * @param {{ [field: string]: number ]}} fields
158
+ */
159
+ async sendMetrics(path, fields) {
160
+ return this.metrics.post(path, [
161
+ {
162
+ timestamp: new Date().toISOString(),
163
+ instance: this.instanceId,
164
+ tags: this.tags,
165
+ fields
166
+ }
167
+ ]);
168
+ }
169
+
170
+ async sendStartupData() {
171
+ try {
172
+ // snapshotted when we send our startup data.
173
+ const memoryUsage = process.memoryUsage();
174
+
175
+ await this.sendMetrics('startup', {
176
+ numCpus: os.cpus().length,
177
+ memTotal: os.totalmem(),
178
+ memHeapTotal: memoryUsage.heapTotal,
179
+ memHeapUsed: memoryUsage.heapUsed,
180
+ memExternal: memoryUsage.external,
181
+ memRss: memoryUsage.rss,
182
+ uptime: process.uptime()
183
+ });
184
+ } catch (err) {
185
+ logger.warn('telemetry update failed: %s', err.toJSON());
186
+ }
187
+ }
188
+ };
@@ -158,24 +158,28 @@ function traverse(obj, fn, visitor, maxDepth) {
158
158
  traverseObject(obj, visitor, maxDepth);
159
159
  }
160
160
 
161
- function traverseObject(obj, visitor, remainingDepth = Infinity) {
162
- if (!obj) {
161
+ function traverseObject(obj, visitor, remainingDepth = 250, visited = new Set()) {
162
+ if (!obj || visited.has(obj)) {
163
163
  return;
164
164
  }
165
+
165
166
  if (visitor.path.length() < 1) {
166
167
  visitor.visit('', obj);
168
+ visited.add(obj);
167
169
  }
168
170
 
169
171
  // This is done just to reset the stack, without changing the current impletion
170
172
  if (remainingDepth == 0) {
171
173
  visitor.visit(null, null, true);
174
+ visited.add(obj);
172
175
  return;
173
176
  }
174
177
 
175
178
  Object.entries(obj).forEach(([key, value]) => {
176
179
  visitor.visit(key, value);
180
+ visited.add(obj);
177
181
  if (value && typeof value === 'object') {
178
- traverseObject(value, visitor, remainingDepth - 1);
182
+ traverseObject(value, visitor, remainingDepth - 1, visited);
179
183
  }
180
184
  });
181
185
  }