@contrast/protect 1.4.0 → 1.5.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.
@@ -34,6 +34,7 @@ class HttpInstrumentation {
34
34
  this.logger = logger.child({ name: 'contrast:protect:input-analysis' });
35
35
  this.depHooks = core.depHooks;
36
36
  this.protect = core.protect;
37
+ this.patcher = core.patcher;
37
38
  this.makeSourceContext = this.protect.makeSourceContext;
38
39
  this.maxBodySize = 16 * 1024 * 1024;
39
40
  this.installed = false;
@@ -51,6 +52,7 @@ class HttpInstrumentation {
51
52
  this.installed = true;
52
53
  this.hookHttp();
53
54
  this.hookHttps();
55
+ this.hookHttp2();
54
56
  }
55
57
 
56
58
  uninstall() {
@@ -62,7 +64,7 @@ class HttpInstrumentation {
62
64
  */
63
65
  hookHttp() {
64
66
  this.logger.debug('hooking library: http');
65
- this.depHooks.resolve({ name: 'http' }, this.hookServer.bind(this));
67
+ this.depHooks.resolve({ name: 'http' }, (http) => this.hookServerEmit.call(this, http, 'httpServer'));
66
68
  }
67
69
 
68
70
  /**
@@ -70,36 +72,78 @@ class HttpInstrumentation {
70
72
  */
71
73
  hookHttps() {
72
74
  this.logger.debug('hooking library: https');
73
- this.depHooks.resolve({ name: 'https' }, this.hookServer.bind(this));
75
+ this.depHooks.resolve({ name: 'https' }, (https) => this.hookServerEmit.call(this, https, 'httpsServer'));
74
76
  }
75
77
 
76
78
  /**
77
- * Instruments the `Server` prototype from `http(s)`. This patches `emit` and
79
+ * Sets hooks to instrument `http2 Servers`.
80
+ */
81
+ hookHttp2() {
82
+ this.logger.debug('hooking library: http2');
83
+ // http2 library does not expose its Server class, so we need to hook the createServer function
84
+ this.depHooks.resolve({ name: 'http2' }, (http2) => this.hookCreateServer.call(this, http2, 'http2Server'));
85
+ this.depHooks.resolve({ name: 'http2' }, (http2) => this.hookCreateServer.call(this, http2, 'http2SecureServer', 'createSecureServer'));
86
+
87
+ this.logger.debug('hooking library: spdy');
88
+ this.depHooks.resolve({ name: 'spdy' }, (spdy) => this.hookServerEmit.call(this, spdy, 'spdyServer'));
89
+ }
90
+
91
+ /**
92
+ * Instruments the `Server` prototype from `http(s)` or spdy's http2 Server. This patches `emit` and
93
+ * invokes the protect service to do analysis when appropriate.
94
+ */
95
+ hookServerEmit(serverSource, sourceName) {
96
+ serverSource.Server.prototype = this.patcher.patch(serverSource.Server.prototype, 'emit', {
97
+ name: `${sourceName}.Server.prototype.emit`,
98
+ patchType: 'initiate-handling',
99
+ around: this.emitAroundHook.bind(this)
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Instruments the `Http2Server` prototype which results from the http2.createServer/createSecureServer() call.
105
+ * This also patches `emit` and
78
106
  * invokes the protect service to do analysis when appropriate.
79
- *
80
- * @param {Object} xport The http(s) module export
81
107
  */
82
- hookServer(xport) {
108
+ hookCreateServer(serverSource, sourceName, constructorName = 'createServer') {
83
109
  const self = this;
84
110
 
85
- const {
86
- Server: {
87
- prototype: { emit }
88
- }
89
- } = xport;
111
+ return this.patcher.patch(serverSource, constructorName, {
112
+ name: sourceName,
113
+ patchType: 'initiate-handling',
114
+ post(data) {
115
+
116
+ const { result: server } = data;
117
+ const serverPrototype = server ? Object.getPrototypeOf(server) : null;
90
118
 
91
- xport.Server.prototype.emit = function(...args) {
92
- const [type] = args;
119
+ if (!serverPrototype) {
120
+ self.logger.error('Unable to patch server prototype, continue without instrumentation');
121
+ return;
122
+ }
93
123
 
94
- if (type !== 'request') {
95
- return emit.call(this, ...args);
124
+ self.patcher.patch(serverPrototype, 'emit', {
125
+ name: `${sourceName}.Server.prototype.emit`,
126
+ patchType: 'req-async-storage',
127
+ around: self.emitAroundHook.bind(self)
128
+ });
96
129
  }
130
+ });
131
+ }
97
132
 
98
- const context = { instance: this, method: emit, args };
99
- self.initiateRequestHandling(context);
133
+ /**
134
+ * The around hook for `emit` that
135
+ * invokes the protect service to do analysis when appropriate.
136
+ */
137
+ emitAroundHook(next, data) {
138
+ const [type] = data.args;
100
139
 
101
- return !!this._events[type];
102
- };
140
+ if (type !== 'request') {
141
+ return next();
142
+ }
143
+
144
+ const context = { instance: data.obj, method: next, args: data.args };
145
+ this.initiateRequestHandling(context);
146
+ return !!data.obj._events[type];
103
147
  }
104
148
 
105
149
  /**
@@ -117,15 +161,6 @@ class HttpInstrumentation {
117
161
  args: [, req, res]
118
162
  } = fnContext;
119
163
 
120
- // URL exclusions should be applied here. there is no point in doing any additional
121
- // work if the url is excluded for a particular rule, i.e., that rule should be removed
122
- // from the list of rules for this request. and if all rules are excluded for this url
123
- // then none of the following needs to be done.
124
- if (this.protect.rules.agentLibRulesMask === 0) {
125
- this.logger.debug('no agent-lib rules are enabled, not checking request');
126
- return;
127
- }
128
-
129
164
  let store;
130
165
  let block;
131
166
 
@@ -136,8 +171,10 @@ class HttpInstrumentation {
136
171
  // so that an async context is present.
137
172
  store = this.scope.getStore();
138
173
  // nothing can be done if async context is not available.
174
+
139
175
  if (!store) {
140
176
  this.logger.debug('cannot acquire store for initiateRequestHandling()');
177
+ setImmediate(() => method.call(instance, ...args));
141
178
  return;
142
179
  }
143
180
 
@@ -169,17 +206,21 @@ class HttpInstrumentation {
169
206
  // TODO AGENT-203 - need to handle method-tampering rule.
170
207
  method: reqData.method,
171
208
  };
209
+
172
210
  // only add queries if it's known that 'qs' or equivalent won't be used.
173
211
  /* c8 ignore next 3 */
174
212
  if (reqData.standardUrlParsing) {
175
213
  connectInputs.queries = reqData.queries;
176
214
  }
215
+
177
216
  if (inputAnalysis.virtualPatchesEvaluators?.length) {
178
217
  store.protect.virtualPatchesEvaluators.push(...inputAnalysis.virtualPatchesEvaluators.map((e) => new Map(e)));
179
218
  }
219
+
180
220
  if (inputAnalysis.ipDenylist?.length) {
181
221
  block = inputAnalysis.handleIpDenylist(store.protect, inputAnalysis.ipDenylist);
182
222
  }
223
+
183
224
  if (inputAnalysis.ipAllowlist?.length) {
184
225
  const allowed = inputAnalysis.handleIpAllowlist(store.protect, inputAnalysis.ipAllowlist);
185
226
  if (!block) Object.assign(store.protect, { allowed });
@@ -16,7 +16,12 @@
16
16
  'use strict';
17
17
 
18
18
  const util = require('util');
19
- const { BLOCKING_MODES, isString, simpleTraverse } = require('@contrast/common');
19
+ const {
20
+ ProtectRuleMode: { OFF },
21
+ BLOCKING_MODES,
22
+ isString,
23
+ simpleTraverse
24
+ } = require('@contrast/common');
20
25
 
21
26
  module.exports = function(core) {
22
27
  const { protect: { agentLib, inputTracing, throwSecurityException } } = core;
@@ -24,7 +29,7 @@ module.exports = function(core) {
24
29
  function handleFindings(sourceContext, sinkContext, ruleId, result, findings) {
25
30
  result.details.push({ sinkContext, findings });
26
31
 
27
- const { mode } = sourceContext.rules.agentLibRules[ruleId];
32
+ const mode = sourceContext.policy[ruleId];
28
33
 
29
34
  if (BLOCKING_MODES.includes(mode)) {
30
35
  result.blocked = true;
@@ -215,7 +220,7 @@ module.exports = function(core) {
215
220
  * @returns {AnalysisResult[]}
216
221
  */
217
222
  function getResultsByRuleId(ruleId, context) {
218
- if (context.rules.agentLibRules[ruleId].mode === 'off') {
223
+ if (context.policy[ruleId] === OFF) {
219
224
  return;
220
225
  }
221
226
  return context.findings.resultsMap[ruleId];
@@ -15,21 +15,15 @@
15
15
 
16
16
  'use strict';
17
17
 
18
- /**
19
- * INPUT TRACING is a STAGE of Protect.
20
- * The specification can be found here https://protect-spec.prod.dotnet.contsec.com/guide/input-tracing.html.
21
- *
22
- * To view other STAGES see https://protect-spec.prod.dotnet.contsec.com/guide/protect-types.html#protection-types
23
- * @param {object} core composed dependencies
24
- * @returns {object}
25
- */
18
+ const { installChildComponentsSync } = require('@contrast/common');
19
+
26
20
  module.exports = function(core) {
27
21
  const inputTracing = core.protect.inputTracing = {};
28
22
 
29
- // load the interfaces that will be used by input tracing instrumentation
23
+ // api
30
24
  require('./handlers')(core);
31
25
 
32
- // load the instrumentation installers
26
+ // instrumentation
33
27
  require('./install/child-process')(core);
34
28
  require('./install/fs')(core);
35
29
  require('./install/mongodb')(core);
@@ -38,17 +32,13 @@ module.exports = function(core) {
38
32
  require('./install/sequelize')(core);
39
33
  require('./install/sqlite3')(core);
40
34
  require('./install/http')(core);
35
+ require('./install/vm')(core);
36
+ require('./install/eval')(core);
37
+ require('./install/function')(core);
38
+ // TODO: NODE-2360 (oracledb)
41
39
 
42
40
  inputTracing.install = function() {
43
- inputTracing.cpInstrumentation.install();
44
- inputTracing.fsInstrumentation.install();
45
- inputTracing.mongodbInstrumentation.install();
46
- inputTracing.mysqlInstrumentation.install();
47
- inputTracing.postgresInstrumentation.install();
48
- inputTracing.sequelizeInstrumentation.install();
49
- inputTracing.sqlite3Instrumentation.install();
50
- inputTracing.httpInstrumentation.install();
51
- // TODO: NODE-2360 (2260?)
41
+ installChildComponentsSync(inputTracing);
52
42
  };
53
43
 
54
44
  return inputTracing;
@@ -29,13 +29,13 @@ module.exports = function(core) {
29
29
  } = core;
30
30
 
31
31
  function install() {
32
- if (!global.ContrastMethods.__contrastEval) {
32
+ if (!global.ContrastMethods.eval) {
33
33
  logger.error('Cannot install `eval` instrumentation - Contrast method DNE');
34
34
  return;
35
35
  }
36
36
 
37
- patcher.patch(global.ContrastMethods, '__contrastEval', {
38
- name: 'global.ContrastMethods.__contrastEval',
37
+ patcher.patch(global.ContrastMethods, 'eval', {
38
+ name: 'global.ContrastMethods.eval',
39
39
  patchType,
40
40
  pre: ({ args, hooked, orig }) => {
41
41
  if (instrumentation.isLocked()) return;
@@ -0,0 +1,60 @@
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
+
16
+ 'use strict';
17
+
18
+ const { isString } = require('@contrast/common');
19
+ const { patchType } = require('../constants');
20
+
21
+ module.exports = function(core) {
22
+ const {
23
+ logger,
24
+ scopes: { instrumentation },
25
+ patcher,
26
+ captureStacktrace,
27
+ protect,
28
+ protect: { inputTracing }
29
+ } = core;
30
+
31
+ function install() {
32
+ if (!global.ContrastMethods.Function) {
33
+ logger.error('Cannot install `Function` instrumentation - Contrast method DNE');
34
+ return;
35
+ }
36
+
37
+ patcher.patch(global.ContrastMethods, 'Function', {
38
+ name: 'global.ContrastMethods.Function',
39
+ patchType,
40
+ pre: ({ args, hooked, orig }) => {
41
+ if (instrumentation.isLocked()) return;
42
+
43
+ const sourceContext = protect.getSourceContext('Function');
44
+ const fnBody = args[args.length - 1];
45
+
46
+ if (!sourceContext || !fnBody || !isString(fnBody)) return;
47
+
48
+ const sinkContext = captureStacktrace(
49
+ { name: 'Function', value: fnBody },
50
+ { constructorOpt: hooked, prependFrames: [orig] }
51
+ );
52
+ inputTracing.ssjsInjection(sourceContext, sinkContext);
53
+ }
54
+ });
55
+ }
56
+
57
+ const functionInstrumentation = inputTracing.functionInstrumentation = { install };
58
+
59
+ return functionInstrumentation;
60
+ };
@@ -16,6 +16,7 @@
16
16
  'use strict';
17
17
 
18
18
  module.exports = function(core) {
19
+ const { protect } = core;
19
20
 
20
21
  function makeSourceContext(req, res) {
21
22
  // make the abstract request. it is an abstraction of a request that
@@ -79,9 +80,8 @@ module.exports = function(core) {
79
80
  // block closure captures res so it isn't exposed to beyond here
80
81
  block: core.protect.makeResponseBlocker(res),
81
82
 
82
- // this should be changed to capture only the rules applicable to this
83
- // particular request (if any route exclusions, etc.)
84
- rules: core.protect.rules,
83
+ policy: protect.getPolicy(),
84
+
85
85
  exclusions: [],
86
86
  virtualPatchesEvaluators: [],
87
87
 
@@ -98,54 +98,10 @@ module.exports = function(core) {
98
98
  semanticResultsMap: Object.create(null),
99
99
  serverFeaturesResultsMap: Object.create(null)
100
100
  },
101
-
102
- /*
103
- findings: {
104
- trackRequest: true,
105
- resultsList: [
106
- // Example 2
107
- {
108
- // return value from agent-lib
109
- value: 'kill -9 1',
110
- type: 'PARAMETER_VALUE',
111
- ruleId: 'cmd-injection',
112
- path: ['path', 'to', 'val'],
113
- // other data added during lifecycle
114
- // could we add these by mutating agent-lib return values?
115
- // What if there are multiple injections for the same value? The `details` value
116
- // could be an array in that case, or is this too complicated.
117
- blocked: false,
118
- details: [
119
- {
120
- context: {
121
- id: 'child_process.exec',
122
- get stack() {}, // lazy
123
- command: 'sudo kill -9 1',
124
- index: 5,
125
- }
126
- },
127
- {
128
- sinkId: 'child_process.exec',
129
- get stack() {}, // lazy
130
- command: 'sudo kill -9 1',
131
- index: 5,
132
- }]
133
- },
134
- ]
135
- }
136
- // (scoreAtom() returns only the ruleId and score because the caller supplied
137
- // the input and type; no key or path is known to scoreAtom(). code calling
138
- // scoreAtom() will need to augment the finding to match the above.)
139
- //
140
- // each finding is augmented with additional properties
141
- // - blocked: false // set to true if the finding causes the request to be blocked
142
- // - mappedId: ruleId // normalized ruleId, e.g., nosql-injection-mongo => nosql-injection
143
- // -
144
- // */
145
101
  };
146
102
 
147
103
  return protectStore;
148
104
  }
149
105
 
150
- core.protect.makeSourceContext = makeSourceContext;
106
+ return core.protect.makeSourceContext = makeSourceContext;
151
107
  };
package/lib/policy.js ADDED
@@ -0,0 +1,134 @@
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
+
16
+ 'use strict';
17
+
18
+ const {
19
+ Rule,
20
+ ProtectRuleMode: {
21
+ BLOCK_AT_PERIMETER,
22
+ BLOCK,
23
+ MONITOR,
24
+ OFF,
25
+ },
26
+ Rule: { BOT_BLOCKER },
27
+ Event: { SERVER_SETTINGS_UPDATE },
28
+ } = require('@contrast/common');
29
+
30
+
31
+ module.exports = function(core) {
32
+ const { config, logger, messages, protect } = core;
33
+ const policy = protect.policy = {};
34
+
35
+ function getModeFromConfig(ruleId) {
36
+ if (config.protect.disabled_rules.includes(ruleId)) {
37
+ return 'off';
38
+ }
39
+ return config.protect.rules?.[ruleId]?.mode;
40
+ }
41
+
42
+ /**
43
+ * Coerces ContrastUI mode names to match from common-agent-configuration
44
+ * @param {} remoteSetting
45
+ * @returns {string}
46
+ */
47
+ function readModeFromSetting(remoteSetting) {
48
+ switch (remoteSetting.mode) {
49
+ case 'OFF': return OFF;
50
+ case 'MONITORING': return MONITOR;
51
+ case 'BLOCKING': return remoteSetting.blockAtEntry ? BLOCK_AT_PERIMETER : BLOCK;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Build out initial policy from configuration data
57
+ */
58
+ function initPolicy() {
59
+ for (const ruleId of Object.values(Rule)) {
60
+ policy[ruleId] = getModeFromConfig(ruleId) || OFF;
61
+ }
62
+ updateRulesMask();
63
+ }
64
+
65
+ /**
66
+ * When updates are given at runtime, we update our local rule policy while
67
+ * respecting rules of precedence.
68
+ * @param {[]} protectionRules
69
+ */
70
+ function updateFromProtectionRules(protectionRules) {
71
+ for (const remoteSetting of Object.values(protectionRules)) {
72
+ const { id: ruleId } = remoteSetting;
73
+
74
+ if (getModeFromConfig(ruleId)) {
75
+ continue;
76
+ }
77
+
78
+ policy[ruleId] = readModeFromSetting(remoteSetting);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Rebuild rules mask based on which agent-lib rules are enabled
84
+ */
85
+ function updateRulesMask() {
86
+ let rulesMask = 0;
87
+ for (const [ruleId, mode] of Object.entries(policy)) {
88
+ if (protect.agentLib.RuleType[ruleId] && mode !== OFF) {
89
+ rulesMask = rulesMask | protect.agentLib.RuleType[ruleId];
90
+ }
91
+ }
92
+ policy.rulesMask = rulesMask;
93
+ }
94
+
95
+ /**
96
+ * This gets called by protect.makeSourceContext(). We return copy of policy to avoid
97
+ * inconsistent behavior if policy is updated during request handling.
98
+ */
99
+ function getPolicy() {
100
+ return { ...policy };
101
+ }
102
+
103
+ initPolicy();
104
+
105
+ messages.on(SERVER_SETTINGS_UPDATE, (remoteSettings) => {
106
+ let update;
107
+
108
+ const protectionRules = remoteSettings?.settings?.defend?.protectionRules
109
+ if (protectionRules) {
110
+ updateFromProtectionRules(protectionRules);
111
+ update = 'application-settings';
112
+ }
113
+
114
+ if (remoteSettings?.features?.defend) {
115
+ const bbEnabled = remoteSettings.features.defend[BOT_BLOCKER];
116
+
117
+ if (
118
+ bbEnabled != null &&
119
+ !config.protect.disabled_rules.includes(BOT_BLOCKER) &&
120
+ !config.protect.rules?.[BOT_BLOCKER]?.mode
121
+ ) {
122
+ policy[BOT_BLOCKER] = bbEnabled ? BLOCK_AT_PERIMETER : OFF;
123
+ update = 'server-features';
124
+ }
125
+ }
126
+
127
+ if (update) {
128
+ updateRulesMask();
129
+ logger.info({ policy: protect.policy }, `protect policy updated from ${update}`);
130
+ }
131
+ });
132
+
133
+ return protect.getPolicy = getPolicy;
134
+ };
@@ -17,6 +17,7 @@
17
17
 
18
18
  const {
19
19
  BLOCKING_MODES,
20
+ ProtectRuleMode: { OFF },
20
21
  InputType,
21
22
  isString,
22
23
  simpleTraverse
@@ -50,9 +51,9 @@ module.exports = function(core) {
50
51
 
51
52
  semanticAnalysis.handleCmdInjectionSemanticDangerous = function(sourceContext, sinkContext) {
52
53
  const ruleId = 'cmd-injection-semantic-dangerous-paths';
53
- const { mode } = sourceContext.rules.agentRules[ruleId];
54
+ const mode = sourceContext.policy[ruleId];
54
55
 
55
- if (mode == 'off') return;
56
+ if (mode == OFF) return;
56
57
 
57
58
  const result = agentLib.containsDangerousPath(sinkContext.value);
58
59
 
@@ -63,9 +64,9 @@ module.exports = function(core) {
63
64
 
64
65
  semanticAnalysis.handleCmdInjectionSemanticChainedCommands = function(sourceContext, sinkContext) {
65
66
  const ruleId = 'cmd-injection-semantic-chained-commands';
66
- const { mode } = sourceContext.rules.agentRules[ruleId];
67
+ const mode = sourceContext.policy[ruleId];
67
68
 
68
- if (mode == 'off') return;
69
+ if (mode == OFF) return;
69
70
 
70
71
  const indexOfChaining = agentLib.indexOfChaining(sinkContext.value);
71
72
 
@@ -76,9 +77,9 @@ module.exports = function(core) {
76
77
 
77
78
  semanticAnalysis.handleCommandInjectionCommandBackdoors = function(sourceContext, sinkContext) {
78
79
  const ruleId = 'cmd-injection-command-backdoors';
79
- const { mode } = sourceContext.rules.agentRules[ruleId];
80
+ const mode = sourceContext.policy[ruleId];
80
81
 
81
- if (mode == 'off') return;
82
+ if (mode == OFF) return;
82
83
 
83
84
  const finding = findBackdoorInjection(sourceContext, sinkContext.value);
84
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrast/protect",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Contrast service providing framework-agnostic Protect support",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Contrast Security <nodejs@contrastsecurity.com> (https://www.contrastsecurity.com)",
@@ -19,10 +19,10 @@
19
19
  "dependencies": {
20
20
  "@babel/template": "^7.16.7",
21
21
  "@babel/types": "^7.16.8",
22
- "@contrast/agent-lib": "^5.0.0",
23
- "@contrast/common": "1.1.0",
24
- "@contrast/core": "1.3.0",
25
- "@contrast/esm-hooks": "1.1.4",
22
+ "@contrast/agent-lib": "^5.1.0",
23
+ "@contrast/common": "1.1.1",
24
+ "@contrast/core": "1.4.0",
25
+ "@contrast/esm-hooks": "1.1.5",
26
26
  "@contrast/scopes": "1.1.1",
27
27
  "builtin-modules": "^3.2.0",
28
28
  "ipaddr.js": "^2.0.1",