@contrast/assess 1.20.2 → 1.21.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.
@@ -72,9 +72,11 @@ module.exports = function(core) {
72
72
 
73
73
  if (isString(params)) {
74
74
  params.split('&').forEach((query) => {
75
- const startIdx = query.indexOf('?') + 1;
76
75
  const endIdx = query.indexOf('=');
77
- const key = query.substring(startIdx, endIdx);
76
+ // this is pretty ugly because we don't want to create a propagation
77
+ // event by splitting off the '?'. so we count on if it's there, we
78
+ // skip it and if it's not there, we start at index 0.
79
+ const key = query.substring(query.indexOf('?') + 1, endIdx);
78
80
  const param = query.substring(endIdx + 1, query.length);
79
81
 
80
82
  const keyInfo = tracker.getData(key);
@@ -12,25 +12,90 @@
12
12
  * engineered, modified, repackaged, sold, redistributed or otherwise used in a
13
13
  * way not consistent with the End User License Agreement.
14
14
  */
15
-
16
15
  'use strict';
17
16
 
18
- function ensureTagsImmutable(obj, tagName) {
19
- return obj[tagName] ? [...obj[tagName]] : [];
20
- }
17
+ //
18
+ // This module implements tag range manipulation functions. There are generally
19
+ // two types of functions:
20
+ //
21
+ // wrappers (create*Tags) - these functions take a set of tags and return a new
22
+ // new set of tags. The new set of tags is *always* a new object with new tag
23
+ // ranges, i.e., the input argument is considered immutable. Tag ranges are
24
+ // *always* returned in "resolved" form, meaning adjacent or overlapping ranges
25
+ // have been merged.
26
+ //
27
+ // core functions - these functions are used by the wrappers to implement their
28
+ // function on a single tag's tag ranges.
29
+ //
30
+ // Tag ranges are represented as an array of integers that must be an even
31
+ // length. The even indices are the start of a tag range and the odd indices
32
+ // are the end of a range. *Unlike most JavaScript string functions*, the end
33
+ // index is inclusive. [0, 0] is a tag range of length 1.
34
+ //
35
+ // All tag ranges are expected to be in "resolved" form, meaning adjacent or
36
+ // overlapping ranges have been merged:
37
+ // - [0, 1] not [0, 0, 1, 1]
38
+ // - [0, 10] not [0, 7, 5, 10]
39
+ //
21
40
 
22
- function ensureObject(tags) {
23
- return tags ? { ...tags } : {};
24
- }
41
+ /**
42
+ * Appends one set of tags to another set of tags.
43
+ *
44
+ * @param {object} firstTags base set of tags
45
+ * @param {object} secondTags tags to be added
46
+ * @param {number} offset offset to apply to the second set of tags. It is
47
+ * the length of the string that firstTags is associated with.
48
+ *
49
+ * @returns {object} - the resulting tag ranges or null if there were no tags.
50
+ *
51
+ * @examples (args => return)
52
+ * - {},null,9 => null
53
+ * - {},{"UNTRUSTED":[0,2]},3 => {"UNTRUSTED":[3,5]}
54
+ * - {"untrusted":[12,18]},{"untrusted":[0,13]},38 => {"untrusted":[12,18,38,51]}
55
+ * - {"untrusted":[0,2]},{},3 => {"untrusted":[0,2]}
56
+ */
57
+ function createAppendTags(firstTags, secondTags, offset) {
58
+ if (!firstTags && !secondTags) {
59
+ return null;
60
+ }
25
61
 
26
- function atomicAppend(firstTagRanges, secondTagRanges, offset) {
27
- if (!firstTagRanges.length) {
28
- const ret = secondTagRanges.map((v) => v + offset);
29
- return ret;
62
+ const ret = Object.create(null);
63
+
64
+ let tagNames;
65
+ if (!firstTags) {
66
+ tagNames = new Set([...Object.keys(secondTags)]);
67
+ firstTags = {};
68
+ } else if (!secondTags) {
69
+ tagNames = new Set([...Object.keys(firstTags)]);
70
+ secondTags = {};
71
+ } else {
72
+ tagNames = new Set([...Object.keys(firstTags), ...Object.keys(secondTags)]);
73
+ }
74
+
75
+ let any = null;
76
+ for (const tagName of tagNames) {
77
+ const newTagRanges = appendCore(firstTags[tagName], secondTags[tagName], offset);
78
+
79
+ if (newTagRanges) {
80
+ ret[tagName] = newTagRanges;
81
+ any = ret;
82
+ }
30
83
  }
31
84
 
85
+ return any;
86
+ }
87
+
88
+ function appendCore(firstTagRanges, secondTagRanges, offset) {
89
+ if (!firstTagRanges?.length && !secondTagRanges?.length) {
90
+ return null;
91
+ } else if (!firstTagRanges?.length) {
92
+ return secondTagRanges.map((v) => v + offset);
93
+ } else if (!secondTagRanges?.length) {
94
+ return [...firstTagRanges];
95
+ }
32
96
 
33
97
  const newTagRanges = [...firstTagRanges];
98
+ secondTagRanges = [...secondTagRanges];
34
99
 
35
100
  for (let i = 0; i < secondTagRanges.length; i++) {
36
101
  secondTagRanges[i] += offset;
@@ -38,7 +103,7 @@ function atomicAppend(firstTagRanges, secondTagRanges, offset) {
38
103
 
39
104
  const firstTagRangesLastEnd = firstTagRanges[firstTagRanges.length - 1];
40
105
 
41
- if (firstTagRangesLastEnd === secondTagRanges[0] || firstTagRangesLastEnd === secondTagRanges[0] - 1) {
106
+ if (firstTagRangesLastEnd >= secondTagRanges[0] - 1) {
42
107
  newTagRanges.pop();
43
108
  secondTagRanges.shift();
44
109
  }
@@ -48,10 +113,85 @@ function atomicAppend(firstTagRanges, secondTagRanges, offset) {
48
113
  return newTagRanges;
49
114
  }
50
115
 
51
- function atomicSubset(tags, subsetStart, len) {
116
+ /**
117
+ * Return the tag ranges that overlap the given range. The overlap does not
118
+ * need to be complete, i.e., an intersection causes the entire tag range
119
+ * to match.
120
+ *
121
+ * @param {object} tags tags
122
+ * @param {number} startIndex
123
+ * @param {number} endIndex
124
+ *
125
+ * @returns {object} the overlapping tag ranges. It can be an empty object
126
+ * when there no tags overlap. NOTE: it does not return null when there are
127
+ * no overlapping tags AND it returns tag ranges as arrays of 2-element
128
+ * arrays. Why is lost to history.
129
+ *
130
+ * @examples
131
+ * - {x: [10, 20]}, 15, 17 => { x: [ [ 10, 20 ] ] }
132
+ * - {x: [17, 20]}, 90, 100 => {}
133
+ * - {x: [17, 20]}, 15, 17 => { x: [ [ 17, 20 ] ] }
134
+ *
135
+ */
136
+ function createOverlappingTags(tags, startIndex, endIndex) {
137
+ const overlappingTags = {};
138
+
139
+ for (const [tag, tagRanges] of Object.entries(tags)) {
140
+ const overlappingRanges = [];
141
+
142
+ for (let i = 0; i < tagRanges.length; i += 2) {
143
+ if (tagRanges[i + 1] >= startIndex && tagRanges[i] <= endIndex) {
144
+ overlappingRanges.push([tagRanges[i], tagRanges[i + 1]]);
145
+ }
146
+ }
147
+
148
+ if (overlappingRanges.length > 0) {
149
+ overlappingTags[tag] = overlappingRanges;
150
+ }
151
+ }
152
+
153
+ return overlappingTags;
154
+ }
155
+
156
+ /**
157
+ * this is not intuitive in that it not only returns the subset of tags that exist
158
+ * within the subsetStart/len range, but it also adjusts the tag ranges to be relative
159
+ * to the subsetStart.
160
+ *
161
+ * @param tags tag ranges to adjust
162
+ * @param subsetStart start index of the subset range
163
+ * @param len length of the subset range (subsetStop = subsetStart + len - 1)
164
+ *
165
+ * @returns {object} tags with adjusted ranges or null if no tags overlapped
166
+ * the subset range.
167
+ *
168
+ * @examples (args => return)
169
+ * - {"UNTRUSTED":[0,16]}, 13, 4 => {"UNTRUSTED":[0,3]}
170
+ * - {"x":[0,1],"ampersand":[6,6], "y":[7,8]}, 0, 1 => {"x":[0,0]}
171
+ */
172
+ function createSubsetTags(tags, subsetStart, len) {
173
+ if (!tags) {
174
+ return null;
175
+ }
176
+ const ret = Object.create(null);
177
+
178
+ let some = null;
179
+ for (const tagName of Object.keys(tags)) {
180
+ const newTagRanges = subsetCore(tags[tagName], subsetStart, len);
181
+ if (newTagRanges) {
182
+ ret[tagName] = newTagRanges;
183
+ some = ret;
184
+ }
185
+ }
186
+
187
+ return some;
188
+ }
189
+
190
+ function subsetCore(tags, subsetStart, len) {
52
191
  const ret = [];
53
192
  const subsetStop = subsetStart + len - 1;
54
193
 
194
+ let some = null;
55
195
  for (let idx = 0; idx < tags.length - 1; idx += 2) {
56
196
  const tagStart = tags[idx];
57
197
  const tagStop = tags[idx + 1];
@@ -69,12 +209,97 @@ function atomicSubset(tags, subsetStart, len) {
69
209
  Math.max(tagStart, subsetStart) - subsetStart,
70
210
  Math.min(tagStop, subsetStop) - subsetStart,
71
211
  );
212
+ some = ret;
213
+ }
214
+
215
+ return some;
216
+ }
217
+
218
+ /**
219
+ * Create new tags from 0 to the specified length for each tag name in the
220
+ * input tags.
221
+ *
222
+ * @param {object} tags tags names to use
223
+ * @param {number} resultLength length for new tag ranges.
224
+ *
225
+ * @returns {object} new tags or null if no tags
226
+ *
227
+ * @examples (args => return)
228
+ * - {"UNTRUSTED":[0,18]}, 24 => {"UNTRUSTED":[0,23]}
229
+ * - {"UNTRUSTED":[0,4], "URL_ENCODED":[0,0]}, 33 => {"UNTRUSTED":[0,32],"URL_ENCODED":[0,32]}
230
+ */
231
+ function createFullLengthCopyTags(tags, resultLength) {
232
+ if (!resultLength || resultLength < 0 || !tags) {
233
+ return null;
234
+ }
235
+
236
+ const tagNames = Object.keys(tags);
237
+ if (!tagNames.length) {
238
+ // no need to check result after the loop
239
+ return null;
240
+ }
241
+
242
+ resultLength = resultLength - 1;
243
+
244
+ const ret = Object.create(null);
245
+ for (const tagName of tagNames) {
246
+ ret[tagName] = [0, resultLength];
72
247
  }
73
248
 
74
249
  return ret;
75
250
  }
76
251
 
77
- function atomicMerge(firstTagRanges, secondTagRanges) {
252
+ /**
253
+ * Create the union of two sets of tags.
254
+ *
255
+ * @param {object} firstTags first set of tags
256
+ * @param {object} secondTags second set of tags
257
+ *
258
+ * @returns {object} new tags or null if no tags
259
+ *
260
+ * @examples (args => return)
261
+ * - {},null => null
262
+ * - {}, {} => null
263
+ * - {},{"UNTRUSTED":[5,7]} => {"UNTRUSTED":[5,7]}
264
+ * - {"UNTRUSTED":[14,22]},{"UNTRUSTED":[11,13]} => {"UNTRUSTED":[11,22]}
265
+ * - {"UNTRUSTED":[5,20]},{"UNTRUSTED":[0,4]} => {"UNTRUSTED":[0,20]}
266
+ */
267
+ function createMergedTags(firstTags, secondTags) {
268
+ const ret = Object.create(null);
269
+
270
+ if (!firstTags && !secondTags) {
271
+ return null;
272
+ } else if (!firstTags) {
273
+ return copyTags(secondTags);
274
+ } else if (!secondTags) {
275
+ return copyTags(firstTags);
276
+ }
277
+
278
+ // only do set building and merging if both sets of tags are not empty
279
+ const tagNames = new Set([...Object.keys(firstTags), ...Object.keys(secondTags)]);
280
+
281
+ let some = null;
282
+ for (const tagName of tagNames) {
283
+ const newTagRanges = mergeCore(firstTags[tagName], secondTags[tagName]);
284
+
285
+ if (newTagRanges.length) {
286
+ some = ret;
287
+ ret[tagName] = newTagRanges;
288
+ }
289
+ }
290
+
291
+ return some;
292
+ }
293
+
294
+ function mergeCore(firstTagRanges, secondTagRanges) {
295
+ if (!firstTagRanges && !secondTagRanges) {
296
+ return [];
297
+ } else if (!firstTagRanges) {
298
+ return [...secondTagRanges];
299
+ } else if (!secondTagRanges) {
300
+ return [...firstTagRanges];
301
+ }
302
+
78
303
  const mergedRanges = [];
79
304
  let i = 0;
80
305
  let j = 0;
@@ -125,7 +350,52 @@ function atomicMerge(firstTagRanges, secondTagRanges) {
125
350
  return finalMergedRanges;
126
351
  }
127
352
 
128
- function atomicExclude(tags, exclusionRange) {
353
+ function copyTags(tags) {
354
+ const ret = Object.create(null);
355
+
356
+ let some = null;
357
+ for (const tagName of Object.keys(tags)) {
358
+ ret[tagName] = [...tags[tagName]];
359
+ some = ret;
360
+ }
361
+ return some;
362
+ }
363
+
364
+ /**
365
+ * Create new tags that exclude the given range.
366
+ *
367
+ * @param {object} tags tags used to create new tags
368
+ * @param {number} exclusionRange the new tags will not include this range
369
+ *
370
+ * @returns {object} new tags or null if no tags after excluding the range
371
+ *
372
+ * @examples (args => return)
373
+ * - {},[13,13] => null
374
+ * - {"UNTRUSTED":[0,8]},[6,6] => {"UNTRUSTED":[0,5,7,8]}
375
+ * - {"UNTRUSTED":[13,16]},[13,13] => {"UNTRUSTED":[14,16]}
376
+ */
377
+ function createTagsWithExclusion(tags, exclusionRange) {
378
+ // if no exclusionRange or no tags, there's nothing to do
379
+ if (!exclusionRange.length || !tags) {
380
+ return null;
381
+ }
382
+
383
+ const ret = Object.create(null);
384
+ let some = null;
385
+
386
+ for (const tagName of Object.keys(tags)) {
387
+ const newTagRanges = excludeRangeCore(tags[tagName], exclusionRange);
388
+
389
+ if (newTagRanges.length) {
390
+ ret[tagName] = newTagRanges;
391
+ some = ret;
392
+ }
393
+ }
394
+
395
+ return some;
396
+ }
397
+
398
+ function excludeRangeCore(tags, exclusionRange) {
129
399
  const ret = [];
130
400
  const [exclusionStart, exclusionStop] = exclusionRange;
131
401
 
@@ -134,17 +404,18 @@ function atomicExclude(tags, exclusionRange) {
134
404
  const tagStop = tags[idx + 1];
135
405
 
136
406
  if (tagStop < exclusionStart) {
407
+ // exclusion is below - continue to check next range
137
408
  ret.push(tagStart, tagStop);
138
- // exlusion is below - continue to check next range
139
409
  continue;
140
410
  }
141
411
 
142
412
  if (tagStart > exclusionStop) {
143
- ret.push(...tags.slice(idx));
144
413
  // all other ranges are above exclusion so we can stop
414
+ ret.push(...tags.slice(idx));
145
415
  break;
146
416
  }
147
417
 
418
+ // this tag range overlaps the exclusion range
148
419
  if (exclusionStart <= tagStart && exclusionStop < tagStop) {
149
420
  ret.push(exclusionStop + 1, tagStop);
150
421
  }
@@ -161,122 +432,57 @@ function atomicExclude(tags, exclusionRange) {
161
432
  return ret;
162
433
  }
163
434
 
164
- function createAppendTags(firstTags, secondTags, offset) {
165
- const ret = Object.create(null);
166
- const firstTagsObject = ensureObject(firstTags);
167
- const secondTagsObject = ensureObject(secondTags);
168
- const tagNames = new Set([...Object.keys(firstTagsObject), ...Object.keys(secondTagsObject)]);
169
-
170
- for (const tagName of tagNames) {
171
- const newTagRanges = atomicAppend(ensureTagsImmutable(firstTagsObject, tagName), ensureTagsImmutable(secondTagsObject, tagName), offset);
172
-
173
- newTagRanges.length && (ret[tagName] = newTagRanges);
174
- }
175
-
176
- return Object.keys(ret).length ? ret : null;
177
- }
178
-
179
- function createOverlappingTags(tags, startIndex, endIndex) {
180
- const overlappingTags = {};
181
-
182
- Object.entries(tags).forEach(([tag, tagRanges]) => {
183
- const overlappingRanges = [];
184
-
185
- for (let i = 0; i < tagRanges.length; i += 2) {
186
- const start = tagRanges[i];
187
- const end = tagRanges[i + 1];
188
-
189
- if (end >= startIndex && start <= endIndex) {
190
- overlappingRanges.push([start, end]);
191
- }
192
- }
193
-
194
- if (overlappingRanges.length > 0) {
195
- overlappingTags[tag] = overlappingRanges;
196
- }
197
- });
198
-
199
- return overlappingTags;
200
- }
201
-
202
435
  /**
203
- * assumes:
204
- * - no mutation of arguments
205
- * - input ranges will be in non-decreasing order
206
- * - input ranges are "merged"
207
- * i.e. no ranges are adjacent
208
- * i.e. [0, 0, 1, 1] should be [0, 1]
209
- * - return ranges will be merged and in non-decreasing order
210
- */
211
- function createSubsetTags(tags, subsetStart, len) {
212
- const ret = Object.create(null);
213
-
214
- for (const tagName of Object.keys(ensureObject(tags))) {
215
- const newTagRanges = atomicSubset(ensureTagsImmutable(tags, tagName), subsetStart, len);
216
-
217
- newTagRanges.length && (ret[tagName] = newTagRanges);
218
- }
219
-
220
- return Object.keys(ret).length ? ret : null;
221
- }
222
-
223
- function createFullLengthCopyTags(tags, resultLength) {
224
- if (!resultLength || resultLength <= 0) return null;
225
- const ret = Object.create(null);
226
-
227
- for (const tagName of Object.keys(ensureObject(tags))) {
228
- ret[tagName] = [0, resultLength - 1];
229
- }
230
-
231
- return Object.keys(ret).length ? ret : null;
232
- }
233
-
234
- function createMergedTags(firstTags, secondTags) {
235
- const ret = Object.create(null);
236
- const firstTagsObject = ensureObject(firstTags);
237
- const secondTagsObject = ensureObject(secondTags);
238
- const tagNames = new Set([...Object.keys(firstTagsObject), ...Object.keys(secondTagsObject)]);
239
-
240
- for (const tagName of tagNames) {
241
- const newTagRanges = atomicMerge(ensureTagsImmutable(firstTagsObject, tagName), ensureTagsImmutable(secondTagsObject, tagName));
242
-
243
- newTagRanges.length && (ret[tagName] = newTagRanges);
244
- }
245
-
246
- return Object.keys(ret).length ? ret : null;
247
- }
248
-
249
- function createTagsWithExclusion(tags, exclusionRange) {
250
- if (!exclusionRange.length) return;
251
-
252
- const ret = Object.create(null);
253
- const tagsObject = ensureObject(tags);
254
-
255
- for (const tagName of Object.keys(tagsObject)) {
256
- const newTagRanges = atomicExclude(ensureTagsImmutable(tagsObject, tagName), exclusionRange);
257
-
258
- newTagRanges.length && (ret[tagName] = newTagRanges);
259
- }
260
-
261
- return Object.keys(ret).length ? ret : null;
262
- }
263
-
436
+ * Create tags for strings inserted into mongo queries.
437
+ * It also appears in vm but that is not evaluated during testing.
438
+ *
439
+ * @param {array | ?} path sequence of keys
440
+ * @param {object} tags tags to adjust. They refer to the user input.
441
+ * @param {string} value the user input
442
+ * @param {string} argString string incorporating the user input. The tag ranges
443
+ * in the returned tags refer to this string.
444
+ *
445
+ * @returns {object} new tags or null if no tags
446
+ *
447
+ * @examples
448
+ * - ["query"],{"UNTRUSTED":[0,2]},"foo","{ query: 'foo' }" => {"UNTRUSTED":[10,12]}
449
+ * - ["val"],{"UNTRUSTED":[0,3],"CUSTOM_VALIDATED":[0,3]},"SAFE","{ val: 'SAFE' }" => {"UNTRUSTED":[8,11],"CUSTOM_VALIDATED":[8,11]}
450
+ *
451
+ */
264
452
  function createAdjustedQueryTags(path, tags, value, argString) {
265
453
  let idx = -1;
266
454
  for (const str of [...path, value]) {
267
- // This is the case where the argument is an array
455
+ // This is the case where the input is an array
268
456
  if (str === 0) continue;
269
457
 
270
458
  idx = argString.indexOf(str, idx);
271
- if (idx == -1) {
459
+ if (idx < 0) {
272
460
  idx = -1;
273
461
  break;
274
462
  }
275
463
  }
276
464
 
277
- return idx > 0 ? createAppendTags([], tags, idx) : [...tags];
465
+ return idx >= 0 ? createAppendTags([], tags, idx) : [...tags];
278
466
  }
279
467
 
468
+ /**
469
+ * Adjust tag ranges for escaped characters in original text. This typically
470
+ * involves splitting a tag range into multiple ranges. I'm not sure this is
471
+ * particularly useful, because the "vulnerable" text has been escaped so, even
472
+ * if the original text is present it's unlikely to be vulnerable.
473
+ *
474
+ * @param {string} input original text
475
+ * @param {string} result escaped text
476
+ * @param {object} tags tags to adjust by removing escaped characters from
477
+ * the tag ranges.
478
+ *
479
+ * @returns {object} new tags
480
+ *
481
+ * @examples (args => return)
482
+ * - "foo","bar",{"untrusted":[0,2]} => [] // wtf?
483
+ * - "<foo>","&lt;foo&gt;",{"untrusted":[1,3]} => {"untrusted":[4,6]}
484
+ * - "?test=str","%3Ftest%3Dstr",{"UNTRUSTED":[0,8]} => {"UNTRUSTED":[3,6,10,12]}
485
+ */
280
486
  function createEscapeTagRanges(input, result, tags) {
281
487
  const inputArr = input.split('');
282
488
  const escapedArr = result.split('');
@@ -285,8 +491,10 @@ function createEscapeTagRanges(input, result, tags) {
285
491
  return x;
286
492
  }
287
493
  });
494
+ const ret = Object.create(null);
495
+
288
496
  if (overlap.length === 0) {
289
- return [];
497
+ return ret;
290
498
  }
291
499
  const newTagRanges = [];
292
500
  let firstIndex = escapedArr.indexOf(overlap[0]);
@@ -304,13 +512,14 @@ function createEscapeTagRanges(input, result, tags) {
304
512
  currIndex = nextIndex;
305
513
  }
306
514
 
307
- const ret = Object.create(null);
308
515
  for (const tagName of Object.keys(tags)) {
309
516
  ret[tagName] = newTagRanges;
310
517
  }
518
+
311
519
  return ret;
312
520
  }
313
521
 
522
+
314
523
  module.exports = {
315
524
  createSubsetTags,
316
525
  createAppendTags,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrast/assess",
3
- "version": "1.20.2",
3
+ "version": "1.21.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.16.1",
20
+ "@contrast/common": "1.17.0",
21
21
  "@contrast/distringuish": "^4.4.0",
22
22
  "@contrast/scopes": "1.4.0"
23
23
  }