@contrast/assess 1.27.0 → 1.27.2

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.
@@ -44,7 +44,7 @@ module.exports = function(core) {
44
44
  },
45
45
  } = core;
46
46
 
47
- const safeTags = [];
47
+ const safeTags = [`excluded:${ruleId}`];
48
48
 
49
49
  function commandCheck(
50
50
  name,
@@ -31,6 +31,7 @@ const { InstrumentationType: { RULE } } = require('../../../constants');
31
31
  const { patchType, filterSafeTags } = require('../common');
32
32
 
33
33
  const safeTags = [
34
+ `excluded:${ruleId}`,
34
35
  CUSTOM_ENCODED_TRUST_BOUNDARY_VIOLATION,
35
36
  CUSTOM_ENCODED,
36
37
  CUSTOM_VALIDATED_TRUST_BOUNDARY_VIOLATION,
@@ -62,6 +62,7 @@ module.exports = function(core) {
62
62
  const inspect = patcher.unwrap(util.inspect);
63
63
 
64
64
  const safeTags = [
65
+ `excluded:${ruleId}`,
65
66
  COOKIE,
66
67
  HEADER,
67
68
  LIMITED_CHARS,
@@ -59,6 +59,7 @@ module.exports = function(core) {
59
59
  const inspect = patcher.unwrap(util.inspect);
60
60
 
61
61
  const safeTags = [
62
+ `excluded:${ruleId}`,
62
63
  CUSTOM_ENCODED,
63
64
  CUSTOM_VALIDATED,
64
65
  HTML_ENCODED,
@@ -79,6 +79,7 @@ module.exports = function(core) {
79
79
  const inspect = patcher.unwrap(util.inspect);
80
80
 
81
81
  const safeTags = [
82
+ `excluded:${ruleId}`,
82
83
  CUSTOM_ENCODED,
83
84
  CUSTOM_VALIDATED,
84
85
  HTML_ENCODED,
@@ -46,6 +46,7 @@ module.exports = function(core) {
46
46
  } = core;
47
47
 
48
48
  const safeTags = [
49
+ `excluded:${ruleId}`,
49
50
  URL_ENCODED,
50
51
  LIMITED_CHARS,
51
52
  ALPHANUM_SPACE_HYPHEN,
@@ -77,6 +78,7 @@ module.exports = function(core) {
77
78
  });
78
79
  for (let i = 0; i < values.length; i++) {
79
80
  const { strInfo } = args[i];
81
+
80
82
  if (!strInfo || !isVulnerable(UNTRUSTED, safeTags, strInfo.tags)) {
81
83
  continue;
82
84
  }
@@ -33,6 +33,7 @@ const { InstrumentationType: { RULE } } = require('../../../constants');
33
33
  const { patchType, filterSafeTags } = require('../common');
34
34
 
35
35
  const safeTags = [
36
+ `excluded:${ruleId}`,
36
37
  CUSTOM_ENCODED_TRUST_BOUNDARY_VIOLATION,
37
38
  CUSTOM_ENCODED,
38
39
  CUSTOM_VALIDATED_TRUST_BOUNDARY_VIOLATION,
@@ -59,6 +59,7 @@ module.exports = function(core) {
59
59
  const inspect = patcher.unwrap(util.inspect);
60
60
 
61
61
  const safeTags = [
62
+ `excluded:${ruleId}`,
62
63
  CUSTOM_ENCODED,
63
64
  CUSTOM_VALIDATED,
64
65
  HTML_ENCODED,
@@ -60,6 +60,7 @@ module.exports = function(core) {
60
60
  const http = core.assess.dataflow.sinks.http.request = {};
61
61
 
62
62
  const safeTags = [
63
+ `excluded:${ruleId}`,
63
64
  CUSTOM_ENCODED_SSRF,
64
65
  CUSTOM_ENCODED,
65
66
  CUSTOM_VALIDATED_SSRF,
@@ -64,6 +64,7 @@ module.exports = function(core) {
64
64
  const http = core.assess.dataflow.sinks.http.serverResponse = {};
65
65
 
66
66
  const safeTags = [
67
+ `excluded:${ruleId}`,
67
68
  ALPHANUM_SPACE_HYPHEN,
68
69
  COOKIE,
69
70
  CUSTOM_ENCODED,
@@ -58,6 +58,7 @@ module.exports = function(core) {
58
58
 
59
59
  const inspect = patcher.unwrap(util.inspect);
60
60
  const safeTags = [
61
+ `excluded:${ruleId}`,
61
62
  CUSTOM_ENCODED,
62
63
  CUSTOM_VALIDATED,
63
64
  HTML_ENCODED,
@@ -29,6 +29,7 @@ const { InstrumentationType: { RULE } } = require('../../../constants');
29
29
  const { patchType } = require('../common');
30
30
 
31
31
  const safeTags = [
32
+ `excluded:${ruleId}`,
32
33
  LIMITED_CHARS,
33
34
  ALPHANUM_SPACE_HYPHEN
34
35
  ];
@@ -31,6 +31,7 @@ const { patchType } = require('../common');
31
31
 
32
32
  const collectionMethods = ['find', 'findOne', 'update', 'remove'];
33
33
  const querySafeTags = [
34
+ `excluded:${ruleId}`,
34
35
  LIMITED_CHARS,
35
36
  ALPHANUM_SPACE_HYPHEN,
36
37
  STRING_TYPE_CHECKED,
@@ -60,6 +60,7 @@ const dbMethods = [
60
60
  const methodsWithNestedCalls = ['findOne', 'eval', 'group'];
61
61
 
62
62
  const querySafeTags = [
63
+ `excluded:${ruleId}`,
63
64
  ALPHANUM_SPACE_HYPHEN,
64
65
  CUSTOM_VALIDATED,
65
66
  CUSTOM_VALIDATED_NOSQL_INJECTION,
@@ -31,6 +31,7 @@ const { createModuleLabel } = require('../../propagation/common');
31
31
  const { patchType, filterSafeTags } = require('../common');
32
32
 
33
33
  const safeTags = [
34
+ `excluded:${ruleId}`,
34
35
  SQL_ENCODED,
35
36
  LIMITED_CHARS,
36
37
  CUSTOM_VALIDATED,
@@ -33,6 +33,7 @@ const {
33
33
  const { InstrumentationType: { RULE } } = require('../../../constants');
34
34
 
35
35
  const safeTags = [
36
+ `excluded:${ruleId}`,
36
37
  CUSTOM_ENCODED_SQL_INJECTION,
37
38
  CUSTOM_ENCODED,
38
39
  CUSTOM_VALIDATED_SQL_INJECTION,
@@ -25,6 +25,8 @@ const {
25
25
  const { InstrumentationType: { RULE } } = require('../../../constants');
26
26
  const { patchType } = require('../common');
27
27
 
28
+ const safeTags = [`excluded:${ruleId}`];
29
+
28
30
  /**
29
31
  * @param {{
30
32
  * assess: import('@contrast/assess').Assess,
@@ -59,7 +61,7 @@ module.exports = function(core) {
59
61
  if (!isString(input)) return;
60
62
 
61
63
  const strInfo = tracker.getData(input);
62
- if (!strInfo || !isVulnerable(UNTRUSTED, [], strInfo.tags)) return;
64
+ if (!strInfo || !isVulnerable(UNTRUSTED, safeTags, strInfo.tags)) return;
63
65
 
64
66
  const sinkEvent = createSinkEvent({
65
67
  name: 'node-serialize.unserialize',
@@ -53,6 +53,7 @@ module.exports = function(core) {
53
53
  } = core;
54
54
 
55
55
  const safeTags = [
56
+ `excluded:${ruleId}`,
56
57
  SQL_ENCODED,
57
58
  LIMITED_CHARS,
58
59
  CUSTOM_VALIDATED,
@@ -52,6 +52,7 @@ module.exports = function (core) {
52
52
  } = core;
53
53
 
54
54
  const safeTags = [
55
+ `excluded:${ruleId}`,
55
56
  SQL_ENCODED,
56
57
  LIMITED_CHARS,
57
58
  CUSTOM_VALIDATED,
@@ -30,6 +30,7 @@ const {
30
30
  const { InstrumentationType: { RULE } } = require('../../../constants');
31
31
 
32
32
  const safeTags = [
33
+ `excluded:${ruleId}`,
33
34
  SQL_ENCODED,
34
35
  LIMITED_CHARS,
35
36
  CUSTOM_VALIDATED,
@@ -39,7 +39,7 @@ module.exports = function (core) {
39
39
 
40
40
  const emptyStack = Object.freeze([]);
41
41
 
42
- sources.createTags = function createTags({ inputType, fieldName = '', value }) {
42
+ sources.createTags = function createTags({ inputType, fieldName = '', value, tagNames }) {
43
43
  if (!value?.length) {
44
44
  return null;
45
45
  }
@@ -49,6 +49,12 @@ module.exports = function (core) {
49
49
  [DataflowTag.UNTRUSTED]: [0, stop]
50
50
  };
51
51
 
52
+ if (tagNames) {
53
+ for (const tag of tagNames) {
54
+ tags[tag] = [0, stop];
55
+ }
56
+ }
57
+
52
58
  if (inputType === InputType.HEADER && fieldName.toLowerCase() === 'referer') {
53
59
  tags[DataflowTag.HEADER] = [0, stop];
54
60
  }
@@ -78,10 +84,16 @@ module.exports = function (core) {
78
84
  return null;
79
85
  }
80
86
 
87
+ // url exclusion
88
+ if (!sourceContext.policy) {
89
+ return null;
90
+ }
91
+
81
92
  if (!context) {
82
93
  context = inputType;
83
94
  }
84
95
 
96
+ const { policy: requestPolicy } = sourceContext;
85
97
  const max = config.assess.max_context_source_events;
86
98
  let _data = data;
87
99
  let stack;
@@ -93,7 +105,8 @@ module.exports = function (core) {
93
105
  }
94
106
  }
95
107
 
96
- function createEvent({ fieldName, pathName, value }) {
108
+ function createEvent({ fieldName, pathName, value, excludedRules }) {
109
+ const tagNames = Array.from(excludedRules).map((ruleId) => `excluded:${ruleId}`);
97
110
  // create the stacktrace once per call to .handle()
98
111
  stack || (stack = sources.createStacktrace(stacktraceOpts));
99
112
  return eventFactory.createSourceEvent({
@@ -103,16 +116,23 @@ module.exports = function (core) {
103
116
  pathName,
104
117
  stack,
105
118
  inputType,
106
- tags: sources.createTags({ inputType, fieldName, value }),
119
+ tags: sources.createTags({ inputType, fieldName, value, tagNames }),
107
120
  result: { tracked: true, value },
108
121
  });
109
122
  }
110
123
 
111
124
  if (Buffer.isBuffer(data) && !tracker.getData(data)) {
112
- const event = createEvent({ pathName: 'body', value: data, fieldName: '' });
125
+ const { track, excludedRules } = requestPolicy.getInputPolicy(InputType.BODY);
126
+ if (!track) {
127
+ core.logger.debug({ inputType }, 'assess input exclusion disabled tracking');
128
+ return;
129
+ }
130
+
131
+ const event = createEvent({ pathName: 'body', value: data, fieldName: '', excludedRules });
113
132
  if (event) {
114
133
  tracker.track(data, event);
115
134
  }
135
+
116
136
  return;
117
137
  }
118
138
 
@@ -124,18 +144,22 @@ module.exports = function (core) {
124
144
  return true;
125
145
  }
126
146
 
147
+ const { track, excludedRules } = sourceContext.policy.getInputPolicy(inputType, fieldName);
148
+ if (!track) {
149
+ core.logger.debug({ fieldName, inputType }, 'assess input exclusion disabling tracking');
150
+ return;
151
+ }
152
+
127
153
  if (isString(value) && value.length) {
128
154
  const strInfo = tracker.getData(value);
129
-
130
155
  if (strInfo) {
131
156
  // TODO: confirm this "layering-on" approach is what we want
132
- // when the value is tracked the handler wins out and we "re-tracks" the value with new source
157
+ // when a value is already tracked, the handler will "re-track" the value with new source
133
158
  // event metadata. without this step tracker would complain about value already being tracked.
134
159
  // alternatively we could treat this more like a propagation event and update existing metadata.
135
160
  value = strInfo.value;
136
161
  }
137
-
138
- const event = createEvent({ pathName, value, fieldName });
162
+ const event = createEvent({ pathName, value, fieldName, excludedRules });
139
163
  if (!event) {
140
164
  logger.warn({ inputType, sourceName: name, pathName, value }, 'unable to create source event');
141
165
  return;
@@ -149,7 +173,7 @@ module.exports = function (core) {
149
173
  sourceContext.sourceEventsCount++;
150
174
  }
151
175
  } else if (Buffer.isBuffer(value) && !tracker.getData(value)) {
152
- const event = createEvent({ pathName, value, fieldName });
176
+ const event = createEvent({ pathName, value, fieldName, excludedRules });
153
177
  if (event) {
154
178
  tracker.track(value, event);
155
179
  } else {
@@ -193,9 +217,7 @@ function traverse(target, cb, path = [], visited = new Set()) {
193
217
 
194
218
  if (isVisitable(value)) {
195
219
  const halt = cb(path, key, value, target) === false;
196
- if (halt) {
197
- return;
198
- }
220
+ if (halt) return;
199
221
  }
200
222
 
201
223
  if (isTraversable(value)) {
@@ -102,6 +102,21 @@ module.exports = function (core) {
102
102
  sourceContext: store.assess
103
103
  };
104
104
 
105
+ // track the headers and the url.
106
+ //
107
+ // note that req.headers and req.headersDistinct are now (as of v15.1.0)
108
+ // lazily computed using an accessor property.
109
+ //
110
+ // there is no need to track headersDistinct because they are not
111
+ // referenced prior to this point. and, when they are referenced, node
112
+ // populates them with references to the (what will be after code below)
113
+ // already-tracked values in rawHeaders. But headers have already been
114
+ // referenced by node before the 'request' event is emitted by the server,
115
+ // so headers need to be tracked independently of rawHeaders. The way
116
+ // node handles the headers is convoluted; it's easier/safer to track the
117
+ // headers as they are. An attacker could use knowledge of node's handling
118
+ // to craft their attack.
119
+ //
105
120
  [
106
121
  {
107
122
  context: 'req.headers',
@@ -117,18 +132,57 @@ module.exports = function (core) {
117
132
  ...sourceInfo,
118
133
  }
119
134
  ].forEach((sourceData) => {
120
- const { inputType } = sourceData;
121
135
  try {
122
136
  dataflow.sources.handle(sourceData);
123
137
  } catch (err) {
138
+ const { inputType } = sourceData;
124
139
  logger.error({ err, inputType, sourceName }, 'unable to handle http source');
125
140
  }
126
141
  });
127
142
 
128
- for (let i = 0; i < req.rawHeaders.length; i += 2) {
129
- const header = toLowerCase(req.rawHeaders[i]);
130
- req.rawHeaders[i + 1] = req.headers[header];
143
+
144
+ //
145
+ // now track the rawHeaders. headers are complicated because they appear
146
+ // three times: headers, headersDistinct, and rawHeaders and we want to
147
+ // create only one event per header value. that turns out not to be as
148
+ // easy/possible as it sounds, due to the way node handles req.headers.
149
+ //
150
+ // see node's lib/_http_incoming.js for details. interesting optimizations
151
+ // and quirky handling per the RFC. some duplicate headers are joined by
152
+ // default, some are not.
153
+ //
154
+ // but we have to track rawHeaders. they are copied to a separate array
155
+ // because the dataflow.sources.handle() doesn't know about an array where
156
+ // only odd indexes are to be tracked.
157
+ //
158
+ // even though we could track the rawHeaders' keys, we don't because they
159
+ // are not used by any application that i'm aware of. it's easy enough to
160
+ // add here if we find there is an edge case where the application bypasses
161
+ // headers and headersDistinct and uses rawHeaders directly.
162
+ //
163
+ const headerValues = [];
164
+ for (let i = 1; i < req.rawHeaders.length; i += 2) {
165
+ headerValues.push(req.rawHeaders[i]);
166
+ }
167
+
168
+ try {
169
+ dataflow.sources.handle({
170
+ context: 'req.headers',
171
+ inputType: InputType.HEADER,
172
+ data: headerValues,
173
+ ...sourceInfo,
174
+ });
175
+ } catch (err) {
176
+ logger.error({ err, inputType: InputType.HEADER, sourceName }, 'unable to handle http source');
131
177
  }
178
+
179
+ //
180
+ // now that the raw headers are tracked, put each tracked value back
181
+ //
182
+ for (let i = 0; i < headerValues.length; i++) {
183
+ req.rawHeaders[(i << 1) + 1] = headerValues[i];
184
+ }
185
+
132
186
  } catch (err) {
133
187
  logger.error({ err, funcKey: data.funcKey }, 'Error during Assess request handling');
134
188
  }
package/lib/get-policy.js CHANGED
@@ -16,51 +16,39 @@
16
16
  'use strict';
17
17
 
18
18
  const {
19
+ Event,
20
+ ExclusionType,
21
+ InputType,
19
22
  Rule,
20
23
  ResponseScanningRule,
21
24
  SessionConfigurationRule,
22
- Event,
23
25
  join,
24
- toLowerCase,
25
26
  } = require('@contrast/common');
26
27
 
27
- const rulesIds = Object.values({
28
+ const ASSESS_RULES = Object.values({
28
29
  ...Rule,
29
30
  ...ResponseScanningRule,
30
31
  ...SessionConfigurationRule,
31
32
  });
32
-
33
- function buildUriPathRegExp(urls) {
34
- let regExpNeeded = false;
35
- for (const url of urls) {
36
- if (regExpCheck(url)) {
37
- regExpNeeded = true;
38
- }
39
- }
40
- if (regExpNeeded) {
41
- const rx = new RegExp(`^${join(urls, '|')}$`);
42
-
43
- return (uriPath) => rx ? rx.test(uriPath) : false;
44
- }
45
-
46
- return (uriPath) => urls.some((url) => url === uriPath);
47
- }
48
-
49
- function createUriPathMatcher(urls) {
50
- if (urls.length) {
51
- return buildUriPathRegExp(urls);
52
- } else {
53
- return () => true;
54
- }
55
- }
56
-
57
- function regExpCheck(str) {
58
- return str.indexOf('*') > 0 ||
59
- str.indexOf('.') > 0 ||
60
- str.indexOf('+') > 0 ||
61
- str.indexOf('?') > 0 ||
62
- str.indexOf('\\') > 0;
63
- }
33
+ const BROAD_INPUT_EXCLUSION_TYPES = [
34
+ ExclusionType.BODY,
35
+ ExclusionType.QUERYSTRING
36
+ ];
37
+ const NAMED_INPUT_EXCLUSION_TYPES = [
38
+ ExclusionType.COOKIE,
39
+ ExclusionType.HEADER,
40
+ ExclusionType.PARAMETER
41
+ ];
42
+ const BODY_TYPES = [
43
+ InputType.BODY,
44
+ InputType.JSON_VALUE,
45
+ InputType.JSON_ARRAYED_VALUE,
46
+ InputType.MULTIPART_CONTENT_TYPE,
47
+ InputType.MULTIPART_FIELD_NAME,
48
+ InputType.MULTIPART_NAME,
49
+ InputType.MULTIPART_VALUE,
50
+ ];
51
+ const DISABLED_INPUT_POLICY = { track: false };
64
52
 
65
53
  /**
66
54
  * @param {{
@@ -73,100 +61,276 @@ function regExpCheck(str) {
73
61
  module.exports = function assess(core) {
74
62
  const { config, logger, messages } = core;
75
63
 
76
- const enabledRules = new Set(rulesIds);
77
- const exclusions = {
78
- url: [],
64
+ const globalPolicy = {
65
+ // by default all rules are enabled
66
+ enabledRules: new Set(ASSESS_RULES),
67
+ exclusionMap: new Map([
68
+ [ExclusionType.BODY, []],
69
+ [ExclusionType.COOKIE, []],
70
+ [ExclusionType.HEADER, []],
71
+ [ExclusionType.PARAMETER, []],
72
+ [ExclusionType.QUERYSTRING, []],
73
+ [ExclusionType.URL, []],
74
+ ]),
79
75
  };
80
76
 
81
- function compileExclusions(settings) {
82
- // reset global exclusion state
83
- for (const key of Object.keys(exclusions)) {
84
- exclusions[key] = [];
85
- }
86
-
87
- const rawDtmList = [
88
- // todo: NODE-3281 input exclusions
89
- ...(settings?.exclusions?.url || [])
90
- ].filter((exclusion) => exclusion.modes.includes('assess'));
91
-
92
- if (!rawDtmList.length) {
93
- return;
94
- }
95
-
96
- for (const dtm of rawDtmList) {
97
- dtm.type = dtm.type || 'URL';
98
-
99
- const { name, assess_rules, urls, type } = dtm;
100
- const key = toLowerCase(type);
101
- try {
102
- const e = {
103
- name,
104
- rules: new Set(assess_rules),
105
- };
106
- e.matchesUriPath = createUriPathMatcher(urls);
107
- exclusions[key].push(e);
108
- } catch (err) {
109
- logger.error({ err, dtm }, 'failed to process exclusion');
110
- }
111
- }
112
- }
113
-
77
+ /**
78
+ * Subscribe to settings updates and modify global policy accordingly.
79
+ */
114
80
  messages.on(Event.SERVER_SETTINGS_UPDATE, (msg) => {
115
81
  if (!config.getEffectiveValue('assess.enable')) return;
116
82
 
117
83
  if (msg.assess) {
118
- for (const ruleId of rulesIds) {
84
+ for (const ruleId of ASSESS_RULES) {
119
85
  const enable = msg.assess[ruleId]?.enable;
120
86
  if (enable === true) {
121
- enabledRules.add(ruleId);
122
- if (ruleId === Rule.NOSQL_INJECTION) enabledRules.add(Rule.NOSQL_INJECTION_MONGO);
87
+ globalPolicy.enabledRules.add(ruleId);
88
+ if (ruleId === Rule.NOSQL_INJECTION) globalPolicy.enabledRules.add(Rule.NOSQL_INJECTION_MONGO);
123
89
  } else if (enable === false) {
124
- if (ruleId === Rule.NOSQL_INJECTION) enabledRules.delete(Rule.NOSQL_INJECTION_MONGO);
125
- enabledRules.delete(ruleId);
90
+ globalPolicy.enabledRules.delete(ruleId);
91
+ if (ruleId === Rule.NOSQL_INJECTION) globalPolicy.enabledRules.delete(Rule.NOSQL_INJECTION_MONGO);
126
92
  }
127
93
  }
94
+ logger.info({ enabledRules: Array.from(globalPolicy.enabledRules) }, 'Assess policy enabled rules updated');
128
95
  }
129
96
 
130
97
  if (msg.exclusions) {
131
- compileExclusions(msg);
132
- }
98
+ const rawDtmList = [
99
+ // todo: NODE-3281 input exclusions
100
+ ...(msg?.exclusions?.input || []),
101
+ ...(msg?.exclusions?.url || []),
102
+ ].filter((exclusion) => exclusion?.modes?.includes?.('assess'));
103
+
104
+ // reset global exclusion state
105
+ for (const type of Object.values(ExclusionType)) {
106
+ globalPolicy.exclusionMap.get(type).length = 0;
107
+ }
108
+
109
+ if (!rawDtmList.length) return;
133
110
 
134
- logger.info({ enabledRules: Array.from(enabledRules) }, 'Assess policy updated');
111
+ for (const dtm of rawDtmList) {
112
+ // normalize different dtm types
113
+ dtm.type = dtm.type || 'URL';
114
+ const { type } = dtm;
115
+ const key = ExclusionType[type];
116
+ // defensive code against unanticipated DTM values
117
+ if (key) {
118
+ const Ctor = dtm.type === ExclusionType.URL ? UrlExclusion : InputExclusion;
119
+ globalPolicy.exclusionMap.get(dtm.type).push(new Ctor(dtm));
120
+ }
121
+ }
122
+
123
+ logger.info({
124
+ exclusions: Object.fromEntries(globalPolicy.exclusionMap)
125
+ }, 'Assess exclusions updated (%s total)', rawDtmList.length);
126
+ }
135
127
  });
136
128
 
137
129
  /**
138
- * This gets called by assess.makeSourceContext(). We return copy of policy to avoid
139
- * inconsistent behavior if policy is updated during request handling.
130
+ * Generates the policy for the current request. We return copy of the global policy
131
+ * to avoid inconsistent behavior if policy is updated during request handling. In
132
+ * addition, the request policy is altered to account for any URL or Input exclusions.
133
+ * @param {string} uriPath
140
134
  */
141
135
  return core.assess.getPolicy = function getPolicy({ uriPath } = {}) {
142
- const _enabledRules = new Set(enabledRules);
136
+ const _enabledRules = new Set(globalPolicy.enabledRules);
137
+ const exclusionState = {
138
+ // types that can be disabled broadly
139
+ [ExclusionType.BODY]: { track: true, excludedRules: new Set() },
140
+ [ExclusionType.QUERYSTRING]: { track: true, excludedRules: new Set() },
141
+ // other types we check by name. parameter applies to body and query params
142
+ [ExclusionType.COOKIE]: [],
143
+ [ExclusionType.HEADER]: [],
144
+ [ExclusionType.PARAMETER]: [],
145
+ };
143
146
 
144
- // Evaluate url exclusions for current request.
145
- // If one matches and applies to all rules, we return `null` for the policy value,
146
- // which will disable assess for the request. If specific rules are disabled, we
147
- // just removed them from the request policy's set of `enabledRules`.
148
- for (const urlExclusion of exclusions.url) {
147
+ // Evaluate URL exclusions.
148
+ // If one matches and applies to all rules, we return `null` for the policy value, which
149
+ // will disable assess for the request (via getSourceContext()). If specific rules are
150
+ // disabled, we remove them from the request policy's set of enabled rules.
151
+ for (const urlExclusion of globalPolicy.exclusionMap.get(ExclusionType.URL)) {
149
152
  if (urlExclusion.matchesUriPath(uriPath)) {
150
153
  if (!urlExclusion.rules?.size) {
151
154
  core.logger.debug({
152
155
  name: urlExclusion.name
153
- }, 'all assess rules disabled by URL exclusion');
156
+ }, 'All Assess rules have been disabled by URL exclusion');
154
157
  return null;
155
158
  } else {
156
159
  for (const ruleId of urlExclusion.rules) {
157
- if (_enabledRules.has(ruleId)) _enabledRules.delete(ruleId);
160
+ _enabledRules.delete(ruleId);
158
161
  }
159
162
  core.logger.debug({
160
163
  name: urlExclusion.name,
161
164
  rules: Array.from(urlExclusion.rules),
162
- }, 'assess rules disabled by URL exclusion');
165
+ }, 'Assess rules disabled by URL exclusion');
166
+ }
167
+ }
168
+ }
169
+
170
+ // Process input exclusions that apply broadly: BODY, QUERYSTRING
171
+ for (const type of BROAD_INPUT_EXCLUSION_TYPES) {
172
+ const _policy = exclusionState[type];
173
+ for (const exclusion of globalPolicy.exclusionMap.get(type)) {
174
+ if (exclusion.matchesUriPath(uriPath)) {
175
+ if (exclusion.rules.size) {
176
+ for (const ruleId of exclusion.rules) {
177
+ _policy.excludedRules.add(ruleId);
178
+ }
179
+ } else {
180
+ _policy.track = false;
181
+ _policy.excludedRules.clear();
182
+ break;
183
+ }
184
+ }
185
+ }
186
+ }
187
+ // Filter input exclusions that will be used to get named input
188
+ // policies: COOKIE, HEADER, PARAMETER
189
+ for (const type of NAMED_INPUT_EXCLUSION_TYPES) {
190
+ for (const exclusion of globalPolicy.exclusionMap.get(type)) {
191
+ if (exclusion.matchesUriPath(uriPath)) {
192
+ exclusionState[type].push(exclusion);
163
193
  }
164
194
  }
165
195
  }
166
196
 
167
- // creates copy of local policy for request store
168
197
  return {
169
- enabledRules: new Set(_enabledRules),
198
+ /**
199
+ * Enabled rules filtered by any applicable URL exclusions
200
+ */
201
+ enabledRules: _enabledRules,
202
+ /**
203
+ * Used by source handler to get policy information for specific named inputs.
204
+ * @param {InputType} inputType
205
+ * @param {string} [fieldName]
206
+ * @returns {InputPolicy}
207
+ */
208
+ getInputPolicy(inputType, fieldName) {
209
+ let exclusionsByType;
210
+ const inputPolicy = { track: true, excludedRules: new Set() };
211
+
212
+ const isBody = BODY_TYPES.includes(inputType);
213
+
214
+ if (isBody || inputType === InputType.QUERYSTRING) {
215
+ // these can be disabled broadly
216
+ const _policy = exclusionState[isBody ? ExclusionType.BODY : ExclusionType.QUERYSTRING];
217
+ if (!_policy.track) {
218
+ return DISABLED_INPUT_POLICY;
219
+ }
220
+ for (const ruleId of _policy.excludedRules) {
221
+ inputPolicy.excludedRules.add(ruleId);
222
+ }
223
+ exclusionsByType = exclusionState[ExclusionType.PARAMETER];
224
+ } else if (inputType === InputType.URL_PARAMETER) {
225
+ exclusionsByType = exclusionState[ExclusionType.PARAMETER];
226
+ } else if (inputType === InputType.HEADER) {
227
+ exclusionsByType = exclusionState[ExclusionType.HEADER];
228
+ } else if ([
229
+ InputType.COOKIE_NAME,
230
+ InputType.COOKIE_VALUE
231
+ ].includes(inputType)) {
232
+ exclusionsByType = exclusionState[ExclusionType.COOKIE];
233
+ }
234
+
235
+ if (!exclusionsByType) {
236
+ return inputPolicy;
237
+ }
238
+
239
+ // check input name
240
+ for (const exclusion of exclusionsByType) {
241
+ if (exclusion.matchesInputName(fieldName)) {
242
+ if (exclusion.rules.size) {
243
+ for (const ruleId of exclusion.rules) {
244
+ inputPolicy.excludedRules.add(ruleId);
245
+ }
246
+ } else {
247
+ return DISABLED_INPUT_POLICY;
248
+ }
249
+ }
250
+ }
251
+
252
+ return inputPolicy;
253
+ },
170
254
  };
171
255
  };
172
256
  };
257
+
258
+ /**
259
+ * @typedef InputPolicy
260
+ * @property {boolean} track
261
+ * @property {Set<Rule>} excludedRules
262
+ */
263
+
264
+ class UrlExclusion {
265
+ constructor(dtm) {
266
+ this._urlRegex = null;
267
+ this._urls = new Set();
268
+ this.name = dtm.name;
269
+ this.type = ExclusionType[dtm.type];
270
+ this.rules = new Set(dtm.assess_rules);
271
+
272
+ if (dtm.urls.length) {
273
+ const regexSegments = [];
274
+ for (const url of dtm.urls) {
275
+ if (shouldBeRegExp(url)) {
276
+ regexSegments.push(url);
277
+ } else {
278
+ this._urls.add(url);
279
+ }
280
+ }
281
+ if (regexSegments.length) {
282
+ this._urlRegex = new RegExp(`^${join(regexSegments, '|')}$`);
283
+ }
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Checks whether the current URI path matches any of the exclusion's URL values.
289
+ * Exclusions that don't match for the current request will not be enabled. The
290
+ * interpretation of the DTM is that if its urls list is empty, then that means
291
+ * it should match all requestss (can be the case for input exclusions).
292
+ * @param {string} uriPath uri to check
293
+ * @returns {boolean}
294
+ */
295
+ matchesUriPath(uriPath) {
296
+ return (!this._urlRegex && !this._urls.size) ||
297
+ this._urls.has(uriPath) ||
298
+ !!this._urlRegex?.test?.(uriPath);
299
+ }
300
+ }
301
+
302
+ class InputExclusion extends UrlExclusion {
303
+ constructor(dtm) {
304
+ super(dtm);
305
+ this._inputNameRegex = null;
306
+ this._inputName = null;
307
+
308
+ // dtm.name value is null for BODY and QUERYSTRING types
309
+ if (dtm.name) {
310
+ if (shouldBeRegExp(dtm.name)) {
311
+ this._inputNameRegex = new RegExp(`^${dtm.name}$`);
312
+ } else {
313
+ this._inputName = dtm.name;
314
+ }
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Checks if the provided name matches the value from the exclusion dtm.
320
+ * @param {string} name field name being evaluated
321
+ * @returns {boolean}
322
+ */
323
+ matchesInputName(name) {
324
+ // BODY and QUERYSTRING always match since they apply broadly
325
+ if (!this._inputName && !this._inputNameRegex) return true;
326
+ return this._inputNameRegex ? this._inputNameRegex.test(name) : this._inputName === name;
327
+ }
328
+ }
329
+
330
+ function shouldBeRegExp(str) {
331
+ return str.indexOf('*') > 0 ||
332
+ str.indexOf('.') > 0 ||
333
+ str.indexOf('+') > 0 ||
334
+ str.indexOf('?') > 0 ||
335
+ str.indexOf('\\') > 0;
336
+ }
@@ -53,7 +53,7 @@ module.exports = function(core) {
53
53
  case InstrumentationType.RULE: {
54
54
  const [ruleId] = rest;
55
55
  if (!ruleId) break;
56
- if (!ctx.policy.enabledRules.has(ruleId) || ruleScopes.isLocked(ruleId)) return null;
56
+ if (!ctx.policy?.enabledRules?.has?.(ruleId) || ruleScopes.isLocked(ruleId)) return null;
57
57
  break;
58
58
  }
59
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrast/assess",
3
- "version": "1.27.0",
3
+ "version": "1.27.2",
4
4
  "description": "Contrast service providing framework-agnostic Assess support",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Contrast Security <nodejs@contrastsecurity.com> (https://www.contrastsecurity.com)",
@@ -11,14 +11,14 @@
11
11
  "types": "lib/index.d.ts",
12
12
  "engines": {
13
13
  "npm": ">=6.13.7 <7 || >= 8.3.1",
14
- "node": ">= 14.15.0"
14
+ "node": ">= 14.18.0"
15
15
  },
16
16
  "scripts": {
17
17
  "test": "../scripts/test.sh"
18
18
  },
19
19
  "dependencies": {
20
- "@contrast/common": "1.20.0",
20
+ "@contrast/common": "1.20.1",
21
21
  "@contrast/distringuish": "^4.4.0",
22
- "@contrast/scopes": "1.4.0"
22
+ "@contrast/scopes": "1.4.1"
23
23
  }
24
24
  }