@contrast/assess 1.24.2 → 1.26.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.
@@ -37,7 +37,8 @@ module.exports = function (core) {
37
37
  rewriter,
38
38
  } = core;
39
39
 
40
- const REWRITE_OPTS = { inject: false, wrap: false };
40
+ /** @type {import('@contrast/rewriter').RewriteOpts} */
41
+ const REWRITE_OPTS = { isModule: false, inject: false, wrap: false, trim: true };
41
42
  const WRAPPER_PREFIX = join([
42
43
  'function tempWrapper() {',
43
44
  'function __append(s) { if (s !== undefined && s !== null) __output += s }'
@@ -63,7 +64,7 @@ module.exports = function (core) {
63
64
  if (!getSourceContext(PROPAGATOR)) return;
64
65
 
65
66
  try {
66
- const { code } = rewriter.rewrite(`${WRAPPER_PREFIX}${data.obj.source}${WRAPPER_SUFFIX}`, REWRITE_OPTS);
67
+ const { code } = rewriter.rewriteSync(`${WRAPPER_PREFIX}${data.obj.source}${WRAPPER_SUFFIX}`, REWRITE_OPTS);
67
68
  data.obj.source = code.substring(code.indexOf('{') + 1, code.lastIndexOf('}'));
68
69
  } catch (err) {
69
70
  logger.error(
@@ -16,6 +16,9 @@
16
16
 
17
17
  const { patchType } = require('../../common');
18
18
 
19
+ /** @type {import('@contrast/rewriter').RewriteOpts} */
20
+ const REWRITE_OPTS = { isModule: false, inject: false, wrap: false, trim: true };
21
+
19
22
  module.exports = function (core) {
20
23
  const store = { lock: true, name: 'assess:propagators:pug-compile' };
21
24
  const {
@@ -23,8 +26,6 @@ module.exports = function (core) {
23
26
  patcher, logger, rewriter, depHooks,
24
27
  } = core;
25
28
 
26
- const rewriterOpts = { isModule: false, wrap: false };
27
-
28
29
  const pugInstrumentation = {
29
30
  install() {
30
31
  depHooks.resolve(
@@ -42,7 +43,7 @@ module.exports = function (core) {
42
43
  postCodeGen: (value, options) => {
43
44
  try {
44
45
  return instrumentation.run(store,
45
- () => rewriter.rewrite(value, rewriterOpts).code
46
+ () => rewriter.rewriteSync(value, REWRITE_OPTS).code
46
47
  );
47
48
  } catch (err) {
48
49
  logger.warn({ err, funcKey: data.funcKey }, 'Failed to rewrite pug code');
@@ -68,6 +68,7 @@ module.exports = function (core) {
68
68
 
69
69
  require('./install/express')(core);
70
70
  require('./install/fastify')(core);
71
+ require('./install/hapi')(core);
71
72
  require('./install/koa')(core);
72
73
  require('./install/child-process')(core);
73
74
  require('./install/eval')(core);
@@ -0,0 +1,30 @@
1
+ /*
2
+ * Copyright: 2024 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 { callChildComponentMethodsSync } = require('@contrast/common');
19
+
20
+ module.exports = function(core) {
21
+ const hapi = core.assess.dataflow.sinks.hapi = {};
22
+
23
+ require('./unvalidated-redirect')(core);
24
+
25
+ hapi.install = function() {
26
+ callChildComponentMethodsSync(hapi, 'install');
27
+ };
28
+
29
+ return hapi;
30
+ };
@@ -0,0 +1,139 @@
1
+ /*
2
+ * Copyright: 2024 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 util = require('util');
19
+ const {
20
+ Rule: { UNVALIDATED_REDIRECT: ruleId },
21
+ DataflowTag: {
22
+ UNTRUSTED,
23
+ CUSTOM_ENCODED,
24
+ CUSTOM_VALIDATED,
25
+ HTML_ENCODED,
26
+ LIMITED_CHARS,
27
+ URL_ENCODED,
28
+ },
29
+ isString
30
+ } = require('@contrast/common');
31
+ const { InstrumentationType: { RULE } } = require('../../../../constants');
32
+ const { patchType, filterSafeTags } = require('../../common');
33
+ const { createSubsetTags } = require('../../../tag-utils');
34
+
35
+ /**
36
+ * @param {{
37
+ * assess: import('@contrast/assess').Assess,
38
+ * config: import('@contrast/config').Config,
39
+ * logger: import('@contrast/logger').Logger,
40
+ * }} core
41
+ * @returns {import('@contrast/common').Installable}
42
+ */
43
+ module.exports = function(core) {
44
+ const {
45
+ depHooks,
46
+ patcher,
47
+ config,
48
+ assess: {
49
+ getSourceContext,
50
+ eventFactory: { createSinkEvent },
51
+ dataflow: {
52
+ tracker,
53
+ sinks: { isVulnerable, reportFindings, reportSafePositive }
54
+ },
55
+ },
56
+ } = core;
57
+
58
+ const unvalidatedRedirect = core.assess.dataflow.sinks.hapi.unvalidatedRedirect = {};
59
+ const inspect = patcher.unwrap(util.inspect);
60
+
61
+ const safeTags = [
62
+ CUSTOM_ENCODED,
63
+ CUSTOM_VALIDATED,
64
+ HTML_ENCODED,
65
+ LIMITED_CHARS,
66
+ URL_ENCODED,
67
+ ];
68
+
69
+ unvalidatedRedirect.install = function() {
70
+ depHooks.resolve({ name: '@hapi/hapi', file: 'lib/response' }, (Response) => {
71
+ const name = 'hapi.Response.prototype.redirect';
72
+ patcher.patch(Response.prototype, 'redirect', {
73
+ name,
74
+ patchType,
75
+ pre: (data) => {
76
+ if (!getSourceContext(RULE, ruleId)) return;
77
+
78
+ const [url] = data.args;
79
+ if (!url || !isString(url)) return;
80
+
81
+ const strInfo = tracker.getData(url);
82
+ if (!strInfo) return;
83
+
84
+ let urlPathTags = strInfo.tags;
85
+ if (url.indexOf('?') > -1) {
86
+ urlPathTags = createSubsetTags(strInfo.tags, 0, url.indexOf('?') + 1);
87
+ }
88
+
89
+ if (urlPathTags && isVulnerable(UNTRUSTED, safeTags, urlPathTags)) {
90
+ const event = createSinkEvent({
91
+ args: [{
92
+ tracked: true,
93
+ value: strInfo.value,
94
+ }],
95
+ context: `response.redirect(${inspect(strInfo.value)})`,
96
+ history: [strInfo],
97
+ name,
98
+ moduleName: 'hapi',
99
+ methodName: 'Response.prototype.redirect',
100
+ object: {
101
+ tracked: false,
102
+ value: 'hapi.Response',
103
+ },
104
+ result: {
105
+ tracked: false,
106
+ value: undefined,
107
+ },
108
+ tags: urlPathTags,
109
+ source: 'P0',
110
+ stacktraceOpts: {
111
+ constructorOpt: data.hooked,
112
+ prependFrames: [data.orig]
113
+ },
114
+ });
115
+
116
+ if (event) {
117
+ reportFindings({
118
+ ruleId,
119
+ sinkEvent: event,
120
+ });
121
+ }
122
+ } else if (config.assess.safe_positives.enable) {
123
+ reportSafePositive({
124
+ name,
125
+ ruleId,
126
+ safeTags: filterSafeTags(safeTags, strInfo),
127
+ strInfo: {
128
+ tags: strInfo.tags,
129
+ value: strInfo.value,
130
+ }
131
+ });
132
+ }
133
+ },
134
+ });
135
+ });
136
+ };
137
+
138
+ return unvalidatedRedirect;
139
+ };
package/lib/get-policy.js CHANGED
@@ -20,6 +20,8 @@ const {
20
20
  ResponseScanningRule,
21
21
  SessionConfigurationRule,
22
22
  Event,
23
+ join,
24
+ toLowerCase,
23
25
  } = require('@contrast/common');
24
26
 
25
27
  const rulesIds = Object.values({
@@ -28,6 +30,38 @@ const rulesIds = Object.values({
28
30
  ...SessionConfigurationRule,
29
31
  });
30
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
+ }
64
+
31
65
  /**
32
66
  * @param {{
33
67
  * config: import('@contrast/config').Config,
@@ -37,32 +71,102 @@ const rulesIds = Object.values({
37
71
  * @returns {import('@contrast/common').Installable}
38
72
  */
39
73
  module.exports = function assess(core) {
40
- const { logger, messages } = core;
74
+ const { config, logger, messages } = core;
41
75
 
42
76
  const enabledRules = new Set(rulesIds);
77
+ const exclusions = {
78
+ url: [],
79
+ };
80
+
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
+ }
43
113
 
44
114
  messages.on(Event.SERVER_SETTINGS_UPDATE, (msg) => {
45
- if (!msg.assess) return;
46
-
47
- for (const ruleId of rulesIds) {
48
- const enable = msg.assess[ruleId]?.enable;
49
- if (enable === true) {
50
- enabledRules.add(ruleId);
51
- if (ruleId === Rule.NOSQL_INJECTION) enabledRules.add(Rule.NOSQL_INJECTION_MONGO);
52
- } else if (enable === false) {
53
- if (ruleId === Rule.NOSQL_INJECTION) enabledRules.delete(Rule.NOSQL_INJECTION_MONGO);
54
- enabledRules.delete(ruleId);
115
+ if (!config.getEffectiveValue('assess.enable')) return;
116
+
117
+ if (msg.assess) {
118
+ for (const ruleId of rulesIds) {
119
+ const enable = msg.assess[ruleId]?.enable;
120
+ if (enable === true) {
121
+ enabledRules.add(ruleId);
122
+ if (ruleId === Rule.NOSQL_INJECTION) enabledRules.add(Rule.NOSQL_INJECTION_MONGO);
123
+ } else if (enable === false) {
124
+ if (ruleId === Rule.NOSQL_INJECTION) enabledRules.delete(Rule.NOSQL_INJECTION_MONGO);
125
+ enabledRules.delete(ruleId);
126
+ }
55
127
  }
56
128
  }
57
- logger.info({
58
- enabledRules: Array.from(enabledRules)
59
- }, 'Assess policy updated');
129
+
130
+ if (msg.exclusions) {
131
+ compileExclusions(msg);
132
+ }
133
+
134
+ logger.info({ enabledRules: Array.from(enabledRules) }, 'Assess policy updated');
60
135
  });
61
136
 
62
- return core.assess.getPolicy = function getPolicy() {
137
+ /**
138
+ * This gets called by assess.makeSourceContext(). We return copy of policy to avoid
139
+ * inconsistent behavior if policy is updated during request handling.
140
+ */
141
+ return core.assess.getPolicy = function getPolicy({ uriPath } = {}) {
142
+ const _enabledRules = new Set(enabledRules);
143
+
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) {
149
+ if (urlExclusion.matchesUriPath(uriPath)) {
150
+ if (!urlExclusion.rules?.size) {
151
+ core.logger.debug({
152
+ name: urlExclusion.name
153
+ }, 'all assess rules disabled by URL exclusion');
154
+ return null;
155
+ } else {
156
+ for (const ruleId of urlExclusion.rules) {
157
+ if (_enabledRules.has(ruleId)) _enabledRules.delete(ruleId);
158
+ }
159
+ core.logger.debug({
160
+ name: urlExclusion.name,
161
+ rules: Array.from(urlExclusion.rules),
162
+ }, 'assess rules disabled by URL exclusion');
163
+ }
164
+ }
165
+ }
166
+
63
167
  // creates copy of local policy for request store
64
168
  return {
65
- enabledRules: new Set(enabledRules),
169
+ enabledRules: new Set(_enabledRules),
66
170
  };
67
171
  };
68
172
  };
@@ -38,7 +38,8 @@ module.exports = function(core) {
38
38
  return core.assess.getSourceContext = function getSourceContext(type, ...rest) {
39
39
  const ctx = sources.getStore()?.assess;
40
40
 
41
- if (!ctx || instrumentation.isLocked()) return null;
41
+ // policy will not exist if assess is altogether disabled for the active request e.g. url exclusion
42
+ if (!ctx || !ctx?.policy || instrumentation.isLocked()) return null;
42
43
 
43
44
  switch (type) {
44
45
  case InstrumentationType.PROPAGATOR: {
@@ -54,7 +54,7 @@ module.exports = function(core) {
54
54
  return {
55
55
  propagationEventsCount: 0,
56
56
  sourceEventsCount: 0,
57
- policy: getPolicy(),
57
+ policy: getPolicy({ uriPath }),
58
58
  reqData: {
59
59
  ip: req.socket.remoteAddress,
60
60
  httpVersion: req.httpVersion,
@@ -36,7 +36,7 @@ module.exports = function (core) {
36
36
  const inspect = patcher.unwrap(util.inspect);
37
37
 
38
38
  hapiSession.install = function () {
39
- return depHooks.resolve({ name: '@hapi/hapi', version: '>=18 <21' }, (hapi) => {
39
+ return depHooks.resolve({ name: '@hapi/hapi', version: '>=18 <22' }, (hapi) => {
40
40
  ['server', 'Server'].forEach((server) => {
41
41
  patcher.patch(hapi, server, {
42
42
  name: `hapi.${server}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrast/assess",
3
- "version": "1.24.2",
3
+ "version": "1.26.0",
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)",
@@ -17,7 +17,7 @@
17
17
  "test": "../scripts/test.sh"
18
18
  },
19
19
  "dependencies": {
20
- "@contrast/common": "1.18.0",
20
+ "@contrast/common": "1.19.0",
21
21
  "@contrast/distringuish": "^4.4.0",
22
22
  "@contrast/scopes": "1.4.0"
23
23
  }