@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.
@@ -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/encode-uri-component')(core);
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
  };
@@ -29,6 +29,7 @@ module.exports = function(core) {
29
29
 
30
30
  require('./add')(core);
31
31
  require('./tag')(core);
32
+ require('./string')(core);
32
33
 
33
34
  return contrastMethodsInstrumentation;
34
35
  };
@@ -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
+ };
@@ -33,6 +33,7 @@ module.exports = function(core) {
33
33
  require('./trim')(core);
34
34
  require('./html-methods')(core);
35
35
  require('./format-methods')(core);
36
+ require('./split')(core);
36
37
 
37
38
  return stringInstrumentation;
38
39
  };
@@ -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
+ };
@@ -25,7 +25,6 @@ module.exports = function(core) {
25
25
  // installers
26
26
  require('./install/http')(core);
27
27
  require('./install/fastify')(core);
28
- require('./install/qs')(core);
29
28
 
30
29
  sources.install = function install() {
31
30
  callChildComponentMethodsSync(sources, 'install');
@@ -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 (key.toLowerCase() === 'content-type') {
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 (key.toLowerCase() === 'content-type') {
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 (name.toLowerCase() === 'content-type' && value) {
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].toLowerCase();
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']?.toLowerCase();
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
- return next();
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.dataflow.install();
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
+ '&': '&amp;',
25
+ '<': '&lt;',
26
+ '>': '&gt;',
27
+ '"': '&quot;',
28
+ "'": '&#39;'
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.1.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.4.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
- };