@contrast/assess 1.62.0 → 1.64.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.
- package/lib/dataflow/propagation/install/ejs/template.js +1 -1
- package/lib/dataflow/propagation/install/pug/index.js +1 -1
- package/lib/dataflow/sources/handler.js +21 -24
- package/lib/get-source-context.js +10 -21
- package/lib/index.js +1 -1
- package/lib/make-source-context.js +5 -10
- package/lib/policy.js +400 -0
- package/lib/response-scanning/handlers/index.js +10 -14
- package/lib/session-configuration/handlers.js +1 -1
- package/package.json +11 -11
- package/lib/get-policy.js +0 -336
|
@@ -37,7 +37,7 @@ module.exports = function (core) {
|
|
|
37
37
|
} = core;
|
|
38
38
|
|
|
39
39
|
/** @type {import('@contrast/rewriter').RewriteOpts} */
|
|
40
|
-
const REWRITE_OPTS = {
|
|
40
|
+
const REWRITE_OPTS = { inject: false, minify: false };
|
|
41
41
|
const WRAPPER_PREFIX = ArrayPrototypeJoin.call([
|
|
42
42
|
'function tempWrapper() {',
|
|
43
43
|
'function __append(s) { if (s !== undefined && s !== null) __output += s }'
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
const { patchType } = require('../../common');
|
|
18
18
|
|
|
19
19
|
/** @type {import('@contrast/rewriter').RewriteOpts} */
|
|
20
|
-
const REWRITE_OPTS = {
|
|
20
|
+
const REWRITE_OPTS = { inject: false, minify: false };
|
|
21
21
|
|
|
22
22
|
module.exports = function (core) {
|
|
23
23
|
const store = { lock: true, name: 'assess:propagators:pug-compile' };
|
|
@@ -45,24 +45,19 @@ module.exports = Core.makeComponent({
|
|
|
45
45
|
const logger = core.logger.child({ name: 'contrast:sources' });
|
|
46
46
|
|
|
47
47
|
sources.createTags = function createTags({ inputType, fieldName = '', value, tagNames }) {
|
|
48
|
-
if (!value?.length)
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
48
|
+
if (!value?.length) return null;
|
|
51
49
|
|
|
52
50
|
const stop = value.length - 1;
|
|
53
|
-
const tags = {
|
|
54
|
-
[DataflowTag.UNTRUSTED]: [0, stop]
|
|
55
|
-
};
|
|
51
|
+
const tags = { [DataflowTag.UNTRUSTED]: [0, stop] };
|
|
56
52
|
|
|
57
|
-
if (tagNames)
|
|
58
|
-
for (const tag of tagNames)
|
|
53
|
+
if (tagNames)
|
|
54
|
+
for (const tag of tagNames)
|
|
59
55
|
tags[tag] = [0, stop];
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
56
|
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
if (
|
|
58
|
+
inputType === InputType.HEADER &&
|
|
59
|
+
StringPrototypeToLowerCase.call(fieldName) === 'referer'
|
|
60
|
+
) tags[DataflowTag.HEADER] = [0, stop];
|
|
66
61
|
|
|
67
62
|
return tags;
|
|
68
63
|
};
|
|
@@ -89,14 +84,7 @@ module.exports = Core.makeComponent({
|
|
|
89
84
|
return null;
|
|
90
85
|
}
|
|
91
86
|
|
|
92
|
-
|
|
93
|
-
if (!sourceContext.policy) {
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (!context) {
|
|
98
|
-
context = inputType;
|
|
99
|
-
}
|
|
87
|
+
if (!context) context = inputType;
|
|
100
88
|
|
|
101
89
|
const { policy: requestPolicy } = sourceContext;
|
|
102
90
|
const max = config.assess.max_context_source_events;
|
|
@@ -111,7 +99,10 @@ module.exports = Core.makeComponent({
|
|
|
111
99
|
}
|
|
112
100
|
|
|
113
101
|
function createEvent({ fieldName, pathName, value, excludedRules }) {
|
|
114
|
-
|
|
102
|
+
let tagNames;
|
|
103
|
+
if (excludedRules) {
|
|
104
|
+
tagNames = Array.from(excludedRules).map((ruleId) => `excluded:${ruleId}`);
|
|
105
|
+
}
|
|
115
106
|
// create the stacktrace once per call to .handle()
|
|
116
107
|
stack || (stack = sources.createStacktrace(stacktraceOpts));
|
|
117
108
|
return eventFactory.createSourceEvent({
|
|
@@ -127,7 +118,10 @@ module.exports = Core.makeComponent({
|
|
|
127
118
|
}
|
|
128
119
|
|
|
129
120
|
if (Buffer.isBuffer(data) && !tracker.getData(data)) {
|
|
130
|
-
const
|
|
121
|
+
const inputPolicy = requestPolicy.getInputPolicy(InputType.BODY);
|
|
122
|
+
const track = !!inputPolicy;
|
|
123
|
+
const excludedRules = inputPolicy?.constructor?.name == 'Set' ? inputPolicy : undefined;
|
|
124
|
+
|
|
131
125
|
if (!track) {
|
|
132
126
|
core.logger.debug({ inputType }, 'assess input exclusion disabled tracking');
|
|
133
127
|
return;
|
|
@@ -149,7 +143,10 @@ module.exports = Core.makeComponent({
|
|
|
149
143
|
return true;
|
|
150
144
|
}
|
|
151
145
|
|
|
152
|
-
const
|
|
146
|
+
const inputPolicy = sourceContext.policy.getInputPolicy(inputType, fieldName);
|
|
147
|
+
const track = !!inputPolicy;
|
|
148
|
+
const excludedRules = inputPolicy?.constructor?.name == 'Set' ? inputPolicy : undefined;
|
|
149
|
+
|
|
153
150
|
if (!track) {
|
|
154
151
|
core.logger.debug({ fieldName, inputType }, 'assess input exclusion disabling tracking');
|
|
155
152
|
return;
|
|
@@ -53,20 +53,11 @@ function factory(core) {
|
|
|
53
53
|
core.assess.getPropagatorContext = function getPropagatorContext() {
|
|
54
54
|
if (instrumentation.isLocked()) return null;
|
|
55
55
|
|
|
56
|
-
// the following logging used to be done by the caller, but has been moved
|
|
57
|
-
// here as opposed to overloading `ctx.policy` with a special value so the
|
|
58
|
-
// caller could determine whether no source context was available or the
|
|
59
|
-
// request is being intentionally excluded. A negative of this is that the
|
|
60
|
-
// function name is not available to be included in the log.
|
|
61
56
|
const ctx = sources.getStore()?.assess;
|
|
62
|
-
if (!ctx) return null;
|
|
63
|
-
|
|
64
57
|
// there is a context, but if policy is null then assess is intentionally
|
|
65
58
|
// disabled (i.e., url exclusion or the request is not sampled).
|
|
66
|
-
if (!ctx.
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
59
|
+
if (!ctx?.policy || ctx?.policy.allowed) return null;
|
|
60
|
+
// event limits
|
|
70
61
|
if (ctx.propagationEventsCount >= config.assess.max_propagation_events) return null;
|
|
71
62
|
|
|
72
63
|
return ctx;
|
|
@@ -80,13 +71,13 @@ function factory(core) {
|
|
|
80
71
|
if (instrumentation.isLocked()) return null;
|
|
81
72
|
|
|
82
73
|
const ctx = sources.getStore()?.assess;
|
|
83
|
-
if (!ctx) return null;
|
|
84
|
-
|
|
85
|
-
if (!ctx.policy) {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
74
|
+
if (!ctx?.policy || ctx.policy?.allowed) return null;
|
|
75
|
+
if (!ruleId) return ctx;
|
|
88
76
|
|
|
89
|
-
if (
|
|
77
|
+
if (
|
|
78
|
+
!ctx.policy?.isRuleEnabled?.(ruleId) ||
|
|
79
|
+
ruleScopes.isLocked(ruleId)
|
|
80
|
+
) return null;
|
|
90
81
|
|
|
91
82
|
return ctx;
|
|
92
83
|
};
|
|
@@ -105,10 +96,8 @@ function factory(core) {
|
|
|
105
96
|
return null;
|
|
106
97
|
}
|
|
107
98
|
|
|
108
|
-
if (!ctx.policy)
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
99
|
+
if (!ctx.policy || ctx.policy.allowed) return null;
|
|
100
|
+
// event limits
|
|
112
101
|
if (ctx.sourceEventsCount >= config.assess.max_context_source_events) return null;
|
|
113
102
|
|
|
114
103
|
return ctx;
|
package/lib/index.js
CHANGED
|
@@ -60,7 +60,7 @@ module.exports = function assess(core) {
|
|
|
60
60
|
|
|
61
61
|
// ancillary tools used by different features
|
|
62
62
|
require('./sampler')(core);
|
|
63
|
-
require('./
|
|
63
|
+
core.initComponentSync(require('./policy'));
|
|
64
64
|
core.initComponentSync(require('./make-source-context'));
|
|
65
65
|
require('./rule-scopes')(core);
|
|
66
66
|
core.initComponentSync(require('./get-source-context'));
|
|
@@ -36,24 +36,19 @@ function factory(core) {
|
|
|
36
36
|
* @returns {import('@contrast/assess').SourceContext}
|
|
37
37
|
*/
|
|
38
38
|
return core.assess.makeSourceContext = function ({ store, incomingMessage: req }) {
|
|
39
|
-
|
|
40
39
|
try {
|
|
41
40
|
const ctx = store.assess = {
|
|
42
|
-
// default policy to `null` until it is set later below. this will cause
|
|
43
|
-
// all instrumentation to short-circuit, see `./get-source-context.js`.
|
|
44
41
|
policy: null,
|
|
45
42
|
};
|
|
46
|
-
|
|
43
|
+
// if assess is disabled or not selected for sampling, the policy will
|
|
44
|
+
// be null (assess disabled) for lifetime of connection, despite UI updates.
|
|
47
45
|
if (!core.config.getEffectiveValue('assess.enable')) return ctx;
|
|
48
|
-
|
|
49
|
-
// check whether sampling allows processing
|
|
50
46
|
ctx.sampleInfo = assess.sampler?.getSampleInfo(store.sourceInfo) ?? null;
|
|
51
47
|
if (ctx.sampleInfo?.canSample === false) return ctx;
|
|
52
48
|
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
49
|
+
// assess-enabled policy from current effective config, but
|
|
50
|
+
// policy is dynamic and will respond to settings updates
|
|
51
|
+
ctx.policy = assess.policy.getRequestPolicy(store.sourceInfo);
|
|
57
52
|
ctx.propagationEventsCount = 0;
|
|
58
53
|
ctx.sourceEventsCount = 0;
|
|
59
54
|
ctx.responseData = {};
|
package/lib/policy.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright: 2025 Contrast Security, Inc
|
|
3
|
+
* Contact: support@contrastsecurity.com
|
|
4
|
+
* License: Commercial
|
|
5
|
+
|
|
6
|
+
* NOTICE: This Software and the patented inventions embodied within may only be
|
|
7
|
+
* used as part of Contrast Security’s commercial offerings. Even though it is
|
|
8
|
+
* made available through public repositories, use of this Software is subject to
|
|
9
|
+
* the applicable End User Licensing Agreement found at
|
|
10
|
+
* https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
|
|
11
|
+
* between Contrast Security and the End User. The Software may not be reverse
|
|
12
|
+
* engineered, modified, repackaged, sold, redistributed or otherwise used in a
|
|
13
|
+
* way not consistent with the End User License Agreement.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
Event,
|
|
20
|
+
ExclusionType,
|
|
21
|
+
InputType,
|
|
22
|
+
Rule,
|
|
23
|
+
ResponseScanningRule,
|
|
24
|
+
SessionConfigurationRule,
|
|
25
|
+
set,
|
|
26
|
+
primordials: { ArrayPrototypeJoin, RegExpPrototypeTest }
|
|
27
|
+
} = require('@contrast/common');
|
|
28
|
+
const { Core } = require('@contrast/core/lib/ioc/core');
|
|
29
|
+
|
|
30
|
+
const ASSESS_RULES = Object.values({
|
|
31
|
+
...Rule,
|
|
32
|
+
...ResponseScanningRule,
|
|
33
|
+
...SessionConfigurationRule,
|
|
34
|
+
});
|
|
35
|
+
const BROAD_INPUT_EXCLUSION_TYPES = [
|
|
36
|
+
ExclusionType.BODY,
|
|
37
|
+
ExclusionType.QUERYSTRING
|
|
38
|
+
];
|
|
39
|
+
const NAMED_INPUT_EXCLUSION_TYPES = [
|
|
40
|
+
ExclusionType.COOKIE,
|
|
41
|
+
ExclusionType.HEADER,
|
|
42
|
+
ExclusionType.PARAMETER
|
|
43
|
+
];
|
|
44
|
+
const BODY_TYPES = [
|
|
45
|
+
InputType.BODY,
|
|
46
|
+
InputType.JSON_VALUE,
|
|
47
|
+
InputType.JSON_ARRAYED_VALUE,
|
|
48
|
+
InputType.MULTIPART_CONTENT_TYPE,
|
|
49
|
+
InputType.MULTIPART_FIELD_NAME,
|
|
50
|
+
InputType.MULTIPART_NAME,
|
|
51
|
+
InputType.MULTIPART_VALUE,
|
|
52
|
+
];
|
|
53
|
+
const COMPONENT_NAME = 'assess.policy';
|
|
54
|
+
|
|
55
|
+
class AssessPolicy {
|
|
56
|
+
/**
|
|
57
|
+
* @param {{
|
|
58
|
+
* config: import('@contrast/config').Config,
|
|
59
|
+
* logger: import('@contrast/logger').Logger,
|
|
60
|
+
* messages: import('@contrast/common').Messages,
|
|
61
|
+
* }} core
|
|
62
|
+
*/
|
|
63
|
+
constructor(core) {
|
|
64
|
+
Object.defineProperty(this, 'core', { value: core });
|
|
65
|
+
|
|
66
|
+
this.version = Date.now();
|
|
67
|
+
this.disabledRules = new Set(core.config.getEffectiveValue('assess.rules.disabled_rules'));
|
|
68
|
+
this.exclusionMap = new Map([
|
|
69
|
+
[ExclusionType.BODY, []],
|
|
70
|
+
[ExclusionType.COOKIE, []],
|
|
71
|
+
[ExclusionType.HEADER, []],
|
|
72
|
+
[ExclusionType.PARAMETER, []],
|
|
73
|
+
[ExclusionType.QUERYSTRING, []],
|
|
74
|
+
[ExclusionType.URL, []],
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
core.messages.on(Event.SERVER_SETTINGS_UPDATE, (msg) => {
|
|
78
|
+
if (!msg.assess && !msg.exclusions) return;
|
|
79
|
+
|
|
80
|
+
this.version = Date.now();
|
|
81
|
+
|
|
82
|
+
if (msg.assess) {
|
|
83
|
+
const enabledRules = new Set();
|
|
84
|
+
this.disabledRules = new Set(core.config.getEffectiveValue('assess.rules.disabled_rules'));
|
|
85
|
+
|
|
86
|
+
for (const ruleId of ASSESS_RULES) {
|
|
87
|
+
const enable = msg.assess[ruleId]?.enable;
|
|
88
|
+
if (enable === false) {
|
|
89
|
+
this.disabledRules.add(ruleId);
|
|
90
|
+
// map to "sub-rules"
|
|
91
|
+
if (ruleId === Rule.NOSQL_INJECTION) this.disabledRules.add(Rule.NOSQL_INJECTION_MONGO);
|
|
92
|
+
} else if (enable === true) {
|
|
93
|
+
enabledRules.add(ruleId);
|
|
94
|
+
if (ruleId === Rule.NOSQL_INJECTION) enabledRules.add(Rule.NOSQL_INJECTION_MONGO);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
this.core.logger.info({
|
|
98
|
+
enabledRules,
|
|
99
|
+
disabledRules: Array.from(this.disabledRules)
|
|
100
|
+
}, 'Assess policy rules updated');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (msg.exclusions) {
|
|
104
|
+
for (const arr of this.exclusionMap.values()) arr.length = 0;
|
|
105
|
+
|
|
106
|
+
const rawDtmList = [
|
|
107
|
+
...(msg?.exclusions?.input || []),
|
|
108
|
+
...(msg?.exclusions?.url || []),
|
|
109
|
+
].filter((exclusion) => exclusion?.modes?.includes?.('assess'));
|
|
110
|
+
|
|
111
|
+
// reset global exclusion state
|
|
112
|
+
for (const type of Object.values(ExclusionType)) {
|
|
113
|
+
this.exclusionMap.get(type).length = 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!rawDtmList.length) return;
|
|
117
|
+
|
|
118
|
+
for (const dtm of rawDtmList) {
|
|
119
|
+
// normalize different dtm types
|
|
120
|
+
dtm.type = dtm.type || 'URL';
|
|
121
|
+
const { type } = dtm;
|
|
122
|
+
const key = ExclusionType[type];
|
|
123
|
+
// defensive code against unanticipated DTM values
|
|
124
|
+
if (key) {
|
|
125
|
+
const Ctor = dtm.type === ExclusionType.URL ? UrlExclusion : InputExclusion;
|
|
126
|
+
this.exclusionMap.get(dtm.type).push(new Ctor(dtm));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.core.logger.info({
|
|
131
|
+
exclusions: Object.fromEntries(this.exclusionMap)
|
|
132
|
+
}, 'Assess exclusions updated (%s total)', rawDtmList.length);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
getRequestPolicy(sourceInfo) {
|
|
138
|
+
return new RequestPolicy(this.core, sourceInfo);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
class RequestPolicy {
|
|
143
|
+
/**
|
|
144
|
+
* @param {{
|
|
145
|
+
* config: import('@contrast/config').Config,
|
|
146
|
+
* logger: import('@contrast/logger').Logger,
|
|
147
|
+
* messages: import('@contrast/common').Messages,
|
|
148
|
+
* }} core
|
|
149
|
+
* @param {import('@contrast/common').SourceInfo} sourceInfo
|
|
150
|
+
*/
|
|
151
|
+
constructor(core, sourceInfo) {
|
|
152
|
+
Object.defineProperty(this, 'core', { value: core });
|
|
153
|
+
this.sourceInfo = sourceInfo;
|
|
154
|
+
this.init();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Used to (re)initialize the instance's exclusions, reading from current assess global policy.
|
|
159
|
+
*/
|
|
160
|
+
init() {
|
|
161
|
+
const { core, sourceInfo } = this;
|
|
162
|
+
this.allowed = false;
|
|
163
|
+
this.version = core.assess.policy.version;
|
|
164
|
+
this.exclusions = {};
|
|
165
|
+
|
|
166
|
+
if (!core.config.getEffectiveValue('assess.enable')) {
|
|
167
|
+
this.allowed = true;
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Evaluate URL exclusions.
|
|
172
|
+
// If one matches and applies to all rules, we set `allowed: true` which will
|
|
173
|
+
// disable assess for the request (via getSourceContext()). If specific rules are
|
|
174
|
+
// disabled, we remove them from the request policy's set of enabled rules.
|
|
175
|
+
for (const urlExclusion of this.core.assess.policy.exclusionMap.get(ExclusionType.URL)) {
|
|
176
|
+
if (urlExclusion.matchesUriPath(sourceInfo.uriPath)) {
|
|
177
|
+
if (!urlExclusion.rules?.size) {
|
|
178
|
+
core.logger.debug({
|
|
179
|
+
name: urlExclusion.name
|
|
180
|
+
}, 'All Assess rules have been disabled by URL exclusion');
|
|
181
|
+
this.allowed = true;
|
|
182
|
+
// no need to further process exclusions - request will be ignored
|
|
183
|
+
return;
|
|
184
|
+
} else {
|
|
185
|
+
// build as needed
|
|
186
|
+
if (!this.exclusions.disabledRules) this.exclusions.disabledRules = new Set();
|
|
187
|
+
|
|
188
|
+
for (const ruleId of urlExclusion.rules) {
|
|
189
|
+
this.exclusions.disabledRules.add(ruleId);
|
|
190
|
+
}
|
|
191
|
+
core.logger.debug({
|
|
192
|
+
name: urlExclusion.name,
|
|
193
|
+
rules: Array.from(urlExclusion.rules),
|
|
194
|
+
}, 'Assess rules disabled by URL exclusion');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Process input exclusions that apply broadly: BODY, QUERYSTRING
|
|
200
|
+
for (const type of BROAD_INPUT_EXCLUSION_TYPES) {
|
|
201
|
+
for (const exclusion of core.assess.policy.exclusionMap.get(type)) {
|
|
202
|
+
if (exclusion.matchesUriPath(sourceInfo.uriPath)) {
|
|
203
|
+
// build as needed
|
|
204
|
+
if (!this.exclusions[type]) this.exclusions[type] = { track: true, excludedRules: new Set() };
|
|
205
|
+
const inputPolicy = this.exclusions[type];
|
|
206
|
+
|
|
207
|
+
if (exclusion.rules.size) {
|
|
208
|
+
for (const ruleId of exclusion.rules) {
|
|
209
|
+
inputPolicy.excludedRules.add(ruleId);
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
inputPolicy.track = false;
|
|
213
|
+
inputPolicy.excludedRules.clear();
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const type of NAMED_INPUT_EXCLUSION_TYPES) {
|
|
221
|
+
for (const exclusion of core.assess.policy.exclusionMap.get(type)) {
|
|
222
|
+
if (exclusion.matchesUriPath(sourceInfo.uriPath)) {
|
|
223
|
+
if (!this.exclusions[type]) this.exclusions[type] = [];
|
|
224
|
+
this.exclusions[type].push(exclusion);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Given input type and optional field name will give instructions on how
|
|
232
|
+
* to track based on global policy and various exclusions that may apply.
|
|
233
|
+
* @param {} inputType
|
|
234
|
+
* @param {} fieldName
|
|
235
|
+
* @returns {boolean|Set<string>} false - do not track
|
|
236
|
+
* true - track
|
|
237
|
+
* Set - track but add tags to exclude these rules
|
|
238
|
+
*/
|
|
239
|
+
getInputPolicy(inputType, fieldName) {
|
|
240
|
+
if (this.version < this.core.assess.policy.version) this.init();
|
|
241
|
+
if (this.allowed) return false; // don't track - request ignored
|
|
242
|
+
|
|
243
|
+
let inputRuleExclusions;
|
|
244
|
+
let excludedRuleIds;
|
|
245
|
+
|
|
246
|
+
if (inputType === InputType.HEADER) {
|
|
247
|
+
inputRuleExclusions = this.exclusions[ExclusionType.HEADER];
|
|
248
|
+
} else if (inputType === InputType.QUERYSTRING) {
|
|
249
|
+
if (this.exclusions[ExclusionType.QUERYSTRING]?.track === false) {
|
|
250
|
+
return false;
|
|
251
|
+
} else {
|
|
252
|
+
if (this.exclusions[ExclusionType.QUERYSTRING]?.excludedRules)
|
|
253
|
+
excludedRuleIds = new Set(this.exclusions[ExclusionType.QUERYSTRING]?.excludedRules);
|
|
254
|
+
inputRuleExclusions = this.exclusions[ExclusionType.PARAMETER];
|
|
255
|
+
}
|
|
256
|
+
} else if (inputType === InputType.URL_PARAMETER) {
|
|
257
|
+
inputRuleExclusions = this.exclusions[ExclusionType.PARAMETER];
|
|
258
|
+
} else if ([
|
|
259
|
+
InputType.COOKIE_NAME,
|
|
260
|
+
InputType.COOKIE_VALUE
|
|
261
|
+
].includes(inputType)) {
|
|
262
|
+
inputRuleExclusions = this.exclusions[ExclusionType.COOKIE];
|
|
263
|
+
} else if (BODY_TYPES.includes(inputType)) {
|
|
264
|
+
if (this.exclusions[ExclusionType.BODY]?.track === false) {
|
|
265
|
+
return false;
|
|
266
|
+
} else {
|
|
267
|
+
inputRuleExclusions = this.exclusions[ExclusionType.PARAMETER];
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (inputRuleExclusions) {
|
|
272
|
+
for (const exclusion of inputRuleExclusions) {
|
|
273
|
+
if (exclusion.matchesInputName(fieldName)) {
|
|
274
|
+
// disables some rules
|
|
275
|
+
if (exclusion.rules.size) {
|
|
276
|
+
for (const ruleId of exclusion.rules) {
|
|
277
|
+
if (!excludedRuleIds) excludedRuleIds = new Set();
|
|
278
|
+
excludedRuleIds.add(ruleId);
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
return false; // don't track - all rules disabled
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (this.exclusions.disabledRules || excludedRuleIds) {
|
|
288
|
+
// only URL Exclusions disabled these rules
|
|
289
|
+
if (!excludedRuleIds) return this.exclusions.disabledRules;
|
|
290
|
+
// only Input Exclusion disabled these
|
|
291
|
+
if (!this.exclusions.disabledRules) return excludedRuleIds;
|
|
292
|
+
// merge since URL Exclusions and Input Exclusions have disabled rules
|
|
293
|
+
return new Set([...this.exclusions.disabledRules, ...excludedRuleIds]);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
isRuleEnabled(ruleId) {
|
|
300
|
+
if (this.version < this.core.assess.policy.version) this.init();
|
|
301
|
+
|
|
302
|
+
if (this.allowed) return false;
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
!this.exclusions.disabledRules?.has?.(ruleId) &&
|
|
306
|
+
!this.core.assess.policy.disabledRules?.has?.(ruleId)
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @typedef InputPolicy
|
|
313
|
+
* @property {boolean} track
|
|
314
|
+
* @property {Set<Rule>} excludedRules
|
|
315
|
+
*/
|
|
316
|
+
|
|
317
|
+
class UrlExclusion {
|
|
318
|
+
constructor(dtm) {
|
|
319
|
+
this._urlRegex = null;
|
|
320
|
+
this._urls = new Set();
|
|
321
|
+
this.name = dtm.name;
|
|
322
|
+
this.type = ExclusionType[dtm.type];
|
|
323
|
+
this.rules = new Set(dtm.assess_rules);
|
|
324
|
+
|
|
325
|
+
if (dtm.urls.length) {
|
|
326
|
+
const regexSegments = [];
|
|
327
|
+
for (const url of dtm.urls) {
|
|
328
|
+
if (shouldBeRegExp(url)) {
|
|
329
|
+
regexSegments.push(url);
|
|
330
|
+
} else {
|
|
331
|
+
this._urls.add(url);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (regexSegments.length) {
|
|
335
|
+
this._urlRegex = new RegExp(`^${ArrayPrototypeJoin.call(regexSegments, '|')}$`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Checks whether the current URI path matches any of the exclusion's URL values.
|
|
342
|
+
* Exclusions that don't match for the current request will not be enabled. The
|
|
343
|
+
* interpretation of the DTM is that if its urls list is empty, then that means
|
|
344
|
+
* it should match all requestss (can be the case for input exclusions).
|
|
345
|
+
* @param {string} uriPath uri to check
|
|
346
|
+
* @returns {boolean}
|
|
347
|
+
*/
|
|
348
|
+
matchesUriPath(uriPath) {
|
|
349
|
+
return (!this._urlRegex && !this._urls.size) ||
|
|
350
|
+
this._urls.has(uriPath) ||
|
|
351
|
+
!!this._urlRegex?.test?.(uriPath);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
class InputExclusion extends UrlExclusion {
|
|
356
|
+
constructor(dtm) {
|
|
357
|
+
super(dtm);
|
|
358
|
+
this._inputNameRegex = null;
|
|
359
|
+
this._inputName = null;
|
|
360
|
+
|
|
361
|
+
// dtm.name value is null for BODY and QUERYSTRING types
|
|
362
|
+
if (dtm.name) {
|
|
363
|
+
if (shouldBeRegExp(dtm.name)) {
|
|
364
|
+
this._inputNameRegex = new RegExp(`^${dtm.name}$`);
|
|
365
|
+
} else {
|
|
366
|
+
this._inputName = dtm.name;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Checks if the provided name matches the value from the exclusion dtm.
|
|
373
|
+
* @param {string} name field name being evaluated
|
|
374
|
+
* @returns {boolean}
|
|
375
|
+
*/
|
|
376
|
+
matchesInputName(name) {
|
|
377
|
+
// BODY and QUERYSTRING always match since they apply broadly
|
|
378
|
+
if (!this._inputName && !this._inputNameRegex) return true;
|
|
379
|
+
return this._inputNameRegex ? RegExpPrototypeTest.call(this._inputNameRegex, name) : this._inputName === name;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function shouldBeRegExp(str) {
|
|
384
|
+
return str.indexOf('*') > 0 ||
|
|
385
|
+
str.indexOf('.') > 0 ||
|
|
386
|
+
str.indexOf('+') > 0 ||
|
|
387
|
+
str.indexOf('?') > 0 ||
|
|
388
|
+
str.indexOf('\\') > 0;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
module.exports = Core.makeComponent({
|
|
392
|
+
name: COMPONENT_NAME,
|
|
393
|
+
factory(core) {
|
|
394
|
+
const policy = new AssessPolicy(core);
|
|
395
|
+
set(core, COMPONENT_NAME, policy);
|
|
396
|
+
return policy;
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
module.exports.AssessPolicy = AssessPolicy;
|
|
400
|
+
module.exports.RequestPolicy = RequestPolicy;
|
|
@@ -60,7 +60,7 @@ module.exports = function(core) {
|
|
|
60
60
|
|
|
61
61
|
responseScanning.handleAutoCompleteMissing = function(sourceContext, resHeaders, resBody) {
|
|
62
62
|
if (
|
|
63
|
-
!
|
|
63
|
+
!sourceContext.policy?.isRuleEnabled(AUTOCOMPLETE_MISSING) ||
|
|
64
64
|
!isHtmlContent(resHeaders)
|
|
65
65
|
) {
|
|
66
66
|
return;
|
|
@@ -91,7 +91,7 @@ module.exports = function(core) {
|
|
|
91
91
|
|
|
92
92
|
// de-dupe; this will be re-emitted for parseableBody handlers anyway
|
|
93
93
|
if (
|
|
94
|
-
!
|
|
94
|
+
!sourceContext.policy?.isRuleEnabled(CACHE_CONTROLS_MISSING) ||
|
|
95
95
|
(isParseableResponse(resHeaders) && !resBody)
|
|
96
96
|
) {
|
|
97
97
|
return;
|
|
@@ -139,7 +139,7 @@ module.exports = function(core) {
|
|
|
139
139
|
};
|
|
140
140
|
|
|
141
141
|
responseScanning.handleClickJackingControlsMissing = function(sourceContext, resHeaders) {
|
|
142
|
-
if (!
|
|
142
|
+
if (!sourceContext.policy?.isRuleEnabled(CLICKJACKING_CONTROL_MISSING)) return;
|
|
143
143
|
|
|
144
144
|
// look for x-frame-options headers with deny or sameorigin
|
|
145
145
|
const xFrameHeaders = resHeaders['x-frame-options'];
|
|
@@ -158,7 +158,7 @@ module.exports = function(core) {
|
|
|
158
158
|
};
|
|
159
159
|
|
|
160
160
|
responseScanning.handleParameterPollution = function(sourceContext, resBody) {
|
|
161
|
-
if (!
|
|
161
|
+
if (!sourceContext.policy?.isRuleEnabled(PARAMETER_POLLUTION)) return;
|
|
162
162
|
|
|
163
163
|
// look for form tag with missing action attribute.
|
|
164
164
|
// ex: <form method="post">..
|
|
@@ -189,12 +189,12 @@ module.exports = function(core) {
|
|
|
189
189
|
const cspHeaders = getCspHeaders(resHeaders);
|
|
190
190
|
|
|
191
191
|
// Don't report if not set; this report belongs to 'csp-header-missing'
|
|
192
|
-
if (!cspHeaders &&
|
|
192
|
+
if (!cspHeaders && sourceContext.policy?.isRuleEnabled(CSP_HEADER_MISSING)) {
|
|
193
193
|
reportFindings(sourceContext, { ruleId: ResponseScanningRule.CSP_HEADER_MISSING });
|
|
194
194
|
return;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
if (!
|
|
197
|
+
if (!sourceContext.policy?.isRuleEnabled(CSP_HEADER_INSECURE)) return;
|
|
198
198
|
|
|
199
199
|
const vulnerabilityMetadata = checkCspSources(cspHeaders);
|
|
200
200
|
|
|
@@ -209,7 +209,7 @@ module.exports = function(core) {
|
|
|
209
209
|
};
|
|
210
210
|
|
|
211
211
|
responseScanning.handleHstsHeaderMissing = function(sourceContext, resHeaders) {
|
|
212
|
-
if (!
|
|
212
|
+
if (!sourceContext?.policy?.isRuleEnabled(HSTS_HEADER_MISSING)) return;
|
|
213
213
|
|
|
214
214
|
let header = resHeaders['strict-transport-security'];
|
|
215
215
|
let maxAge;
|
|
@@ -241,7 +241,7 @@ module.exports = function(core) {
|
|
|
241
241
|
};
|
|
242
242
|
|
|
243
243
|
responseScanning.handleXContentTypeHeaderMissing = function(sourceContext, resHeaders) {
|
|
244
|
-
if (!
|
|
244
|
+
if (!sourceContext.policy?.isRuleEnabled(XCONTENTTYPE_HEADER_MISSING)) return;
|
|
245
245
|
|
|
246
246
|
const headerName = 'x-content-type-options';
|
|
247
247
|
let header = resHeaders[headerName];
|
|
@@ -262,7 +262,7 @@ module.exports = function(core) {
|
|
|
262
262
|
};
|
|
263
263
|
|
|
264
264
|
responseScanning.handleXPoweredByHeader = function(sourceContext, resHeaders) {
|
|
265
|
-
if (!
|
|
265
|
+
if (!sourceContext.policy?.isRuleEnabled(X_POWERED_BY_HEADER)) return;
|
|
266
266
|
|
|
267
267
|
const headerName = 'x-powered-by';
|
|
268
268
|
let header = resHeaders[headerName];
|
|
@@ -280,7 +280,7 @@ module.exports = function(core) {
|
|
|
280
280
|
};
|
|
281
281
|
|
|
282
282
|
responseScanning.handleXxsProtectionHeaderDisabled = function(sourceContext, responseHeaders) {
|
|
283
|
-
if (!
|
|
283
|
+
if (!sourceContext?.policy?.isRuleEnabled(XXSPROTECTION_HEADER_DISABLED)) return;
|
|
284
284
|
|
|
285
285
|
const header = responseHeaders['x-xss-protection'];
|
|
286
286
|
|
|
@@ -294,9 +294,5 @@ module.exports = function(core) {
|
|
|
294
294
|
}
|
|
295
295
|
};
|
|
296
296
|
|
|
297
|
-
function isEnabled(ruleId, sourceContext) {
|
|
298
|
-
return !!sourceContext?.policy?.enabledRules?.has?.(ruleId);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
297
|
return responseScanning;
|
|
302
298
|
};
|
|
@@ -67,7 +67,7 @@ module.exports = function (core) {
|
|
|
67
67
|
function handle(ruleId, sourceContext, cookie, sessionEvent) {
|
|
68
68
|
const state = ensureState(ruleId, sourceContext);
|
|
69
69
|
|
|
70
|
-
if (
|
|
70
|
+
if (sourceContext?.policy?.disabledRules?.has?.(ruleId) || state.reported) return;
|
|
71
71
|
|
|
72
72
|
for (const value of ensureIterable(cookie)) {
|
|
73
73
|
if (state.valuesAnalyzed.has(value)) continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contrast/assess",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.64.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)",
|
|
@@ -21,17 +21,17 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@contrast/common": "1.37.0",
|
|
24
|
-
"@contrast/config": "1.
|
|
25
|
-
"@contrast/core": "1.
|
|
26
|
-
"@contrast/dep-hooks": "1.
|
|
24
|
+
"@contrast/config": "1.53.0",
|
|
25
|
+
"@contrast/core": "1.58.0",
|
|
26
|
+
"@contrast/dep-hooks": "1.27.0",
|
|
27
27
|
"@contrast/distringuish": "^6.0.2",
|
|
28
|
-
"@contrast/instrumentation": "1.
|
|
29
|
-
"@contrast/logger": "1.
|
|
30
|
-
"@contrast/patcher": "1.
|
|
31
|
-
"@contrast/rewriter": "1.
|
|
32
|
-
"@contrast/route-coverage": "1.
|
|
33
|
-
"@contrast/scopes": "1.
|
|
34
|
-
"@contrast/sources": "1.
|
|
28
|
+
"@contrast/instrumentation": "1.37.0",
|
|
29
|
+
"@contrast/logger": "1.31.0",
|
|
30
|
+
"@contrast/patcher": "1.30.0",
|
|
31
|
+
"@contrast/rewriter": "1.35.0",
|
|
32
|
+
"@contrast/route-coverage": "1.50.0",
|
|
33
|
+
"@contrast/scopes": "1.28.0",
|
|
34
|
+
"@contrast/sources": "1.4.0",
|
|
35
35
|
"semver": "^7.6.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/lib/get-policy.js
DELETED
|
@@ -1,336 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright: 2025 Contrast Security, Inc
|
|
3
|
-
* Contact: support@contrastsecurity.com
|
|
4
|
-
* License: Commercial
|
|
5
|
-
|
|
6
|
-
* NOTICE: This Software and the patented inventions embodied within may only be
|
|
7
|
-
* used as part of Contrast Security’s commercial offerings. Even though it is
|
|
8
|
-
* made available through public repositories, use of this Software is subject to
|
|
9
|
-
* the applicable End User Licensing Agreement found at
|
|
10
|
-
* https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
|
|
11
|
-
* between Contrast Security and the End User. The Software may not be reverse
|
|
12
|
-
* engineered, modified, repackaged, sold, redistributed or otherwise used in a
|
|
13
|
-
* way not consistent with the End User License Agreement.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
'use strict';
|
|
17
|
-
|
|
18
|
-
const {
|
|
19
|
-
Event,
|
|
20
|
-
ExclusionType,
|
|
21
|
-
InputType,
|
|
22
|
-
Rule,
|
|
23
|
-
ResponseScanningRule,
|
|
24
|
-
SessionConfigurationRule,
|
|
25
|
-
primordials: { ArrayPrototypeJoin, RegExpPrototypeTest }
|
|
26
|
-
} = require('@contrast/common');
|
|
27
|
-
|
|
28
|
-
const ASSESS_RULES = Object.values({
|
|
29
|
-
...Rule,
|
|
30
|
-
...ResponseScanningRule,
|
|
31
|
-
...SessionConfigurationRule,
|
|
32
|
-
});
|
|
33
|
-
const BROAD_INPUT_EXCLUSION_TYPES = [
|
|
34
|
-
ExclusionType.BODY,
|
|
35
|
-
ExclusionType.QUERYSTRING
|
|
36
|
-
];
|
|
37
|
-
const NAMED_INPUT_EXCLUSION_TYPES = [
|
|
38
|
-
ExclusionType.COOKIE,
|
|
39
|
-
ExclusionType.HEADER,
|
|
40
|
-
ExclusionType.PARAMETER
|
|
41
|
-
];
|
|
42
|
-
const BODY_TYPES = [
|
|
43
|
-
InputType.BODY,
|
|
44
|
-
InputType.JSON_VALUE,
|
|
45
|
-
InputType.JSON_ARRAYED_VALUE,
|
|
46
|
-
InputType.MULTIPART_CONTENT_TYPE,
|
|
47
|
-
InputType.MULTIPART_FIELD_NAME,
|
|
48
|
-
InputType.MULTIPART_NAME,
|
|
49
|
-
InputType.MULTIPART_VALUE,
|
|
50
|
-
];
|
|
51
|
-
const DISABLED_INPUT_POLICY = { track: false };
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* @param {{
|
|
55
|
-
* config: import('@contrast/config').Config,
|
|
56
|
-
* logger: import('@contrast/logger').Logger,
|
|
57
|
-
* messages: import('@contrast/common').Messages,
|
|
58
|
-
* }} core
|
|
59
|
-
* @returns {import('@contrast/common').Installable}
|
|
60
|
-
*/
|
|
61
|
-
module.exports = function assess(core) {
|
|
62
|
-
const { config, logger, messages } = core;
|
|
63
|
-
|
|
64
|
-
const globalPolicy = {
|
|
65
|
-
// by default all rules are enabled
|
|
66
|
-
enabledRules: new Set(ASSESS_RULES),
|
|
67
|
-
exclusionMap: new Map([
|
|
68
|
-
[ExclusionType.BODY, []],
|
|
69
|
-
[ExclusionType.COOKIE, []],
|
|
70
|
-
[ExclusionType.HEADER, []],
|
|
71
|
-
[ExclusionType.PARAMETER, []],
|
|
72
|
-
[ExclusionType.QUERYSTRING, []],
|
|
73
|
-
[ExclusionType.URL, []],
|
|
74
|
-
]),
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Subscribe to settings updates and modify global policy accordingly.
|
|
79
|
-
*/
|
|
80
|
-
messages.on(Event.SERVER_SETTINGS_UPDATE, (msg) => {
|
|
81
|
-
if (!config.getEffectiveValue('assess.enable')) return;
|
|
82
|
-
|
|
83
|
-
if (msg.assess) {
|
|
84
|
-
for (const ruleId of ASSESS_RULES) {
|
|
85
|
-
const enable = msg.assess[ruleId]?.enable;
|
|
86
|
-
if (enable === true) {
|
|
87
|
-
globalPolicy.enabledRules.add(ruleId);
|
|
88
|
-
if (ruleId === Rule.NOSQL_INJECTION) globalPolicy.enabledRules.add(Rule.NOSQL_INJECTION_MONGO);
|
|
89
|
-
} else if (enable === false) {
|
|
90
|
-
globalPolicy.enabledRules.delete(ruleId);
|
|
91
|
-
if (ruleId === Rule.NOSQL_INJECTION) globalPolicy.enabledRules.delete(Rule.NOSQL_INJECTION_MONGO);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
logger.info({ enabledRules: Array.from(globalPolicy.enabledRules) }, 'Assess policy enabled rules updated');
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (msg.exclusions) {
|
|
98
|
-
const rawDtmList = [
|
|
99
|
-
// todo: NODE-3281 input exclusions
|
|
100
|
-
...(msg?.exclusions?.input || []),
|
|
101
|
-
...(msg?.exclusions?.url || []),
|
|
102
|
-
].filter((exclusion) => exclusion?.modes?.includes?.('assess'));
|
|
103
|
-
|
|
104
|
-
// reset global exclusion state
|
|
105
|
-
for (const type of Object.values(ExclusionType)) {
|
|
106
|
-
globalPolicy.exclusionMap.get(type).length = 0;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (!rawDtmList.length) return;
|
|
110
|
-
|
|
111
|
-
for (const dtm of rawDtmList) {
|
|
112
|
-
// normalize different dtm types
|
|
113
|
-
dtm.type = dtm.type || 'URL';
|
|
114
|
-
const { type } = dtm;
|
|
115
|
-
const key = ExclusionType[type];
|
|
116
|
-
// defensive code against unanticipated DTM values
|
|
117
|
-
if (key) {
|
|
118
|
-
const Ctor = dtm.type === ExclusionType.URL ? UrlExclusion : InputExclusion;
|
|
119
|
-
globalPolicy.exclusionMap.get(dtm.type).push(new Ctor(dtm));
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
logger.info({
|
|
124
|
-
exclusions: Object.fromEntries(globalPolicy.exclusionMap)
|
|
125
|
-
}, 'Assess exclusions updated (%s total)', rawDtmList.length);
|
|
126
|
-
}
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Generates the policy for the current request. We return copy of the global policy
|
|
131
|
-
* to avoid inconsistent behavior if policy is updated during request handling. In
|
|
132
|
-
* addition, the request policy is altered to account for any URL or Input exclusions.
|
|
133
|
-
* @param {string} uriPath
|
|
134
|
-
*/
|
|
135
|
-
return core.assess.getPolicy = function getPolicy({ uriPath } = {}) {
|
|
136
|
-
const _enabledRules = new Set(globalPolicy.enabledRules);
|
|
137
|
-
const exclusionState = {
|
|
138
|
-
// types that can be disabled broadly
|
|
139
|
-
[ExclusionType.BODY]: { track: true, excludedRules: new Set() },
|
|
140
|
-
[ExclusionType.QUERYSTRING]: { track: true, excludedRules: new Set() },
|
|
141
|
-
// other types we check by name. parameter applies to body and query params
|
|
142
|
-
[ExclusionType.COOKIE]: [],
|
|
143
|
-
[ExclusionType.HEADER]: [],
|
|
144
|
-
[ExclusionType.PARAMETER]: [],
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
// Evaluate URL exclusions.
|
|
148
|
-
// If one matches and applies to all rules, we return `null` for the policy value, which
|
|
149
|
-
// will disable assess for the request (via getSourceContext()). If specific rules are
|
|
150
|
-
// disabled, we remove them from the request policy's set of enabled rules.
|
|
151
|
-
for (const urlExclusion of globalPolicy.exclusionMap.get(ExclusionType.URL)) {
|
|
152
|
-
if (urlExclusion.matchesUriPath(uriPath)) {
|
|
153
|
-
if (!urlExclusion.rules?.size) {
|
|
154
|
-
core.logger.debug({
|
|
155
|
-
name: urlExclusion.name
|
|
156
|
-
}, 'All Assess rules have been disabled by URL exclusion');
|
|
157
|
-
return null;
|
|
158
|
-
} else {
|
|
159
|
-
for (const ruleId of urlExclusion.rules) {
|
|
160
|
-
_enabledRules.delete(ruleId);
|
|
161
|
-
}
|
|
162
|
-
core.logger.debug({
|
|
163
|
-
name: urlExclusion.name,
|
|
164
|
-
rules: Array.from(urlExclusion.rules),
|
|
165
|
-
}, 'Assess rules disabled by URL exclusion');
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Process input exclusions that apply broadly: BODY, QUERYSTRING
|
|
171
|
-
for (const type of BROAD_INPUT_EXCLUSION_TYPES) {
|
|
172
|
-
const _policy = exclusionState[type];
|
|
173
|
-
for (const exclusion of globalPolicy.exclusionMap.get(type)) {
|
|
174
|
-
if (exclusion.matchesUriPath(uriPath)) {
|
|
175
|
-
if (exclusion.rules.size) {
|
|
176
|
-
for (const ruleId of exclusion.rules) {
|
|
177
|
-
_policy.excludedRules.add(ruleId);
|
|
178
|
-
}
|
|
179
|
-
} else {
|
|
180
|
-
_policy.track = false;
|
|
181
|
-
_policy.excludedRules.clear();
|
|
182
|
-
break;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
// Filter input exclusions that will be used to get named input
|
|
188
|
-
// policies: COOKIE, HEADER, PARAMETER
|
|
189
|
-
for (const type of NAMED_INPUT_EXCLUSION_TYPES) {
|
|
190
|
-
for (const exclusion of globalPolicy.exclusionMap.get(type)) {
|
|
191
|
-
if (exclusion.matchesUriPath(uriPath)) {
|
|
192
|
-
exclusionState[type].push(exclusion);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return {
|
|
198
|
-
/**
|
|
199
|
-
* Enabled rules filtered by any applicable URL exclusions
|
|
200
|
-
*/
|
|
201
|
-
enabledRules: _enabledRules,
|
|
202
|
-
/**
|
|
203
|
-
* Used by source handler to get policy information for specific named inputs.
|
|
204
|
-
* @param {InputType} inputType
|
|
205
|
-
* @param {string} [fieldName]
|
|
206
|
-
* @returns {InputPolicy}
|
|
207
|
-
*/
|
|
208
|
-
getInputPolicy(inputType, fieldName) {
|
|
209
|
-
let exclusionsByType;
|
|
210
|
-
const inputPolicy = { track: true, excludedRules: new Set() };
|
|
211
|
-
|
|
212
|
-
const isBody = BODY_TYPES.includes(inputType);
|
|
213
|
-
|
|
214
|
-
if (isBody || inputType === InputType.QUERYSTRING) {
|
|
215
|
-
// these can be disabled broadly
|
|
216
|
-
const _policy = exclusionState[isBody ? ExclusionType.BODY : ExclusionType.QUERYSTRING];
|
|
217
|
-
if (!_policy.track) {
|
|
218
|
-
return DISABLED_INPUT_POLICY;
|
|
219
|
-
}
|
|
220
|
-
for (const ruleId of _policy.excludedRules) {
|
|
221
|
-
inputPolicy.excludedRules.add(ruleId);
|
|
222
|
-
}
|
|
223
|
-
exclusionsByType = exclusionState[ExclusionType.PARAMETER];
|
|
224
|
-
} else if (inputType === InputType.URL_PARAMETER) {
|
|
225
|
-
exclusionsByType = exclusionState[ExclusionType.PARAMETER];
|
|
226
|
-
} else if (inputType === InputType.HEADER) {
|
|
227
|
-
exclusionsByType = exclusionState[ExclusionType.HEADER];
|
|
228
|
-
} else if ([
|
|
229
|
-
InputType.COOKIE_NAME,
|
|
230
|
-
InputType.COOKIE_VALUE
|
|
231
|
-
].includes(inputType)) {
|
|
232
|
-
exclusionsByType = exclusionState[ExclusionType.COOKIE];
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (!exclusionsByType) {
|
|
236
|
-
return inputPolicy;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// check input name
|
|
240
|
-
for (const exclusion of exclusionsByType) {
|
|
241
|
-
if (exclusion.matchesInputName(fieldName)) {
|
|
242
|
-
if (exclusion.rules.size) {
|
|
243
|
-
for (const ruleId of exclusion.rules) {
|
|
244
|
-
inputPolicy.excludedRules.add(ruleId);
|
|
245
|
-
}
|
|
246
|
-
} else {
|
|
247
|
-
return DISABLED_INPUT_POLICY;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return inputPolicy;
|
|
253
|
-
},
|
|
254
|
-
};
|
|
255
|
-
};
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* @typedef InputPolicy
|
|
260
|
-
* @property {boolean} track
|
|
261
|
-
* @property {Set<Rule>} excludedRules
|
|
262
|
-
*/
|
|
263
|
-
|
|
264
|
-
class UrlExclusion {
|
|
265
|
-
constructor(dtm) {
|
|
266
|
-
this._urlRegex = null;
|
|
267
|
-
this._urls = new Set();
|
|
268
|
-
this.name = dtm.name;
|
|
269
|
-
this.type = ExclusionType[dtm.type];
|
|
270
|
-
this.rules = new Set(dtm.assess_rules);
|
|
271
|
-
|
|
272
|
-
if (dtm.urls.length) {
|
|
273
|
-
const regexSegments = [];
|
|
274
|
-
for (const url of dtm.urls) {
|
|
275
|
-
if (shouldBeRegExp(url)) {
|
|
276
|
-
regexSegments.push(url);
|
|
277
|
-
} else {
|
|
278
|
-
this._urls.add(url);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
if (regexSegments.length) {
|
|
282
|
-
this._urlRegex = new RegExp(`^${ArrayPrototypeJoin.call(regexSegments, '|')}$`);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* Checks whether the current URI path matches any of the exclusion's URL values.
|
|
289
|
-
* Exclusions that don't match for the current request will not be enabled. The
|
|
290
|
-
* interpretation of the DTM is that if its urls list is empty, then that means
|
|
291
|
-
* it should match all requestss (can be the case for input exclusions).
|
|
292
|
-
* @param {string} uriPath uri to check
|
|
293
|
-
* @returns {boolean}
|
|
294
|
-
*/
|
|
295
|
-
matchesUriPath(uriPath) {
|
|
296
|
-
return (!this._urlRegex && !this._urls.size) ||
|
|
297
|
-
this._urls.has(uriPath) ||
|
|
298
|
-
!!this._urlRegex?.test?.(uriPath);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
class InputExclusion extends UrlExclusion {
|
|
303
|
-
constructor(dtm) {
|
|
304
|
-
super(dtm);
|
|
305
|
-
this._inputNameRegex = null;
|
|
306
|
-
this._inputName = null;
|
|
307
|
-
|
|
308
|
-
// dtm.name value is null for BODY and QUERYSTRING types
|
|
309
|
-
if (dtm.name) {
|
|
310
|
-
if (shouldBeRegExp(dtm.name)) {
|
|
311
|
-
this._inputNameRegex = new RegExp(`^${dtm.name}$`);
|
|
312
|
-
} else {
|
|
313
|
-
this._inputName = dtm.name;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Checks if the provided name matches the value from the exclusion dtm.
|
|
320
|
-
* @param {string} name field name being evaluated
|
|
321
|
-
* @returns {boolean}
|
|
322
|
-
*/
|
|
323
|
-
matchesInputName(name) {
|
|
324
|
-
// BODY and QUERYSTRING always match since they apply broadly
|
|
325
|
-
if (!this._inputName && !this._inputNameRegex) return true;
|
|
326
|
-
return this._inputNameRegex ? RegExpPrototypeTest.call(this._inputNameRegex, name) : this._inputName === name;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function shouldBeRegExp(str) {
|
|
331
|
-
return str.indexOf('*') > 0 ||
|
|
332
|
-
str.indexOf('.') > 0 ||
|
|
333
|
-
str.indexOf('+') > 0 ||
|
|
334
|
-
str.indexOf('?') > 0 ||
|
|
335
|
-
str.indexOf('\\') > 0;
|
|
336
|
-
}
|