@contrast/protect 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -38,7 +38,7 @@ module.exports = function(core) {
38
38
  protect.version = pkj.version;
39
39
 
40
40
  protect.install = function() {
41
- installChildComponentsSync(protect)
41
+ installChildComponentsSync(protect);
42
42
  };
43
43
 
44
44
  return protect;
@@ -17,6 +17,7 @@
17
17
 
18
18
  const { BLOCKING_MODES, simpleTraverse } = require('@contrast/common');
19
19
  const address = require('ipaddr.js');
20
+ const { Rule } = require('@contrast/common');
20
21
 
21
22
  //
22
23
  // these rules are not implemented by agent-lib, but are being considered for
@@ -54,6 +55,7 @@ module.exports = function(core) {
54
55
  agentLib,
55
56
  inputAnalysis,
56
57
  },
58
+ config,
57
59
  } = core;
58
60
 
59
61
  // all handlers will be invoked with two arguments:
@@ -127,12 +129,68 @@ module.exports = function(core) {
127
129
  return block;
128
130
  };
129
131
 
130
- // this is called before the request goes away. this is where probe detection takes
131
- // place. basically loop through findings.resultsList and, for all those that weren't
132
- // blocked, run scoreAtom() with preferWorthWatching: false. for any that have a score
133
- // >= 90 report them as probes.
132
+ /**
133
+ * handleRequestEnd()
134
+ *
135
+ * Invoked when the request is complete.
136
+ *
137
+ * @param {Object} sourceContext
138
+ */
134
139
  inputAnalysis.handleRequestEnd = function handleRequestEnd(sourceContext) {
135
- throw new Error('nyi', sourceContext);
140
+ if (!config.protect.probe_analysis.enable) return;
141
+
142
+ const { resultsMap } = sourceContext.findings;
143
+ const probesRules = [Rule.CMD_INJECTION, Rule.PATH_TRAVERSAL, Rule.SQL_INJECTION, Rule.XXE];
144
+ const props = {};
145
+
146
+ // Detecting probes
147
+ Object.values(resultsMap).forEach(resultsByRuleId => {
148
+ resultsByRuleId.forEach((resultByRuleId) => {
149
+ const {
150
+ ruleId,
151
+ blocked,
152
+ details,
153
+ value,
154
+ inputType
155
+ } = resultByRuleId;
156
+ if (blocked || !blocked && details.length > 0 || !probesRules.some(rule => rule === ruleId)) return;
157
+
158
+ const { policy: { rulesMask } } = sourceContext;
159
+
160
+ const results = (agentLib.scoreAtom(
161
+ rulesMask,
162
+ value,
163
+ agentLib.InputType[inputType],
164
+ {
165
+ preferWorthWatching: false
166
+ }
167
+ ) || []).filter(({ score }) => score >= 90);
168
+
169
+ if (!results.length) return;
170
+
171
+ results.forEach(result => {
172
+ const isAlreadyBlocked = (resultsMap[result.ruleId] || []).some(element =>
173
+ element.blocked && element.inputType === inputType && element.value === value
174
+ );
175
+
176
+ if (isAlreadyBlocked) return;
177
+
178
+ const probe = Object.assign({}, resultByRuleId, result, {
179
+ mappedId: result.ruleId
180
+ });
181
+ const key = [probe.ruleId, probe.inputType, ...probe.path, probe.value].join('|');
182
+ props[key] = probe;
183
+ });
184
+ });
185
+ });
186
+
187
+ Object.values(props).forEach(prop => {
188
+ if (!resultsMap[prop.ruleId]) {
189
+ resultsMap[prop.ruleId] = [];
190
+ }
191
+
192
+ resultsMap[prop.ruleId].push(prop);
193
+ });
136
194
  };
137
195
 
138
196
  const jsonInputTypes = {
@@ -36,48 +36,46 @@ module.exports = (core) => {
36
36
  * registers a depHook for fastify module instrumentation
37
37
  */
38
38
  function install() {
39
- depHooks.resolve({ name: 'fastify', version: '>=3 <5' }, (fastify) => {
40
- return patcher.patch(fastify, {
41
- name: 'fastify.build',
42
- patchType,
43
- post({ result: server }) {
44
- server.addHook('preValidation', function(request, reply, done) {
45
- let securityException;
46
- const sourceContext = protect.getSourceContext('Fastify.preValidationHook');
39
+ depHooks.resolve({ name: 'fastify', version: '>=3 <5' }, (fastify) => patcher.patch(fastify, {
40
+ name: 'fastify.build',
41
+ patchType,
42
+ post({ result: server }) {
43
+ server.addHook('preValidation', function(request, reply, done) {
44
+ let securityException;
45
+ const sourceContext = protect.getSourceContext('Fastify.preValidationHook');
47
46
 
48
- if (sourceContext) {
49
- try {
50
- if (request.params) {
51
- sourceContext.parsedParams = request.params;
52
- inputAnalysis.handleUrlParams(sourceContext, request.params);
53
- }
54
- if (request.cookies) {
55
- sourceContext.parsedCookies = request.cookies;
56
- inputAnalysis.handleCookies(sourceContext, request.cookies);
57
- }
58
- if (request.body) {
59
- sourceContext.parsedBody = request.body;
60
- inputAnalysis.handleParsedBody(sourceContext, request.body);
61
- }
47
+ if (sourceContext) {
48
+ try {
49
+ if (request.params) {
50
+ sourceContext.parsedParams = request.params;
51
+ inputAnalysis.handleUrlParams(sourceContext, request.params);
52
+ }
53
+ if (request.cookies) {
54
+ sourceContext.parsedCookies = request.cookies;
55
+ inputAnalysis.handleCookies(sourceContext, request.cookies);
56
+ }
57
+ if (request.body) {
58
+ sourceContext.parsedBody = request.body;
59
+ inputAnalysis.handleParsedBody(sourceContext, request.body);
60
+ }
62
61
 
63
- if (request.query) {
64
- sourceContext.parsedQuery = request.query;
65
- inputAnalysis.handleQueryParams(sourceContext, request.query);
66
- }
67
- } catch (err) {
68
- if (isSecurityException(err)) {
69
- securityException = err;
70
- } else {
71
- logger.error({ err }, 'Unexpected error during input analysis');
72
- }
62
+ if (request.query) {
63
+ sourceContext.parsedQuery = request.query;
64
+ inputAnalysis.handleQueryParams(sourceContext, request.query);
65
+ }
66
+ } catch (err) {
67
+ if (isSecurityException(err)) {
68
+ securityException = err;
69
+ } else {
70
+ logger.error({ err }, 'Unexpected error during input analysis');
73
71
  }
74
72
  }
73
+ }
75
74
 
76
- done(securityException);
77
- });
78
- },
79
- });
80
- });
75
+ done(securityException);
76
+ });
77
+ },
78
+ }));
81
79
  }
82
80
 
83
81
  return inputAnalysis.fastifyInstrumentation = {
@@ -27,11 +27,10 @@ module.exports = function(core) {
27
27
  };
28
28
  class HttpInstrumentation {
29
29
  constructor(core) {
30
- const { logger } = core;
31
30
  this.messages = core.messages;
32
31
  this.scope = core.scopes.sources;
33
32
  this.config = core.config;
34
- this.logger = logger.child({ name: 'contrast:protect:input-analysis' });
33
+ this.logger = core.logger.child('contrast:protect:input-analysis');
35
34
  this.depHooks = core.depHooks;
36
35
  this.protect = core.protect;
37
36
  this.patcher = core.patcher;
@@ -181,7 +180,10 @@ class HttpInstrumentation {
181
180
  store.protect = this.makeSourceContext(req, res);
182
181
  const { reqData } = store.protect;
183
182
 
184
- res.on('finish', () => messages.emit(Event.PROTECT, store));
183
+ res.on('finish', () => {
184
+ this.protect.inputAnalysis.handleRequestEnd(store.protect);
185
+ messages.emit(Event.PROTECT, store);
186
+ });
185
187
 
186
188
  // don't put inputs in the store; they are a param to each handler. findings
187
189
  // associated with inputs do go into the store. why not put the inputs
@@ -54,6 +54,7 @@ module.exports = function(core) {
54
54
  core.protect.semanticAnalysis.handleCommandInjectionCommandBackdoors(sourceContext, sinkContext);
55
55
  core.protect.semanticAnalysis.handleCmdInjectionSemanticChainedCommands(sourceContext, sinkContext);
56
56
  core.protect.semanticAnalysis.handleCmdInjectionSemanticDangerous(sourceContext, sinkContext);
57
+ core.protect.semanticAnalysis.handlePathTraversalFileSecurityBypass(sourceContext, sinkContext);
57
58
  }
58
59
  });
59
60
  });
@@ -106,6 +106,7 @@ module.exports = function(core) {
106
106
  { constructorOpt: hooked, prependFrames: [orig] }
107
107
  );
108
108
  inputTracing.handlePathTraversal(sourceContext, sinkContext);
109
+ core.protect.semanticAnalysis.handlePathTraversalFileSecurityBypass(sourceContext, sinkContext);
109
110
  }
110
111
  }
111
112
  });
@@ -34,23 +34,25 @@ module.exports = function(core) {
34
34
  return;
35
35
  }
36
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
- }
37
+ Object.assign(global.ContrastMethods, {
38
+ Function: patcher.patch(global.ContrastMethods.Function, {
39
+ name: 'global.ContrastMethods.Function',
40
+ patchType,
41
+ pre: ({ args, hooked, orig }) => {
42
+ if (instrumentation.isLocked()) return;
43
+
44
+ const sourceContext = protect.getSourceContext('Function');
45
+ const fnBody = args[args.length - 1];
46
+
47
+ if (!sourceContext || !fnBody || !isString(fnBody)) return;
48
+
49
+ const sinkContext = captureStacktrace(
50
+ { name: 'Function', value: fnBody },
51
+ { constructorOpt: hooked, prependFrames: [orig] }
52
+ );
53
+ inputTracing.ssjsInjection(sourceContext, sinkContext);
54
+ }
55
+ })
54
56
  });
55
57
  }
56
58
 
package/lib/policy.js CHANGED
@@ -105,7 +105,7 @@ module.exports = function(core) {
105
105
  messages.on(SERVER_SETTINGS_UPDATE, (remoteSettings) => {
106
106
  let update;
107
107
 
108
- const protectionRules = remoteSettings?.settings?.defend?.protectionRules
108
+ const protectionRules = remoteSettings?.settings?.defend?.protectionRules;
109
109
  if (protectionRules) {
110
110
  updateFromProtectionRules(protectionRules);
111
111
  update = 'application-settings';
@@ -16,6 +16,7 @@
16
16
  'use strict';
17
17
 
18
18
  const {
19
+ Rule,
19
20
  BLOCKING_MODES,
20
21
  ProtectRuleMode: { OFF },
21
22
  InputType,
@@ -26,12 +27,17 @@ const {
26
27
  const SINK_EXPLOIT_PATTERN_START = /(?:^|\\|\/)(?:sh|bash|zsh|ksh|tcsh|csh|fish|cmd)/;
27
28
  const stripWhiteSpace = (str) => str.replace(/\s/g, '');
28
29
 
29
- // The sink instrumentation for this rule is in `protect/lib/input-tracing/install/child-process.js
30
+ const getRuleResults = function(obj, prop) {
31
+ return obj[prop] || (obj[prop] = []);
32
+ };
33
+
34
+ // Semantic analysis currently shares instrumentation with the input-tracing sinks.
35
+ // See files in protect/lib/input-tracing/install/.
36
+
30
37
  module.exports = function(core) {
31
38
  const { protect: { agentLib, semanticAnalysis, throwSecurityException } } = core;
32
39
 
33
40
  function handleResult(sourceContext, sinkContext, ruleId, mode, finding) {
34
- const sinkResults = sourceContext.findings.semanticResultsMap[ruleId];
35
41
  const result = {
36
42
  blocked: false,
37
43
  findings: { command: sinkContext.value },
@@ -39,7 +45,7 @@ module.exports = function(core) {
39
45
  ...finding
40
46
  };
41
47
 
42
- sourceContext.findings.semanticResultsMap[ruleId] = sinkResults ? [...sinkResults, result] : [result];
48
+ getRuleResults(sourceContext.findings.semanticResultsMap, ruleId).push(result);
43
49
 
44
50
  if (BLOCKING_MODES.includes(mode)) {
45
51
  result.blocked = true;
@@ -50,41 +56,50 @@ module.exports = function(core) {
50
56
  }
51
57
 
52
58
  semanticAnalysis.handleCmdInjectionSemanticDangerous = function(sourceContext, sinkContext) {
53
- const ruleId = 'cmd-injection-semantic-dangerous-paths';
54
- const mode = sourceContext.policy[ruleId];
59
+ const mode = sourceContext.policy[Rule.CMD_INJECTION_SEMANTIC_DANGEROUS_PATHS];
55
60
 
56
61
  if (mode == OFF) return;
57
62
 
58
63
  const result = agentLib.containsDangerousPath(sinkContext.value);
59
64
 
60
65
  if (result) {
61
- handleResult(sourceContext, sinkContext, ruleId, mode);
66
+ handleResult(sourceContext, sinkContext, Rule.CMD_INJECTION_SEMANTIC_DANGEROUS_PATHS, mode);
62
67
  }
63
68
  };
64
69
 
65
70
  semanticAnalysis.handleCmdInjectionSemanticChainedCommands = function(sourceContext, sinkContext) {
66
- const ruleId = 'cmd-injection-semantic-chained-commands';
67
- const mode = sourceContext.policy[ruleId];
71
+ const mode = sourceContext.policy[Rule.CMD_INJECTION_SEMANTIC_CHAINED_COMMANDS];
68
72
 
69
73
  if (mode == OFF) return;
70
74
 
71
75
  const indexOfChaining = agentLib.indexOfChaining(sinkContext.value);
72
76
 
73
77
  if (indexOfChaining != -1) {
74
- handleResult(sourceContext, sinkContext, ruleId, mode);
78
+ handleResult(sourceContext, sinkContext, Rule.CMD_INJECTION_SEMANTIC_CHAINED_COMMANDS, mode);
75
79
  }
76
80
  };
77
81
 
78
82
  semanticAnalysis.handleCommandInjectionCommandBackdoors = function(sourceContext, sinkContext) {
79
- const ruleId = 'cmd-injection-command-backdoors';
80
- const mode = sourceContext.policy[ruleId];
83
+ const mode = sourceContext.policy[Rule.CMD_INJECTION_COMMAND_BACKDOORS];
81
84
 
82
85
  if (mode == OFF) return;
83
86
 
84
87
  const finding = findBackdoorInjection(sourceContext, sinkContext.value);
85
88
 
86
89
  if (finding) {
87
- handleResult(sourceContext, sinkContext, ruleId, mode, finding);
90
+ handleResult(sourceContext, sinkContext, Rule.CMD_INJECTION_COMMAND_BACKDOORS, mode, finding);
91
+ }
92
+ };
93
+
94
+ semanticAnalysis.handlePathTraversalFileSecurityBypass = function(sourceContext, sinkContext) {
95
+ const mode = sourceContext.policy[Rule.PATH_TRAVERSAL_SEMANTIC_FILE_SECURITY_BYPASS];
96
+
97
+ if (mode == OFF) return;
98
+
99
+ if (agentLib.isDangerousPath(sinkContext.value, true)) {
100
+ handleResult(sourceContext, sinkContext, Rule.PATH_TRAVERSAL_SEMANTIC_FILE_SECURITY_BYPASS, mode, {
101
+ findings: { path: sinkContext.value }
102
+ });
88
103
  }
89
104
  };
90
105
 
@@ -119,20 +134,22 @@ function findBackdoorInjection(sourceContext, command) {
119
134
  simpleTraverse(values, (path, type, value, obj) => {
120
135
  if (
121
136
  !found &&
122
- value &&
123
- type === 'Value' &&
124
- isString(value) &&
125
- isBackdoorDetected(value, command)
137
+ type === 'Value' &&
138
+ isBackdoorDetected(value, command)
126
139
  ) {
127
140
  let key;
128
- key = inputType === InputType.HEADER ? obj.indexOf(command) - 1 : path[path.length - 1];
129
- if (Number.isInteger(key) && obj[key]) {
130
- key = obj[key];
141
+ if (inputType === InputType.HEADER) {
142
+ key = obj[path[0] - 1]
143
+ } else {
144
+ key = path[path.length - 1];
131
145
  }
132
- path = path.length === 1 ? [] : Array.from(path).slice(0, path.length - 1);
133
- inputType = path.length > 1 ? InputType.JSON_VALUE : inputType;
134
146
 
135
- found = { key, inputType, path, value: command };
147
+ found = {
148
+ key,
149
+ inputType: path.length > 1 ? InputType.JSON_VALUE : inputType,
150
+ path: path.slice(0, -1),
151
+ value: command
152
+ };
136
153
  }
137
154
  });
138
155
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrast/protect",
3
- "version": "1.5.0",
3
+ "version": "1.6.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)",
@@ -20,9 +20,9 @@
20
20
  "@babel/template": "^7.16.7",
21
21
  "@babel/types": "^7.16.8",
22
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",
23
+ "@contrast/common": "1.1.2",
24
+ "@contrast/core": "1.5.0",
25
+ "@contrast/esm-hooks": "1.1.6",
26
26
  "@contrast/scopes": "1.1.1",
27
27
  "builtin-modules": "^3.2.0",
28
28
  "ipaddr.js": "^2.0.1",