@contrast/assess 1.7.0 → 1.8.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.
@@ -47,11 +47,16 @@ const collectionMethods = [
47
47
  'updateMany',
48
48
  'deleteOne',
49
49
  'deleteMany',
50
+ 'aggregate',
51
+ 'mapReduce',
52
+ 'group'
50
53
  ];
51
54
  const dbMethods = [
52
55
  'command',
53
- 'eval'
56
+ 'eval',
57
+ 'aggregate'
54
58
  ];
59
+ const methodsWithNestedCalls = ['findOne', 'eval', 'group'];
55
60
 
56
61
  const querySafeTags = [
57
62
  ALPHANUM_SPACE_HYPHEN,
@@ -80,7 +85,7 @@ module.exports = function(core) {
80
85
  const inspect = patcher.unwrap(util.inspect);
81
86
  const instr = core.assess.dataflow.sinks.mongodb = {};
82
87
 
83
- instr.getVulnerabilityInfo = function getVulnerabilityInfo(query) {
88
+ instr.getQueryVulnerabilityInfo = function getQueryVulnerabilityInfo(query) {
84
89
  let vulnInfo = null;
85
90
  let reportSafe = null;
86
91
 
@@ -103,10 +108,10 @@ module.exports = function(core) {
103
108
  const strInfo = tracker.getData(value);
104
109
  if (strInfo) {
105
110
  if (isVulnerable(UNTRUSTED, querySafeTags, strInfo.tags)) {
106
- vulnInfo = { path, strInfo };
111
+ vulnInfo = { path: [...path], strInfo };
107
112
  return true; // halts traversal
108
113
  } else {
109
- reportSafe = { path, strInfo };
114
+ reportSafe = { path: [...path], strInfo };
110
115
  }
111
116
  }
112
117
  });
@@ -114,6 +119,202 @@ module.exports = function(core) {
114
119
  return { vulnInfo, reportSafe };
115
120
  };
116
121
 
122
+ instr.getAggregateVulnerabilityInfo = function getAggregateVulnerabilityInfo(aggregation) {
123
+ let vulnInfo = null;
124
+ let reportSafe = null;
125
+
126
+ if (!isNonEmptyObject(aggregation)) return { vulnInfo, reportSafe };
127
+
128
+ traverseValues(aggregation, (path, _type, value) => {
129
+ const accumulatorFuncProps = [
130
+ 'init',
131
+ 'merge',
132
+ 'accumulate',
133
+ 'finalize'
134
+ ];
135
+ const lastIdx = path.length - 1;
136
+ if (
137
+ (path[lastIdx - 1] === '$function' && path[lastIdx] === 'body') ||
138
+ (path[lastIdx - 1] === '$accumulator' && accumulatorFuncProps.includes(path[lastIdx]))
139
+ ) {
140
+ const strInfo = tracker.getData(value);
141
+ if (strInfo) {
142
+ if (isVulnerable(UNTRUSTED, querySafeTags, strInfo.tags)) {
143
+ vulnInfo = { path: [...path], strInfo };
144
+ return true; // halts traversal
145
+ } else {
146
+ reportSafe = { path: [...path], strInfo };
147
+ }
148
+ }
149
+ }
150
+ });
151
+
152
+ return { vulnInfo, reportSafe };
153
+ };
154
+
155
+ instr.getMapReduceVulnerabilityInfo = function getMapReduceVulnerabilityInfo(argToCheck, argIdx) {
156
+ let vulnInfo = null;
157
+ let reportSafe = null;
158
+
159
+ if (argIdx !== 2 && isString(argToCheck)) {
160
+ const strInfo = tracker.getData(argToCheck);
161
+ if (strInfo) {
162
+ if (isVulnerable(UNTRUSTED, querySafeTags, strInfo.tags)) {
163
+ vulnInfo = { strInfo };
164
+ } else {
165
+ reportSafe = { strInfo };
166
+ }
167
+ }
168
+
169
+ return { vulnInfo, reportSafe };
170
+ }
171
+
172
+ if (!isNonEmptyObject(argToCheck)) return { vulnInfo, reportSafe };
173
+
174
+ traverseValues(argToCheck, (path, _type, value) => {
175
+ const vulnerableProps = [
176
+ 'query',
177
+ 'finalize',
178
+ ];
179
+ if (vulnerableProps.includes(path[0])) {
180
+ const strInfo = tracker.getData(value);
181
+ if (strInfo) {
182
+ if (isVulnerable(UNTRUSTED, querySafeTags, strInfo.tags)) {
183
+ vulnInfo = { path: [...path], strInfo };
184
+ return true; // halts traversal
185
+ } else {
186
+ reportSafe = { path: [...path], strInfo };
187
+ }
188
+ }
189
+ }
190
+ });
191
+
192
+ return { vulnInfo, reportSafe };
193
+ };
194
+
195
+ instr.getGroupVulnerabilityInfo = function getGroupVulnerabilityInfo(argToCheck, argIdx) {
196
+ let vulnInfo = null;
197
+ let reportSafe = null;
198
+
199
+ if (argIdx !== 1 && isString(argToCheck)) {
200
+ const strInfo = tracker.getData(argToCheck);
201
+ if (strInfo) {
202
+ if (isVulnerable(UNTRUSTED, querySafeTags, strInfo.tags)) {
203
+ vulnInfo = { strInfo };
204
+ } else {
205
+ reportSafe = { strInfo };
206
+ }
207
+ }
208
+
209
+ return { vulnInfo, reportSafe };
210
+ }
211
+
212
+ if (!isNonEmptyObject(argToCheck)) return { vulnInfo, reportSafe };
213
+
214
+ traverseValues(argToCheck, (path, _type, value) => {
215
+ const strInfo = tracker.getData(value);
216
+ if (strInfo) {
217
+ if (isVulnerable(UNTRUSTED, querySafeTags, strInfo.tags)) {
218
+ vulnInfo = { path: [...path], strInfo };
219
+ return true; // halts traversal
220
+ } else {
221
+ reportSafe = { path: [...path], strInfo };
222
+ }
223
+ }
224
+ });
225
+
226
+ return { vulnInfo, reportSafe };
227
+ };
228
+
229
+ function createAroundHook(entity, name, method, getInfoMethod, vulnerableArgIdxs) {
230
+ const argsIdxsToCheck = vulnerableArgIdxs || [0];
231
+ return function(next, data) {
232
+ const { obj, args: origArgs } = data;
233
+ const sourceCtx = sources.getStore()?.assess;
234
+
235
+ if (isLocked(NOSQL_INJECTION_MONGO) || instrumentation.isLocked() || !sourceCtx) {
236
+ return next();
237
+ }
238
+
239
+ let vulnInfo;
240
+ let reportSafe;
241
+ let vulnArgIdx;
242
+ const safeReports = [];
243
+
244
+ try {
245
+ for (const argIdx of argsIdxsToCheck) {
246
+ ({ vulnInfo, reportSafe } = getInfoMethod(origArgs[argIdx], argIdx));
247
+
248
+ if (vulnInfo) {
249
+ vulnArgIdx = argIdx;
250
+ break;
251
+ }
252
+ if (reportSafe) safeReports.push({ ...reportSafe, argIdx });
253
+ }
254
+
255
+ if (!vulnInfo) {
256
+ if (safeReports.length && config.assess.safe_positives.enable) {
257
+ const safeTags = safeReports.map((report) => filterSafeTags(querySafeTags, report.strInfo));
258
+ const strInfo = safeReports.map((report) => {
259
+ const tags = report.path ? getAdjustedQueryTags(report.path, report.strInfo, inspect(origArgs[report.argIdx], { depth: 4 })) : report.strInfo?.tags;
260
+
261
+ return {
262
+ value: inspect(origArgs[report.argIdx], { depth: 4 }),
263
+ tags
264
+ };
265
+ });
266
+
267
+ reportSafePositive({
268
+ name,
269
+ ruleId: NOSQL_INJECTION_MONGO,
270
+ safeTags: safeTags.length === 1 ? safeTags[0] : safeTags,
271
+ strInfo: strInfo.length === 1 ? strInfo[0] : strInfo
272
+ });
273
+ }
274
+
275
+ return methodsWithNestedCalls.includes(method) ? runInActiveSink(NOSQL_INJECTION_MONGO, async () => await next()) : next();
276
+ }
277
+
278
+ const { path, strInfo } = vulnInfo;
279
+ const objName = getObjectName(obj, entity);
280
+ const args = origArgs.map((arg, idx) => ({
281
+ value: isString(arg) ? arg : inspect(arg, { depth: 4 }),
282
+ tracked: idx === vulnArgIdx,
283
+ }));
284
+
285
+ const tags = path ? getAdjustedQueryTags(path, strInfo, args[vulnArgIdx].value) : strInfo?.tags;
286
+ const resultVal = args[args.length - 1].value.startsWith('[Function') ? '' : 'Promise';
287
+ const sinkEvent = createSinkEvent({
288
+ args,
289
+ context: `${objName}.${method}(${args.map((a) => a.value)})`,
290
+ history: [strInfo],
291
+ object: {
292
+ tracked: false,
293
+ value: `mongodb.${entity}`,
294
+ },
295
+ name,
296
+ result: {
297
+ tracked: false,
298
+ value: resultVal,
299
+ },
300
+ source: `P${vulnArgIdx}`,
301
+ stacktraceOpts: {
302
+ constructorOpt: data.hooked,
303
+ },
304
+ tags,
305
+ });
306
+
307
+ if (sinkEvent) {
308
+ reportFindings({ ruleId: NOSQL_INJECTION_MONGO, sinkEvent });
309
+ }
310
+ } catch (err) {
311
+ core.logger.error({ name, err }, 'assess sink analysis failed');
312
+ }
313
+
314
+ return methodsWithNestedCalls.includes(method) ? runInActiveSink(NOSQL_INJECTION_MONGO, async () => await next()) : next();
315
+ };
316
+ }
317
+
117
318
  instr.install = function() {
118
319
  depHooks.resolve({ name: 'mongodb' }, (mongodb, version) => {
119
320
  patchCollection(mongodb, version);
@@ -133,78 +334,31 @@ module.exports = function(core) {
133
334
  continue;
134
335
  }
135
336
 
136
- patcher.patch(proto, method, {
137
- name,
138
- patchType,
139
- around(next, data) {
140
- const { obj, args: origArgs } = data;
141
- const sourceCtx = sources.getStore()?.assess;
142
-
143
- if (isLocked(NOSQL_INJECTION_MONGO) || instrumentation.isLocked() || !sourceCtx) {
144
- return next();
145
- }
146
-
147
- const argIdx = 0;
148
- try {
149
- const { vulnInfo, reportSafe } = instr.getVulnerabilityInfo(origArgs[argIdx]);
150
-
151
- if (!vulnInfo) {
152
- reportSafe && config.assess.safe_positives.enable && reportSafePositive({
153
- name,
154
- ruleId: NOSQL_INJECTION_MONGO,
155
- safeTags: filterSafeTags(querySafeTags, reportSafe.strInfo),
156
- strInfo: {
157
- value: inspect(origArgs[argIdx]),
158
- tags: getAdjustedQueryTags(reportSafe.path, reportSafe.strInfo, inspect(origArgs[argIdx])),
159
- },
160
- });
161
- return method === 'findOne' ? runInActiveSink(NOSQL_INJECTION_MONGO, async () => await next()) : next();
162
- }
163
-
164
- const { path, strInfo } = vulnInfo;
165
- const objName = getObjectName(obj);
166
- const args = origArgs.map((arg, idx) => ({
167
- value: inspect(arg),
168
- tracked: idx === argIdx,
169
- }));
170
-
171
- const tags = getAdjustedQueryTags(path, strInfo, args[argIdx].value);
172
- const resultVal = args[args.length - 1].value.startsWith('[Function') ? '' : 'Promise';
173
- const sinkEvent = createSinkEvent({
174
- args,
175
- context: `${objName}.${method}(${args.map((a) => a.value)})`,
176
- history: [strInfo],
177
- object: {
178
- tracked: false,
179
- value: 'mongodb.Collection',
180
- },
181
- name,
182
- result: {
183
- tracked: false,
184
- value: resultVal,
185
- },
186
- source: `P${argIdx}`,
187
- stacktraceOpts: {
188
- constructorOpt: data.hooked,
189
- },
190
- tags,
191
- });
192
-
193
- if (sinkEvent) {
194
- reportFindings({ ruleId: NOSQL_INJECTION_MONGO, sinkEvent });
195
- }
196
- } catch (err) {
197
- core.logger.error({ name, err }, 'assess sink analysis failed');
198
- }
199
-
200
- if (method === 'findOne') {
201
- // `findOne` will call `find` so don't analyze in nested call
202
- return runInActiveSink(NOSQL_INJECTION_MONGO, async () => await next());
203
- }
204
-
205
- return next();
206
- },
207
- });
337
+ if (method === 'aggregate') {
338
+ patcher.patch(proto, method, {
339
+ name,
340
+ patchType,
341
+ around: createAroundHook('Collection', name, method, instr.getAggregateVulnerabilityInfo)
342
+ });
343
+ } else if (method === 'mapReduce') {
344
+ patcher.patch(proto, method, {
345
+ name,
346
+ patchType,
347
+ around: createAroundHook('Collection', name, method, instr.getMapReduceVulnerabilityInfo, [0, 1, 2])
348
+ });
349
+ } else if (method === 'group') {
350
+ patcher.patch(proto, method, {
351
+ name,
352
+ patchType,
353
+ around: createAroundHook('Collection', name, method, instr.getGroupVulnerabilityInfo, [0, 1, 3])
354
+ });
355
+ } else {
356
+ patcher.patch(proto, method, {
357
+ name,
358
+ patchType,
359
+ around: createAroundHook('Collection', name, method, instr.getQueryVulnerabilityInfo)
360
+ });
361
+ }
208
362
  }
209
363
  }
210
364
 
@@ -218,79 +372,19 @@ module.exports = function(core) {
218
372
  continue;
219
373
  }
220
374
 
221
- patcher.patch(proto, method, {
222
- name,
223
- patchType,
224
- around(next, data) {
225
- const { obj, args: origArgs } = data;
226
- const sourceCtx = sources.getStore()?.assess;
227
-
228
- if (isLocked(NOSQL_INJECTION_MONGO) || instrumentation.isLocked() || !sourceCtx) {
229
- return next();
230
- }
231
-
232
- const argIdx = 0;
233
- try {
234
- const { vulnInfo, reportSafe } = instr.getVulnerabilityInfo(origArgs[argIdx]);
235
-
236
- if (!vulnInfo) {
237
- const tags = reportSafe?.path ? getAdjustedQueryTags(reportSafe.path, reportSafe.strInfo, inspect(origArgs[argIdx])) : reportSafe?.strInfo?.tags;
238
- reportSafe && config.assess.safe_positives.enable && reportSafePositive({
239
- name,
240
- ruleId: NOSQL_INJECTION_MONGO,
241
- safeTags: filterSafeTags(querySafeTags, reportSafe.strInfo),
242
- strInfo: {
243
- value: inspect(origArgs[argIdx]),
244
- tags
245
- },
246
- });
247
- return method === 'eval' ? runInActiveSink(NOSQL_INJECTION_MONGO, async () => await next()) : next();
248
- }
249
-
250
- const { path, strInfo } = vulnInfo;
251
- const objName = getObjectName(obj, 'Db');
252
- const args = origArgs.map((arg, idx) => ({
253
- value: isString(arg) ? arg : inspect(arg),
254
- tracked: idx === argIdx,
255
- }));
256
-
257
- const tags = path ? getAdjustedQueryTags(path, strInfo, args[argIdx].value) : strInfo?.tags;
258
- const resultVal = args[args.length - 1].value.startsWith('[Function') ? '' : 'Promise';
259
- const sinkEvent = createSinkEvent({
260
- args,
261
- context: `${objName}.${method}(${args.map((a) => a.value)})`,
262
- history: [strInfo],
263
- object: {
264
- tracked: false,
265
- value: 'mongodb.Db',
266
- },
267
- name,
268
- result: {
269
- tracked: false,
270
- value: resultVal,
271
- },
272
- source: `P${argIdx}`,
273
- stacktraceOpts: {
274
- constructorOpt: data.hooked,
275
- },
276
- tags,
277
- });
278
-
279
- if (sinkEvent) {
280
- reportFindings({ ruleId: NOSQL_INJECTION_MONGO, sinkEvent });
281
- }
282
- } catch (err) {
283
- core.logger.error({ name, err }, 'assess sink analysis failed');
284
- }
285
-
286
- if (method === 'eval') {
287
- // `eval` will call `command` so don't analyze in nested call
288
- return runInActiveSink(NOSQL_INJECTION_MONGO, async () => await next());
289
- }
290
-
291
- return next();
292
- },
293
- });
375
+ if (method === 'aggregate') {
376
+ patcher.patch(proto, method, {
377
+ name,
378
+ patchType,
379
+ around: createAroundHook('Db', name, method, instr.getAggregateVulnerabilityInfo)
380
+ });
381
+ } else {
382
+ patcher.patch(proto, method, {
383
+ name,
384
+ patchType,
385
+ around: createAroundHook('Db', name, method, instr.getQueryVulnerabilityInfo)
386
+ });
387
+ }
294
388
  }
295
389
  }
296
390
 
@@ -310,6 +404,10 @@ module.exports = function(core) {
310
404
  const { tags } = strInfo;
311
405
  let idx = -1;
312
406
  for (const str of [...path, strInfo.value]) {
407
+ // This is the case with the `.aggregate` method
408
+ // where the argument is an array
409
+ if (str === 0) continue;
410
+
313
411
  idx = argString.indexOf(str, idx);
314
412
  if (idx == -1) {
315
413
  idx = -1;
@@ -19,7 +19,6 @@ const {
19
19
  InputType,
20
20
  DataflowTag,
21
21
  isString,
22
- traverseValues
23
22
  } = require('@contrast/common');
24
23
 
25
24
  module.exports = function(core) {
@@ -28,17 +27,17 @@ module.exports = function(core) {
28
27
  dataflow: {
29
28
  sources,
30
29
  tracker,
31
- eventFactory: { createSourceEvent }
30
+ eventFactory
32
31
  }
33
32
  },
34
- createSnapshot,
35
33
  config,
34
+ createSnapshot,
36
35
  logger,
37
36
  } = core;
38
37
 
39
38
  const emptyStack = Object.freeze([]);
40
39
 
41
- sources.createTags = function createTags({ inputType, key, value }) {
40
+ sources.createTags = function createTags({ inputType, fieldName = '', value }) {
42
41
  if (!value?.length) {
43
42
  return null;
44
43
  }
@@ -48,25 +47,30 @@ module.exports = function(core) {
48
47
  [DataflowTag.UNTRUSTED]: [0, stop]
49
48
  };
50
49
 
51
- if (inputType === InputType.HEADER && key.toLowerCase() === 'referer') {
50
+ if (inputType === InputType.HEADER && fieldName.toLowerCase() === 'referer') {
52
51
  tags[DataflowTag.HEADER] = [0, stop];
53
52
  }
54
53
 
55
54
  return tags;
56
55
  };
57
56
 
57
+ sources.createStacktrace = function(stacktraceOpts) {
58
+ return config.assess.stacktraces === 'NONE'
59
+ ? emptyStack
60
+ : createSnapshot(stacktraceOpts)();
61
+ };
62
+
58
63
  sources.handle = function({
59
64
  context,
65
+ keys,
60
66
  name,
61
67
  inputType = InputType.UNKNOWN,
62
68
  stacktraceOpts,
63
69
  data,
64
- sourceContext
70
+ sourceContext,
65
71
  }) {
66
72
  if (!data) return;
67
73
 
68
- const max = config.assess.max_context_source_events;
69
-
70
74
  if (!sourceContext) {
71
75
  core.logger.trace({ inputType, name }, 'skipping assess source handling - no request context');
72
76
  return null;
@@ -76,31 +80,60 @@ module.exports = function(core) {
76
80
  context = inputType;
77
81
  }
78
82
 
83
+ const max = config.assess.max_context_source_events;
84
+ let _data = data;
79
85
  let stack;
80
86
 
81
- traverseValues(data, (path, type, value, obj) => {
87
+ if (keys) {
88
+ _data = {};
89
+ for (const key of keys) {
90
+ _data[key] = data[key];
91
+ }
92
+ }
93
+
94
+ function createEvent({ fieldName, pathName, value }) {
95
+ // create the stacktrace once per call to .handle()
96
+ stack || (stack = sources.createStacktrace(stacktraceOpts));
97
+ return eventFactory.createSourceEvent({
98
+ context: `${context}.${pathName}`,
99
+ name,
100
+ fieldName,
101
+ pathName,
102
+ stack,
103
+ inputType,
104
+ tags: sources.createTags({ inputType, fieldName, value }),
105
+ result: { tracked: true, value },
106
+ });
107
+ }
108
+
109
+ if (Buffer.isBuffer(data) && !tracker.getData(data)) {
110
+ const event = createEvent({ pathName: 'body', value: data, fieldName: '' });
111
+ if (event) {
112
+ tracker.track(data, event);
113
+ }
114
+ return;
115
+ }
116
+
117
+ traverse(_data, (path, fieldName, value, obj) => {
118
+ const pathName = path.join('.');
119
+
82
120
  if (sourceContext.sourceEventsCount >= max) {
83
121
  core.logger.trace({ inputType, name }, 'exiting assess source handling - %s max events exceeded', max);
84
122
  return true;
85
123
  }
86
124
 
87
125
  if (isString(value) && value.length) {
88
- stack = stack || config.assess.stacktraces === 'NONE'
89
- ? emptyStack
90
- : createSnapshot(stacktraceOpts)();
91
- const key = path[path.length - 1];
92
- const pathName = path.join('.');
93
- const event = createSourceEvent({
94
- context: `${context}.${pathName}`,
95
- name,
96
- fieldName: key,
97
- pathName,
98
- stack,
99
- inputType,
100
- tags: sources.createTags({ inputType, key, value }),
101
- result: { tracked: true, value },
102
- });
126
+ const strInfo = tracker.getData(value);
127
+
128
+ if (strInfo) {
129
+ // TODO: confirm this "layering-on" approach is what we want
130
+ // when the value is tracked the handler wins out and we "re-tracks" the value with new source
131
+ // event metadata. without this step tracker would complain about value already being tracked.
132
+ // alternatively we could treat this more like a propagation event and update existing metadata.
133
+ value = strInfo.value;
134
+ }
103
135
 
136
+ const event = createEvent({ pathName, value, fieldName });
104
137
  if (!event) {
105
138
  core.logger.warn({ inputType, name, pathName, value }, 'unable to create source event');
106
139
  return;
@@ -108,15 +141,96 @@ module.exports = function(core) {
108
141
 
109
142
  const { extern } = tracker.track(value, event);
110
143
  if (extern) {
111
- logger.trace({ extern, key, name, inputType }, 'tracked');
112
- obj[key] = extern;
144
+ logger.trace({ extern, fieldName, name, inputType }, 'tracked');
145
+ obj[fieldName] = extern;
146
+
113
147
  sourceContext.sourceEventsCount++;
114
148
  }
149
+ } else if (Buffer.isBuffer(value) && !tracker.getData(value)) {
150
+ const event = createEvent({ pathName, value, fieldName });
151
+ if (event) {
152
+ tracker.track(value, event);
153
+ } else {
154
+ core.logger.warn({ inputType, name, pathName, value }, 'unable to create source event');
155
+ }
115
156
  }
116
157
  });
117
158
 
159
+ if (keys) {
160
+ for (const key of keys) {
161
+ data[key] = _data[key];
162
+ }
163
+ }
164
+
118
165
  return data;
119
166
  };
120
167
 
121
168
  return sources;
122
169
  };
170
+
171
+ /**
172
+ * A custom traversal function for handling source value tracking efficiently.
173
+ * Implementation was adapted from traversal methods in @contrast/common.
174
+ * @param {any} target object to traverse
175
+ * @param {function} cb function<path, key, value, obj>
176
+ * @param {string[]} path path of node being visted; constructed of nested keys
177
+ * @param {boolean} halt whether to halt traversal; determined by callback
178
+ * @param {Set} visited used to dedupe circular references
179
+ */
180
+ function traverse(target, cb, path = [], visited = new Set()) {
181
+ if (isTraversable(target)) {
182
+ for (const key in target) {
183
+ path.push(key);
184
+
185
+ const value = target[key];
186
+
187
+ if (visited.has(value)) {
188
+ path.pop();
189
+ break;
190
+ }
191
+
192
+ if (isVisitable(value)) {
193
+ const halt = cb(path, key, value, target) === false;
194
+ if (halt) {
195
+ return;
196
+ }
197
+ }
198
+
199
+ if (isTraversable(value)) {
200
+ visited.add(value);
201
+ traverse(value, cb, path, visited);
202
+ }
203
+
204
+ path.pop();
205
+ }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Visit strings, buffers, basic objects and arrays.
211
+ * @param {any} value the value to check
212
+ * @returns {boolean}
213
+ */
214
+ function isVisitable(value) {
215
+ if (!value) return false;
216
+
217
+ return value.constructor?.name === 'String' ||
218
+ value.constructor?.name === 'Buffer' ||
219
+ (typeof value === 'object' && !value.constructor);
220
+ }
221
+
222
+ /**
223
+ * The criteria for traversal is a strict as possible. We only traverse plain
224
+ * objects and arrays and objects created via Object.create(null).
225
+ * @param {any} value the value to check
226
+ * @returns {boolean}
227
+ */
228
+ function isTraversable(value) {
229
+ if (!value || typeof value !== 'object') return false;
230
+
231
+ return value.constructor?.name === 'Object' ||
232
+ value.constructor?.name === 'Array' ||
233
+ !value.constructor;
234
+ }
235
+
236
+ module.exports.traverse = traverse;