@contrast/assess 1.1.1 → 1.2.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/index.js +6 -7
- package/lib/dataflow/propagation/install/contrast-methods/index.js +1 -0
- package/lib/dataflow/propagation/install/contrast-methods/string.js +56 -0
- package/lib/dataflow/propagation/install/string/index.js +1 -0
- package/lib/dataflow/propagation/install/string/split.js +108 -0
- package/lib/dataflow/sources/index.js +0 -1
- package/lib/dataflow/sources/install/http.js +9 -6
- package/lib/index.js +4 -2
- package/lib/response-scanning/handlers/index.js +274 -0
- package/lib/response-scanning/handlers/utils.js +394 -0
- package/lib/response-scanning/index.js +36 -0
- package/lib/response-scanning/install/http.js +119 -0
- package/package.json +2 -2
- package/lib/dataflow/sources/install/qs.js +0 -88
|
@@ -20,15 +20,15 @@ const { callChildComponentMethodsSync } = require('@contrast/common');
|
|
|
20
20
|
module.exports = function(core) {
|
|
21
21
|
const propagation = core.assess.dataflow.propagation = {};
|
|
22
22
|
|
|
23
|
-
require('./install/string')(core);
|
|
24
|
-
require('./install/array-prototype-join')(core);
|
|
25
|
-
require('./install/pug')(core);
|
|
26
23
|
require('./install/contrast-methods')(core);
|
|
27
|
-
require('./install/validator')(core);
|
|
28
|
-
require('./install/url')(core);
|
|
29
24
|
require('./install/ejs')(core);
|
|
30
|
-
require('./install/
|
|
25
|
+
require('./install/pug')(core);
|
|
26
|
+
require('./install/string')(core);
|
|
27
|
+
require('./install/url')(core);
|
|
28
|
+
require('./install/validator')(core);
|
|
29
|
+
require('./install/array-prototype-join')(core);
|
|
31
30
|
require('./install/decode-uri-component')(core);
|
|
31
|
+
require('./install/encode-uri-component')(core);
|
|
32
32
|
require('./install/escape-html')(core);
|
|
33
33
|
require('./install/escape')(core);
|
|
34
34
|
require('./install/handlebars-utils-escape-expression')(core);
|
|
@@ -37,7 +37,6 @@ module.exports = function(core) {
|
|
|
37
37
|
require('./install/sql-template-strings')(core);
|
|
38
38
|
require('./install/unescape')(core);
|
|
39
39
|
|
|
40
|
-
|
|
41
40
|
propagation.install = function() {
|
|
42
41
|
callChildComponentMethodsSync(propagation, 'install');
|
|
43
42
|
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright: 2022 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 { patchType } = require('../../common');
|
|
19
|
+
|
|
20
|
+
module.exports = function(core) {
|
|
21
|
+
const {
|
|
22
|
+
scopes: { sources, instrumentation },
|
|
23
|
+
patcher,
|
|
24
|
+
assess: {
|
|
25
|
+
dataflow: { tracker }
|
|
26
|
+
}
|
|
27
|
+
} = core;
|
|
28
|
+
|
|
29
|
+
return core.assess.dataflow.propagation.contrastMethodsInstrumentation.string = {
|
|
30
|
+
install() {
|
|
31
|
+
patcher.patch(global.ContrastMethods, 'String', {
|
|
32
|
+
name: 'ContrastMethods.String',
|
|
33
|
+
patchType,
|
|
34
|
+
post(data) {
|
|
35
|
+
if (!data.result || !sources.getStore() || instrumentation.isLocked()) return;
|
|
36
|
+
|
|
37
|
+
const arg = data.args[0];
|
|
38
|
+
let argInfo = tracker.getData(arg);
|
|
39
|
+
|
|
40
|
+
if (data.obj && !argInfo) {
|
|
41
|
+
argInfo = tracker.getData(data.result.toString());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!arg || !argInfo) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { extern } = tracker.track(data.result, argInfo);
|
|
49
|
+
if (extern) {
|
|
50
|
+
data.result = extern;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright: 2022 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 { patchType } = require('../../common');
|
|
19
|
+
const { join } = require('@contrast/common');
|
|
20
|
+
const { createSubsetTags } = require('../../../tag-utils');
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
module.exports = function(core) {
|
|
24
|
+
const {
|
|
25
|
+
scopes: { sources, instrumentation },
|
|
26
|
+
patcher,
|
|
27
|
+
assess: {
|
|
28
|
+
dataflow: { tracker, eventFactory: { createPropagationEvent } }
|
|
29
|
+
}
|
|
30
|
+
} = core;
|
|
31
|
+
|
|
32
|
+
return core.assess.dataflow.propagation.stringInstrumentation.split = {
|
|
33
|
+
install() {
|
|
34
|
+
const name = 'String.prototype.split';
|
|
35
|
+
|
|
36
|
+
patcher.patch(String.prototype, 'split', {
|
|
37
|
+
name,
|
|
38
|
+
patchType,
|
|
39
|
+
post(data) {
|
|
40
|
+
const { name, args, obj, result, hooked, orig } = data;
|
|
41
|
+
if (
|
|
42
|
+
!obj ||
|
|
43
|
+
!result ||
|
|
44
|
+
args.length === 0 ||
|
|
45
|
+
result.length === 0 ||
|
|
46
|
+
!sources.getStore() ||
|
|
47
|
+
typeof obj !== 'string' ||
|
|
48
|
+
instrumentation.isLocked() ||
|
|
49
|
+
(args.length === 1 && args[0] == null)
|
|
50
|
+
) return;
|
|
51
|
+
|
|
52
|
+
const objInfo = tracker.getData(obj);
|
|
53
|
+
if (!objInfo) return;
|
|
54
|
+
|
|
55
|
+
let idx = 0;
|
|
56
|
+
for (let i = 0; i < result.length; i++) {
|
|
57
|
+
const res = result[i];
|
|
58
|
+
const start = obj.indexOf(res, idx);
|
|
59
|
+
idx += res.length;
|
|
60
|
+
const objSubstr = obj.substring(start, start + res.length);
|
|
61
|
+
const objSubstrInfo = tracker.getData(objSubstr);
|
|
62
|
+
if (objSubstrInfo) {
|
|
63
|
+
const tags = createSubsetTags(objInfo.tags, start, res.length - 1);
|
|
64
|
+
if (!tags) continue;
|
|
65
|
+
|
|
66
|
+
const event = createPropagationEvent({
|
|
67
|
+
name,
|
|
68
|
+
history: [objInfo],
|
|
69
|
+
object: {
|
|
70
|
+
value: obj,
|
|
71
|
+
isTracked: true,
|
|
72
|
+
},
|
|
73
|
+
args: args.map((arg) => {
|
|
74
|
+
const argInfo = tracker.getData(arg);
|
|
75
|
+
return {
|
|
76
|
+
value: argInfo ? argInfo.value : arg.toString(),
|
|
77
|
+
isTracked: !!argInfo
|
|
78
|
+
};
|
|
79
|
+
}),
|
|
80
|
+
tags,
|
|
81
|
+
result: {
|
|
82
|
+
value: join(result),
|
|
83
|
+
isTracked: false
|
|
84
|
+
},
|
|
85
|
+
stacktraceOpts: {
|
|
86
|
+
constructorOpt: hooked,
|
|
87
|
+
prependFrames: [orig]
|
|
88
|
+
},
|
|
89
|
+
source: 'O',
|
|
90
|
+
target: 'R'
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (event) {
|
|
94
|
+
const { extern } = tracker.track(res, event);
|
|
95
|
+
if (extern) {
|
|
96
|
+
data.result[i] = extern;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
uninstall() {
|
|
105
|
+
String.prototype.split = patcher.unwrap(String.prototype.split);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
};
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
'use strict';
|
|
17
17
|
const { patchType } = require('../common');
|
|
18
|
+
const { toLowerCase } = require('@contrast/common');
|
|
18
19
|
|
|
19
20
|
// This is only just initiating an async storage for the http source
|
|
20
21
|
// TODO Tracking the user input
|
|
@@ -59,13 +60,13 @@ module.exports = function(core) {
|
|
|
59
60
|
const key = obj[i];
|
|
60
61
|
const value = obj[i + 1];
|
|
61
62
|
|
|
62
|
-
if (
|
|
63
|
+
if (toLowerCase(key) === 'content-type') {
|
|
63
64
|
store.assess.responseData.contentType = value;
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
} else if (typeof obj === 'object') {
|
|
67
68
|
for (const [key, value] of Object.entries(obj)) {
|
|
68
|
-
if (
|
|
69
|
+
if (toLowerCase(key) === 'content-type') {
|
|
69
70
|
store.assess.responseData.contentType = value;
|
|
70
71
|
}
|
|
71
72
|
}
|
|
@@ -79,7 +80,7 @@ module.exports = function(core) {
|
|
|
79
80
|
patchType,
|
|
80
81
|
pre(data) {
|
|
81
82
|
const [name = '', value] = data.args;
|
|
82
|
-
if (
|
|
83
|
+
if (toLowerCase(name) === 'content-type' && value) {
|
|
83
84
|
store.assess.responseData.contentType = value;
|
|
84
85
|
}
|
|
85
86
|
}
|
|
@@ -99,11 +100,11 @@ module.exports = function(core) {
|
|
|
99
100
|
const headers = {};
|
|
100
101
|
|
|
101
102
|
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
102
|
-
const header = req.rawHeaders[i]
|
|
103
|
+
const header = toLowerCase(req.rawHeaders[i]);
|
|
103
104
|
headers[header] = req.rawHeaders[i + 1];
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
const contentType = headers['content-type']
|
|
107
|
+
const contentType = headers['content-type'] && toLowerCase(headers['content-type']);
|
|
107
108
|
const reqData = {
|
|
108
109
|
ip: req.socket.remoteAddress,
|
|
109
110
|
httpVersion: req.httpVersion,
|
|
@@ -125,7 +126,9 @@ module.exports = function(core) {
|
|
|
125
126
|
logger.error({ err }, 'Error during assess request handling');
|
|
126
127
|
}
|
|
127
128
|
|
|
128
|
-
|
|
129
|
+
setImmediate(() => {
|
|
130
|
+
next.call(data.obj, ...data.args);
|
|
131
|
+
});
|
|
129
132
|
}
|
|
130
133
|
|
|
131
134
|
function install() {
|
package/lib/index.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
|
|
16
16
|
'use strict';
|
|
17
17
|
|
|
18
|
+
const { callChildComponentMethodsSync } = require('@contrast/common');
|
|
18
19
|
const dataflow = require('./dataflow');
|
|
20
|
+
const responseScanning = require('./response-scanning');
|
|
19
21
|
|
|
20
22
|
module.exports = function assess(core) {
|
|
21
23
|
if (!core.config.assess.enable) return;
|
|
@@ -25,14 +27,14 @@ module.exports = function assess(core) {
|
|
|
25
27
|
// Does this order matter? Probably not
|
|
26
28
|
// 1. dataflow
|
|
27
29
|
dataflow(core);
|
|
30
|
+
responseScanning(core);
|
|
28
31
|
|
|
29
|
-
// response-scanning
|
|
30
32
|
// crypto
|
|
31
33
|
// static (in coordination with rewriter)
|
|
32
34
|
|
|
33
35
|
assess.install = function() {
|
|
34
36
|
core.rewriter.install('assess');
|
|
35
|
-
core.assess
|
|
37
|
+
callChildComponentMethodsSync(core.assess, 'install');
|
|
36
38
|
};
|
|
37
39
|
|
|
38
40
|
return assess;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright: 2022 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
|
+
escapeHtml,
|
|
20
|
+
isHtmlContent,
|
|
21
|
+
getElements,
|
|
22
|
+
getAttribute,
|
|
23
|
+
isParseableResponse,
|
|
24
|
+
checkCacheControlValue,
|
|
25
|
+
checkMetaTags,
|
|
26
|
+
getCspHeaders,
|
|
27
|
+
checkCspSources
|
|
28
|
+
} = require('./utils');
|
|
29
|
+
const { toLowerCase, substring, ResponseScanningRule } = require('@contrast/common');
|
|
30
|
+
|
|
31
|
+
module.exports = function(core) {
|
|
32
|
+
const {
|
|
33
|
+
assess: {
|
|
34
|
+
responseScanning,
|
|
35
|
+
responseScanning: {
|
|
36
|
+
reportFindings
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} = core;
|
|
40
|
+
|
|
41
|
+
responseScanning.handleAutoCompleteMissing = function(sourceContext, { responseHeaders, responseBody }) {
|
|
42
|
+
if (!isHtmlContent(responseHeaders)) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const elements = getElements('form', responseBody);
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < elements.length; i++) {
|
|
49
|
+
const autocomplete = getAttribute('autocomplete', elements[i]);
|
|
50
|
+
if (autocomplete !== 'off') {
|
|
51
|
+
reportFindings(sourceContext,
|
|
52
|
+
{
|
|
53
|
+
ruleId: ResponseScanningRule.AUTOCOMPLETE_MISSING,
|
|
54
|
+
vulnerabilityMetadata: {
|
|
55
|
+
attribute: autocomplete,
|
|
56
|
+
html: escapeHtml(elements[i]),
|
|
57
|
+
start: 0,
|
|
58
|
+
end: elements[i].length
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
responseScanning.handleCacheControlsMissing = function(sourceContext, { responseHeaders, responseBody }) {
|
|
67
|
+
const instructions = [];
|
|
68
|
+
|
|
69
|
+
// de-dupe; this will be re-emitted for parseableBody handlers anyway
|
|
70
|
+
if (isParseableResponse(responseHeaders) && !responseBody) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const cacheControlHeader = responseHeaders['cache-control'];
|
|
74
|
+
|
|
75
|
+
// save the Pragma Header
|
|
76
|
+
if (responseHeaders['pragma']) {
|
|
77
|
+
instructions.push({
|
|
78
|
+
type: 'Header',
|
|
79
|
+
name: 'pragma',
|
|
80
|
+
value: responseHeaders['pragma']
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (cacheControlHeader) {
|
|
85
|
+
const [containsNoCache, containsNoStore] = checkCacheControlValue(
|
|
86
|
+
cacheControlHeader
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// got everything from header so we don't care about meta tags
|
|
90
|
+
if (containsNoCache && containsNoStore) {
|
|
91
|
+
return;
|
|
92
|
+
} else {
|
|
93
|
+
// save the instructions in case meta tags are missing
|
|
94
|
+
instructions.push({
|
|
95
|
+
type: 'Header',
|
|
96
|
+
name: 'cache-control',
|
|
97
|
+
value: cacheControlHeader
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// we checked the headers now time to check the HTML content
|
|
103
|
+
checkMetaTags({ body: responseBody, cacheControlHeader, instructions });
|
|
104
|
+
|
|
105
|
+
if (instructions.length) {
|
|
106
|
+
reportFindings(sourceContext, {
|
|
107
|
+
ruleId: ResponseScanningRule.CACHE_CONTROLS_MISSING,
|
|
108
|
+
vulnerabilityMetadata: {
|
|
109
|
+
data: JSON.stringify(instructions)
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
responseScanning.handleClickJackingControlsMissing = function(sourceContext, { responseHeaders }) {
|
|
116
|
+
// look for x-frame-options headers with deny or sameorigin
|
|
117
|
+
const xFrameHeaders = responseHeaders['x-frame-options'];
|
|
118
|
+
let hasFrameBusting = false;
|
|
119
|
+
|
|
120
|
+
if (xFrameHeaders) {
|
|
121
|
+
const xFrameHeadersLC = toLowerCase(xFrameHeaders);
|
|
122
|
+
hasFrameBusting =
|
|
123
|
+
xFrameHeadersLC.indexOf('deny') > -1 ||
|
|
124
|
+
xFrameHeadersLC.indexOf('sameorigin') > -1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!hasFrameBusting) {
|
|
128
|
+
reportFindings(sourceContext, { ruleId: ResponseScanningRule.CLICKJACKING_CONTROL_MISSING });
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
responseScanning.handleParameterPollution = function(sourceContext, { responseBody }) {
|
|
133
|
+
// look for form tag with missing action attribute.
|
|
134
|
+
// ex: <form method="post">..
|
|
135
|
+
const elements = getElements('form', responseBody);
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < elements.length; i++) {
|
|
138
|
+
const action = getAttribute('action', elements[i]);
|
|
139
|
+
if (!action) {
|
|
140
|
+
reportFindings(sourceContext, {
|
|
141
|
+
ruleId: ResponseScanningRule.PARAMETER_POLLUTION,
|
|
142
|
+
vulnerabilityMetadata: {
|
|
143
|
+
attribute: action,
|
|
144
|
+
html: escapeHtml(elements[i])
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Checks the response headers for the CSP. If found, and insecure, will return
|
|
153
|
+
* the evidence for reporting.
|
|
154
|
+
*
|
|
155
|
+
* @param {Object} responseHeaders - HTTP headers object.
|
|
156
|
+
* @returns {Object} - Evidence for insecure CSP header.
|
|
157
|
+
*/
|
|
158
|
+
responseScanning.handleCspHeader = function(sourceContext, { responseHeaders }) {
|
|
159
|
+
const cspHeaders = getCspHeaders(responseHeaders);
|
|
160
|
+
|
|
161
|
+
// Don't report if not set; this report belongs to 'csp-header-missing'
|
|
162
|
+
if (!cspHeaders) {
|
|
163
|
+
reportFindings(sourceContext, { ruleId: ResponseScanningRule.CSP_HEADER_MISSING });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const vulnerabilityMetadata = checkCspSources(cspHeaders);
|
|
168
|
+
|
|
169
|
+
if (vulnerabilityMetadata.insecure) {
|
|
170
|
+
// We do this because TS API will expect these keys (although it is a typo)
|
|
171
|
+
// When they fix the API we can remove this
|
|
172
|
+
vulnerabilityMetadata.refererSecure = vulnerabilityMetadata.referrerSecure;
|
|
173
|
+
vulnerabilityMetadata.refererValue = vulnerabilityMetadata.referrerValue;
|
|
174
|
+
|
|
175
|
+
delete vulnerabilityMetadata.insecure;
|
|
176
|
+
delete vulnerabilityMetadata.referrerSecure;
|
|
177
|
+
delete vulnerabilityMetadata.referrerValue;
|
|
178
|
+
|
|
179
|
+
reportFindings(sourceContext, { ruleId: ResponseScanningRule.CSP_HEADER_INSECURE, vulnerabilityMetadata });
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
responseScanning.handleHstsHeaderMissing = function(sourceContext, { responseHeaders }) {
|
|
184
|
+
let header = responseHeaders['strict-transport-security'];
|
|
185
|
+
let maxAge;
|
|
186
|
+
|
|
187
|
+
if (header) {
|
|
188
|
+
header = toLowerCase(header);
|
|
189
|
+
const flag = header.indexOf('max-age');
|
|
190
|
+
if (flag > -1) {
|
|
191
|
+
const equal = header.indexOf('=', flag);
|
|
192
|
+
if (equal > -1) {
|
|
193
|
+
const semicolon = header.indexOf(';', equal);
|
|
194
|
+
if (semicolon > -1) {
|
|
195
|
+
maxAge = substring(header, equal + 1, semicolon);
|
|
196
|
+
} else {
|
|
197
|
+
maxAge = substring(header, equal + 1);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!(parseInt(maxAge) > 0)) {
|
|
204
|
+
reportFindings(sourceContext, {
|
|
205
|
+
ruleId: ResponseScanningRule.HSTS_HEADER_MISSING,
|
|
206
|
+
vulnerabilityMetadata: {
|
|
207
|
+
data: maxAge || ''
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
responseScanning.handlePoweredByHeader = function(sourceContext, { responseHeaders }) {
|
|
214
|
+
const headerName = 'x-powered-by';
|
|
215
|
+
let header = responseHeaders[headerName];
|
|
216
|
+
|
|
217
|
+
if (header) {
|
|
218
|
+
header = toLowerCase(header);
|
|
219
|
+
|
|
220
|
+
const instructions = [
|
|
221
|
+
{
|
|
222
|
+
type: 'Header',
|
|
223
|
+
name: headerName,
|
|
224
|
+
value: header
|
|
225
|
+
}
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
reportFindings(sourceContext, {
|
|
229
|
+
ruleId: ResponseScanningRule.POWERED_BY_HEADER,
|
|
230
|
+
vulnerabilityMetadata: {
|
|
231
|
+
data: JSON.stringify(instructions)
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
responseScanning.handleXContentTypeHeaderMissing = function(sourceContext, { responseHeaders }) {
|
|
238
|
+
const headerName = 'x-content-type-options';
|
|
239
|
+
let header = responseHeaders[headerName];
|
|
240
|
+
|
|
241
|
+
if (header) {
|
|
242
|
+
header = toLowerCase(header);
|
|
243
|
+
if (header === 'nosniff') {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
reportFindings(sourceContext, {
|
|
249
|
+
ruleId: ResponseScanningRule.XCONTENTTYPE_HEADER_MISSING,
|
|
250
|
+
vulnerabilityMetadata: {
|
|
251
|
+
data: header || ''
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
responseScanning.handleXxsProtectionHeaderDisabled = function(sourceContext, { responseHeaders }) {
|
|
257
|
+
const header = responseHeaders['x-xss-protection'];
|
|
258
|
+
|
|
259
|
+
// This header is set by default, so `header` should always be present.
|
|
260
|
+
if (header && header.startsWith('1')) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
reportFindings(sourceContext, {
|
|
265
|
+
ruleId: ResponseScanningRule.XXSPROTECTION_HEADER_DISABLED,
|
|
266
|
+
vulnerabilityMetadata: {
|
|
267
|
+
data: header
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
return responseScanning;
|
|
273
|
+
};
|
|
274
|
+
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright: 2022 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 { join, substring, toLowerCase, split, trim } = require('@contrast/common');
|
|
19
|
+
|
|
20
|
+
//
|
|
21
|
+
// General HTML utils
|
|
22
|
+
//
|
|
23
|
+
const htmlEscapes = {
|
|
24
|
+
'&': '&',
|
|
25
|
+
'<': '<',
|
|
26
|
+
'>': '>',
|
|
27
|
+
'"': '"',
|
|
28
|
+
"'": '''
|
|
29
|
+
};
|
|
30
|
+
const reUnescapedHtml = /[&<>"']/g;
|
|
31
|
+
const reHasUnescapedHtml = RegExp(reUnescapedHtml.source);
|
|
32
|
+
|
|
33
|
+
function escapeHtml(string) {
|
|
34
|
+
return (string && reHasUnescapedHtml.test(string))
|
|
35
|
+
? string.replace(reUnescapedHtml, (chr) => htmlEscapes[chr])
|
|
36
|
+
: (string || '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getHtmlTagRange(htmlTag, content, offset) {
|
|
40
|
+
const htmlTagStart = content.indexOf(`<${htmlTag}`, offset);
|
|
41
|
+
if (htmlTagStart < 0) return [-1, 0];
|
|
42
|
+
|
|
43
|
+
const htmlTagEnd = content.indexOf('>', htmlTagStart);
|
|
44
|
+
return [htmlTagStart, htmlTagEnd - htmlTagStart + 1];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getElements(htmlTag, content = '') {
|
|
48
|
+
const elements = [];
|
|
49
|
+
let offset = 0;
|
|
50
|
+
let htmlTagRangeStart = -1;
|
|
51
|
+
let htmlTagRangeLength = 0;
|
|
52
|
+
|
|
53
|
+
do {
|
|
54
|
+
[htmlTagRangeStart, htmlTagRangeLength] = getHtmlTagRange(htmlTag, content, offset);
|
|
55
|
+
if (htmlTagRangeStart >= 0) {
|
|
56
|
+
offset = htmlTagRangeStart + htmlTagRangeLength;
|
|
57
|
+
elements.push(substring(content, htmlTagRangeStart, offset));
|
|
58
|
+
}
|
|
59
|
+
} while (htmlTagRangeStart >= 0);
|
|
60
|
+
|
|
61
|
+
return elements;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isHtmlContent(responseHeaders) {
|
|
65
|
+
if (!responseHeaders) {
|
|
66
|
+
// this should never happen, but better safe than sorry;
|
|
67
|
+
// worst case, we parse image data or something
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// we may want to do a case-insensitive search through object keys here
|
|
72
|
+
const contentType = toLowerCase(responseHeaders['Content-Type'] || responseHeaders['content-type'] || '');
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
!contentType ||
|
|
76
|
+
contentType.includes('text') ||
|
|
77
|
+
contentType.includes('html')
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isParseableResponse(responseHeaders) {
|
|
82
|
+
if (!responseHeaders) {
|
|
83
|
+
// this should never happen, but better safe than sorry;
|
|
84
|
+
// worst case, we parse image data or something
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// we may want to do a case-insensitive search through object keys here
|
|
89
|
+
let contentType =
|
|
90
|
+
responseHeaders['Content-Type'] || responseHeaders['content-type'];
|
|
91
|
+
if (contentType) contentType = toLowerCase(contentType);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
!contentType ||
|
|
95
|
+
contentType.includes('text') ||
|
|
96
|
+
contentType.includes('html') ||
|
|
97
|
+
contentType.includes('xml') ||
|
|
98
|
+
contentType.includes('json') ||
|
|
99
|
+
contentType.includes('soap') ||
|
|
100
|
+
contentType.includes('pdf')
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getAttribute(attribute, htmlTag = '') {
|
|
105
|
+
let attrStart = htmlTag.indexOf(`${attribute}=`);
|
|
106
|
+
if (attrStart === -1) return undefined;
|
|
107
|
+
|
|
108
|
+
attrStart += attribute.length + 1;
|
|
109
|
+
const quoteChar = htmlTag[attrStart];
|
|
110
|
+
if (quoteChar === ' ') return undefined;
|
|
111
|
+
|
|
112
|
+
let attrEnd = -1;
|
|
113
|
+
if (quoteChar === '"' || quoteChar === "'") {
|
|
114
|
+
attrStart += 1;
|
|
115
|
+
attrEnd = htmlTag.indexOf(quoteChar, attrStart);
|
|
116
|
+
} else {
|
|
117
|
+
// handle unquoted <form method=post >
|
|
118
|
+
attrEnd = htmlTag.indexOf(' ', attrStart);
|
|
119
|
+
|
|
120
|
+
// handle unquoted and last attribute <form method=post>
|
|
121
|
+
if (attrEnd === -1) {
|
|
122
|
+
attrEnd = htmlTag[htmlTag.length - 2] === '/' ? htmlTag.length - 2 : htmlTag.length - 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return substring(htmlTag, attrStart, attrEnd);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Checks if http-equiv is one of the following
|
|
131
|
+
* below
|
|
132
|
+
*
|
|
133
|
+
* @param {String} httpequiv value of http-equiv meta attr
|
|
134
|
+
*/
|
|
135
|
+
function applicableHttpEquiv(httpequiv) {
|
|
136
|
+
return (
|
|
137
|
+
httpequiv == 'cache-control' ||
|
|
138
|
+
httpequiv == 'pragma' ||
|
|
139
|
+
httpequiv == 'expires'
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Checks every meta html tag from body for http-equiv attributes.
|
|
144
|
+
* If attr exists it will check the content attr if http-equiv is cache-control
|
|
145
|
+
* Otherwise it will add value of attr if cache-control, pragma or expires
|
|
146
|
+
*/
|
|
147
|
+
function checkMetaTags({ body, cacheControlHeader, instructions }) {
|
|
148
|
+
const metaTags = getElements('meta', body);
|
|
149
|
+
metaTags.forEach((tag) => {
|
|
150
|
+
const httpequiv = getAttribute('http-equiv', tag);
|
|
151
|
+
if (httpequiv) {
|
|
152
|
+
const content = getAttribute('content', tag);
|
|
153
|
+
if (httpequiv == 'cache-control') {
|
|
154
|
+
const [
|
|
155
|
+
containsMetaNoCache,
|
|
156
|
+
containsMetaNoStore
|
|
157
|
+
] = checkCacheControlValue(content);
|
|
158
|
+
if (containsMetaNoCache && containsMetaNoStore) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const [containsNoCache, containsNoStore] = checkCacheControlValue(
|
|
163
|
+
cacheControlHeader
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// got no-store from headers and no-cache from meta
|
|
167
|
+
// or no-cache from headers and no-store from meta
|
|
168
|
+
if (
|
|
169
|
+
(containsNoStore && containsMetaNoCache) ||
|
|
170
|
+
(containsNoCache && containsMetaNoStore)
|
|
171
|
+
) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// if we make it this far push the values into instructions
|
|
177
|
+
if (applicableHttpEquiv(httpequiv)) {
|
|
178
|
+
instructions.push({
|
|
179
|
+
type: 'META tag',
|
|
180
|
+
name: httpequiv,
|
|
181
|
+
value: tag
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
//
|
|
190
|
+
// Utils regarding Content Security Policy headers rules
|
|
191
|
+
//
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Terminology for Content Security Policies
|
|
195
|
+
* See https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
|
|
196
|
+
* csp - Content Security Policy
|
|
197
|
+
* policy - the entire csp header value
|
|
198
|
+
* directive - a specific key of the policy(e.g default-src, media-src)
|
|
199
|
+
* sources - the value(s) of the directive(default-src foobar.com;)
|
|
200
|
+
*/
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Checks the value of cache-control
|
|
204
|
+
* either header or meta content attr value
|
|
205
|
+
* for if it contains no-cache or no-store
|
|
206
|
+
*
|
|
207
|
+
* @param {String} value value of cache-control
|
|
208
|
+
* @return {Array} [no-cache present, no-store present]
|
|
209
|
+
*/
|
|
210
|
+
function checkCacheControlValue(value = '') {
|
|
211
|
+
if (Array.isArray(value)) {
|
|
212
|
+
let noCache = false;
|
|
213
|
+
let noStore = false;
|
|
214
|
+
|
|
215
|
+
value.forEach((directive) => {
|
|
216
|
+
if (toLowerCase(directive).includes('no-cache')) {
|
|
217
|
+
noCache = true;
|
|
218
|
+
}
|
|
219
|
+
if (toLowerCase(directive).includes('no-store')) {
|
|
220
|
+
noStore = true;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
return [noCache, noStore];
|
|
224
|
+
} else {
|
|
225
|
+
value = toLowerCase(value);
|
|
226
|
+
return [value.includes('no-cache'), value.includes('no-store')];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Special evaluator for any -src directives
|
|
232
|
+
* If it is empty and default is secure then it is secure
|
|
233
|
+
*
|
|
234
|
+
* @param {Array} sources sources for a given -src directive
|
|
235
|
+
* @param {Array} defaultSources sources for the default-src directive
|
|
236
|
+
* @return {Boolean}
|
|
237
|
+
*/
|
|
238
|
+
function isSrcSecure(sources, defaultSources) {
|
|
239
|
+
return sources.length === 0
|
|
240
|
+
? isSourceSecure(defaultSources)
|
|
241
|
+
: isSourceSecure(sources);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Checks if a given source(s) for a directive is defined and is secure(does not contain a '*')
|
|
246
|
+
*
|
|
247
|
+
* @param {Array} sources sources for a given csp directive
|
|
248
|
+
* @return {Boolean} whether a directive is secure
|
|
249
|
+
*/
|
|
250
|
+
function isSourceSecure(sources) {
|
|
251
|
+
return (
|
|
252
|
+
sources.length > 0 &&
|
|
253
|
+
sources.every((source) => !!source && !/\*/.test(source))
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Evaluator for reflected-xss directive. Checks if the value is not
|
|
259
|
+
* equal to 1
|
|
260
|
+
* Note: If empty it is secure
|
|
261
|
+
* @param {Array} sources sources for a given csp directive
|
|
262
|
+
* @return {Boolean} whether a directive is secure
|
|
263
|
+
*/
|
|
264
|
+
function xssCheck(sources) {
|
|
265
|
+
return sources.every((source) => parseInt(source) === 1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Evaluator for referrer directive. Checks if value is not *
|
|
270
|
+
* or contains unsafe-url
|
|
271
|
+
* @param {Array} sources sources for a given csp directive
|
|
272
|
+
* @return {Boolean} whether a directive is secure
|
|
273
|
+
*/
|
|
274
|
+
function referrerCheck(sources) {
|
|
275
|
+
return sources.every((source) => !/unsafe-url|\*/.test(source));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const KNOWN_DIRECTIVES = [
|
|
279
|
+
{ name: 'default-src', camelCasedName: 'defaultSrc', evaluator: isSourceSecure },
|
|
280
|
+
{ name: 'base-uri', camelCasedName: 'baseUri', evaluator: isSourceSecure },
|
|
281
|
+
{ name: 'child-src', camelCasedName: 'childSrc' },
|
|
282
|
+
{ name: 'connect-src', camelCasedName: 'connectSrc' },
|
|
283
|
+
{ name: 'frame-src', camelCasedName: 'frameSrc' },
|
|
284
|
+
{ name: 'media-src', camelCasedName: 'mediaSrc' },
|
|
285
|
+
{ name: 'object-src', camelCasedName: 'objectSrc' },
|
|
286
|
+
{ name: 'script-src', camelCasedName: 'scriptSrc' },
|
|
287
|
+
{ name: 'style-src', camelCasedName: 'styleSrc' },
|
|
288
|
+
{ name: 'form-action', camelCasedName: 'formAction', evaluator: isSourceSecure },
|
|
289
|
+
{ name: 'frame-ancestors', camelCasedName: 'frameAncestors', evaluator: isSourceSecure },
|
|
290
|
+
{ name: 'plugin-types', camelCasedName: 'pluginTypes', evaluator: isSourceSecure },
|
|
291
|
+
{ name: 'reflected-xss', camelCasedName: 'reflectedXss', evaluator: xssCheck },
|
|
292
|
+
{ name: 'referrer', evaluator: referrerCheck }
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Convenience method for formatting a given directive's
|
|
297
|
+
* secure and value properties. It also camel cases the key
|
|
298
|
+
* to match the spec for the rule's finding
|
|
299
|
+
*
|
|
300
|
+
* @param {Object} params
|
|
301
|
+
* @param {Object} params.data obj to store the full props object
|
|
302
|
+
* @param {String} params.key the csp header directive
|
|
303
|
+
* @param {Array} params.value array of sources for the directive
|
|
304
|
+
* @param {Boolean} params.isSecure flag indicating if directive is secure
|
|
305
|
+
*/
|
|
306
|
+
function formatSource({
|
|
307
|
+
data,
|
|
308
|
+
key,
|
|
309
|
+
sources = [],
|
|
310
|
+
defaultSources = [],
|
|
311
|
+
evaluator = isSrcSecure,
|
|
312
|
+
}) {
|
|
313
|
+
const isSecure = evaluator(sources, defaultSources);
|
|
314
|
+
|
|
315
|
+
if (!isSecure && !data.insecure) {
|
|
316
|
+
data.insecure = true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
data[`${key}Secure`] = isSecure;
|
|
320
|
+
data[`${key}Value`] = join(sources, ' ');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* This will take a Content Security Policy string and parse it
|
|
325
|
+
*/
|
|
326
|
+
function policyParser(policy) {
|
|
327
|
+
const result = {};
|
|
328
|
+
for (const directive of split(policy, ';')) {
|
|
329
|
+
const [directiveKey, ...directiveValue] = split(trim(directive), /\s+/g);
|
|
330
|
+
if (
|
|
331
|
+
directiveKey &&
|
|
332
|
+
!Object.prototype.hasOwnProperty.call(result, directiveKey)
|
|
333
|
+
) {
|
|
334
|
+
result[directiveKey] = directiveValue;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return result;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* This will parse a CSP header value into an object
|
|
342
|
+
* It will then iterate over all known keys and build
|
|
343
|
+
* which are secure and which aren't with the values
|
|
344
|
+
* for each. If a directive is undefined, it assumed
|
|
345
|
+
* insecure and will provide '' as the value
|
|
346
|
+
*
|
|
347
|
+
* See: https://contrast.atlassian.net/wiki/spaces/ENG/pages/805503460/Content-Security-Policy+Header+Misconfigured
|
|
348
|
+
*/
|
|
349
|
+
function checkCspSources(policy) {
|
|
350
|
+
const csp = policyParser(policy);
|
|
351
|
+
const data = {};
|
|
352
|
+
|
|
353
|
+
KNOWN_DIRECTIVES.forEach(({ name, evaluator, camelCasedName }) => {
|
|
354
|
+
const sources = csp[name];
|
|
355
|
+
formatSource({
|
|
356
|
+
data,
|
|
357
|
+
defaultSources: csp['default-src'],
|
|
358
|
+
key: camelCasedName || name,
|
|
359
|
+
sources,
|
|
360
|
+
evaluator,
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
return data;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Extracts the Content Security Policy from headers
|
|
369
|
+
* in one of the CSP_HEADERS constants
|
|
370
|
+
*
|
|
371
|
+
* @param {Object} responseHeaders
|
|
372
|
+
* @return {*} the csp header value or false(if not present)
|
|
373
|
+
*/
|
|
374
|
+
function getCspHeaders(responseHeaders) {
|
|
375
|
+
const CSP_HEADERS = [
|
|
376
|
+
'content-security-policy',
|
|
377
|
+
'x-content-security-policy',
|
|
378
|
+
'x-webkit-csp'
|
|
379
|
+
];
|
|
380
|
+
const headerName = CSP_HEADERS.filter((header) => responseHeaders[header]);
|
|
381
|
+
return headerName.length ? responseHeaders[headerName[0]] : false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
module.exports = {
|
|
385
|
+
escapeHtml,
|
|
386
|
+
isHtmlContent,
|
|
387
|
+
getElements,
|
|
388
|
+
getAttribute,
|
|
389
|
+
isParseableResponse,
|
|
390
|
+
checkCacheControlValue,
|
|
391
|
+
checkMetaTags,
|
|
392
|
+
getCspHeaders,
|
|
393
|
+
checkCspSources
|
|
394
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright: 2022 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 { callChildComponentMethodsSync, Event } = require('@contrast/common');
|
|
19
|
+
|
|
20
|
+
module.exports = function(core) {
|
|
21
|
+
const { messages } = core;
|
|
22
|
+
const responseScanning = core.assess.responseScanning = {
|
|
23
|
+
reportFindings(_sourceContext, vulnerabilityMetadata) {
|
|
24
|
+
messages.emit(Event.ASSESS_RESPONSE_SCANNING_FINDING, vulnerabilityMetadata);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
require('./handlers')(core);
|
|
29
|
+
require('./install/http')(core);
|
|
30
|
+
|
|
31
|
+
responseScanning.install = function() {
|
|
32
|
+
callChildComponentMethodsSync(responseScanning, 'install');
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return responseScanning;
|
|
36
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright: 2022 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 { split, substring, toLowerCase, trim } = require('@contrast/common');
|
|
19
|
+
|
|
20
|
+
module.exports = function(core) {
|
|
21
|
+
const {
|
|
22
|
+
depHooks,
|
|
23
|
+
patcher,
|
|
24
|
+
scopes: { sources },
|
|
25
|
+
assess: {
|
|
26
|
+
responseScanning: {
|
|
27
|
+
handleAutoCompleteMissing,
|
|
28
|
+
handleCacheControlsMissing,
|
|
29
|
+
handleClickJackingControlsMissing,
|
|
30
|
+
handleParameterPollution,
|
|
31
|
+
handleCspHeader,
|
|
32
|
+
handleHstsHeaderMissing,
|
|
33
|
+
handlePoweredByHeader,
|
|
34
|
+
handleXContentTypeHeaderMissing,
|
|
35
|
+
handleXxsProtectionHeaderDisabled,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} = core;
|
|
39
|
+
const http = core.assess.responseScanning.httpInstrumentation = {};
|
|
40
|
+
|
|
41
|
+
function parseHeaders(rawHeaders = '') {
|
|
42
|
+
const headersArray = split(rawHeaders, '\r\n').filter(Boolean);
|
|
43
|
+
return headersArray.reduce((acc, header) => {
|
|
44
|
+
const idx = header.indexOf(':');
|
|
45
|
+
|
|
46
|
+
if (idx > -1) {
|
|
47
|
+
const name = toLowerCase(substring(header, 0, idx));
|
|
48
|
+
const value = trim(substring(header, idx + 1));
|
|
49
|
+
const currentValue = acc[name];
|
|
50
|
+
acc[name] = currentValue
|
|
51
|
+
? Array.isArray(currentValue)
|
|
52
|
+
? currentValue.push(value) && currentValue
|
|
53
|
+
: [currentValue, value]
|
|
54
|
+
: value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return acc;
|
|
58
|
+
}, {});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const patchType = 'response-scanning';
|
|
62
|
+
|
|
63
|
+
http.install = function() {
|
|
64
|
+
depHooks.resolve({ name: 'http' }, (http) => {
|
|
65
|
+
{
|
|
66
|
+
const name = 'http.ServerResponse.prototype.write';
|
|
67
|
+
patcher.patch(http.ServerResponse.prototype, 'write', {
|
|
68
|
+
name,
|
|
69
|
+
patchType,
|
|
70
|
+
post(data) {
|
|
71
|
+
const sourceContext = sources.getStore()?.assess;
|
|
72
|
+
if (!sourceContext) return;
|
|
73
|
+
|
|
74
|
+
const evaluationContext = {
|
|
75
|
+
responseBody: toLowerCase(data.args[0] || ''),
|
|
76
|
+
responseHeaders: parseHeaders(data.result._header),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Check only the rules concerning the response body
|
|
80
|
+
handleAutoCompleteMissing(sourceContext, evaluationContext);
|
|
81
|
+
handleCacheControlsMissing(sourceContext, evaluationContext);
|
|
82
|
+
handleParameterPollution(sourceContext, evaluationContext);
|
|
83
|
+
handleXxsProtectionHeaderDisabled(sourceContext, evaluationContext);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
{
|
|
88
|
+
const name = 'http.ServerResponse.prototype.end';
|
|
89
|
+
patcher.patch(http.ServerResponse.prototype, 'end', {
|
|
90
|
+
name,
|
|
91
|
+
patchType,
|
|
92
|
+
post(data) {
|
|
93
|
+
const sourceContext = sources.getStore()?.assess;
|
|
94
|
+
if (!sourceContext) return;
|
|
95
|
+
|
|
96
|
+
const evaluationContext = {
|
|
97
|
+
responseBody: toLowerCase(data.args[0] || ''),
|
|
98
|
+
responseHeaders: parseHeaders(data.result._header),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Check all the response scanning rules
|
|
102
|
+
handleAutoCompleteMissing(sourceContext, evaluationContext);
|
|
103
|
+
handleCacheControlsMissing(sourceContext, evaluationContext);
|
|
104
|
+
handleClickJackingControlsMissing(sourceContext, evaluationContext);
|
|
105
|
+
handleParameterPollution(sourceContext, evaluationContext);
|
|
106
|
+
handleCspHeader(sourceContext, evaluationContext);
|
|
107
|
+
handleHstsHeaderMissing(sourceContext, evaluationContext);
|
|
108
|
+
handlePoweredByHeader(sourceContext, evaluationContext);
|
|
109
|
+
handleXContentTypeHeaderMissing(sourceContext, evaluationContext);
|
|
110
|
+
handleXxsProtectionHeaderDisabled(sourceContext, evaluationContext);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return http;
|
|
118
|
+
};
|
|
119
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contrast/assess",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@contrast/distringuish": "^4.1.0",
|
|
17
17
|
"@contrast/scopes": "1.3.0",
|
|
18
|
-
"@contrast/common": "1.
|
|
18
|
+
"@contrast/common": "1.5.0",
|
|
19
19
|
"parseurl": "^1.3.3"
|
|
20
20
|
}
|
|
21
21
|
}
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright: 2022 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 { patchType } = require('../common');
|
|
19
|
-
|
|
20
|
-
module.exports = function(core) {
|
|
21
|
-
const {
|
|
22
|
-
createSnapshot,
|
|
23
|
-
patcher,
|
|
24
|
-
depHooks,
|
|
25
|
-
scopes,
|
|
26
|
-
assess: {
|
|
27
|
-
dataflow: { tracker, sources },
|
|
28
|
-
},
|
|
29
|
-
logger
|
|
30
|
-
} = core;
|
|
31
|
-
|
|
32
|
-
function makeCapturer(opts) {
|
|
33
|
-
const snapshot = createSnapshot(opts);
|
|
34
|
-
return function(obj) {
|
|
35
|
-
obj.stack = snapshot();
|
|
36
|
-
return obj;
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const qsSourcesInstr = sources.qsSourcesInstr = {
|
|
41
|
-
install() {
|
|
42
|
-
depHooks.resolve({ name: 'qs' }, (qs) => {
|
|
43
|
-
patcher.patch(qs, 'parse', {
|
|
44
|
-
name: 'qs',
|
|
45
|
-
patchType,
|
|
46
|
-
post({ args, orig, hooked, result }) {
|
|
47
|
-
if (result && Object.keys(result).length) {
|
|
48
|
-
const assessStore = scopes.sources.getStore().assess;
|
|
49
|
-
|
|
50
|
-
if (!assessStore) {
|
|
51
|
-
logger.debug('assessStore not available in `qs` hook');
|
|
52
|
-
} else {
|
|
53
|
-
const captureStack = makeCapturer({ constructorOpt: hooked, prependFrames: [orig] });
|
|
54
|
-
|
|
55
|
-
// We need to track `qs` result only when it's used as a query parser.
|
|
56
|
-
// `qs` is used also for parsing bodies, but these cases we handle individually with
|
|
57
|
-
// the respective library that's using it (e.g. `formidable`, `co-body`) because in
|
|
58
|
-
// some cases its use is optional and we cannot rely on it.
|
|
59
|
-
if (assessStore.reqData.queries === args[0]) {
|
|
60
|
-
for (const [key, value] of Object.entries(result)) {
|
|
61
|
-
// TODO Same as fastify - do we need a try-catch
|
|
62
|
-
// and checks for already tracked strings
|
|
63
|
-
const { extern } = tracker.track(
|
|
64
|
-
value,
|
|
65
|
-
captureStack({
|
|
66
|
-
inputType: 'query',
|
|
67
|
-
name: key,
|
|
68
|
-
tags: {
|
|
69
|
-
untrusted: [0, value.length - 1],
|
|
70
|
-
}
|
|
71
|
-
})
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
if (extern) {
|
|
75
|
-
result[key] = extern;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
},
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
return qsSourcesInstr;
|
|
88
|
-
};
|