@contrast/protect 1.0.1

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.
Files changed (48) hide show
  1. package/LICENSE +12 -0
  2. package/README.md +9 -0
  3. package/lib/cli-rewriter.js +20 -0
  4. package/lib/error-handlers/constants.js +5 -0
  5. package/lib/error-handlers/index.js +13 -0
  6. package/lib/error-handlers/install/fastify3.js +88 -0
  7. package/lib/error-handlers/install/fastify3.test.js +142 -0
  8. package/lib/esm-loader.mjs +2 -0
  9. package/lib/esm-loader.test.mjs +11 -0
  10. package/lib/index.d.ts +36 -0
  11. package/lib/index.js +89 -0
  12. package/lib/index.test.js +32 -0
  13. package/lib/input-analysis/handlers.js +462 -0
  14. package/lib/input-analysis/handlers.test.js +898 -0
  15. package/lib/input-analysis/index.js +16 -0
  16. package/lib/input-analysis/index.test.js +28 -0
  17. package/lib/input-analysis/install/fastify3.js +79 -0
  18. package/lib/input-analysis/install/fastify3.test.js +71 -0
  19. package/lib/input-analysis/install/http.js +185 -0
  20. package/lib/input-analysis/install/http.test.js +315 -0
  21. package/lib/input-tracing/constants.js +5 -0
  22. package/lib/input-tracing/handlers/index.js +117 -0
  23. package/lib/input-tracing/handlers/index.test.js +395 -0
  24. package/lib/input-tracing/handlers/nosql-injection-mongo.js +48 -0
  25. package/lib/input-tracing/index.js +32 -0
  26. package/lib/input-tracing/install/README.md +1 -0
  27. package/lib/input-tracing/install/child-process.js +45 -0
  28. package/lib/input-tracing/install/child-process.test.js +112 -0
  29. package/lib/input-tracing/install/fs.js +107 -0
  30. package/lib/input-tracing/install/fs.test.js +118 -0
  31. package/lib/input-tracing/install/mysql.js +57 -0
  32. package/lib/input-tracing/install/mysql.test.js +108 -0
  33. package/lib/input-tracing/install/postgres.js +61 -0
  34. package/lib/input-tracing/install/postgres.test.js +125 -0
  35. package/lib/input-tracing/install/sequelize.js +51 -0
  36. package/lib/input-tracing/install/sequelize.test.js +79 -0
  37. package/lib/input-tracing/install/sqlite3.js +45 -0
  38. package/lib/input-tracing/install/sqlite3.test.js +88 -0
  39. package/lib/make-response-blocker.js +35 -0
  40. package/lib/make-response-blocker.test.js +88 -0
  41. package/lib/make-source-context.js +130 -0
  42. package/lib/make-source-context.test.js +298 -0
  43. package/lib/security-exception.js +12 -0
  44. package/lib/throw-security-exception.js +30 -0
  45. package/lib/throw-security-exception.test.js +50 -0
  46. package/lib/utils.js +88 -0
  47. package/lib/utils.test.js +40 -0
  48. package/package.json +32 -0
@@ -0,0 +1,462 @@
1
+ 'use strict';
2
+
3
+ const { simpleTraverse } = require('../utils');
4
+
5
+ //
6
+ // these rules are not implemented by agent-lib, but are being considered for
7
+ // implementation:
8
+ // - semantic SQL injection: chaining, dangerous functions, suspicious unions, tautologies
9
+ // - malformed header
10
+ //
11
+ // the following rules are out-of-scope for agent-lib and should be implemented in
12
+ // the agent:
13
+ // - cmd-injection-backdoors (simple indexing)
14
+ // - expression-language-injection (java specific)
15
+ // - method-tampering
16
+ // - ReDOS (java specific)
17
+ // - untrusted-deserialization (agent/framework specific)
18
+ // - xxe (agent/framework specific)
19
+ //
20
+ // of the rules not implemented by agent-lib, only method-tampering and xxe check
21
+ // input and make a decision immediately (i.e., there is no sink). so these two
22
+ // rules will need to be implemented by the agent and called from these handlers.
23
+
24
+
25
+ // agent-lib treats each type of nosql database as a separate
26
+ // rule. they are all mapped to 'nosql-injection' in the agent.
27
+ // maybe this will need to change.
28
+ const agentLibRuleTypeToName = {
29
+ 'nosql-injection-mongo': 'nosql-injection'
30
+ };
31
+
32
+ const preferWW = { preferWorthWatching: true };
33
+
34
+ module.exports = function(core) {
35
+ const {
36
+ logger,
37
+ protect: {
38
+ agentLib,
39
+ inputAnalysis,
40
+ },
41
+ } = core;
42
+
43
+ // all handlers will be invoked with two arguments:
44
+ // 1) sourceContext object containing:
45
+ // - reqData, the abstract request object containing only what is needed
46
+ // - protect, the protect context
47
+ // - rules, exclusions, virtual patches (TS data). what was in effect for this
48
+ // url *at the time the request was started*. these will not change.
49
+ // - block(), function that executes block for this specific request
50
+ // - findings{}, consolidated collection of findings returned by score functions
51
+ // and augmented with disposition and additional information as needed.
52
+ // 2) input or inputs specific to that handler
53
+ //
54
+ // exclusions
55
+ // - url exclusions are applied at the level above the connect handler, if no rules
56
+ // are applicable to a given URL then it's not processed in any way by handlers.
57
+ // - input exclusions are applied **after** analysis. The rationale is that checking
58
+ // inputs against rules 1) is very fast and 2) dramatically pares down the number
59
+ // of exclusion checks that need to be made.
60
+
61
+ /**
62
+ * handleConnect()
63
+ *
64
+ * handle the inputs that are available when the HTTP 'request' event is emitted.
65
+ *
66
+ * this should *always* be the first handler called; it does setup that other
67
+ * handlers require.
68
+ *
69
+ * the specific data that is available to be processed at this time:
70
+ * - URI
71
+ * - queries
72
+ * - url params
73
+ * - headers (should exclude cookies header)
74
+ * - cookies (special handling of the header)
75
+ *
76
+ * but it really only makes sense to process:
77
+ * - URI
78
+ * - headers except cookies
79
+ *
80
+ * why? because the query string will (probably) be interpreted by the framework using
81
+ * 'qs' and that could result in creating an array or an object instead of a simple text
82
+ * value. and the cookies need to be parsed into individual cookies by the framework so
83
+ * that our code doesn't have to guess at the format, encoding, and other permutations.
84
+ * and for url params - only the framework knows that a portion of the url is a param.
85
+ *
86
+ * if it is known that 'qs' is not being used, then passing the query string in the
87
+ * 'connectInputs' makes sense; a flag similar to 'contentType' can be set and it can be
88
+ * used later to avoid calling 'handleQueryParams()'
89
+ *
90
+ * @param {Object} sourceContext { reqData, protect } that will be supplied to
91
+ * all handlers and sinks for this request. It will always be supplied by the caller
92
+ * to a handler; the handler is not aware of the implementation.
93
+ * @param {Object} connectInputs each property is an input to be evaluated by this
94
+ * handler.
95
+ * @returns {undefined|[String]} undefined to permit else [mode, rule] to block.
96
+ */
97
+ inputAnalysis.handleConnect = function handleConnect(sourceContext, connectInputs) {
98
+ const { rules: { agentLibRules, agentLibRulesMask: mask } } = sourceContext;
99
+
100
+ // initialize findings to the basics
101
+ let block = undefined;
102
+ if (mask !== 0) {
103
+ const findings = agentLib.scoreRequestConnect(mask, connectInputs, preferWW);
104
+ block = mergeFindings(agentLibRules, sourceContext.findings, findings);
105
+ }
106
+
107
+ return block;
108
+ };
109
+
110
+ // this is called before the request goes away. this is where probe detection takes
111
+ // place. basically loop through findings.resultsList and, for all those that weren't
112
+ // blocked, run scoreAtom() with preferWorthWatching: false. for any that have a score
113
+ // >= 90 report them as probes.
114
+ inputAnalysis.handleRequestEnd = function handleRequestEnd(sourceContext) {
115
+ throw new Error('nyi', sourceContext);
116
+ };
117
+
118
+
119
+ const jsonInputTypes = {
120
+ keyType: agentLib.InputType.JsonKey, valueType: agentLib.InputType.JsonValue
121
+ };
122
+ const parameterInputTypes = {
123
+ keyType: agentLib.InputType.ParameterKey, valueType: agentLib.InputType.ParameterValue
124
+ };
125
+
126
+ /**
127
+ * handleQueryParams()
128
+ *
129
+ * If all values are strings there there is no need to re-evaluate them; that was
130
+ * done on requestConnect. But some frameworks use packages like 'qs' to parse the
131
+ * search params, which is non-standard and has many options including changing the
132
+ * delimiters and the ability to create the equivalent of JSON from the search params
133
+ * string. If any of the values are not strings then they need to be evaluated here;
134
+ * these results replace the results generated at requestConnect.
135
+ *
136
+ * If it is known that 'qs' is being used then it is better not to have passed the
137
+ * querystring to scoreRequestConnect().
138
+ *
139
+ * @param {Object} sourceContext
140
+ * @param {Object} queryParams pojo {key: value, ...} for all query params/search params
141
+ */
142
+ inputAnalysis.handleQueryParams = function handleQueryParams(sourceContext, queryParams) {
143
+ if (typeof queryParams !== 'object') {
144
+ logger.debug({ queryParams }, 'handleQueryParams() called with non-object');
145
+ return;
146
+ }
147
+ commonObjectAnalyzer(sourceContext, queryParams, parameterInputTypes);
148
+ };
149
+
150
+ /**
151
+ * handleUrlParams()
152
+ *
153
+ * Invoked when a framework emits URL params. It is similar to handling queryParams
154
+ * except no finding for URL params can be present.
155
+ *
156
+ * @param {Object} sourceContext
157
+ * @param {Object} urlParams pojo
158
+ */
159
+ inputAnalysis.handleUrlParams = function(sourceContext, urlParams) {
160
+ if (typeof urlParams !== 'object') {
161
+ logger.debug({ urlParams }, 'handleUrlParams() called with non-object');
162
+ return;
163
+ }
164
+
165
+ const { rules, rules: { agentLibRulesMask: mask } } = sourceContext;
166
+ const resultsList = [];
167
+ const { UrlParameter } = agentLib.InputType;
168
+
169
+ simpleTraverse(urlParams, function(path, type, value) {
170
+ // url param names are not checked.
171
+ if (type !== 'Value') {
172
+ return;
173
+ }
174
+ const items = agentLib.scoreAtom(mask, value, UrlParameter, preferWW);
175
+ if (!items) {
176
+ return;
177
+ }
178
+ for (const item of items) {
179
+ resultsList.push({
180
+ ruleId: item.ruleId,
181
+ inputType: 'UrlParameter',
182
+ path: path.slice(),
183
+ key: path.pop(), // there should always be at least the param name
184
+ value,
185
+ score: item.score,
186
+ idsList: [],
187
+ });
188
+ }
189
+ });
190
+
191
+ // if nothing was found then nothing needs to be done.
192
+ if (resultsList.length === 0) {
193
+ return;
194
+ }
195
+
196
+ // something was found, so create "complete" findings.
197
+ const urlParamsFindings = {
198
+ trackRequest: true,
199
+ securityException: undefined,
200
+ resultsList,
201
+ };
202
+ const block = mergeFindings(rules.agentLibRules, sourceContext.findings, urlParamsFindings);
203
+
204
+ if (block) {
205
+ sourceContext.block(...block);
206
+ }
207
+ };
208
+
209
+ /**
210
+ * handleCookies()
211
+ *
212
+ * Invoked when a framework emits cookies. It is similar to handling queryParams except
213
+ * no findings for cookies can be present.
214
+ * @param {Object} sourceContext
215
+ * @param {Object} cookies pojo
216
+ */
217
+ inputAnalysis.handleCookies = function(sourceContext, cookies) {
218
+ const cookiesArr = Object.entries(cookies).reduce((acc, [key, value]) => {
219
+ acc.push(key, value);
220
+ return acc;
221
+ }, []);
222
+ const { rules, rules: { agentLibRulesMask: mask } } = sourceContext;
223
+ const cookieFindings = agentLib.scoreRequestConnect(mask, { cookies: cookiesArr }, preferWW);
224
+
225
+ const block = mergeFindings(rules.agentLibRules, sourceContext.findings, cookieFindings);
226
+
227
+ if (block) {
228
+ sourceContext.block(...block);
229
+ }
230
+ };
231
+
232
+ /**
233
+ * handleParsedBody() is called with the body after a framework has parsed
234
+ * the body. If the body was JSON then it may have already been scored by
235
+ * handleRawBody(); if it was, bodyType was set to 'json' and this code only
236
+ * converts the previous findings, using the parsed body.
237
+ *
238
+ * @param {Object} sourceContext
239
+ * @param {Object} parsedBody
240
+ */
241
+ inputAnalysis.handleParsedBody = function(sourceContext, parsedBody) {
242
+ if (typeof parsedBody !== 'object') {
243
+ logger.debug({ parsedBody }, 'handleParsedBody() called with non-object');
244
+ return;
245
+ }
246
+
247
+ let bodyType;
248
+ let inputTypes;
249
+ if (sourceContext.reqData.contentType.includes('/json')) {
250
+ bodyType = 'json';
251
+ inputTypes = jsonInputTypes;
252
+ } else {
253
+ bodyType = 'urlencoded';
254
+ inputTypes = parameterInputTypes;
255
+ }
256
+ commonObjectAnalyzer(sourceContext, parsedBody, inputTypes);
257
+ sourceContext.findings.bodyType = bodyType;
258
+ };
259
+
260
+ // was MULTIPART_NAME but maybe we should just call it what it is. it's kind
261
+ // of a dumb rule anyway. but maybe some code actually uses the name provided.
262
+ inputAnalysis.handleFileUploadName = function(sourceContext, name) {
263
+ throw new Error('nyi', sourceContext, name);
264
+ };
265
+
266
+
267
+ /**
268
+ * commonObjectAnalyzer() walks an object supplied by the end-user and checks
269
+ * it for vulnerabilities.
270
+ *
271
+ * This can cause the request to be blocked, depending on the mode and findings.
272
+ *
273
+ * @param {Object} sourceContext the sourceContext for the request
274
+ * @param {Object} object the object to analyze. It could be from any input
275
+ * source that can resolve to an object: x-www-form-urlencoded, JSON,
276
+ * query params.
277
+ * @param {Object} inputTypes is either jsonInputTypes or parameterInputTypes,
278
+ * both are defined above. They specify the input types to be used when evaluating
279
+ * the object.
280
+ * @returns undefined
281
+ */
282
+ function commonObjectAnalyzer(sourceContext, object, inputTypes) {
283
+ const { rules, rules: { agentLibRulesMask: mask } } = sourceContext;
284
+ // use inputTypes to set params...
285
+ const { keyType, valueType } = inputTypes;
286
+ const inputTypeStr = inputTypes === jsonInputTypes ? 'Json' : 'Parameter';
287
+ const { Where } = agentLib.MongoQueryType;
288
+ const resultsList = [];
289
+
290
+ // it's possible to optimize this if qs (or a similar package) is not loaded
291
+ // or if none of the values of queryParams are objects. a quick '.includes()'
292
+ // could be used to determine that. if none are objects then simpleTraverse()
293
+ // wouldn't be used, just a simple "for (const key in queryParams) {...}" to
294
+ // check each key and value associated with the key.
295
+ //
296
+ // otoh, it's a fair amount of additional logic and the gain is likely to be
297
+ // small, so it probably only makes sense to check if qs (or similar) is actually
298
+ // in use. a benchmark of "for (const key in queryParams) {...}" vs simpleTraverse
299
+ // should be created to see if, and in what cases, it makes sense.
300
+ //
301
+ // another day.
302
+
303
+ /* eslint-disable-next-line complexity */
304
+ simpleTraverse(object, function(path, type, value) {
305
+ let itemType;
306
+ let mongoQueryType;
307
+ // this is a bit awkward now because nosql-injection-mongo is not integrated
308
+ // into the scoreAtom() function (or the check_input() function it uses). as
309
+ // a result, the two rules need to be checked independently and the results
310
+ // merged, which is kind of a pia.
311
+ // TODO AGENT-205
312
+ if (type === 'Key') {
313
+ itemType = keyType;
314
+ if (mask & agentLib.RuleType['nosql-injection-mongo']) {
315
+ mongoQueryType = agentLib.getMongoQueryType(value);
316
+ }
317
+ } else {
318
+ itemType = valueType;
319
+ }
320
+ let items = agentLib.scoreAtom(mask, value, itemType, preferWW);
321
+ if (!items && !mongoQueryType) {
322
+ return;
323
+ }
324
+ if (!items) {
325
+ items = [];
326
+ }
327
+ let mongoPath;
328
+ // if the key was a mongo query key, then add it to the items. it requires
329
+ // that additional information is kept as well.
330
+ if (mongoQueryType) {
331
+ const inputToCheck = getValueAtKey(object, path, value);
332
+ // because scoreRequestConnect() returns the query type in the value, we
333
+ // mimic it here (where scoreAtom() was used). the actual object/string
334
+ // to match is stored as `inputToCheck`.
335
+ const inputType = typeof inputToCheck;
336
+ if ((mongoQueryType <= Where && inputType === 'object') || (mongoQueryType >= Where && inputType === 'string')) {
337
+ // the query-type/input-type combination is valid. add a synthesized item.
338
+ const item = { ruleId: 'nosql-injection-mongo', score: 10, mongoContext: { inputToCheck } };
339
+ items.push(item);
340
+ }
341
+ }
342
+ // make each item a complete Finding
343
+ for (const item of items) {
344
+ const result = {
345
+ ruleId: item.ruleId,
346
+ inputType: `${inputTypeStr}${type}`,
347
+ path: mongoPath || path.slice(),
348
+ key: type === 'Key' ? value : path[path.length - 1],
349
+ // mimic scoreRequestConnect() returning the query type as the value
350
+ value: mongoQueryType || value,
351
+ score: item.score,
352
+ idsList: [],
353
+ };
354
+ if (item.mongoContext) {
355
+ result.mongoContext = item.mongoContext;
356
+ }
357
+ resultsList.push(result);
358
+ }
359
+ });
360
+
361
+ // if nothing was found then nothing needs to be done.
362
+ if (resultsList.length === 0) {
363
+ return;
364
+ }
365
+
366
+ // something was found, so create "complete" findings.
367
+ const findings = {
368
+ trackRequest: true,
369
+ securityException: undefined,
370
+ resultsList,
371
+ };
372
+
373
+ const block = mergeFindings(rules.agentLibRules, sourceContext.findings, findings);
374
+ if (block) {
375
+ sourceContext.block(...block);
376
+ }
377
+ }
378
+ };
379
+
380
+ /**
381
+ * merge new findings into the existing findings
382
+ *
383
+ * @param {Object} sourceContext sourceContext.findings is the existing findings
384
+ * @param {Object} newFindings the findings, in {trackRequest, resultsList} format.
385
+ * @returns {undefined|[String]} undefined to permit else [mode, rule] to block.
386
+ */
387
+ function mergeFindings(rules, findings, newFindings) {
388
+ if (!newFindings.trackRequest) {
389
+ return findings.securityException;
390
+ }
391
+ normalizeFindings(rules, newFindings);
392
+
393
+ findings.trackRequest = findings.trackRequest || newFindings.trackRequest;
394
+ findings.securityException = findings.securityException || newFindings.securityException;
395
+
396
+ // merge them into a ruleId-indexed map (pojo)
397
+ for (const result of newFindings.resultsList) {
398
+ if (!findings.resultsMap[result.ruleId]) {
399
+ findings.resultsMap[result.ruleId] = [];
400
+ }
401
+ findings.resultsMap[result.ruleId].push(result);
402
+ }
403
+
404
+ return findings.securityException;
405
+ }
406
+
407
+ //
408
+ // add common fields to findings.
409
+ //
410
+ function normalizeFindings(rules, findings) {
411
+ // now both augment the rules and check to see if any require blocking
412
+ // at perimeter.
413
+ for (const r of findings.resultsList) {
414
+ // augment
415
+ // what additional augmentations are needed?
416
+ // the name/id might need to be mapped but keep the original so it's not lost
417
+ r.mappedId = agentLibRuleTypeToName[r.ruleId] || r.ruleId;
418
+ // this finding resulted in blocking, i.e., it is not a probe.
419
+ r.blocked = false;
420
+
421
+ // sink analysis will add findings here
422
+ r.details = [];
423
+
424
+ // apply exclusions here.
425
+ //
426
+ // apply exclusions after scoring inputs as it will require less work
427
+ // most of the time.
428
+ //
429
+ // the following might need to be changed. BAP is legacy behavior; beyond that,
430
+ // the only way a score >= 90 can come back is if there is no "worth-watching"
431
+ // option and that implies that there is no sink, so this is the only place at
432
+ // which the block can occur. so at a minimum 'block' should also result in a
433
+ // block.
434
+ const { mode } = rules[r.ruleId];
435
+ if (r.score >= 90 && ['block', 'block_at_perimeter'].includes(mode)) {
436
+ r.blocked = true;
437
+ findings.securityException = [mode, r.ruleId];
438
+ }
439
+ }
440
+ }
441
+
442
+ /**
443
+ * getValueAtKey() is used to fetch the object (expected) associated
444
+ * with the path of keys in obj. i say expected because this is only used
445
+ * for fetching the objects associated with a nosql vulnerability and those
446
+ * should always be objects.
447
+ *
448
+ * @param {Object} obj an object with keys
449
+ * @param {Array} path list of keys to walk through the object
450
+ * @param {String} lastKey the last key (it's not in path)
451
+ *
452
+ * @returns the value at end of walking path in obj
453
+ */
454
+ function getValueAtKey(obj, path, key) {
455
+ for (const p of path) {
456
+ if (!(p in obj)) {
457
+ return undefined;
458
+ }
459
+ obj = obj[p];
460
+ }
461
+ return key in obj ? obj[key] : undefined;
462
+ }