@contrast/route-coverage 1.33.0 → 1.35.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/index.js CHANGED
@@ -16,7 +16,6 @@
16
16
  'use strict';
17
17
 
18
18
  const { callChildComponentMethodsSync, Event } = require('@contrast/common');
19
- const { routeIdentifier } = require('./utils/route-info');
20
19
  const NormalizedUrlMapper = require('./normalized-url-mapper');
21
20
 
22
21
  /**
@@ -36,6 +35,7 @@ module.exports = function init(core) {
36
35
  const recentlyObserved = new Set();
37
36
  const routeQueue = new Map();
38
37
 
38
+ const routeIdentifier = (method, signature) => `${method}.${signature}`;
39
39
  const routeCoverage = core.routeCoverage = {
40
40
  _normalizedUrlMapper: new NormalizedUrlMapper(),
41
41
 
@@ -45,8 +45,9 @@ module.exports = function init(core) {
45
45
 
46
46
  discover(info) {
47
47
  logger.trace({ info }, 'Discovered new route:');
48
- routeInfo.set(routeIdentifier(info), info);
48
+ routeInfo.set(routeIdentifier(info.method, info.signature), info);
49
49
  this._normalizedUrlMapper.handleDiscover(info);
50
+
50
51
  },
51
52
 
52
53
  discoveryFinished() {
@@ -58,7 +59,7 @@ module.exports = function init(core) {
58
59
 
59
60
  // See NODE-3548: routes defined "lazily" will be discovered here after startup, queued, and reported
60
61
  queue(info) {
61
- const id = routeIdentifier(info);
62
+ const id = routeIdentifier(info.method, info.signature);
62
63
  if (routeInfo.has(id)) return;
63
64
 
64
65
  routeInfo.set(id, info);
@@ -77,13 +78,20 @@ module.exports = function init(core) {
77
78
  },
78
79
 
79
80
  observe(info) {
80
- const route = info.signature ? info : routeInfo.get(routeIdentifier(info));
81
+ let route;
82
+ const { signature } = info;
83
+ const methods = [info.method, 'all', 'use'];
84
+ for (const method of methods) {
85
+ route = routeInfo.get(routeIdentifier(method, signature));
86
+ if (route) break;
87
+ }
81
88
 
82
89
  if (!route) {
83
90
  logger.debug(info, 'unable to observe undiscovered route');
84
91
  return;
85
92
  }
86
93
 
94
+ route.method = info.method;
87
95
  route.url = info.url;
88
96
  const store = scopes.sources.getStore();
89
97
  if (store && !store.route) {
@@ -112,6 +120,7 @@ module.exports = function init(core) {
112
120
  require('./install/http')(core);
113
121
  require('./install/express')(core);
114
122
  require('./install/fastify')(core);
123
+ require('./install/graphql')(core);
115
124
  require('./install/hapi')(core);
116
125
  require('./install/koa')(core);
117
126
  require('./install/restify')(core);
package/lib/index.test.js CHANGED
@@ -48,8 +48,8 @@ describe('route coverage', function () {
48
48
  });
49
49
 
50
50
  it('emits an event when discovery is finished', function () {
51
- const eventA = { signature: 'hello', url: 'url', method: 'get' };
52
- const eventB = { signature: 'hello', url: 'url', method: 'post' };
51
+ const eventA = { signature: 'url.get', url: 'url', method: 'get' };
52
+ const eventB = { signature: 'url.post', url: 'url', method: 'post' };
53
53
  core.routeCoverage.discover(eventA);
54
54
  core.routeCoverage.discover(eventA); // check that we dedupe discovery.
55
55
  core.routeCoverage.discover(eventB);
@@ -62,7 +62,7 @@ describe('route coverage', function () {
62
62
  });
63
63
 
64
64
  it('queues new events after initial discovery is finished', function () {
65
- const eventA = { signature: 'hello', url: 'url', method: 'get' };
65
+ const eventA = { signature: 'url.get', url: 'url', method: 'get' };
66
66
  core.routeCoverage.discover(eventA);
67
67
  core.routeCoverage.discoveryFinished();
68
68
  expect(core.messages.emit).to.have.been.calledWith(
@@ -70,7 +70,7 @@ describe('route coverage', function () {
70
70
  [eventA],
71
71
  );
72
72
 
73
- const eventB = { signature: 'hello', url: 'url', method: 'post' };
73
+ const eventB = { signature: 'url.post', url: 'url', method: 'post' };
74
74
  core.routeCoverage.discover(eventA); // check that we dedupe routes discoverd on startup
75
75
  core.routeCoverage.discover(eventB);
76
76
  core.routeCoverage.discover(eventB); // check that we dedupe routes defined lazily
@@ -105,7 +105,7 @@ describe('route coverage', function () {
105
105
  sourceInfo: undefined
106
106
  };
107
107
  core.routeCoverage.discover(event);
108
- core.routeCoverage.observe({ url: 'url', method: 'get' });
108
+ core.routeCoverage.observe(event);
109
109
 
110
110
  expect(core.messages.emit).to.have.been.calledWith(
111
111
  Event.ROUTE_COVERAGE_OBSERVATION,
@@ -122,7 +122,7 @@ describe('route coverage', function () {
122
122
  core.routeCoverage.discover(event);
123
123
 
124
124
  simulateRequestScope(() => {
125
- core.routeCoverage.observe({ url: 'url', method: 'get' });
125
+ core.routeCoverage.observe(event);
126
126
  const { sourceInfo, route } = core.scopes.sources.getStore();
127
127
  expect(sourceInfo).to.be.ok;
128
128
  expect(route).to.eql({ method: 'get', signature: 'hello', url: 'url' });
@@ -12,7 +12,6 @@
12
12
  * engineered, modified, repackaged, sold, redistributed or otherwise used in a
13
13
  * way not consistent with the End User License Agreement.
14
14
  */
15
- // @ts-check
16
15
  'use strict';
17
16
 
18
17
  const METHODS = [
@@ -27,7 +26,7 @@ const METHODS = [
27
26
  ];
28
27
 
29
28
  const fnInspect = require('@contrast/fn-inspect');
30
- const { createSignature, patchType } = require('../utils/route-info');
29
+ const { createSignature, patchType } = require('../../utils/route-info');
31
30
  const { isString, primordials: { ArrayPrototypeJoin, StringPrototypeToLowerCase, StringPrototypeReplace, StringPrototypeReplaceAll, StringPrototypeSplit, StringPrototypeSlice } } = require('@contrast/common');
32
31
 
33
32
  // Spec: https://contrast.atlassian.net/wiki/spaces/NOD/pages/3454861621/Node.js+Agent+Route+Signatures#Express
@@ -113,7 +112,7 @@ module.exports = function init(core) {
113
112
  }
114
113
  });
115
114
  }
116
- return core.routeCoverage.express = {
115
+ return core.routeCoverage.express4 = {
117
116
  install() {
118
117
  depHooks.resolve({ name: 'express', version: '>=4 <5' }, (express) => {
119
118
  patcher.patch(express.application, 'use', {
@@ -86,7 +86,7 @@ describe('route-coverage express', function () {
86
86
  core.depHooks.resolve.withArgs({ name: 'express', version: '>=4 <5' }).yields(express);
87
87
  core.depHooks.resolve.withArgs({ name: 'http' }).yields(http);
88
88
 
89
- require('./express')(core).install();
89
+ require('./express4')(core).install();
90
90
  });
91
91
 
92
92
  describe('app', function () {
@@ -0,0 +1,256 @@
1
+ /*
2
+ * Copyright: 2024 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
+ 'use strict';
16
+
17
+ const METHODS = [
18
+ 'all',
19
+ 'get',
20
+ 'post',
21
+ 'put',
22
+ 'delete',
23
+ 'patch',
24
+ 'options',
25
+ 'head',
26
+ ];
27
+ const framework = 'express';
28
+ const { patchType, createSignature } = require('../../utils/route-info');
29
+ const {
30
+ isString,
31
+ primordials: {
32
+ ArrayPrototypeJoin,
33
+ ArrayPrototypeSlice,
34
+ StringPrototypeSplit,
35
+ StringPrototypeToLowerCase,
36
+ StringPrototypeReplace,
37
+ StringPrototypeSlice,
38
+ PathBasename
39
+ }
40
+ } = require('@contrast/common');
41
+ const { match } = require('path-to-regexp');
42
+ const fnInspect = require('@contrast/fn-inspect');
43
+
44
+ module.exports = function init(core) {
45
+
46
+ const discovered = [];
47
+ const routerMap = new Map();
48
+ const { patcher, depHooks, routeCoverage } = core;
49
+
50
+ const removeTrailingSlash = (url) => (url.endsWith('/') && url !== '/') ? StringPrototypeSlice.call(url, 0, -1) : url;
51
+ const format = (url) => Array.isArray(url)
52
+ ? `/[${ArrayPrototypeJoin.call(url)}]`
53
+ : removeTrailingSlash(url); // remove trailing slash
54
+ const isRouter = (layer) => layer?.name && StringPrototypeToLowerCase.call(layer.name) === 'router';
55
+
56
+ function getFnName({ method, file, lineNumber, column }) {
57
+ if (method) return method;
58
+ if (!file) return '(anonymous)';
59
+ const base = PathBasename(file);
60
+ return `(anonymous ${base} ${lineNumber}:${column})`;
61
+ }
62
+
63
+ function createRouteInfo(url, method, obj, fn) {
64
+ const fnInfo = fnInspect.funcInfo(fn);
65
+ const fnName = getFnName(fnInfo);
66
+ const originalUrl = url;
67
+ url = format(url);
68
+ return {
69
+ url,
70
+ method,
71
+ fnName,
72
+ framework,
73
+ originalUrl,
74
+ normalizedUrl: url,
75
+ signature: createSignature(url, method, obj, fnName)
76
+ };
77
+ }
78
+
79
+ function updateRouteInfo(prefix, routeInfo) {
80
+ const { url, normalizedUrl, fnName, method, addedPrefix = '' } = routeInfo;
81
+ const updatedUrl = removeTrailingSlash(prefix + url);
82
+ const updatedNormalizedUrl = removeTrailingSlash(prefix + normalizedUrl);
83
+ const updatedAddedPrefix = removeTrailingSlash(prefix + addedPrefix);
84
+ const updatedSignature = createSignature(updatedUrl, method, 'router', fnName);
85
+ return {
86
+ ...routeInfo,
87
+ url: updatedUrl,
88
+ addedPrefix: updatedAddedPrefix,
89
+ normalizedUrl: updatedNormalizedUrl,
90
+ signature: updatedSignature
91
+ };
92
+ }
93
+
94
+ function discover(routeInfo) {
95
+ const { url, normalizedUrl, signature, method, framework } = routeInfo;
96
+ if (!url || !normalizedUrl || !signature || !method || !framework) return;
97
+ routeCoverage.discover({
98
+ url,
99
+ normalizedUrl,
100
+ signature,
101
+ method,
102
+ framework
103
+ });
104
+
105
+ // Used to match urls during route observation
106
+ let urlToMatch = routeInfo.normalizedUrl;
107
+ if (Array.isArray(routeInfo.originalUrl)) {
108
+ const prefix = routeInfo?.addedPrefix || '';
109
+ urlToMatch = routeInfo.originalUrl.map(seg => prefix + seg);
110
+ }
111
+ const matchUrl = match(urlToMatch);
112
+ routeInfo.match = matchUrl;
113
+
114
+ discovered.push(routeInfo);
115
+ }
116
+
117
+ function wrapForObservation(handler, routeInfo, isRouter = false) {
118
+ const handlerInfo = fnInspect.funcInfo(handler);
119
+ const handlerName = getFnName(handlerInfo);
120
+ return patcher.patch(handler, {
121
+ name: 'handler',
122
+ patchType,
123
+ post(data) {
124
+ const [req] = data.args;
125
+ const [url] = StringPrototypeSplit.call(req.originalUrl, '?');
126
+ const method = StringPrototypeToLowerCase.call(req.method);
127
+ if (url && method) {
128
+ if (isRouter) {
129
+ for (const route of discovered) {
130
+ if (route.match(url) && route.fnName === handlerName) {
131
+ const { signature, normalizedUrl } = route;
132
+ routeCoverage.observe({ url, method, signature, framework, normalizedUrl });
133
+ break;
134
+ }
135
+ }
136
+ } else {
137
+ const { signature, normalizedUrl } = routeInfo;
138
+ routeCoverage.observe({ url, method, signature, framework, normalizedUrl });
139
+ }
140
+ }
141
+ }
142
+ });
143
+ }
144
+
145
+ return core.routeCoverage.express5 = {
146
+ install() {
147
+ depHooks.resolve({ name: 'express', version: '5' }, (express) => {
148
+ METHODS.forEach((method) => {
149
+ patcher.patch(express.application, method, {
150
+ name: `express.application.${method}`,
151
+ patchType,
152
+ pre(data) {
153
+ const [path, ...args] = data.args;
154
+ if ((!isString(path) && !Array.isArray(path)) || path === '') return;
155
+
156
+ const fns = args.flat(Infinity);
157
+ for (let i = 0; i < fns.length; i++) {
158
+ if (typeof fns[i] !== 'function') continue;
159
+ const routeInfo = createRouteInfo(path, method, 'app', fns[i]);
160
+
161
+ discover(routeInfo);
162
+ fns[i] = wrapForObservation(fns[i], routeInfo);
163
+ }
164
+ data.args.splice(1, fns.length, ...fns);
165
+ }
166
+ });
167
+
168
+ patcher.patch(express.Router.prototype, method, {
169
+ name: `express.Router.prototype.${method}`,
170
+ patchType,
171
+ pre(data) {
172
+ const Router = data.obj;
173
+ const [path, ...args] = data.args;
174
+ if (!isString(path) && !Array.isArray(path)) return;
175
+
176
+ const fns = args.flat(Infinity);
177
+ const routes = routerMap.get(Router) || [];
178
+ for (let i = 0; i < fns.length; i++) {
179
+ if (typeof fns[i] !== 'function') continue;
180
+ const routeInfo = createRouteInfo(path, method, 'router', fns[i]);
181
+
182
+ fns[i] = wrapForObservation(fns[i], routeInfo, true);
183
+
184
+ routes.push(routeInfo);
185
+ data.args.splice(1, fns.length, ...fns);
186
+ }
187
+ routerMap.set(Router, routes);
188
+ }
189
+ });
190
+ });
191
+
192
+ let appRouter;
193
+ patcher.patch(express.application, 'use', {
194
+ name: 'express.application.use',
195
+ patchType,
196
+ pre(data) {
197
+ appRouter = data.obj.router;
198
+
199
+ const arg0 = data.args[0];
200
+ const prefix = (Array.isArray(arg0) || isString(arg0)) ? arg0 : undefined;
201
+ const args = prefix ? ArrayPrototypeSlice.call(data.args, 1) : data.args;
202
+ const fns = args.flat(Infinity);
203
+ for (let i = 0; i < fns.length; i++) {
204
+ if (isRouter(fns[i])) {
205
+ let routes = routerMap.get(fns[i]);
206
+ if (routes) {
207
+ if (prefix) routes = routes.map(route => updateRouteInfo(prefix, route));
208
+ routes.forEach((route) => discover(route));
209
+ }
210
+ } else if (prefix && typeof fns[i] === 'function') {
211
+ const routeInfo = createRouteInfo(prefix, 'use', 'app', fns[i]);
212
+ discover(routeInfo);
213
+ fns[i] = wrapForObservation(fns[i], routeInfo);
214
+ }
215
+ }
216
+ data.args.splice(prefix ? 1 : 0, fns.length, ...fns);
217
+ }
218
+ });
219
+ patcher.patch(express.Router.prototype, 'use', {
220
+ name: 'express.Router.prototype.use',
221
+ patchType,
222
+ pre(data) {
223
+ const Router = data.obj;
224
+ const isAppRouter = Router === appRouter;
225
+ if (isAppRouter) return;
226
+
227
+ let routerRoutes = routerMap.get(Router) || [];
228
+
229
+ const arg0 = data.args[0];
230
+ let prefix = (Array.isArray(arg0) || isString(arg0)) ? arg0 : undefined;
231
+ const args = prefix ? ArrayPrototypeSlice.call(data.args, 1) : data.args;
232
+ const fns = args.flat(Infinity);
233
+
234
+ for (let i = 0; i < fns.length; i++) {
235
+ const selfNested = fns[i] === Router;
236
+ if (isRouter(fns[i])) {
237
+ let routes = routerMap.get(fns[i]) || [];
238
+ if (prefix) {
239
+ if (selfNested) prefix = StringPrototypeReplace.call(prefix, '/', '/*');
240
+ routes = routes.map(route => updateRouteInfo(prefix, route));
241
+ }
242
+ routerRoutes = selfNested ? routes : routerRoutes.concat(routes);
243
+ } else if (prefix && typeof fns[i] === 'function') {
244
+ const routeInfo = createRouteInfo(prefix, 'use', 'router', fns[i]);
245
+ routerRoutes.push(routeInfo);
246
+ fns[i] = wrapForObservation(fns[i], routeInfo);
247
+ }
248
+ }
249
+ routerMap.set(Router, routerRoutes);
250
+ data.args.splice(prefix ? 1 : 0, fns.length, ...fns);
251
+ }
252
+ });
253
+ });
254
+ }
255
+ };
256
+ };