@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 +13 -4
- package/lib/index.test.js +6 -6
- package/lib/install/{express.js → express/express4.js} +2 -3
- package/lib/install/{express.test.js → express/express4.test.js} +1 -1
- package/lib/install/express/express5.js +256 -0
- package/lib/install/express/express5.test.js +813 -0
- package/lib/install/express/index.js +31 -0
- package/lib/install/graphql.js +115 -0
- package/lib/install/graphql.test.js +175 -0
- package/lib/install/hapi.js +6 -3
- package/lib/install/hapi.test.js +2 -0
- package/lib/utils/route-info.js +5 -10
- package/package.json +9 -8
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
|
-
|
|
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: '
|
|
52
|
-
const eventB = { signature: '
|
|
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: '
|
|
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: '
|
|
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(
|
|
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(
|
|
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('
|
|
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.
|
|
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('./
|
|
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
|
+
};
|