@contrast/assess 1.46.2 → 1.47.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.
@@ -18,11 +18,10 @@
18
18
  const {
19
19
  DataflowTag: { UNTRUSTED },
20
20
  primordials: {
21
- StringPrototypeMatch,
22
21
  ArrayPrototypeJoin,
23
- ArrayPrototypeSlice,
24
- StringPrototypeSubstring,
25
- StringPrototypeReplace
22
+ StringPrototypeMatchAll,
23
+ StringPrototypeReplace,
24
+ StringPrototypeReplaceAll,
26
25
  }
27
26
  } = require('@contrast/common');
28
27
  const {
@@ -43,98 +42,157 @@ module.exports = function(core) {
43
42
  } = core;
44
43
 
45
44
  function parseArgs(args) {
46
- // [match, p1, p2, ..., matchIdx, str, ?groups]
47
- const match = args[0];
48
- const len = args.length;
49
- const lastEl = args[len - 1];
50
- const hasNamedGroup = typeof lastEl === 'object';
51
- const captureGroups = ArrayPrototypeSlice.call(args, 1, hasNamedGroup ? -3 : -2);
52
- const matchIdx = args[hasNamedGroup ? len - 3 : len - 2];
53
- const str = hasNamedGroup ? args[len - 2] : lastEl;
54
- const namedGroups = hasNamedGroup ? lastEl : null;
55
- return { match, captureGroups, matchIdx, str, namedGroups };
56
- }
57
-
58
- // these functions specifically use the patched versions of Substring so that
59
- // the tags are propagated.
60
- function replaceSpecialCharacters(replacement, { captureGroups, match, str, namedGroups }, replacementType) {
61
- let ret = replacement;
62
- [
63
- {
64
- regex: /\$\$/g,
65
- replace: '$'
66
- },
67
- {
68
- regex: /\$&/g,
69
- replace: match
70
- },
71
- {
72
- regex: /\$`/g,
73
- replace: str.substring(0, str.indexOf(match))
74
- },
75
- {
76
- regex: /\$'/g,
77
- replace: str.substring(str.indexOf(match) + match.length, str.length)
78
- }
79
- ].forEach(({ regex, replace }) => {
80
- if (ret && StringPrototypeMatch.call(ret, regex)) {
81
- // If the match string is tracked, we can actually use the patched replace
82
- // to keep track of its tag ranges
83
- if (tracker.getData(replace)) {
84
- ret = ret.replace(regex, replace);
85
- } else {
86
- ret = StringPrototypeReplace.call(ret, regex, replace);
87
- }
88
- }
89
- });
90
-
91
- const numberedGroupMatches = replacementType !== 'function' && StringPrototypeMatch.call(replacement, /\$[1-9][0-9]|\$[1-9]/g);
92
- if (numberedGroupMatches) {
93
- numberedGroupMatches.forEach((numberedGroup) => {
94
- const group = Number(StringPrototypeSubstring.call(numberedGroup, 1));
95
- ret = StringPrototypeReplace.call(ret, numberedGroup, captureGroups[group - 1] || '');
96
- });
45
+ //
46
+ // convert replace args (match, p1, p2, ..., matchIdx, str, ?groups) to
47
+ // re.exec(string) format [match, g1, g2, ..., index: N, input: '', groups?]
48
+ //
49
+ const r = [];
50
+ let ix = -1;
51
+ if (typeof args.at(-1) === 'object') {
52
+ r.groups = args.at(-1);
53
+ ix = -2;
97
54
  }
55
+ r.input = args.at(ix);
56
+ ix -= 1;
57
+ r.index = args.at(ix);
98
58
 
99
- if (namedGroups) {
100
- for (const name in namedGroups) {
101
- ret = StringPrototypeReplace.call(ret, `$${name}`, namedGroups[name]);
102
- }
59
+ // ix is negative from the end, so add it
60
+ ix = args.length + ix;
61
+ for (let i = 0; i < ix; i++) {
62
+ r.push(args[i]);
103
63
  }
104
64
 
105
- return ret;
65
+ return r;
106
66
  }
107
67
 
68
+ const RE_REPS = /\$(\$|&|`|'|[1-9][0-9]?|<[a-zA-Z0-9_]+>)/g;
69
+ const STR_REPS = /\$(\$|&|`|')/g;
70
+
108
71
  function getReplacementInfo(data, replacerArgs, parsedArgs) {
109
- let replacement = data._replacementType === 'function' ?
110
- data._replacement.call(global, ...replacerArgs) :
111
- data._replacement;
72
+ // patternIsRE is two different flags, both booleans.
73
+ // - set true if the first argument to String.prototype.replace is an RE.
74
+ // - after the $N, $<name> checks, it flags that a substitution was made.
75
+ const patternIsRE = data.args[0] instanceof RegExp;
76
+ let replacement;
77
+ if (typeof data._replacement === 'function') {
78
+ // no special replacements apply to the string returned by a replacement
79
+ // function.
80
+ replacement = data._replacement.call(global, ...replacerArgs);
81
+ } else {
82
+ // if it's not a function then the valid special replacements depend on
83
+ // whether the pattern is a regex or a string. first find substitution
84
+ // patterns present in the replacement string. Don't find patterns that
85
+ // aren't valid, e.g., $0, $<name> when the pattern is a string.
86
+ replacement = String(data._replacement);
87
+
88
+ const matches = [...StringPrototypeMatchAll.call(replacement, patternIsRE ? RE_REPS : STR_REPS)];
89
+ let trackedReplacementsDone = 0;
112
90
 
113
- replacement = replaceSpecialCharacters(String(replacement), parsedArgs, data._replacementType);
91
+ for (const m of matches) {
92
+ let substitution;
93
+ let substitutionDone = false;
94
+ // format of m: ['$`', '`', index: 0, input: string, groups: undefined|{}]
95
+ // if the pattern is a regex, then $1 to $99 and $<name> are valid
96
+ if (patternIsRE) {
97
+ // my guess is $1 to $99 are most likely, after that, who knows? so
98
+ // we check named groups next, because 1) they seem more useful than
99
+ // the other $ patterns and 2) they are in the same patternIsRE test.
100
+ //
101
+ // in any case, the following will be false if m[1][0] is not a number.
102
+ if (m[1][0] >= 1 && m[1][0] <= 9) {
103
+ //const group = Number(m[1]);
104
+ // a group might not be present, e.g., (pattern)?(a) or (a)|(b).
105
+ if (parsedArgs[m[1]] === undefined && m[1] in parsedArgs) {
106
+ // need to remove $m[1] from replacement pattern. N.B. this could
107
+ // mess up if the replacement text contains text that matches a
108
+ // subsequent replacement (either RE or string).
109
+ replacement = StringPrototypeReplaceAll.call(replacement, m[0], '');
110
+ continue;
111
+ }
112
+ substitution = parsedArgs[m[1]];
113
+ substitutionDone = true;
114
+ } else if (m[1][0] === '<') {
115
+ // named group
116
+ const groupName = m[1].substring(1, m[1].length - 1);
117
+ if (parsedArgs.groups[groupName] === undefined && groupName in parsedArgs.groups) {
118
+ // remove $<groupName> from the replacement pattern. N.B. this
119
+ // also could mess up if the replacement text containts text that
120
+ // matches a subsequent replacement.
121
+ replacement = StringPrototypeReplaceAll.call(replacement, m[0], '');
122
+ continue;
123
+ }
124
+ substitution = parsedArgs.groups[groupName];
125
+ substitutionDone = true;
126
+ }
127
+ }
128
+
129
+ // the following are valid whether the pattern is a regex or a string.
130
+ //
131
+ // any idea what order $&'` should be in from a most-common to least-
132
+ // common perspective? nfi.
133
+ if (!substitutionDone) {
134
+ if (m[1] === '$') {
135
+ // this could actually be tracked but in order to do this "right"
136
+ // we have to be able to distinguish whether it is the first or
137
+ // the second $ that is tracked. so punt, and just ignore it, as
138
+ // originally implemented.
139
+ substitution = '$';
140
+ } else if (m[1] === '&') {
141
+ // replace these '$&' in parsedArgs[0] (replacerArgs[0], i.e., the
142
+ // match). if tracked, handle it. do so by index, so we don't have to
143
+ // call replace again? e.g., string[m.index..m.index+2]
144
+ substitution = parsedArgs[0];
145
+ } else if (m[1] === '`') {
146
+ substitution = parsedArgs.input.substring(0, parsedArgs.index);
147
+ } else if (m[1] === "'") {
148
+ substitution = parsedArgs.input.substring(parsedArgs.index + parsedArgs[0].length);
149
+ } // else {
150
+ // throw new Error('how can it have matched RE and gotten here?');
151
+ // }
152
+ // i'm not sure what the proper handling of this is. if either char
153
+ // of the $$&`' sequence is tracked, then something the user input
154
+ // is manipulating the output, even if it is just duplicating what
155
+ // might be tracked or untracked input. does that count?
156
+ }
157
+ // if the substitution is tracked just use heavy-weight replacer.
158
+ if (trackedReplacementsDone || tracker.isTracked(substitution)) {
159
+ replacement = replacement.replace(m[0], substitution);
160
+ trackedReplacementsDone += 1;
161
+ } else {
162
+ replacement = StringPrototypeReplace.call(replacement, m[0], substitution);
163
+ }
164
+ }
165
+
166
+ }
167
+
168
+ // coerce the replacement to a string, e.g., null => 'null'
169
+ replacement = String(replacement);
114
170
 
115
171
  data._replacementInfo = tracker.getData(replacement);
116
172
  if (data._replacementInfo) {
117
173
  data._history.add(data._replacementInfo);
118
174
  }
175
+
119
176
  return { replacement, replacementInfo: data._replacementInfo };
120
177
  }
121
178
 
122
179
  function getReplacer(data) {
123
180
  return function replacer(...args) {
124
181
  const parsedArgs = parseArgs(args);
125
- const { match, matchIdx, str } = parsedArgs;
182
+ const match = parsedArgs[0];
183
+ const { index, input } = parsedArgs;
126
184
 
127
185
  const { _accumOffset, _accumTags } = data;
128
186
  const { replacement, replacementInfo } = getReplacementInfo(data, args, parsedArgs);
129
187
 
130
- const preTags = createSubsetTags(_accumTags, 0, _accumOffset + matchIdx);
131
- const postTags = createSubsetTags(_accumTags, _accumOffset + matchIdx + match.length, str.length - matchIdx - match.length);
188
+ const preTags = createSubsetTags(_accumTags, 0, _accumOffset + index);
189
+ const postTags = createSubsetTags(_accumTags, _accumOffset + index + match.length, input.length - index - match.length);
132
190
  data._accumOffset += (replacement.length - match.length);
133
191
  if (preTags || postTags || replacementInfo) {
134
192
  data._accumTags = createAppendTags(
135
- createAppendTags(preTags, replacementInfo?.tags, _accumOffset + matchIdx),
193
+ createAppendTags(preTags, replacementInfo?.tags, _accumOffset + index),
136
194
  postTags,
137
- data._accumOffset + matchIdx + match.length
195
+ data._accumOffset + index + match.length
138
196
  );
139
197
  } else {
140
198
  data._accumTags = {};
@@ -0,0 +1,705 @@
1
+ import { describe, it, beforeEach, afterEach, after } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { inspect } from 'util';
4
+ import { initAssessFixture } from '@contrast/test/fixtures/index.js';
5
+
6
+ describe('assess dataflow propagation string replace', function () {
7
+ let core, simulateRequestScope, trackString, tracker;
8
+ const allPatcher = new Map();
9
+
10
+ beforeEach(function () {
11
+ ({
12
+ core,
13
+ trackString,
14
+ simulateRequestScope
15
+ } = initAssessFixture());
16
+
17
+ // print test name
18
+ //console.log(`beforeEach TestContext(${this.name})`);
19
+
20
+ tracker = core.assess.dataflow.tracker;
21
+
22
+ core.assess.dataflow.propagation.stringInstrumentation.substring.install();
23
+ core.assess.dataflow.propagation.stringInstrumentation.replace.install();
24
+ });
25
+
26
+ afterEach(function () {
27
+ core.assess.dataflow.propagation.stringInstrumentation.replace.uninstall();
28
+ });
29
+
30
+ // eslint-disable-next-line mocha/no-sibling-hooks
31
+ afterEach(function() {
32
+ core.Perf.fromAllToMap('patcher', allPatcher);
33
+ });
34
+
35
+ after(function() {
36
+ const stats = core.Perf.getStats(allPatcher);
37
+ for (const [key, { n, totalMicros, mean }] of stats.entries()) {
38
+ console.log(key, n, totalMicros, 'nsec', mean, 'mean');
39
+ }
40
+ });
41
+
42
+ ['string', 'function'].forEach((replacerType) => {
43
+ function getReplacement(replacerType, replacement) {
44
+ return replacerType === 'function' ? () => replacement : replacement;
45
+ }
46
+ describe(`string-arg custom ${replacerType} replacer`, function () {
47
+ it('untracked string replace with untracked string', function () {
48
+ simulateRequestScope(() => {
49
+ const val = '?bcd';
50
+ const ret = val.replace('?', getReplacement(replacerType, 'a'));
51
+ assert.strictEqual(ret, 'abcd');
52
+ assert.strictEqual(tracker.getData(ret), null);
53
+ });
54
+ });
55
+
56
+ [
57
+ [],
58
+ [undefined],
59
+ [null]
60
+ ].forEach((rest) => {
61
+ it(`full replacement with nullish value: ${inspect(rest[0])}`, function () {
62
+ simulateRequestScope(() => {
63
+ const val = 'abcd';
64
+ const extern = trackString(val);
65
+ const ret = extern.replace(val, getReplacement(replacerType, rest[0]));
66
+ assert.strictEqual(ret, String(rest[0]));
67
+ assert.strictEqual(tracker.getData(ret), null);
68
+ });
69
+ });
70
+ });
71
+
72
+ it('empty match string', function () {
73
+ simulateRequestScope(() => {
74
+ const extern = trackString('bcde', {
75
+ tags: {
76
+ UNTRUSTED: [0, 3]
77
+ }
78
+ });
79
+ const ret = extern.replace('', getReplacement(replacerType, 'a'));
80
+ assert.strictEqual(ret, 'abcde');
81
+ const tags = tracker.getData(ret).tags;
82
+ const expectedTags = { UNTRUSTED: [1, 4] };
83
+ assertTagsEqual(tags, expectedTags);
84
+ });
85
+ });
86
+
87
+ it('empty replacer string', function () {
88
+ simulateRequestScope(() => {
89
+ const extern = trackString('abcd', {
90
+ tags: {
91
+ UNTRUSTED: [0, 3]
92
+ }
93
+ });
94
+ const ret = extern.replace('', getReplacement(replacerType, ''));
95
+ assert.strictEqual(ret, 'abcd');
96
+ const tags = tracker.getData(ret).tags;
97
+ const expectedTags = { UNTRUSTED: [0, 3] };
98
+ assertTagsEqual(tags, expectedTags);
99
+ });
100
+ });
101
+
102
+ it('untracked string replaced with tracked', function () {
103
+ simulateRequestScope(() => {
104
+ const val = '?bcd';
105
+ const replacement = trackString('a', {
106
+ tags: {
107
+ UNTRUSTED: [0, 0]
108
+ }
109
+ });
110
+ const ret = val.replace('?', getReplacement(replacerType, replacement));
111
+ assert.strictEqual(ret, 'abcd');
112
+ const tags = tracker.getData(ret).tags;
113
+ const expectedTags = { UNTRUSTED: [0, 0] };
114
+ assertTagsEqual(tags, expectedTags);
115
+ });
116
+ });
117
+
118
+ it('does not track return with all tracked values removed', function () {
119
+ simulateRequestScope(() => {
120
+ const extern = trackString('_TT_', {
121
+ tags: {
122
+ UNTRUSTED: [1, 2]
123
+ }
124
+ });
125
+ const ret = extern.replace('TT', getReplacement(replacerType, '__'));
126
+ assert.strictEqual(ret, '____');
127
+ assert.strictEqual(tracker.getData(ret), null);
128
+ });
129
+ });
130
+
131
+ it('tracks result highspan', function () {
132
+ simulateRequestScope(() => {
133
+ const extern = trackString('_TT_', {
134
+ tags: {
135
+ UNTRUSTED: [1, 2],
136
+ history: [{ mock: 'sourceEvent' }]
137
+ }
138
+ });
139
+ const ret = extern.replace('_T', getReplacement(replacerType, '__'));
140
+ assert.strictEqual(ret, '__T_');
141
+ const tags = tracker.getData(ret).tags;
142
+ const expectedTags = { UNTRUSTED: [2, 2] };
143
+ assertTagsEqual(tags, expectedTags);
144
+ });
145
+ });
146
+
147
+ it('tracks result lospan', function () {
148
+ simulateRequestScope(() => {
149
+ const extern = trackString('_TT_', {
150
+ tags: {
151
+ UNTRUSTED: [1, 2],
152
+ history: [{ mock: 'sourceEvent' }]
153
+ }
154
+ });
155
+ const ret = extern.replace('T_', getReplacement(replacerType, '__'));
156
+ assert.strictEqual(ret, '_T__');
157
+ const tags = tracker.getData(ret).tags;
158
+ const expectedTags = { UNTRUSTED: [1, 1] };
159
+ assertTagsEqual(tags, expectedTags);
160
+ });
161
+ });
162
+
163
+ it('tracks result highspan tracked replacement', function () {
164
+ simulateRequestScope(() => {
165
+ const extern = trackString('_TT_', {
166
+ tags: {
167
+ UNTRUSTED: [1, 2]
168
+ }
169
+ });
170
+ const replacement = trackString('TT', {
171
+ tags: {
172
+ UNTRUSTED: [0, 1]
173
+ }
174
+ });
175
+ const ret = extern.replace('T_', getReplacement(replacerType, replacement));
176
+ assert.strictEqual(ret, '_TTT');
177
+ const tags = tracker.getData(ret).tags;
178
+ const expectedTags = { UNTRUSTED: [1, 3] };
179
+ assertTagsEqual(tags, expectedTags);
180
+
181
+ });
182
+ });
183
+
184
+ it('keeps track of multiple tag ranges', function () {
185
+ simulateRequestScope(() => {
186
+ const extern = trackString('a?cd', {
187
+ tags: {
188
+ UNTRUSTED: [0, 3],
189
+ foo: [1, 2]
190
+ }
191
+ });
192
+ const replacement = trackString('b', {
193
+ tags: {
194
+ foo: [0, 0],
195
+ bar: [0, 0]
196
+ }
197
+ });
198
+ const ret = extern.replace('?', getReplacement(replacerType, replacement));
199
+ assert.strictEqual(ret, 'abcd');
200
+ const tags = tracker.getData(ret).tags;
201
+ assertTagsEqual(tags, {
202
+ UNTRUSTED: [0, 0, 2, 3],
203
+ foo: [1, 2],
204
+ bar: [1, 1]
205
+ });
206
+ });
207
+ });
208
+ });
209
+
210
+ describe(`regEx custom ${replacerType} replacer`, function () {
211
+ it('keeps track of tagged strings not replaced', function () {
212
+ simulateRequestScope(() => {
213
+ const extern = trackString('abcd');
214
+ const ret = extern.replace(/e/g, getReplacement(replacerType, 'e'));
215
+ assert.strictEqual(ret, 'abcd');
216
+ const tags = tracker.getData(ret).tags;
217
+ assertTagsEqual(tags, {
218
+ UNTRUSTED: [0, 3]
219
+ });
220
+ });
221
+ });
222
+
223
+ it('keeps track of tagged strings replaced by untracked strings', function () {
224
+ simulateRequestScope(() => {
225
+ const extern = trackString('a?a?');
226
+ const ret = extern.replace(/\?/g, getReplacement(replacerType, 'b'));
227
+ assert.strictEqual(ret, 'abab');
228
+ const tags = tracker.getData(ret).tags;
229
+ const expectedTags = {
230
+ UNTRUSTED: [0, 0, 2, 2]
231
+ };
232
+ assertTagsEqual(tags, expectedTags);
233
+ });
234
+ });
235
+
236
+ it('keeps track of tagged strings replaced by larger untracked strings', function () {
237
+ simulateRequestScope(() => {
238
+ const extern = trackString('a?a?');
239
+ const ret = extern.replace(/\?/g, getReplacement(replacerType, 'bbb'));
240
+ assert.strictEqual(ret, 'abbbabbb');
241
+ const tags = tracker.getData(ret).tags;
242
+ const expectedTags = {
243
+ UNTRUSTED: [0, 0, 4, 4]
244
+ };
245
+ assertTagsEqual(tags, expectedTags);
246
+ });
247
+ });
248
+
249
+ it('keeps track of tagged strings replaced by smaller untracked strings', function () {
250
+ simulateRequestScope(() => {
251
+ const extern = trackString('a???a???');
252
+ const ret = extern.replace(/\?\?\?/g, getReplacement(replacerType, 'b'));
253
+ assert.strictEqual(ret, 'abab');
254
+ const tags = tracker.getData(ret).tags;
255
+ const expectedTags = {
256
+ UNTRUSTED: [0, 0, 2, 2]
257
+ };
258
+ assertTagsEqual(tags, expectedTags);
259
+ });
260
+ });
261
+
262
+ it('keeps track of tagged strings replaced by tracked strings', function () {
263
+ simulateRequestScope(() => {
264
+ const extern = trackString('a?a?', {
265
+ tags: {
266
+ UNTRUSTED: [0, 3]
267
+ }
268
+ });
269
+ const replacement = trackString('b', {
270
+ tags: {
271
+ UNTRUSTED: [0, 0]
272
+ }
273
+ });
274
+ const ret = extern.replace(/\?/g, getReplacement(replacerType, replacement));
275
+ assert.strictEqual(ret, 'abab');
276
+ const tags = tracker.getData(ret).tags;
277
+ const expectedTags = {
278
+ UNTRUSTED: [0, 3]
279
+ };
280
+ assertTagsEqual(tags, expectedTags);
281
+ });
282
+ });
283
+
284
+ it('keeps track of tagged strings replaced by larger tracked strings', function () {
285
+ simulateRequestScope(() => {
286
+ const extern = trackString('a?a?', {
287
+ tags: {
288
+ UNTRUSTED: [0, 3]
289
+ }
290
+ });
291
+ const replacement = trackString('bbb', {
292
+ tags: {
293
+ UNTRUSTED: [0, 2]
294
+ }
295
+ });
296
+ const ret = extern.replace(/\?/g, getReplacement(replacerType, replacement));
297
+ assert.strictEqual(ret, 'abbbabbb');
298
+ const tags = tracker.getData(ret).tags;
299
+ const expectedTags = {
300
+ UNTRUSTED: [0, 7]
301
+ };
302
+ assertTagsEqual(tags, expectedTags);
303
+ });
304
+ });
305
+
306
+ it('keeps track of tagged strings replaced by smaller tracked strings', function () {
307
+ simulateRequestScope(() => {
308
+ const extern = trackString('a???a???', {
309
+ tags: {
310
+ UNTRUSTED: [0, 7]
311
+ }
312
+ });
313
+ const replacement = trackString('b', {
314
+ tags: {
315
+ UNTRUSTED: [0, 0]
316
+ }
317
+ });
318
+ const ret = extern.replace(/\?\?\?/g, getReplacement(replacerType, replacement));
319
+ assert.strictEqual(ret, 'abab');
320
+ const tags = tracker.getData(ret).tags;
321
+ const expectedTags = {
322
+ UNTRUSTED: [0, 3]
323
+ };
324
+ assertTagsEqual(tags, expectedTags);
325
+ });
326
+ });
327
+
328
+ it('keeps track of multiple tag ranges', function () {
329
+ simulateRequestScope(() => {
330
+ const extern = trackString('a??a??a', {
331
+ tags: {
332
+ UNTRUSTED: [0, 6],
333
+ foo: [0, 2],
334
+ bar: [4, 6]
335
+ }
336
+ });
337
+ const replacement = trackString('b', {
338
+ tags: {
339
+ foo: [0, 0],
340
+ bar: [0, 0]
341
+ }
342
+ });
343
+ const ret = extern.replace(/\?/g, replacement);
344
+ assert.strictEqual(ret, 'abbabba');
345
+ const tags = tracker.getData(ret).tags;
346
+ const expectedTags = {
347
+ UNTRUSTED: [0, 0, 3, 3, 6, 6],
348
+ foo: [0, 2, 4, 5],
349
+ bar: [1, 2, 4, 6],
350
+ };
351
+ assertTagsEqual(tags, expectedTags);
352
+ });
353
+ });
354
+ });
355
+ });
356
+
357
+ describe('complex/edge cases', function () {
358
+ it('replacer function returns a non-string', function () {
359
+ simulateRequestScope(() => {
360
+ const extern = trackString('123?', {
361
+ tags: {
362
+ UNTRUSTED: [0, 3]
363
+ }
364
+ });
365
+ const ret = extern.replace('?', (match, offset) => offset + 1);
366
+ assert.strictEqual(ret, '1234');
367
+ const tags = tracker.getData(ret).tags;
368
+ assertTagsEqual(tags, {
369
+ UNTRUSTED: [0, 2]
370
+ });
371
+ });
372
+ });
373
+
374
+ it('replacer operates on capture groups', function () {
375
+ simulateRequestScope(() => {
376
+ const extern = trackString('AaaBbbCcc', {
377
+ tags: {
378
+ UNTRUSTED: [0, 8],
379
+ groupOne: [0, 2],
380
+ groupTwo: [3, 5],
381
+ groupThree: [6, 8]
382
+ }
383
+ });
384
+ const ret = extern.replace(/(A)|(B)|(C)/g, (match => match.toLowerCase()));
385
+ assert.strictEqual(ret, 'aaabbbccc');
386
+ const tags = tracker.getData(ret).tags;
387
+ assertTagsEqual(tags, {
388
+ UNTRUSTED: [1, 2, 4, 5, 7, 8],
389
+ groupOne: [1, 2],
390
+ groupTwo: [4, 5],
391
+ groupThree: [7, 8]
392
+ });
393
+ });
394
+ });
395
+
396
+ it('handles edge case where we have a function that sets the replacement to be a special value',
397
+ function () {
398
+ simulateRequestScope(() => {
399
+ let counter = 0;
400
+ const extern = trackString('SELECT ? FROM ?', {
401
+ tags: {
402
+ UNTRUSTED: [0, 14],
403
+ groupOne: [9, 12],
404
+ }
405
+ });
406
+ const ret = extern.replace(/(\?)/g, () => `$${counter++}`);
407
+ assert.strictEqual(ret, 'SELECT $0 FROM $1');
408
+ const tags = tracker.getData(ret).tags;
409
+ assertTagsEqual(tags, {
410
+ UNTRUSTED: [0, 6, 9, 14],
411
+ groupOne: [10, 13],
412
+ });
413
+ });
414
+ });
415
+
416
+ it('replacer operates on named capture groups', function () {
417
+ simulateRequestScope(() => {
418
+ const extern = trackString('AaaBbbCcc', {
419
+ tags: {
420
+ UNTRUSTED: [0, 8],
421
+ groupOne: [0, 2],
422
+ groupTwo: [3, 5],
423
+ groupThree: [6, 8]
424
+ }
425
+ });
426
+ const ret = extern.replace(/(?<g1>A)|(?<g2>B)|(?<g3>C)/g, (match => match.toLowerCase()));
427
+ assert.strictEqual(ret, 'aaabbbccc');
428
+ const tags = tracker.getData(ret).tags;
429
+ assertTagsEqual(tags, {
430
+ UNTRUSTED: [1, 2, 4, 5, 7, 8],
431
+ groupOne: [1, 2],
432
+ groupTwo: [4, 5],
433
+ groupThree: [7, 8]
434
+ });
435
+ });
436
+ });
437
+
438
+ it('keeps track of multiple overlapping tag ranges over multiple calls', function () {
439
+ simulateRequestScope(() => {
440
+ let ret;
441
+ const extern = trackString('a??cd?g?', {
442
+ tags: {
443
+ UNTRUSTED: [0, 7],
444
+ foo: [0, 2],
445
+ bar: [1, 3],
446
+ fizz: [4, 7],
447
+ buzz: [1, 6]
448
+ }
449
+ });
450
+ const replB = trackString('b', {
451
+ tags: {
452
+ foo: [0, 0],
453
+ bar: [0, 0]
454
+ }
455
+ });
456
+ const replEf = trackString('ef', {
457
+ tags: {
458
+ fizz: [0, 0],
459
+ buzz: [1, 1]
460
+ }
461
+ });
462
+ const replH = trackString('h', {
463
+ tags: {
464
+ foobar: [0, 0]
465
+ }
466
+ });
467
+ ret = extern.replace('??', replB);
468
+ ret = ret.replace('?', () => replEf);
469
+ ret = ret.replace(/\?/g, replH);
470
+ assert.strictEqual(ret, 'abcdefgh');
471
+ const tags = tracker.getData(ret).tags;
472
+ assertTagsEqual(tags, {
473
+ UNTRUSTED: [0, 0, 2, 3, 6, 6],
474
+ foo: [0, 1],
475
+ bar: [1, 2],
476
+ fizz: [3, 4, 6, 6],
477
+ buzz: [2, 3, 5, 6],
478
+ foobar: [7, 7]
479
+ });
480
+ });
481
+ });
482
+
483
+ it('keeps track of replacement history', function () {
484
+ simulateRequestScope(() => {
485
+ const extern = trackString('aa??');
486
+ const replacementOne = trackString('bb');
487
+ const replacementTwo = trackString('cc');
488
+ const replacements = ['', '', replacementOne, replacementTwo];
489
+ const ret = extern.replace(/\?/g, (match, offset) => replacements[offset]);
490
+ assert.strictEqual(ret, 'aabbcc');
491
+ const history = tracker.getData(ret).history;
492
+ assert.strictEqual(history.length, 3);
493
+ const expected = [
494
+ { value: 'aa??', tags: { UNTRUSTED: [0, 3] } },
495
+ { value: 'bb', tags: { UNTRUSTED: [0, 1] } },
496
+ { value: 'cc', tags: { UNTRUSTED: [0, 1] } }
497
+ ];
498
+ for (const {value, tags: expectedTags} of expected) {
499
+ const {tags} = history.find(({value: v}) => v === value);
500
+ assertTagsEqual(tags, expectedTags);
501
+ }
502
+ });
503
+ });
504
+ });
505
+
506
+ describe('special characters', function () {
507
+
508
+ it('replaces and handles $$', function () {
509
+ simulateRequestScope(() => {
510
+ const extern = trackString('foo?foo');
511
+ const ret = extern.replace('?', '$$ $$');
512
+ assert.strictEqual(ret, 'foo$ $foo');
513
+ const tags = tracker.getData(ret).tags;
514
+ const expectedTags = { UNTRUSTED: [0, 2, 6, 8] };
515
+ assertTagsEqual(tags, expectedTags);
516
+ });
517
+ });
518
+
519
+ it('BUG: does not replace $$ from a function replacer', function() {
520
+ // BUG
521
+ // $$ should no be interpreted when returned from a function
522
+ //
523
+ // NEED TEST when '$$' replacement is tracked?
524
+ simulateRequestScope(() => {
525
+ const extern = trackString('foo?foo');
526
+ const ret = extern.replace('?', () => '$$');
527
+ assert.strictEqual(ret, 'foo$$foo');
528
+ const tags = tracker.getData(ret).tags;
529
+ const expectedTags = { UNTRUSTED: [0, 2, 5, 7] };
530
+ assertTagsEqual(tags, expectedTags);
531
+ });
532
+ });
533
+
534
+ it('replaces and handles untracked $&', function () {
535
+ simulateRequestScope(() => {
536
+ const extern = trackString('foo bar');
537
+ const ret = extern.replace('bar', '"$&"-[$&]');
538
+ assert.strictEqual(ret, 'foo "bar"-[bar]');
539
+ const tags = tracker.getData(ret).tags;
540
+ const expectedTags = { UNTRUSTED: [0, 3] };
541
+ assertTagsEqual(tags, expectedTags);
542
+ });
543
+ });
544
+
545
+ it('replaces and handles tracked $&', function () {
546
+ simulateRequestScope(() => {
547
+ const extern = trackString('foo bar');
548
+ const replacement = trackString('bar');
549
+ const ret = extern.replace(replacement, '[$&]-[$&]');
550
+ assert.strictEqual(ret, 'foo [bar]-[bar]');
551
+ const tags = tracker.getData(ret).tags;
552
+ const expectedTags = { UNTRUSTED: [0, 3, 5, 7, 11, 13] };
553
+ assertTagsEqual(tags, expectedTags);
554
+ });
555
+ });
556
+
557
+ it('replaces and handles $`', function () {
558
+ simulateRequestScope(() => {
559
+ const extern = trackString('foobar');
560
+ const ret = extern.replace('bar', '$`$`');
561
+ assert.strictEqual(ret, 'foofoofoo');
562
+ const tags = tracker.getData(ret).tags;
563
+ const expectedTags = { UNTRUSTED: [0, 8] };
564
+ assertTagsEqual(tags, expectedTags);
565
+ });
566
+ });
567
+
568
+ it('replaces and handles $\'', function () {
569
+ simulateRequestScope(() => {
570
+ const extern = trackString('foobar');
571
+ const ret = extern.replace('foo', '$\'$\'');
572
+ assert.strictEqual(ret, 'barbarbar');
573
+ const tags = tracker.getData(ret).tags;
574
+ const expectedTags = { UNTRUSTED: [0, 8] };
575
+ assertTagsEqual(tags, expectedTags);
576
+ });
577
+ });
578
+
579
+ it('replaces and handles $N', function () {
580
+ simulateRequestScope(() => {
581
+ const extern = trackString('aa bbcc');
582
+ const ret = extern.replace(/(aa) (bb)/g, '$2 $1');
583
+ assert.strictEqual(ret, 'bb aacc');
584
+ const { tags } = tracker.getData(ret);
585
+ // BUG? the entire input string is tracked. replace() reorganizes it,
586
+ // but shouldn't the entire resultant string be tracked? maybe just
587
+ // handle the simplest case where the entire input string is tracked
588
+ // because if only subsets of the string are tracked it will be quite
589
+ // expensive to propagate the tag ranges correctly.
590
+ const expectedTags = { UNTRUSTED: [5, 6] };
591
+ assertTagsEqual(tags, expectedTags);
592
+ });
593
+ });
594
+
595
+ it('BUG: replaces and handles $name', function () {
596
+ // BUG string needs to be $<name> not $name
597
+ simulateRequestScope(() => {
598
+ const extern = trackString('aabbcc');
599
+ const ret = extern.replace(/(?<g1>aa)(?<g2>bb)/g, '$<g2>$<g1>');
600
+ assert.strictEqual(ret, 'bbaacc');
601
+ const tags = tracker.getData(ret).tags;
602
+ const expectedTags = { UNTRUSTED: [4, 5] };
603
+ assertTagsEqual(tags, expectedTags);
604
+ });
605
+ });
606
+
607
+ it('maintains tracking with $$', function() {
608
+ simulateRequestScope(() => {
609
+ const extern = trackString('foobarfoo');
610
+ const ret = extern.replace('bar', '$$');
611
+ assert.strictEqual(ret, 'foo$foo');
612
+ const tags = tracker.getData(ret).tags;
613
+ const expectedTags = { UNTRUSTED: [0, 2, 4, 6] };
614
+ assertTagsEqual(tags, expectedTags);
615
+ });
616
+ });
617
+
618
+ it('BUG: maintains tracking when $$ is tracked', function() {
619
+ // BUG this should be [0, 6] but isn't because we don't consider whether
620
+ // the $$ is tracked or not.
621
+ simulateRequestScope(() => {
622
+ const dollardollar = trackString('$$');
623
+ const extern = trackString('foobarfoo');
624
+ const ret = extern.replace('bar', dollardollar);
625
+ assert.strictEqual(ret, 'foo$foo');
626
+ const tags = tracker.getData(ret).tags;
627
+ const expectedTags = { UNTRUSTED: [0, 2, 4, 6] };
628
+ assertTagsEqual(tags, expectedTags);
629
+ });
630
+ });
631
+
632
+ it('replaces and handles strings containing multiple special characters', function () {
633
+ simulateRequestScope(() => {
634
+ const extern = trackString('aabbcc');
635
+ const ret = extern.replace('bb', '($&)-($\')-($`)');
636
+ assert.strictEqual(ret, 'aa(bb)-(cc)-(aa)cc');
637
+ const tags = tracker.getData(ret).tags;
638
+ const expectedTags = { UNTRUSTED: [0, 1, 8, 9, 13, 14, 16, 17] };
639
+ assertTagsEqual(tags, expectedTags);
640
+ });
641
+ });
642
+
643
+ it('replaces and handles strings containing multiple special characters that replace multiple strings', function () {
644
+ simulateRequestScope(() => {
645
+ const extern = trackString('aa?bb?cc?');
646
+ const ret = extern.replace(/(\?)/g, '$$$1');
647
+ assert.strictEqual(ret, 'aa$?bb$?cc$?');
648
+ const tags = tracker.getData(ret).tags;
649
+ const expectedTags = { UNTRUSTED: [0, 1, 4, 5, 8, 9 ]};
650
+ assertTagsEqual(tags, expectedTags);
651
+ });
652
+ });
653
+
654
+ it('replaces and handles multiple special characters and tracked strings', function () {
655
+ simulateRequestScope(() => {
656
+ const extern = trackString('foobar');
657
+ const replacement = trackString('bar');
658
+ const ret = extern.replace(replacement, '$$$&$$$&');
659
+ assert.strictEqual(ret, 'foo$bar$bar');
660
+ const tags = tracker.getData(ret).tags;
661
+ const expectedTags = {UNTRUSTED: [0, 2, 4, 6, 8, 10]};
662
+ assertTagsEqual(tags, expectedTags);
663
+ });
664
+ });
665
+
666
+ // example is from shell-quote
667
+ it('replaces and handles $\\d replacements when capture group exists or doesn\'t', function() {
668
+ const s = 'http://example.com?a=1&a=2';
669
+ const expected = s.replace(/([A-Za-z]:)?([#!"$&'()*,:;<=>?@[\\\]^`{|}])/g, '$1\\$2');
670
+ simulateRequestScope(() => {
671
+ const result = s.replace(/([A-Za-z]:)?([#!"$&'()*,:;<=>?@[\\\]^`{|}])/g, '$1\\$2');
672
+ // http\://example.com\?a\=1\&a\=2
673
+ assert.strictEqual(result, expected);
674
+ });
675
+ });
676
+
677
+ it('handles disjunction (unmatched but present group)', function() {
678
+ const s = 'http://example.com?a=1&a=2';
679
+ const expected = s.replace(/(http)|(https)/g, '$2');
680
+ simulateRequestScope(() => {
681
+ const result = s.replace(/(http)|(https)/g, '$2');
682
+ // http\://example.com\?a\=1\&a\=2
683
+ assert.strictEqual(result, expected);
684
+ });
685
+ });
686
+
687
+ it('replaces and handles $<name> replacements when capture group exists or doesn\'t', function() {
688
+ const s = 'http://example.com?a=1&a=2';
689
+ const expected = s.replace(/(?<protocol>[A-Za-z]:)?(?<special>[#!"$&'()*,:;<=>?@[\\\]^`{|}])/g, '$<protocol>\\$<special>');
690
+ simulateRequestScope(() => {
691
+ const result = s.replace(/(?<protocol>[A-Za-z]:)?(?<special>[#!"$&'()*,:;<=>?@[\\\]^`{|}])/g, '$<protocol>\\$<special>');
692
+ // http\://example.com\?a\=1\&a\=2
693
+ assert.strictEqual(result, expected);
694
+ });
695
+ });
696
+
697
+ });
698
+ });
699
+
700
+ function assertTagsEqual(tags, expectedTags) {
701
+ assert.deepStrictEqual(Object.keys(tags), Object.keys(expectedTags), 'tag mismatch');
702
+ for (const tag of Object.keys(tags)) {
703
+ assert.deepStrictEqual(tags[tag], expectedTags[tag], "tag range mismatch");
704
+ }
705
+ }
@@ -37,6 +37,10 @@ module.exports = function tracker(core) {
37
37
  return objMap.get(value) || null;
38
38
  }
39
39
 
40
+ function isTracked(value) {
41
+ return distringuish.isExternal(value);
42
+ }
43
+
40
44
  function track(value, metadata) {
41
45
  let ret = Object.create(null);
42
46
 
@@ -148,5 +152,6 @@ module.exports = function tracker(core) {
148
152
  untrack,
149
153
  getData,
150
154
  getInfo: getData,
155
+ isTracked,
151
156
  };
152
157
  };
@@ -61,7 +61,7 @@ module.exports = function(core) {
61
61
  return null;
62
62
  }
63
63
 
64
- if (ctx.propagationEventsCount > config.assess.max_propagation_events) return null;
64
+ if (ctx.propagationEventsCount >= config.assess.max_propagation_events) return null;
65
65
 
66
66
  return ctx;
67
67
  };
@@ -105,9 +105,8 @@ module.exports = function(core) {
105
105
  return null;
106
106
  }
107
107
 
108
- if (ctx.sourceEventsCount > config.assess.max_context_source_events) return null;
108
+ if (ctx.sourceEventsCount >= config.assess.max_context_source_events) return null;
109
109
 
110
110
  return ctx;
111
111
  };
112
-
113
112
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrast/assess",
3
- "version": "1.46.2",
3
+ "version": "1.47.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)",
@@ -28,7 +28,7 @@
28
28
  "@contrast/instrumentation": "1.24.2",
29
29
  "@contrast/logger": "1.18.2",
30
30
  "@contrast/patcher": "1.17.2",
31
- "@contrast/rewriter": "1.21.3",
31
+ "@contrast/rewriter": "1.21.4",
32
32
  "@contrast/route-coverage": "1.35.2",
33
33
  "@contrast/scopes": "1.15.2",
34
34
  "semver": "^7.6.0"