@contrast/agent 4.11.0 → 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.
package/bin/VERSION CHANGED
@@ -1 +1 @@
1
- 2.28.13
1
+ 2.28.14
Binary file
Binary file
Binary file
package/lib/agent.js CHANGED
@@ -31,8 +31,13 @@ class ContrastAgent {
31
31
  constructor() {
32
32
  /**
33
33
  * Instance of AppInfo containing application information.
34
- * @member */
34
+ * @type {import('./app-info')}
35
+ */
35
36
  this.appInfo = null;
37
+
38
+ /** @type {import('./telemetry') */
39
+ this.telemetry = null;
40
+
36
41
  /**
37
42
  * Holds user settings for the agent
38
43
  * @member */
@@ -94,7 +99,9 @@ class ContrastAgent {
94
99
  */
95
100
  this.argv = process.argv;
96
101
  this.tsFeatureSet._subscribers.push(this.agentEmitter);
97
- this.exclusions = new ExclusionFactory({ featureSet: this.tsFeatureSet });
102
+ this.exclusions = new ExclusionFactory({
103
+ featureSet: this.tsFeatureSet
104
+ });
98
105
  this.agentEmitter.on('application-settings', (applicationSettings) => {
99
106
  this.exclusions.updateSettings({
100
107
  settings: applicationSettings,
package/lib/app-info.js CHANGED
@@ -14,87 +14,123 @@ Copyright: 2022 Contrast Security, Inc
14
14
  */
15
15
  'use strict';
16
16
  const os = require('os');
17
+ const fs = require('fs');
17
18
  const path = require('path');
18
-
19
+ const parentPackageJson = require('parent-package-json');
20
+ const semver = require('semver');
21
+ const { AGENT_INFO } = require('./constants');
19
22
  const logger = require('./core/logger')('contrast:appInfo');
20
- const fileFinder = require('./util/file-finder');
21
- const {
22
- AGENT_INFO: { VERSION }
23
- } = require('./constants');
23
+
24
+ const isContainer = () => {
25
+ try {
26
+ fs.statSync('/.dockerenv');
27
+ return true;
28
+ } catch (err) {
29
+ // if no docker env, check /proc/self/cgroup
30
+ }
31
+
32
+ try {
33
+ return fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker');
34
+ } catch (err) {
35
+ // if file not present,
36
+ return false;
37
+ }
38
+ };
24
39
 
25
40
  /**
26
41
  * An AppInfo instance carries properties of the application being instrumented,
27
42
  * such as OS-related information, hostname, name and version information. The
28
43
  * name, version, and package.json data
29
44
  *
30
- * @class
31
- * @param {String} script File name/location for the app.
32
- * @param {} options Current configuration for the agent
33
- *
34
45
  * TODO(ehden): I'd like to get rid of this and put it more inline with what's happening in config/util.js,
35
46
  * but that's a bigger refactor and out of the scope of common config work.
36
47
  */
37
48
  class AppInfo {
49
+ /**
50
+ * @param {string} script File name/location for the app.
51
+ * @param {any} config Current configuration for the agent
52
+ */
38
53
  constructor(script, config) {
54
+ this.indexFile = path.resolve(script);
55
+ let isDir = false;
56
+
57
+ try {
58
+ const stats = fs.statSync(script);
59
+ isDir = stats.isDirectory();
60
+ } catch (err) {
61
+ // if we can't stat the start script we'll likely throw unless `app_root`
62
+ // is set. We'll let the logic below handle that.
63
+ }
64
+
39
65
  this.os = {
40
- type: os.type(),
41
- platform: os.platform(),
42
66
  architecture: os.arch(),
43
- release: os.release()
67
+ platform: os.platform(),
68
+ release: os.release(),
69
+ type: os.type()
44
70
  };
45
71
  this.hostname = os.hostname();
46
- const cmd = path.resolve(script);
47
- logger.info('finding package.json for %s', cmd);
48
- this.path = AppInfo.resolveAppPath(
49
- config.agent.node.app_root,
50
- path.dirname(cmd)
72
+ this.isContainer = isContainer();
73
+
74
+ logger.info('finding package.json for %s', this.indexFile);
75
+
76
+ // If started with `script` as a directory, append a file similar to below.
77
+ if (isDir) {
78
+ this.indexFile = path.join(this.indexFile, 'index.js');
79
+ }
80
+
81
+ const manifest = parentPackageJson(
82
+ // The `parent-package-json` library expects the start path to be a file,
83
+ // not a directory, so we append `package.json`. This lets us use the lib
84
+ // without changing the expected config option.
85
+ config.agent.node.app_root
86
+ ? path.join(config.agent.node.app_root, 'package.json')
87
+ : this.indexFile
51
88
  );
52
- config.agent.node.app_root = this.path;
53
89
 
54
- try {
55
- this.appPackage = require(this.path);
56
- } catch (e) {
90
+ if (!manifest) {
57
91
  throw new Error("Unable to find application's package.json.");
58
92
  }
59
- this.name = config.application.name || this.appPackage.name;
60
93
 
61
- const packageVersion = this.appPackage.version;
62
- this.version = config.application.version || packageVersion;
63
- this.serverVersion = config.server.version || VERSION;
94
+ /**
95
+ * Path to the application's package.json
96
+ * @type {string}
97
+ */
98
+ this.path = manifest.path;
99
+ logger.info('using package.json at %s', this.path);
64
100
 
65
- logger.info('using appname %s', this.name);
101
+ /**
102
+ * Actual application location (directory containing package.json)
103
+ * @type {string}
104
+ */
105
+ this.appDir = path.dirname(this.path);
66
106
 
67
- this.node_version = process.version;
68
- this.app_dir = path.dirname(this.path);
69
- this.appPath = config.application.path || this.app_dir;
70
- this.indexFile = script; // cli.js, app.js, index.js, server.js... etc
71
- this.serverName = config.server.name;
72
- this.serverEnvironment = config.server.environment;
73
- }
74
-
75
- /**
76
- * Returns the location of the app package
77
- *
78
- * @param {string} appRoot config.agent.node.app_root
79
- * @param {string} scriptPath directory the script is in
80
- * @returns {string} location of the app package
81
- */
82
- static resolveAppPath(appRoot, scriptPath) {
83
- let packageLocation;
107
+ /**
108
+ * Contents of the application's package.json
109
+ * @type {Record<string, any>}
110
+ */
111
+ this.appPackage = manifest.parse();
84
112
 
85
- if (appRoot) {
86
- packageLocation = fileFinder.findFile(appRoot, 'package.json');
113
+ if (isDir) {
114
+ this.indexFile = path.resolve(this.appDir, this.appPackage.main);
87
115
  }
88
116
 
89
- if (!packageLocation) {
90
- packageLocation = fileFinder.findFile(scriptPath, 'package.json');
91
- }
117
+ this.name = config.application.name || this.appPackage.name;
118
+ logger.info('using appname %s', this.name);
92
119
 
93
- if (packageLocation) {
94
- logger.info('using package.json at %s', packageLocation);
95
- }
120
+ this.version = config.application.version || this.appPackage.version;
121
+ this.serverVersion = config.server.version || AGENT_INFO.VERSION;
96
122
 
97
- return packageLocation;
123
+ this.nodeVersion = process.version;
124
+ this.nodeVersionMajor = semver.major(this.nodeVersion);
125
+
126
+ /**
127
+ * Configured application path to report
128
+ * @type {string}
129
+ */
130
+ this.appPath = config.application.path || this.appDir;
131
+
132
+ this.serverName = config.server.name;
133
+ this.serverEnvironment = config.server.environment;
98
134
  }
99
135
  }
100
136
 
@@ -24,7 +24,7 @@ const { funcinfo } = require('@contrast/fn-inspect');
24
24
  class RouteCoverage {
25
25
  constructor(agent) {
26
26
  this.routes = new Map();
27
- this.appDir = agent.appInfo.app_dir;
27
+ this.appDir = agent.appInfo.appDir;
28
28
  moduleHook.resolve(
29
29
  { name: '@loopback/rest', file: 'dist/router/routing-table.js' },
30
30
  this.patchRoutingTable.bind(this)
package/lib/contrast.js CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
  /**
3
2
  Copyright: 2022 Contrast Security, Inc
4
3
  Contact: support@contrastsecurity.com
@@ -13,13 +12,8 @@ Copyright: 2022 Contrast Security, Inc
13
12
  engineered, modified, repackaged, sold, redistributed or otherwise used in a
14
13
  way not consistent with the End User License Agreement.
15
14
  */
16
- /**
17
- * Process flows to bootstrapping user code with the node agent code
18
- *
19
- * @module lib/contrastAgent
20
- *
21
- */
22
15
  'use strict';
16
+
23
17
  const { program } = require('./core/config/options');
24
18
  const path = require('path');
25
19
  const os = require('os');
@@ -27,8 +21,6 @@ const semver = require('semver');
27
21
  const colors = require('./util/colors');
28
22
  const { AGENT_INFO } = require('./constants');
29
23
  const { VERSION, SUPPORTED_VERSIONS, NAME } = AGENT_INFO;
30
- const contrastAgent = module.exports;
31
- const Promise = require('bluebird');
32
24
  const Module = require('module');
33
25
  const sourceMapUtility = require('./util/source-map');
34
26
  const loggerFactory = require('./core/logger');
@@ -43,10 +35,18 @@ function getAgentSnippet() {
43
35
  }
44
36
 
45
37
  let AppInfo,
38
+ /** @type {import('./agent')} */
46
39
  agent = {},
47
40
  TSReporter,
48
- tracker,
49
- instrument;
41
+ instrument,
42
+ /** @type {import('./telemetry')} */
43
+ Telemetry,
44
+ tracker;
45
+
46
+ /**
47
+ * Process flows to bootstrapping user code with the node agent code
48
+ */
49
+ const contrastAgent = module.exports;
50
50
 
51
51
  /**
52
52
  * Who doesn't like ASCII art. Displays Matt's cat when CONTRAST_CAT env var is set to MATT
@@ -56,7 +56,6 @@ contrastAgent.showBanner = function showBanner() {
56
56
  const fs = require('fs');
57
57
  const file = path.resolve(__dirname, 'cat.txt');
58
58
  const cat = fs.readFileSync(file, 'utf8');
59
- // eslint-disable-next-line no-console
60
59
  console.log(colors.red(cat));
61
60
  }
62
61
 
@@ -71,9 +70,10 @@ contrastAgent.showBanner = function showBanner() {
71
70
  contrastAgent.doImports = function doImports() {
72
71
  AppInfo = require('./app-info');
73
72
  agent = require('./agent');
74
- tracker = require('./tracker');
75
73
  TSReporter = require('./reporter/ts-reporter');
76
74
  instrument = require('./instrumentation');
75
+ Telemetry = require('./telemetry');
76
+ tracker = require('./tracker');
77
77
  };
78
78
 
79
79
  /**
@@ -229,7 +229,7 @@ contrastAgent.prepare = function(...args) {
229
229
 
230
230
  if (isCli) {
231
231
  logger.error(
232
- `DEPRECATED: Agent is started as runner. Please use '%s'`,
232
+ "DEPRECATED: Agent is started as runner. Please use '%s'",
233
233
  getAgentSnippet()
234
234
  );
235
235
  } else {
@@ -256,7 +256,7 @@ contrastAgent.prepare = function(...args) {
256
256
  }
257
257
 
258
258
  if (config.agent.node.dev.global_tracker) {
259
- logger.debug('>> config.agent.node.dev.global_tracker enabled <<'); // eslint-disable-line
259
+ logger.debug('>> config.agent.node.dev.global_tracker enabled <<');
260
260
  global.contrast_tracker = tracker;
261
261
  }
262
262
 
@@ -271,6 +271,7 @@ contrastAgent.prepare = function(...args) {
271
271
 
272
272
  config.version = semver.valid(semver.coerce(VERSION));
273
273
  agent.appInfo = app;
274
+ agent.telemetry = new Telemetry(agent, config);
274
275
 
275
276
  config.override('application.name', app.name);
276
277
 
@@ -358,12 +359,11 @@ contrastAgent.init = async function(args, isCli = false) {
358
359
  }
359
360
  })
360
361
  .on('--help', function() {
361
- // eslint-disable-next-line no-console
362
- console.log(`
363
- Example:
364
- ${colors.cyan(
365
- ` $ ${getAgentSnippet()} -c ../contrast_security.yaml -- --appArg1 --appArg2 -e -t -c`
366
- )}`);
362
+ console.log('Example:');
363
+ console.log(
364
+ colors.cyan('\t$ %s -c ../contrast_security.yaml -- --appArg1 --appArg2 -e -t -c'),
365
+ getAgentSnippet(),
366
+ );
367
367
  });
368
368
 
369
369
  await program.parseAsync(args);
@@ -407,7 +407,9 @@ contrastAgent.resetArgs = function(nodePath, script) {
407
407
  process.argv = agent.config
408
408
  ? [nodePath, script].concat(agent.config.application.args)
409
409
  : process.argv;
410
- const isPrimary = !agent.hasOwnProperty('cluster') || agent.cluster.isPrimary;
410
+ const isPrimary =
411
+ !Object.prototype.hasOwnProperty.call(agent, 'cluster') ||
412
+ agent.cluster.isPrimary;
411
413
  const location = isPrimary ? 'Entering main' : 'Entering fork';
412
414
 
413
415
  logger.debug('%s at %s', location, script);
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrast/agent",
3
- "version": "4.11.0",
3
+ "version": "4.12.0",
4
4
  "description": "Node.js security instrumentation by Contrast Security",
5
5
  "keywords": [
6
6
  "security",
@@ -81,12 +81,12 @@
81
81
  "@contrast/fn-inspect": "^2.4.4",
82
82
  "@contrast/heapdump": "^1.1.0",
83
83
  "@contrast/protobuf-api": "^3.2.3",
84
- "@contrast/require-hook": "^2.0.8",
84
+ "@contrast/require-hook": "^2.0.10",
85
85
  "@contrast/synchronous-source-maps": "^1.1.0",
86
86
  "amqp-connection-manager": "^3.2.2",
87
87
  "amqplib": "^0.8.0",
88
+ "axios": "^0.25.0",
88
89
  "big-integer": "^1.6.36",
89
- "bluebird": "^3.5.3",
90
90
  "builtin-modules": "^3.2.0",
91
91
  "cls-hooked": "^4.2.2",
92
92
  "commander": "^8.3.0",
@@ -95,6 +95,7 @@
95
95
  "crc-32": "^1.0.0",
96
96
  "fast-deep-equal": "^3.1.3",
97
97
  "find-cache-dir": "^3.3.1",
98
+ "getmac": "^5.20.0",
98
99
  "ipaddr.js": "^1.8.1",
99
100
  "json-stable-stringify": "^1.0.1",
100
101
  "jspack": "0.0.4",
@@ -102,10 +103,12 @@
102
103
  "make-dir": "^3.1.0",
103
104
  "multi-stage-sourcemap": "^0.3.1",
104
105
  "on-finished": "^2.3.0",
106
+ "parent-package-json": "^2.0.1",
105
107
  "parseurl": "^1.3.3",
106
108
  "prom-client": "^12.0.0",
107
109
  "recursive-readdir": "^2.2.2",
108
110
  "semver": "^7.3.2",
111
+ "uuid": "^8.3.2",
109
112
  "winston": "^3.1.0",
110
113
  "winston-daily-rotate-file": "^3.5.1",
111
114
  "yaml": "^1.10.0"
@@ -113,19 +116,19 @@
113
116
  "devDependencies": {
114
117
  "@aws-sdk/client-dynamodb": "^3.39.0",
115
118
  "@bmacnaughton/string-generator": "^1.0.0",
116
- "@contrast/eslint-config": "^2.2.0",
119
+ "@contrast/eslint-config": "^3.0.0-beta.4",
117
120
  "@contrast/fake-module": "file:test/mock/contrast-fake",
118
121
  "@contrast/screener-service": "^1.12.9",
119
122
  "@hapi/boom": "file:test/mock/boom",
120
123
  "@hapi/hapi": "file:test/mock/hapi",
121
124
  "@ls-lint/ls-lint": "^1.8.1",
122
- "@typescript-eslint/eslint-plugin": "^5.10.2",
123
- "@typescript-eslint/parser": "^5.10.2",
125
+ "@typescript-eslint/eslint-plugin": "^5.12.0",
126
+ "@typescript-eslint/parser": "^5.12.0",
124
127
  "ajv": "^8.5.0",
125
128
  "ast-types": "^0.12.4",
126
129
  "aws-sdk": "file:test/mock/aws-sdk",
127
- "axios": "^0.21.4",
128
130
  "base58": "^2.0.1",
131
+ "bluebird": "^3.7.2",
129
132
  "callsite": "^1.0.0",
130
133
  "chai": "^4.2.0",
131
134
  "chai-as-promised": "^7.1.1",
@@ -137,7 +140,7 @@
137
140
  "dustjs-linkedin": "^3.0.1",
138
141
  "ejs": "^3.1.6",
139
142
  "escape-html": "^1.0.3",
140
- "eslint": "^8.8.0",
143
+ "eslint": "^8.9.0",
141
144
  "eslint-config-prettier": "^8.3.0",
142
145
  "eslint-plugin-mocha": "^10.0.3",
143
146
  "eslint-plugin-node": "^11.1.0",
@@ -182,7 +185,6 @@
182
185
  "swig": "file:test/mock/swig",
183
186
  "triple-beam": "^1.3.0",
184
187
  "typeorm": "file:test/mock/typeorm",
185
- "uuid": "^8.3.1",
186
188
  "validator": "^13.7.0",
187
189
  "xpath": "file:test/mock/xpath"
188
190
  },