@contrast/protect 1.67.0 → 1.68.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.
@@ -42,11 +42,10 @@ module.exports = function(core) {
42
42
  return results;
43
43
  }
44
44
 
45
- function handleFindings(sourceContext, sinkContext, ruleId, result, findings) {
45
+ function handleFindings(sourceContext, sinkContext, ruleId, result, findings, mode) {
46
46
  const { stacktraceOpts } = sinkContext;
47
- captureStacktrace(sinkContext, stacktraceOpts);
48
47
 
49
- const mode = sourceContext.policy[ruleId];
48
+ captureStacktrace(sinkContext, stacktraceOpts);
50
49
  getResults(sourceContext, ruleId).push(result);
51
50
 
52
51
  let blockInfo;
@@ -63,7 +62,7 @@ module.exports = function(core) {
63
62
 
64
63
  hardening.handleUntrustedDeserialization = function (sourceContext, sinkContext) {
65
64
  const ruleId = UNTRUSTED_DESERIALIZATION;
66
- const mode = sourceContext.policy[ruleId];
65
+ const mode = sourceContext.policy.getRuleMode(ruleId);
67
66
  const { name, value } = sinkContext;
68
67
 
69
68
  if (
@@ -82,7 +81,7 @@ module.exports = function(core) {
82
81
  };
83
82
  const findings = { deserializer: name, command: false };
84
83
 
85
- handleFindings(sourceContext, sinkContext, ruleId, result, findings);
84
+ handleFindings(sourceContext, sinkContext, ruleId, result, findings, mode);
86
85
  };
87
86
 
88
87
  return hardening;
@@ -32,6 +32,7 @@ const {
32
32
  }
33
33
  } = require('@contrast/common');
34
34
  const { Core } = require('@contrast/core/lib/ioc/core');
35
+
35
36
  //
36
37
  // these rules are not implemented by agent-lib, but are being considered for
37
38
  // implementation:
@@ -133,6 +134,124 @@ module.exports = Core.makeComponent({
133
134
  // inputs against rules 1) is very fast and 2) dramatically pares down the number
134
135
  // of exclusion checks that need to be made.
135
136
 
137
+ /**
138
+ * merge new findings into the existing findings
139
+ *
140
+ * @param {Object} sourceContext sourceContext.findings is the existing findings
141
+ * @param {Object} newFindings the findings, in {trackRequest, resultsList} format.
142
+ * @returns {undefined|[String]} undefined to permit else [mode, rule] to block.
143
+ */
144
+ function mergeFindings(sourceContext, newFindings) {
145
+ const { policy } = sourceContext;
146
+ const { securityException, resultsMap } = sourceContext;
147
+
148
+ if (!newFindings.trackRequest) {
149
+ return securityException;
150
+ }
151
+
152
+ newFindings.resultsList = newFindings.resultsList.filter(
153
+ (result) => !inputAnalysis.isResultExcluded(sourceContext, result)
154
+ );
155
+
156
+ normalizeFindings(policy, newFindings);
157
+
158
+ sourceContext.trackRequest = sourceContext.trackRequest || newFindings.trackRequest;
159
+ sourceContext.securityException = sourceContext.securityException || newFindings.securityException;
160
+
161
+ // merge them into a ruleId-indexed map (pojo)
162
+ for (const result of newFindings.resultsList) {
163
+ if (!resultsMap[result.ruleId]) {
164
+ resultsMap[result.ruleId] = [];
165
+ }
166
+ resultsMap[result.ruleId].push(result);
167
+ }
168
+
169
+ return sourceContext.securityException;
170
+ }
171
+
172
+ //
173
+ // add common fields to findings.
174
+ //
175
+ function normalizeFindings(policy, findings) {
176
+ // now both augment the rules and check to see if any require blocking
177
+ // at perimeter.
178
+ for (const r of findings.resultsList) {
179
+ // augment
180
+ // what additional augmentations are needed?
181
+ // the name/id might need to be mapped but keep the original so it's not lost
182
+ r.mappedId = agentLibRuleTypeToName[r.ruleId] || r.ruleId;
183
+
184
+ // if we block this or the value is found in sink, we'll know not to check
185
+ // this result for probe analysis in handleRequestEnd().
186
+ r.blocked = false;
187
+ r.exploited = false;
188
+
189
+ // apply exclusions here.
190
+ //
191
+ // apply exclusions after scoring inputs as it will require less work
192
+ // most of the time.
193
+ //
194
+ // the following might need to be changed. BAP is legacy behavior; beyond that,
195
+ // the only way a score >= 90 can come back is if there is no "worth-watching"
196
+ // option and that implies that there is no sink, so this is the only place at
197
+ // which the block can occur. so at a minimum 'block' should also result in a
198
+ // block.
199
+ const mode = policy.getRuleMode(r.ruleId);
200
+
201
+ if (r.score >= 90 && BLOCKING_MODES.includes(mode)) {
202
+ r.blocked = true;
203
+ findings.securityException = [mode, r.ruleId, { result: r }];
204
+ }
205
+ }
206
+ }
207
+
208
+ function checkIpsMatch(listEntry, ip) {
209
+ const parsed = address.process(ip);
210
+
211
+ // Check if IP is in CIDR range,
212
+ if (listEntry.cidr) {
213
+ if (parsed.kind() !== listEntry.cidr.kind) {
214
+ return null;
215
+ }
216
+
217
+ if (parsed.match(listEntry.cidr.range)) {
218
+ return { ...listEntry, match: ip };
219
+ } else {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ // or do a direct comparison
225
+ if (parsed.toNormalizedString() === listEntry.normalizedValue) {
226
+ return { ...listEntry, matchedIp: ip };
227
+ }
228
+
229
+ return null;
230
+ }
231
+
232
+ /**
233
+ * getValueAtKey() is used to fetch the object (expected) associated
234
+ * with the path of keys in obj. i say expected because this is only used
235
+ * for fetching the objects associated with a nosql vulnerability and those
236
+ * should always be objects.
237
+ *
238
+ * @param {Object} obj an object with keys
239
+ * @param {Array} path list of keys to walk through the object
240
+ * @param {String} lastKey the last key (it's not in path)
241
+ *
242
+ * @returns the value at end of walking path in obj
243
+ */
244
+ function getValueAtKey(obj, path, key) {
245
+ for (const p of path) {
246
+ /* c8 ignore next 6 */
247
+ if (!(p in obj)) {
248
+ return undefined;
249
+ }
250
+ obj = obj[p];
251
+ }
252
+ return key in obj ? obj[key] : undefined;
253
+ }
254
+
136
255
  /**
137
256
  * handleConnect()
138
257
  *
@@ -170,7 +289,7 @@ module.exports = Core.makeComponent({
170
289
  * @returns {undefined|[String]} undefined to permit else [mode, rule] to block.
171
290
  */
172
291
  inputAnalysis.handleConnect = function handleConnect(sourceContext, connectInputs) {
173
- const { policy: { rulesMask } } = sourceContext;
292
+ const rulesMask = sourceContext.policy.getRulesMask();
174
293
 
175
294
  inputAnalysis.handleVirtualPatches(
176
295
  sourceContext,
@@ -210,16 +329,13 @@ module.exports = Core.makeComponent({
210
329
  inputAnalysis.handleQueryParams = function handleQueryParams(sourceContext, queryParams) {
211
330
  if (sourceContext.analyzedQuery) return;
212
331
  sourceContext.analyzedQuery = true;
213
-
214
332
  if (typeof queryParams !== 'object') {
215
333
  logger.debug({ queryParams }, 'handleQueryParams() called with non-object');
216
334
  return;
217
335
  }
218
-
219
336
  inputAnalysis.handleVirtualPatches(sourceContext, { PARAMETERS: queryParams });
220
337
 
221
338
  const block = commonObjectAnalyzer(sourceContext, queryParams, parameterInputTypes);
222
-
223
339
  if (block) {
224
340
  core.protect.reportFinding(block[2]);
225
341
  core.protect.throwSecurityException(sourceContext);
@@ -236,6 +352,9 @@ module.exports = Core.makeComponent({
236
352
  * @param {Object} urlParams pojo
237
353
  */
238
354
  inputAnalysis.handleUrlParams = function(sourceContext, urlParams) {
355
+ const rulesMask = sourceContext.policy.getRulesMask();
356
+ if (!rulesMask) return;
357
+
239
358
  if (sourceContext.analyzedUrlParams) return;
240
359
  sourceContext.analyzedUrlParams = true;
241
360
 
@@ -246,7 +365,6 @@ module.exports = Core.makeComponent({
246
365
 
247
366
  inputAnalysis.handleVirtualPatches(sourceContext, { PARAMETERS: urlParams });
248
367
 
249
- const { policy: { rulesMask } } = sourceContext;
250
368
  const resultsList = [];
251
369
  const { UrlParameter } = agentLib.InputType;
252
370
 
@@ -257,7 +375,6 @@ module.exports = Core.makeComponent({
257
375
  }
258
376
 
259
377
  const items = agentLib.scoreAtom(rulesMask, value, UrlParameter, preferWW);
260
-
261
378
  if (!items) {
262
379
  return;
263
380
  }
@@ -311,7 +428,8 @@ module.exports = Core.makeComponent({
311
428
 
312
429
  inputAnalysis.handleVirtualPatches(sourceContext, { HEADERS: cookies });
313
430
 
314
- const { policy: { rulesMask } } = sourceContext;
431
+ const rulesMask = sourceContext.policy.getRulesMask();
432
+ if (!rulesMask) return;
315
433
 
316
434
  const cookiesArr = Object.entries(cookies).reduce((acc, [key, value]) => {
317
435
  // things like booleans will cause agent-lib to throw
@@ -321,7 +439,6 @@ module.exports = Core.makeComponent({
321
439
 
322
440
  const cookieFindings = agentLib.scoreRequestConnect(rulesMask, { cookies: cookiesArr }, preferWW);
323
441
 
324
-
325
442
  const block = mergeFindings(sourceContext, cookieFindings);
326
443
 
327
444
  if (block) {
@@ -379,7 +496,7 @@ module.exports = Core.makeComponent({
379
496
  const { policy } = sourceContext;
380
497
  const resultsList = [];
381
498
 
382
- if (policy[Rule.UNSAFE_FILE_UPLOAD] === 'off') return;
499
+ if (policy.getRuleMode(Rule.UNSAFE_FILE_UPLOAD) === 'off') return;
383
500
 
384
501
  for (const name of names) {
385
502
  if (!isString(name)) {
@@ -387,7 +504,7 @@ module.exports = Core.makeComponent({
387
504
  return;
388
505
  }
389
506
 
390
- const items = agentLib.scoreAtom(policy.rulesMask, name, type);
507
+ const items = agentLib.scoreAtom(policy.getRulesMask(), name, type);
391
508
 
392
509
  if (!items) {
393
510
  return;
@@ -424,6 +541,7 @@ module.exports = Core.makeComponent({
424
541
 
425
542
  if (!Object.keys(requestInput).filter(Boolean).length || !sourceContext?.virtualPatchesEvaluators.length) return;
426
543
 
544
+ // todo: get virtualPatchesEvaluators from protect policy instead of request
427
545
  for (const vpEvaluators of sourceContext.virtualPatchesEvaluators) {
428
546
  for (const key in requestInput) {
429
547
  const evaluator = vpEvaluators.get(key);
@@ -502,7 +620,7 @@ module.exports = Core.makeComponent({
502
620
 
503
621
  inputAnalysis.handleMethodTampering = function(sourceContext, connectInputs) {
504
622
  const ruleId = Rule.METHOD_TAMPERING;
505
- const mode = sourceContext.policy[ruleId];
623
+ const mode = sourceContext.policy.getRuleMode(ruleId);
506
624
  if (mode !== OFF) {
507
625
  const { method } = connectInputs;
508
626
 
@@ -533,9 +651,10 @@ module.exports = Core.makeComponent({
533
651
  * @param {Object} sourceContext
534
652
  */
535
653
  inputAnalysis.handleRequestEnd = function handleRequestEnd(sourceContext) {
654
+ const { policy } = sourceContext;
536
655
  // check status code to verify method-tampering exploitation
537
656
  const mtResult = sourceContext.resultsMap[Rule.METHOD_TAMPERING]?.[0];
538
- if (mtResult) {
657
+ if (mtResult && policy.getRuleMode(Rule.METHOD_TAMPERING) !== OFF) {
539
658
  const { statusCode } = sourceContext.resData;
540
659
  if (statusCode !== 405 || statusCode !== 501) {
541
660
  mtResult.exploited = true;
@@ -543,12 +662,11 @@ module.exports = Core.makeComponent({
543
662
  }
544
663
  }
545
664
 
546
- if (!config.protect.probe_analysis.enable) return;
547
-
548
- const probeReports = [];
549
-
550
665
  // Detecting probes
551
- const { resultsMap, policy: { rulesMask } } = sourceContext;
666
+ const rulesMask = sourceContext.policy.getRulesMask();
667
+ if (rulesMask == 0 || !config.protect.probe_analysis.enable) return;
668
+ const probeReports = [];
669
+ const { resultsMap } = sourceContext;
552
670
  const probesRules = [Rule.CMD_INJECTION, Rule.PATH_TRAVERSAL, Rule.SQL_INJECTION, Rule.XXE];
553
671
  const probes = {};
554
672
  const findingsForScoreRequest = {
@@ -571,7 +689,7 @@ module.exports = Core.makeComponent({
571
689
  } = resultByRuleId;
572
690
 
573
691
  if (
574
- !isMonitorMode(ruleId, sourceContext) ||
692
+ sourceContext.policy.getRuleMode(ruleId) !== MONITOR ||
575
693
  exploited === true || // todo: remove
576
694
  score >= 90 ||
577
695
  !probesRules.some((rule) => rule === ruleId) ||
@@ -623,7 +741,7 @@ module.exports = Core.makeComponent({
623
741
  });
624
742
 
625
743
  results
626
- .filter(({ score, ruleId }) => score >= 90 && isMonitorMode(ruleId, sourceContext))
744
+ .filter(({ score, ruleId }) => score >= 90 && sourceContext.policy.getRuleMode(ruleId) == MONITOR)
627
745
  .forEach((result) => {
628
746
  const resultByRuleId = valueToResultByRuleId[result.value];
629
747
  const probe = Object.assign({}, resultByRuleId, result, {
@@ -652,11 +770,80 @@ module.exports = Core.makeComponent({
652
770
  }
653
771
  };
654
772
 
773
+ /**
774
+ * Reads the source context's policy and compares to result item to check whether to ignore it.
775
+ * @param {ProtectMessage} sourceContext
776
+ * @param {Result} result
777
+ * @returns {boolean} whether result should be excluded
778
+ */
779
+ inputAnalysis.isResultExcluded = function isResultExcluded(sourceContext, result) {
780
+ const exclusions = sourceContext.policy.getExclusionInfo();
781
+ if (!exclusions) return false;
782
+
783
+ const { ruleId, path, inputType, value } = result;
784
+ const inputName = path ? path[path.length - 1] : null;
785
+
786
+ let checkCookiesInHeader = false;
787
+ let inputExclusions;
788
+
789
+ switch (inputType) {
790
+ case 'JsonKey':
791
+ case 'JsonValue':
792
+ case 'MultipartName': {
793
+ if (
794
+ exclusions?.ignoreBody ||
795
+ exclusions?.bodyPolicy?.[ruleId] == OFF
796
+ ) return true;
797
+
798
+ return false;
799
+ }
800
+ case 'ParameterKey':
801
+ case 'ParameterValue': {
802
+ const qsExcluded = exclusions.ignoreQuerystring || exclusions.querystringPolicy?.[ruleId] === OFF;
803
+ if (qsExcluded) return true;
804
+ inputExclusions = exclusions.parameter;
805
+ break;
806
+ }
807
+ case 'CookieValue': {
808
+ inputExclusions = exclusions.cookie;
809
+ break;
810
+ }
811
+ case 'HeaderKey':
812
+ case 'HeaderValue': {
813
+ if (path[0] && StringPrototypeToLowerCase.call(path[0]) === 'cookie') {
814
+ inputExclusions = exclusions.cookie;
815
+ checkCookiesInHeader = true;
816
+ } else {
817
+ inputExclusions = exclusions?.header;
818
+ }
819
+ break;
820
+ }
821
+ }
822
+
823
+ if (!inputName || !inputExclusions) return false;
824
+
825
+ for (const excl of inputExclusions) {
826
+ let nameCheck = false;
827
+ if (checkCookiesInHeader) {
828
+ nameCheck = excl.checkCookiesInHeader(value);
829
+ } else {
830
+ nameCheck = excl.matchesInputName(inputName);
831
+ }
832
+ if (!nameCheck) continue;
833
+ if (!excl.policy || excl.policy[ruleId] === OFF) {
834
+ return true;
835
+ }
836
+ }
837
+
838
+ return false;
839
+ };
840
+
655
841
  /**
656
842
  * commonObjectAnalyzer() walks an object supplied by the end-user and checks
657
843
  * it for vulnerabilities.
658
844
  *
659
- * This can cause the request to be blocked, depending on the mode and findings.
845
+ *
846
+ This can cause the request to be blocked, depending on the mode and findings.
660
847
  *
661
848
  * @param {Object} sourceContext the sourceContext for the request
662
849
  * @param {Object} object the object to analyze. It could be from any input
@@ -668,14 +855,14 @@ module.exports = Core.makeComponent({
668
855
  * @returns {Array | undefined} returns an array with block info if vulnerability was found.
669
856
  */
670
857
  function commonObjectAnalyzer(sourceContext, object, inputTypes) {
671
- const { policy: { rulesMask } } = sourceContext;
672
- if (!rulesMask) return;
673
-
674
858
  // use inputTypes to set params...
675
859
  const { keyType, inputType } = inputTypes;
676
860
  const inputTypeStr = inputTypes === jsonInputTypes ? 'Json' : 'Parameter';
677
861
  const resultsList = [];
678
862
 
863
+ const rulesMask = sourceContext.policy.getRulesMask();
864
+ if (!rulesMask) return;
865
+
679
866
  // it's possible to optimize this if qs (or a similar package) is not loaded
680
867
  // or if none of the values of queryParams are objects. a quick '.includes()'
681
868
  // could be used to determine that. if none are objects then traverseKeysAndValues()
@@ -804,184 +991,3 @@ module.exports = Core.makeComponent({
804
991
  }
805
992
  },
806
993
  });
807
-
808
- /**
809
- * Reads the source context's policy and compares to result item to check whether to ignore it.
810
- * @param {ProtectMessage} sourceContext
811
- * @param {Result} result
812
- * @returns {boolean} whether result should be excluded
813
- */
814
- function isResultExcluded(sourceContext, result) {
815
- const { policy: { exclusions } } = sourceContext;
816
- const { ruleId, path, inputType, value } = result;
817
- const inputName = path ? path[path.length - 1] : null;
818
-
819
- let checkCookiesInHeader = false;
820
- let inputExclusions;
821
- switch (inputType) {
822
- case 'JsonKey':
823
- case 'JsonValue':
824
- case 'MultipartName': {
825
- return exclusions.ignoreBody || exclusions.bodyPolicy?.[ruleId] === OFF;
826
- }
827
- case 'ParameterKey':
828
- case 'ParameterValue': {
829
- const qsExcluded = exclusions.ignoreQuerystring || exclusions.querystringPolicy?.[ruleId] === OFF;
830
- if (qsExcluded) return true;
831
- inputExclusions = exclusions.parameter;
832
- break;
833
- }
834
- case 'CookieValue': {
835
- inputExclusions = exclusions.cookie;
836
- break;
837
- }
838
- case 'HeaderKey':
839
- case 'HeaderValue': {
840
- if (path[0] && StringPrototypeToLowerCase.call(path[0]) === 'cookie') {
841
- inputExclusions = exclusions.cookie;
842
- checkCookiesInHeader = true;
843
- } else {
844
- inputExclusions = exclusions.header;
845
- }
846
- break;
847
- }
848
- }
849
-
850
- if (!inputName || !inputExclusions) return false;
851
-
852
- for (const excl of inputExclusions) {
853
- let nameCheck = false;
854
- if (checkCookiesInHeader) {
855
- nameCheck = excl.checkCookiesInHeader(value);
856
- } else {
857
- nameCheck = excl.matchesInputName(inputName);
858
- }
859
- if (!nameCheck) continue;
860
- if (!excl.policy || excl.policy[ruleId] === OFF) {
861
- return true;
862
- }
863
- }
864
-
865
- return false;
866
- }
867
-
868
- /**
869
- * merge new findings into the existing findings
870
- *
871
- * @param {Object} sourceContext sourceContext.findings is the existing findings
872
- * @param {Object} newFindings the findings, in {trackRequest, resultsList} format.
873
- * @returns {undefined|[String]} undefined to permit else [mode, rule] to block.
874
- */
875
- function mergeFindings(sourceContext, newFindings) {
876
- const { policy, securityException, resultsMap } = sourceContext;
877
-
878
- if (!newFindings.trackRequest) {
879
- return securityException;
880
- }
881
-
882
- newFindings.resultsList = newFindings.resultsList.filter(
883
- (result) => !isResultExcluded(sourceContext, result)
884
- );
885
-
886
- normalizeFindings(policy, newFindings);
887
-
888
- sourceContext.trackRequest = sourceContext.trackRequest || newFindings.trackRequest;
889
- sourceContext.securityException = sourceContext.securityException || newFindings.securityException;
890
-
891
- // merge them into a ruleId-indexed map (pojo)
892
- for (const result of newFindings.resultsList) {
893
- if (!resultsMap[result.ruleId]) {
894
- resultsMap[result.ruleId] = [];
895
- }
896
- resultsMap[result.ruleId].push(result);
897
- }
898
-
899
- return sourceContext.securityException;
900
- }
901
-
902
- //
903
- // add common fields to findings.
904
- //
905
- function normalizeFindings(policy, findings) {
906
- // now both augment the rules and check to see if any require blocking
907
- // at perimeter.
908
- for (const r of findings.resultsList) {
909
- // augment
910
- // what additional augmentations are needed?
911
- // the name/id might need to be mapped but keep the original so it's not lost
912
- r.mappedId = agentLibRuleTypeToName[r.ruleId] || r.ruleId;
913
-
914
- // if we block this or the value is found in sink, we'll know not to check
915
- // this result for probe analysis in handleRequestEnd().
916
- r.blocked = false;
917
- r.exploited = false;
918
-
919
- // apply exclusions here.
920
- //
921
- // apply exclusions after scoring inputs as it will require less work
922
- // most of the time.
923
- //
924
- // the following might need to be changed. BAP is legacy behavior; beyond that,
925
- // the only way a score >= 90 can come back is if there is no "worth-watching"
926
- // option and that implies that there is no sink, so this is the only place at
927
- // which the block can occur. so at a minimum 'block' should also result in a
928
- // block.
929
- const mode = policy[r.ruleId];
930
- if (r.score >= 90 && BLOCKING_MODES.includes(mode)) {
931
- r.blocked = true;
932
- findings.securityException = [mode, r.ruleId, { result: r }];
933
- }
934
- }
935
- }
936
-
937
-
938
- function checkIpsMatch(listEntry, ip) {
939
- const parsed = address.process(ip);
940
-
941
- // Check if IP is in CIDR range,
942
- if (listEntry.cidr) {
943
- if (parsed.kind() !== listEntry.cidr.kind) {
944
- return null;
945
- }
946
-
947
- if (parsed.match(listEntry.cidr.range)) {
948
- return { ...listEntry, match: ip };
949
- } else {
950
- return null;
951
- }
952
- }
953
-
954
- // or do a direct comparison
955
- if (parsed.toNormalizedString() === listEntry.normalizedValue) {
956
- return { ...listEntry, matchedIp: ip };
957
- }
958
-
959
- return null;
960
- }
961
-
962
- /**
963
- * getValueAtKey() is used to fetch the object (expected) associated
964
- * with the path of keys in obj. i say expected because this is only used
965
- * for fetching the objects associated with a nosql vulnerability and those
966
- * should always be objects.
967
- *
968
- * @param {Object} obj an object with keys
969
- * @param {Array} path list of keys to walk through the object
970
- * @param {String} lastKey the last key (it's not in path)
971
- *
972
- * @returns the value at end of walking path in obj
973
- */
974
- function getValueAtKey(obj, path, key) {
975
- for (const p of path) {
976
- /* c8 ignore next 6 */
977
- if (!(p in obj)) {
978
- return undefined;
979
- }
980
- obj = obj[p];
981
- }
982
- return key in obj ? obj[key] : undefined;
983
- }
984
-
985
- function isMonitorMode(ruleId, sourceContext) {
986
- return sourceContext.policy[ruleId] === MONITOR;
987
- }
@@ -43,7 +43,7 @@ module.exports = function(core) {
43
43
  captureStacktrace(sinkContext, stacktraceOpts);
44
44
  result.exploited = true;
45
45
 
46
- const mode = sourceContext.policy[ruleId];
46
+ const mode = sourceContext.policy.getRuleMode(ruleId);
47
47
  const eventArg = { findings, result, sinkContext };
48
48
 
49
49
  let blockInfo;
@@ -328,7 +328,7 @@ module.exports = function(core) {
328
328
  * @returns {AnalysisResult[]}
329
329
  */
330
330
  function getResultsByRuleId(ruleId, context) {
331
- if (!context.policy || context.policy[ruleId] === OFF) {
331
+ if (!context.policy || context.policy.getRuleMode(ruleId) === OFF) {
332
332
  return;
333
333
  }
334
334
  // because agent-lib stores all nosql-injection results under nosql-injection-mongo
@@ -18,8 +18,6 @@
18
18
  module.exports = function(core) {
19
19
  const { protect } = core;
20
20
 
21
- const DISABLED_POLICY = { allowed: true };
22
-
23
21
  /**
24
22
  * @param {object} param
25
23
  * @param {object} param.store
@@ -33,12 +31,7 @@ module.exports = function(core) {
33
31
  // incomingMessage,
34
32
  serverResponse,
35
33
  }) {
36
- if (!core.config.getEffectiveValue('protect.enable')) return DISABLED_POLICY;
37
-
38
34
  const policy = protect.getPolicy({ uriPath: sourceInfo.uriPath });
39
- // URL exclusions can disable all rules
40
- if (!policy || policy.rulesMask === 0) return DISABLED_POLICY;
41
-
42
35
  const protectStore = {
43
36
  resData: {
44
37
  statusCode: null,
@@ -56,6 +49,11 @@ module.exports = function(core) {
56
49
  resultsMap: Object.create(null),
57
50
  };
58
51
 
52
+ if (policy.allowed) {
53
+ protectStore.allowed = true;
54
+ }
55
+
56
+
59
57
  return protectStore;
60
58
  }
61
59
 
package/lib/policy.js CHANGED
@@ -24,10 +24,10 @@ const {
24
24
  StringPrototypeToLowerCase,
25
25
  StringPrototypeSplit,
26
26
  RegExpPrototypeTest
27
- }
27
+ },
28
+ set,
28
29
  } = require('@contrast/common');
29
30
  const { ConfigSource } = require('@contrast/config');
30
-
31
31
  const { BLOCK_AT_PERIMETER, OFF } = ProtectRuleMode;
32
32
  const {
33
33
  BOT_BLOCKER,
@@ -58,6 +58,121 @@ module.exports = function (core) {
58
58
  protect: { agentLib }
59
59
  } = core;
60
60
 
61
+ // todo: can we not init this and just set what's needed
62
+ let processedExclusions = initCompiled();
63
+
64
+ const policy = protect.policy = {
65
+ version: Date.now(),
66
+ exclusions: processedExclusions
67
+ };
68
+
69
+
70
+ class RequestPolicy {
71
+ constructor(core, sourceInfo) {
72
+ Object.defineProperty(this, 'core', { value: core });
73
+ Object.defineProperty(this, 'sourceInfo', { value: sourceInfo });
74
+
75
+ this.init();
76
+ }
77
+
78
+ init() {
79
+ const { uriPath } = this.sourceInfo;
80
+ this.version = core.protect.policy.version;
81
+
82
+ if (!this.core.config.getEffectiveValue('protect.enable')) {
83
+ this.allowed = true;
84
+ return;
85
+ }
86
+
87
+ // todo build exclusions
88
+ for (const [inputType, exclusions] of Object.entries(processedExclusions)) {
89
+ for (const e of exclusions) {
90
+ if (!e.matchesUriPath(uriPath)) continue;
91
+
92
+ // url exclusions
93
+ if (inputType === 'url') {
94
+ // if applies to all rules, there is no policy for the request i.e. disable protect
95
+ if (!e.policy) {
96
+ this.allowed = true;
97
+ return;
98
+ }
99
+
100
+ // merge exclusion's policy into the request's policy
101
+ for (const key of Object.keys(e.policy)) {
102
+ const value = e.policy[key];
103
+ if (key === 'rulesMask') {
104
+ if (this.exclusions?.rulesMask == null)
105
+ set(this, 'exclusions.rulesMask', this.core.protect.policy.rulesMask);
106
+ // this is how to disable rules bitwise
107
+ this.exclusions.rulesMask = this.exclusions.rulesMask & ~value;
108
+ } else {
109
+ set(this, `exclusions.${key}`, value);
110
+ }
111
+ }
112
+ } else if (inputType === 'querystring') {
113
+ if (!e.policy) {
114
+ set(this, 'exclusions.ignoreQuerystring', true);
115
+ } else {
116
+ // merge exclusion's policy into the querystring's policy
117
+ // this.exclusions.querystringPolicy = this.exclusions.querystringPolicy || {};
118
+ for (const key of Object.keys(e.policy)) {
119
+ const value = e.policy[key];
120
+ if (key !== 'rulesMask') {
121
+ set(this, `exclusions.querystringPolicy.${key}`, value);
122
+ }
123
+ }
124
+ }
125
+ } else if (inputType === 'body') {
126
+ if (!e.policy) {
127
+ set(this, 'exclusions.ignoreBody', true);
128
+ } else {
129
+ // merge exclusion's policy into the querystring's policy
130
+ // set(this, `exclusions.bodyPolicy = this.exclusions.bodyPolicy || {};
131
+ for (const key of Object.keys(e.policy)) {
132
+ const value = e.policy[key];
133
+ if (key !== 'rulesMask') {
134
+ set(this, `exclusions.bodyPolicy.${key}`, value);
135
+ }
136
+ }
137
+ }
138
+ } else {
139
+ // copy matching input exclusions into request policy
140
+ if (!this.exclusions?.[inputType]) set(this, `exclusions.${inputType}`, []);
141
+ this.exclusions[inputType].push(e);
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ checkInit() {
148
+ if (!this.version == core.protect.policy.version) {
149
+ this.init();
150
+ }
151
+ }
152
+
153
+ isDisabled() {
154
+ this.checkInit();
155
+ return this.allowed === true;
156
+ }
157
+
158
+ getRulesMask(inputType) {
159
+ this.checkInit();
160
+ if (this.allowed) return 0;
161
+ return this.exclusions?.rulesMask ?? this.core.protect.policy.rulesMask;
162
+ }
163
+
164
+ getRuleMode(ruleId) {
165
+ this.checkInit();
166
+ if (this.allowed) return OFF;
167
+ return this.exclusions?.[ruleId] ?? this.core.protect.policy[ruleId];
168
+ }
169
+
170
+ getExclusionInfo(key, inputType) {
171
+ this.checkInit();
172
+ return key ? this.exclusions?.[key] : this.exclusions;
173
+ }
174
+ }
175
+
61
176
  function initCompiled() {
62
177
  return {
63
178
  url: [],
@@ -69,12 +184,6 @@ module.exports = function (core) {
69
184
  };
70
185
  }
71
186
 
72
- let compiled = initCompiled();
73
-
74
- const policy = protect.policy = {
75
- exclusions: compiled
76
- };
77
-
78
187
  function regExpCheck(str) {
79
188
  return str.indexOf('*') > 0 ||
80
189
  str.indexOf('.') > 0 ||
@@ -156,96 +265,19 @@ module.exports = function (core) {
156
265
  ruleId = 'nosql-injection-mongo';
157
266
  }
158
267
 
159
- if (protect.agentLib.RuleType[ruleId] && mode !== OFF) {
160
- rulesMask = rulesMask | protect.agentLib.RuleType[ruleId];
268
+ if (agentLib.RuleType[ruleId] && mode !== OFF) {
269
+ rulesMask = rulesMask | agentLib.RuleType[ruleId];
161
270
  }
162
271
  }
163
272
 
164
273
  policy.rulesMask = rulesMask;
165
274
  }
166
275
 
167
- /**
168
- * This gets called by protect.makeSourceContext(). We return copy of policy to avoid
169
- * inconsistent behavior if policy is updated during request handling.
170
- */
171
- function getPolicy({ uriPath } = {}) {
172
- const requestPolicy = {
173
- exclusions: {
174
- ignoreQuerystring: false,
175
- querystringPolicy: null,
176
- ignoreBody: false,
177
- bodyPolicy: null,
178
- header: [],
179
- cookie: [],
180
- parameter: [],
181
- },
182
- rulesMask: policy.rulesMask,
183
- };
184
-
185
- for (const ruleId of Object.values(Rule)) {
186
- requestPolicy[ruleId] = policy[ruleId];
187
- }
188
-
189
- // handle exclusions
190
- for (const [inputType, exclusions] of Object.entries(compiled)) {
191
- for (const e of exclusions) {
192
- if (!e.matchesUriPath(uriPath)) continue;
193
-
194
- // url exclusions
195
- if (inputType === 'url') {
196
- // if applies to all rules, there is no policy for the request i.e. disable protect
197
- if (!e.policy) {
198
- return null;
199
- }
200
-
201
- // merge exclusion's policy into the request's policy
202
- for (const key of Object.keys(e.policy)) {
203
- const value = e.policy[key];
204
- if (key === 'rulesMask') {
205
- // this is how to disable rules bitwise
206
- requestPolicy.rulesMask = requestPolicy.rulesMask & ~value;
207
- } else {
208
- requestPolicy[key] = value;
209
- }
210
- }
211
- } else if (inputType === 'querystring') {
212
- if (!e.policy) {
213
- requestPolicy.exclusions.ignoreQuerystring = true;
214
- } else {
215
- // merge exclusion's policy into the querystring's policy
216
- requestPolicy.exclusions.querystringPolicy = requestPolicy.exclusions.querystringPolicy || {};
217
- for (const key of Object.keys(e.policy)) {
218
- const value = e.policy[key];
219
- if (key !== 'rulesMask') {
220
- requestPolicy.exclusions.querystringPolicy[key] = value;
221
- }
222
- }
223
- }
224
- } else if (inputType === 'body') {
225
- if (!e.policy) {
226
- requestPolicy.exclusions.ignoreBody = true;
227
- } else {
228
- // merge exclusion's policy into the querystring's policy
229
- requestPolicy.exclusions.bodyPolicy = requestPolicy.exclusions.bodyPolicy || {};
230
- for (const key of Object.keys(e.policy)) {
231
- const value = e.policy[key];
232
- if (key !== 'rulesMask') {
233
- requestPolicy.exclusions.bodyPolicy[key] = value;
234
- }
235
- }
236
- }
237
- } else {
238
- // copy matching input exclusions into request policy
239
- requestPolicy.exclusions[inputType].push(e);
240
- }
241
- }
242
- }
243
-
244
- return requestPolicy;
245
- }
246
-
247
276
  function updateGlobalPolicy(remoteSettings) {
248
277
  const protectionRules = remoteSettings?.protect?.rules;
278
+ // last updated
279
+ protect.policy.version = Date.now();
280
+
249
281
  if (protectionRules) {
250
282
  [
251
283
  CMD_INJECTION,
@@ -290,7 +322,8 @@ module.exports = function (core) {
290
322
  }
291
323
 
292
324
  updateRulesMask();
293
- protect.policy.exclusions = compiled;
325
+ protect.policy.exclusions = processedExclusions;
326
+
294
327
  logger.info({ policy: protect.policy }, 'Protect policy updated');
295
328
  }
296
329
  }
@@ -302,7 +335,7 @@ module.exports = function (core) {
302
335
  ].filter((exclusion) => exclusion.modes.includes('defend'));
303
336
 
304
337
  if (!exclusions.length) return;
305
- compiled = initCompiled();
338
+ processedExclusions = initCompiled();
306
339
 
307
340
  for (const exclusionDtm of exclusions) {
308
341
  exclusionDtm.type = exclusionDtm.type || 'URL';
@@ -310,7 +343,7 @@ module.exports = function (core) {
310
343
  const { name, protect_rules, urls, type } = exclusionDtm;
311
344
  const key = StringPrototypeToLowerCase.call(type);
312
345
 
313
- if (!compiled[key]) continue;
346
+ if (!processedExclusions[key]) continue;
314
347
 
315
348
  try {
316
349
  const e = { name };
@@ -354,7 +387,7 @@ module.exports = function (core) {
354
387
  };
355
388
  }
356
389
 
357
- compiled[key].push(e);
390
+ processedExclusions[key].push(e);
358
391
  } catch (err) {
359
392
  logger.error({ err, exclusionDtm }, 'failed to process exclusion');
360
393
  }
@@ -370,5 +403,7 @@ module.exports = function (core) {
370
403
 
371
404
  initPolicy();
372
405
 
373
- return protect.getPolicy = getPolicy;
406
+ return protect.getPolicy = function getPolicy(sourceInfo) {
407
+ return new RequestPolicy(core, sourceInfo);
408
+ };
374
409
  };
@@ -155,7 +155,7 @@ module.exports = function(core) {
155
155
  }
156
156
 
157
157
  semanticAnalysis.handleCmdInjectionSemanticDangerous = function(sourceContext, sinkContext) {
158
- const mode = sourceContext.policy[Rule.CMD_INJECTION_SEMANTIC_DANGEROUS_PATHS];
158
+ const mode = sourceContext.policy.getRuleMode(Rule.CMD_INJECTION_SEMANTIC_DANGEROUS_PATHS);
159
159
 
160
160
  if (mode == OFF) return;
161
161
 
@@ -167,7 +167,7 @@ module.exports = function(core) {
167
167
  };
168
168
 
169
169
  semanticAnalysis.handleCmdInjectionSemanticChainedCommands = function(sourceContext, sinkContext) {
170
- const mode = sourceContext.policy[Rule.CMD_INJECTION_SEMANTIC_CHAINED_COMMANDS];
170
+ const mode = sourceContext.policy.getRuleMode(Rule.CMD_INJECTION_SEMANTIC_CHAINED_COMMANDS);
171
171
 
172
172
  if (mode == OFF) return;
173
173
 
@@ -179,10 +179,9 @@ module.exports = function(core) {
179
179
  };
180
180
 
181
181
  semanticAnalysis.handleCommandInjectionCommandBackdoors = function(sourceContext, sinkContext) {
182
- const mode = sourceContext.policy[Rule.CMD_INJECTION_COMMAND_BACKDOORS];
182
+ const mode = sourceContext.policy.getRuleMode(Rule.CMD_INJECTION_COMMAND_BACKDOORS);
183
183
 
184
184
  if (mode == OFF) return;
185
-
186
185
  const finding = findBackdoorInjection(sourceContext, sinkContext.value);
187
186
 
188
187
  if (finding) {
@@ -191,7 +190,7 @@ module.exports = function(core) {
191
190
  };
192
191
 
193
192
  semanticAnalysis.handlePathTraversalFileSecurityBypass = function(sourceContext, sinkContext) {
194
- const mode = sourceContext.policy[Rule.PATH_TRAVERSAL_SEMANTIC_FILE_SECURITY_BYPASS];
193
+ const mode = sourceContext.policy.getRuleMode(Rule.PATH_TRAVERSAL_SEMANTIC_FILE_SECURITY_BYPASS);
195
194
 
196
195
  if (mode == OFF) return;
197
196
 
@@ -201,7 +200,7 @@ module.exports = function(core) {
201
200
  };
202
201
 
203
202
  semanticAnalysis.handleXXE = function (sourceContext, sinkContext) {
204
- const mode = sourceContext.policy[Rule.XXE];
203
+ const mode = sourceContext.policy.getRuleMode(Rule.XXE);
205
204
  if (mode == OFF) return;
206
205
 
207
206
  const findings = findExternalEntities(sinkContext.value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrast/protect",
3
- "version": "1.67.0",
3
+ "version": "1.68.0",
4
4
  "description": "Contrast service providing framework-agnostic Protect support",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Contrast Security <nodejs@contrastsecurity.com> (https://www.contrastsecurity.com)",
@@ -22,15 +22,15 @@
22
22
  "dependencies": {
23
23
  "@contrast/agent-lib": "^9.1.0",
24
24
  "@contrast/common": "1.37.0",
25
- "@contrast/config": "1.52.0",
26
- "@contrast/core": "1.57.0",
27
- "@contrast/dep-hooks": "1.26.0",
28
- "@contrast/esm-hooks": "2.31.0",
29
- "@contrast/instrumentation": "1.36.0",
30
- "@contrast/logger": "1.30.0",
31
- "@contrast/patcher": "1.29.0",
32
- "@contrast/rewriter": "1.33.0",
33
- "@contrast/scopes": "1.27.0",
25
+ "@contrast/config": "1.52.1",
26
+ "@contrast/core": "1.57.1",
27
+ "@contrast/dep-hooks": "1.26.1",
28
+ "@contrast/esm-hooks": "2.32.0",
29
+ "@contrast/instrumentation": "1.36.1",
30
+ "@contrast/logger": "1.30.1",
31
+ "@contrast/patcher": "1.29.1",
32
+ "@contrast/rewriter": "1.34.0",
33
+ "@contrast/scopes": "1.27.1",
34
34
  "async-hook-domain": "^4.0.1",
35
35
  "ipaddr.js": "^2.0.1",
36
36
  "on-finished": "^2.4.1",