@contrast/assess 1.24.1 → 1.25.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.
@@ -13,6 +13,7 @@
13
13
  * way not consistent with the End User License Agreement.
14
14
  */
15
15
  'use strict';
16
+
16
17
  const { trim } = require('@contrast/common');
17
18
 
18
19
  function isNumber(value) {
@@ -200,25 +201,48 @@ function getValueIndexes(value, index, accumulator) {
200
201
  }
201
202
  }
202
203
 
204
+ /**
205
+ * JSON strings can have leading and trailing whitespace characters. This will return
206
+ * the start and end indices of the input's characters that represent actual JSON data.
207
+ * Examples:
208
+ * `"hi"` => [0, 3]
209
+ * ` "hi"\n` => [1, 4]
210
+ * @param {string} input raw JSON value being parsed
211
+ * @returns {number[]}
212
+ */
213
+ function getStartEndIndices(input) {
214
+ let startCharIdx = 0;
215
+ let endCharIdx = input.length - 1;
216
+
217
+ while (!trim(input[startCharIdx])) {
218
+ startCharIdx++;
219
+ }
220
+ while (!trim(input[endCharIdx])) {
221
+ endCharIdx--;
222
+ }
223
+ return [startCharIdx, endCharIdx];
224
+ }
225
+
203
226
  function processInput(input) {
204
- const firstElement = input[0];
205
- const lastElement = input[input.length - 1];
227
+ const [startIdx, endIdx] = getStartEndIndices(input);
228
+ const firstChar = input[startIdx];
229
+ const lastChar = input[endIdx];
206
230
  const accumulator = [];
207
231
 
208
- if (firstElement === '{' && lastElement === '}') {
209
- return object(input, 1, accumulator);
232
+ if (firstChar === '{' && lastChar === '}') {
233
+ return object(input, startIdx + 1, accumulator);
210
234
  }
211
235
 
212
- if (firstElement === '[' && lastElement === ']') {
213
- return array(input, 1, accumulator);
236
+ if (firstChar === '[' && lastChar === ']') {
237
+ return array(input, startIdx + 1, accumulator);
214
238
  }
215
239
 
216
240
  if (isNumber(input)) {
217
- return number(input, 0);
241
+ return number(input, startIdx);
218
242
  }
219
243
 
220
- if (firstElement === '"' && lastElement === '"') {
221
- return string(input, 1);
244
+ if (firstChar === '"' && lastChar === '"') {
245
+ return string(input, startIdx + 1);
222
246
  }
223
247
 
224
248
  switch (input) {
@@ -228,21 +252,20 @@ function processInput(input) {
228
252
  case 'null':
229
253
  return nully();
230
254
  default:
231
- return string(input, 0);
255
+ return string(input, startIdx);
232
256
  }
233
257
  }
234
258
 
235
259
  function wrapEndResult({ accumulator, startIndex, endIndex }) {
236
260
  accumulator = accumulator || [];
237
261
  accumulator.push({ key: '', value: [startIndex, endIndex] });
238
-
239
262
  return accumulator;
240
263
  }
241
264
 
242
- function getKeyValueIndexes(input) {
265
+ function getKeyValueIndices(input) {
243
266
  return wrapEndResult(processInput(input));
244
267
  }
245
268
 
246
269
  module.exports = {
247
- getKeyValueIndexes,
270
+ getKeyValueIndices,
248
271
  };
@@ -16,11 +16,9 @@
16
16
  'use strict';
17
17
 
18
18
  const { isString, inspect } = require('@contrast/common');
19
+ const { createSubsetTags } = require('../../../tag-utils');
19
20
  const { patchType } = require('../../common');
20
- const { getKeyValueIndexes } = require('./parse-fn');
21
- const {
22
- createOverlappingTags
23
- } = require('../../../tag-utils');
21
+ const { getKeyValueIndices } = require('./parse-fn');
24
22
 
25
23
  /*
26
24
  When we return a string as a result of a reviver call
@@ -69,6 +67,7 @@ module.exports = function (core) {
69
67
  tracked: false,
70
68
  }
71
69
  ].filter(Boolean);
70
+
72
71
  return createPropagationEvent({
73
72
  context: `${method}(${eventArgs.map((arg) => `'${arg.value}'`)})`,
74
73
  name: method,
@@ -95,37 +94,7 @@ module.exports = function (core) {
95
94
  });
96
95
  }
97
96
 
98
- function getNewTags(strInfo, startIndex, endIndex) {
99
- const overlappingRanges = createOverlappingTags(
100
- strInfo.tags,
101
- startIndex,
102
- endIndex
103
- );
104
-
105
- const tags = {};
106
- const startingOffset = startIndex;
107
- const normalizedEndIndex = endIndex - startingOffset;
108
-
109
- Object.entries(overlappingRanges).forEach(([tag, ranges]) => {
110
- tags[tag] = [];
111
-
112
- ranges.forEach(([start, end]) => {
113
- const transferredStartIndex = start - startingOffset;
114
- const transferredEndIndex = end - startingOffset;
115
-
116
- tags[tag].push(transferredStartIndex < 0 ? 0 : transferredStartIndex);
117
- tags[tag].push(
118
- transferredEndIndex > normalizedEndIndex
119
- ? normalizedEndIndex
120
- : transferredEndIndex
121
- );
122
- });
123
- });
124
-
125
- return tags;
126
- }
127
-
128
- return (core.assess.dataflow.propagation.jsonInstrumentation.parse = {
97
+ return core.assess.dataflow.propagation.jsonInstrumentation.parse = {
129
98
  install() {
130
99
  patcher.patch(JSON, 'parse', {
131
100
  name: 'JSON.prototype.parse',
@@ -138,36 +107,37 @@ module.exports = function (core) {
138
107
  if (!strInfo) return;
139
108
 
140
109
  const stack = [];
141
- let keyValueIndexes = [];
110
+ let keyValueIndices = [];
142
111
 
143
112
  try {
144
- keyValueIndexes = getKeyValueIndexes(input);
113
+ keyValueIndices = getKeyValueIndices(input);
145
114
  } catch (err) {
146
115
  logger.warn({ err, funcKey: data.funcKey, string: input }, 'JSON.parse() propagation failed');
147
116
  }
148
117
 
149
- if (keyValueIndexes.length === 0) return;
118
+ if (keyValueIndices.length === 0) return;
150
119
 
151
120
  let i = 0;
152
- function contrastParseReviver(key, value) {
153
- const { value: [startIndex, endIndex] } = keyValueIndexes[i];
154
121
 
155
- if (!value || !isString(value)) {
122
+ function contrastParseReviver(key, value) {
123
+ // if keyValueIndices[i] doesn't exist then getKeyValueIndexes() returned incorrect data
124
+ if (!value || !isString(value) || !keyValueIndices[i]) {
156
125
  return reviver ? reviver(key, value) : value;
157
126
  }
158
127
 
159
- const newTags = getNewTags(strInfo, startIndex, endIndex);
128
+ const { value: [startIdx, endIdx] } = keyValueIndices[i];
129
+ const newTags = createSubsetTags(strInfo.tags, startIdx, endIdx - startIdx + 1);
130
+ if (newTags) {
131
+ const event = createEvent(data, value, newTags, reviver, strInfo);
132
+ if (!event || Object.keys(newTags).length === 0) {
133
+ return reviver ? reviver(key, value) : value;
134
+ }
160
135
 
161
- const event = createEvent(data, value, newTags, reviver, strInfo);
162
- if (!event || Object.keys(newTags).length === 0) {
163
- return reviver ? reviver(key, value) : value;
136
+ const { extern } = tracker.track(value, event);
137
+ if (extern) return reviver ? reviver(key, extern) : extern;
164
138
  }
165
139
 
166
- const { extern } = tracker.track(value, event);
167
-
168
- if (reviver) return reviver(key, extern);
169
-
170
- return extern;
140
+ return reviver ? reviver(key, value) : value;
171
141
  }
172
142
 
173
143
  data.args[1] = function (key, value) {
@@ -188,5 +158,5 @@ module.exports = function (core) {
188
158
  uninstall() {
189
159
  JSON.parse = patcher.unwrap(JSON.parse);
190
160
  },
191
- });
161
+ };
192
162
  };
@@ -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
+ };
@@ -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.1",
3
+ "version": "1.25.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
  }