@contrast/route-coverage 1.20.6 → 1.21.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
@@ -37,7 +37,6 @@ module.exports = function init(core) {
37
37
  const routeCoverage = core.routeCoverage = {
38
38
  discover(info) {
39
39
  routeInfo.set(routeIdentifier(info), info);
40
- messages.emit(Event.ROUTE_COVERAGE_DISCOVERY, info);
41
40
  },
42
41
 
43
42
  delete(info) {
@@ -30,6 +30,7 @@ const fnInspect = require('@contrast/fn-inspect');
30
30
  const { createSignature, patchType } = require('../utils/route-info');
31
31
  const { ArrayPrototypeJoin, StringPrototypeToLowerCase, isString } = require('@contrast/common');
32
32
 
33
+ // Spec: https://contrast.atlassian.net/wiki/spaces/NOD/pages/3454861621/Node.js+Agent+Route+Signatures#Express
33
34
  module.exports = function init(core) {
34
35
  const { patcher, depHooks, routeCoverage } = core;
35
36
  const discover = (route) => routeCoverage.discover(route);
@@ -12,99 +12,116 @@
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
- const { createSignature } = require('./../utils/route-info');
19
- const { StringPrototypeToLowerCase } = require('@contrast/common');
17
+ const { StringPrototypeToLowerCase, isString } = require('@contrast/common');
18
+ const { getFastifyMethods } = require('./../utils/methods');
19
+ const { patchType } = require('./../utils/route-info');
20
20
 
21
- /** @typedef {Parameters<import('fastify-3.0.0').onRouteHookHandler>[0]} RouteOptions */
22
-
23
- /**
24
- * @param {import('..').Core & {
25
- * routeCoverage: import('..').RouteCoverage & {
26
- * fastify?: import('@contrast/common').Installable
27
- * }
28
- * }} core
29
- */
21
+ // Spec: https://contrast.atlassian.net/wiki/spaces/NOD/pages/3454861621/Node.js+Agent+Route+Signatures#Fastify
30
22
  module.exports = function init(core) {
23
+ /*
24
+ Fastify has two ways of defining routes:
25
+ 1. fastify.route({ method: '<method>', url: '<url>', handler: <fn> })
26
+ 2. fastify.<method>('<url>', <fn>)
27
+ (See NODE-3483 for more detail)
28
+ We need a way to keep track of which routes were fully declared using .route
29
+ So, we instrument the route method below and add identifying info to this array
30
+ */
31
+ const fullyDeclaredRoutes = [];
31
32
  const { patcher, depHooks, routeCoverage } = core;
32
33
 
33
- /** @param {RouteOptions} routeOptions */
34
- function registerRouteHandler(routeOptions) {
35
- if (!routeOptions || !routeOptions.method || !routeOptions.url) return;
36
-
37
- const { url, method } = routeOptions;
38
- if (Array.isArray(method)) {
39
- method.forEach((method) => {
40
- emitRouteCoverage(url, method);
41
- });
42
- } else {
43
- emitRouteCoverage(url, method);
34
+ const isFullyDeclared = (method, url) => isString(method) && fullyDeclaredRoutes.includes(method + url);
35
+ function fullyDeclare(method, url) {
36
+ if (isString(method)) {
37
+ fullyDeclaredRoutes.push(method + url);
38
+ if (method === 'GET') fullyDeclaredRoutes.push(`HEAD${url}`);
44
39
  }
45
-
46
- // TODO
47
- observationListener(routeOptions, url);
48
- }
49
-
50
- /**
51
- * @param {RouteOptions} routeOptions
52
- * @param {string} url
53
- */
54
- function observationListener(routeOptions, url) {
55
- patcher.patch(routeOptions, 'handler', {
56
- name: 'fastify.routeOptions.handler',
57
- patchType: 'route-coverage',
58
- pre(data) {
59
- const [request] = data.args;
60
- const { method } = request.raw;
61
- const [parsedUrl] = request.url.split(/\?/);
62
- emitObservation(parsedUrl, url, method);
63
- },
64
- });
65
40
  }
66
41
 
67
- /**
68
- * @param {string} url
69
- * @param {string} method
70
- */
71
- function emitRouteCoverage(url, method) {
42
+ function createRouteInfo(method, url, routePath) {
43
+ const fullyDeclared = isFullyDeclared(method, routePath);
72
44
  method = StringPrototypeToLowerCase.call(method);
73
- const event = { signature: createSignature(url, method), url, method, normalizedUrl: url, framework: 'fastify' };
74
- routeCoverage.discover(event);
75
- }
76
45
 
77
- /**
78
- * @param {string} url
79
- * @param {string} normalizedUrl
80
- * @param {string=} method
81
- */
82
- function emitObservation(url, normalizedUrl, method) {
83
- method = method && StringPrototypeToLowerCase.call(method);
84
- routeCoverage.observe({ method, url, normalizedUrl });
46
+ const signature = fullyDeclared
47
+ ? `fastify.route({ method: '${method}', url: '${url}', handler: [Function] })`
48
+ : `fastify.${method}('${url}', [Function])`;
49
+
50
+ const routeInfo = {
51
+ signature,
52
+ url,
53
+ method,
54
+ normalizedUrl: url,
55
+ framework: 'fastify'
56
+ };
57
+ return routeInfo;
85
58
  }
86
59
 
87
60
  return core.routeCoverage.fastify = {
88
61
  install() {
89
- depHooks.resolve(
90
- { name: 'fastify', version: '>=3.0.0' },
91
- /** @param {import("fastify-3.0.0").fastify} fastify */
92
- (fastify) =>
93
- patcher.patch(fastify, {
94
- name: 'fastify.build',
95
- patchType: 'route-coverage',
96
- post({ result: server }) {
97
- server.addHook('onRoute', (routeOptions) => {
98
- registerRouteHandler(routeOptions);
99
- });
62
+ // The routePath property used below was introduced in 3.2.0
63
+ return depHooks.resolve({ name: 'fastify', version: '>=3.2.0' }, (fastify) => patcher.patch(fastify, {
64
+ name: 'fastify',
65
+ patchType,
66
+ post({ result: server }) {
67
+ patcher.patch(server, 'route', {
68
+ name: 'server.route',
69
+ patchType,
70
+ pre({ args }) {
71
+ const [{ method, url }] = args;
72
+ if (!isString(url)) return;
73
+
74
+ if (Array.isArray(method)) {
75
+ method.forEach((verb) => {
76
+ fullyDeclare(verb, url);
77
+ });
78
+ } else {
79
+ fullyDeclare(method, url);
80
+ }
81
+ }
82
+ });
83
+ server.addHook('onRoute', (routeOptions) => {
84
+ if (!routeOptions) return;
85
+
86
+ const { method, url, routePath } = routeOptions;
87
+ if (!method || !url || !routePath || !isString(routePath)) return;
88
+
89
+ let routeInfo;
90
+ const FASTIFY_METHODS = getFastifyMethods(server.version);
91
+ if (Array.isArray(method)) {
92
+ // If a route was defined using .all this method property will be an
93
+ // array of all methods supported by Fastify
94
+ if (FASTIFY_METHODS.every(m => method.includes(m))) {
95
+ routeInfo = createRouteInfo('all', url, routePath);
96
+ routeCoverage.discover(routeInfo);
97
+ } else {
98
+ method.forEach((verb) => {
99
+ routeInfo = createRouteInfo(verb, url, routePath);
100
+ routeCoverage.discover(routeInfo);
101
+ });
102
+ }
103
+ } else {
104
+ routeInfo = createRouteInfo(method, url, routePath);
105
+ routeCoverage.discover(routeInfo);
106
+ }
100
107
 
101
- server.addHook('onReady', (done) => {
102
- routeCoverage.discoveryFinished();
103
- return done();
104
- });
105
- },
106
- }),
107
- );
108
+ patcher.patch(routeOptions, 'handler', {
109
+ name: 'fastify.routeOptions.handler',
110
+ patchType,
111
+ pre({ args }) {
112
+ const [req] = args;
113
+ const method = StringPrototypeToLowerCase.call(req.raw?.method);
114
+ const [url] = req.url.split(/\?/);
115
+ routeCoverage.observe({ ...routeInfo, url, method });
116
+ },
117
+ });
118
+ });
119
+ server.addHook('onReady', (done) => {
120
+ routeCoverage.discoveryFinished();
121
+ return done();
122
+ });
123
+ }
124
+ }));
108
125
  }
109
126
  };
110
127
  };
@@ -15,14 +15,21 @@
15
15
  'use strict';
16
16
 
17
17
  const { StringPrototypeToLowerCase } = require('@contrast/common');
18
- const { createSignature, patchType } = require('./../utils/route-info');
18
+ const { patchType } = require('./../utils/route-info');
19
19
 
20
+ // Spec: https://contrast.atlassian.net/wiki/spaces/NOD/pages/3454861621/Node.js+Agent+Route+Signatures#Hapi
20
21
  module.exports = function init(core) {
21
22
  const { patcher, depHooks, routeCoverage } = core;
22
23
 
23
24
  function emitRouteCoverage(url, method) {
24
25
  method = StringPrototypeToLowerCase.call(method);
25
- const event = { signature: createSignature(url, method), url, method, normalizedUrl: url, framework: 'hapi' };
26
+ const event = {
27
+ signature: `server.route({ method: '${method}', path: '${url}', handler: [Function] })`,
28
+ url,
29
+ method,
30
+ normalizedUrl: url,
31
+ framework: 'hapi'
32
+ };
26
33
  routeCoverage.discover(event);
27
34
  }
28
35
 
@@ -14,56 +14,76 @@
14
14
  */
15
15
  'use strict';
16
16
 
17
- const { StringPrototypeToLowerCase } = require('@contrast/common');
17
+ const { METHODS } = require('./../utils/methods');
18
+ const { StringPrototypeToLowerCase, isString } = require('@contrast/common');
18
19
  const { createSignature, patchType } = require('./../utils/route-info');
19
20
 
21
+ // Spec: https://contrast.atlassian.net/wiki/spaces/NOD/pages/3454861621/Node.js+Agent+Route+Signatures#Koa
20
22
  module.exports = function init(core) {
21
23
  const { patcher, depHooks, routeCoverage } = core;
22
24
 
23
- function emitRouteCoverage(url, method) {
24
- const event = { signature: createSignature(url, method), url, method, normalizedUrl: url, framework: 'koa' };
25
- routeCoverage.discover(event);
26
- }
27
-
28
- const routeObservationPathClosure = ({ path }) =>
29
- async function routeObservationMiddleware(ctx, next) {
30
- const req = ctx.request;
31
-
32
- if (req) {
33
- const { url: reqUrl, method } = req;
34
- const [url] = reqUrl.split(/\?/);
35
- routeCoverage.observe({ url, method: StringPrototypeToLowerCase.call(method || ''), normalizedUrl: path });
36
- }
37
-
38
- await next();
25
+ function createRouteInfo(method, url) {
26
+ method = StringPrototypeToLowerCase.call(method);
27
+ const routeInfo = {
28
+ signature: createSignature(url, method),
29
+ url,
30
+ method,
31
+ normalizedUrl: url,
32
+ framework: 'koa'
39
33
  };
34
+ return routeInfo;
35
+ }
40
36
 
41
37
  return core.routeCoverage.koa = {
42
38
  install() {
43
- ['@koa/router', 'koa-router'].forEach((name) => {
44
- depHooks.resolve({ name }, (_export) => {
45
- if (!_export?.prototype?.register) return;
39
+ depHooks.resolve({ name: 'koa', version: '>=2.3.0' }, (Koa) => {
40
+ // Koa uses its own routing library @koa/router to define routes before
41
+ // mounting them on the app with .use so instrumenting use and traversing
42
+ // the constructed routes is the more technically correct approach than
43
+ // instrumenting the routing library
44
+ patcher.patch(Koa.prototype, 'use', {
45
+ name: 'Koa.prototype.use',
46
+ patchType,
47
+ pre: ({ args }) => {
48
+ if (args?.length === 0) return;
49
+ const [router] = args;
46
50
 
47
- patcher.patch(_export.prototype, 'register', {
48
- name: 'koaRouter.prototype',
49
- patchType,
50
- post: ({ args, result: layer }) => {
51
- const [path, methods] = args;
52
- if (!path || !methods || Array.isArray(path)) {
53
- return;
54
- }
51
+ if (!router?.router) return;
55
52
 
53
+ router.router.stack.forEach((Layer) => {
54
+ const { methods, path } = Layer;
55
+ if (!path || !isString(path)) return;
56
+
57
+ let routeInfo;
56
58
  if (methods.length === 0) {
57
- emitRouteCoverage(path, 'use');
59
+ routeInfo = createRouteInfo('use', path);
60
+ routeCoverage.discover(routeInfo);
61
+ } else if (METHODS.every(m => methods.includes(m))) {
62
+ // If a route was defined using .all this methods property will be an
63
+ // array of all methods supported by Koa
64
+ routeInfo = createRouteInfo('all', path);
65
+ routeCoverage.discover(routeInfo);
58
66
  } else {
59
67
  methods.forEach((method) => {
60
- emitRouteCoverage(path, StringPrototypeToLowerCase.call(method || ''));
68
+ routeInfo = createRouteInfo(method, path);
69
+ routeCoverage.discover(routeInfo);
61
70
  });
62
71
  }
63
72
 
64
- layer.stack.unshift(routeObservationPathClosure({ path }).bind(this));
65
- }
66
- });
73
+ if (!Layer.stack || Layer.stack.length === 0) return;
74
+ async function observationMiddleware(ctx, next) {
75
+ if (!ctx.request) return;
76
+ const { url: reqUrl, method } = ctx.request;
77
+ const [url] = reqUrl.split(/\?/);
78
+ routeCoverage.observe({ ...routeInfo, url, method: StringPrototypeToLowerCase.call(method) });
79
+ await next();
80
+ }
81
+ // If two routes share middleware, the same stack is used
82
+ // To add our observation middleware without adding them to all routes
83
+ // we need to create a shallow copy
84
+ Layer.stack = [observationMiddleware, ...Layer.stack];
85
+ });
86
+ }
67
87
  });
68
88
  });
69
89
  }
@@ -17,6 +17,7 @@
17
17
  const { StringPrototypeToLowerCase, isString } = require('@contrast/common');
18
18
  const { createSignature, patchType } = require('../utils/route-info');
19
19
 
20
+ // Spec: https://contrast.atlassian.net/wiki/spaces/NOD/pages/3454861621/Node.js+Agent+Route+Signatures#Restify
20
21
  module.exports = function init(core) {
21
22
  const { patcher, depHooks, routeCoverage } = core;
22
23
  const discover = (route) => routeCoverage.discover(route);
@@ -25,7 +26,7 @@ module.exports = function init(core) {
25
26
  function createRoute(url, method) {
26
27
  method = StringPrototypeToLowerCase.call(method);
27
28
  return {
28
- signature: createSignature(url, method, 'Server'),
29
+ signature: createSignature(url, method, 'server'),
29
30
  method,
30
31
  url,
31
32
  normalizedUrl: url,
@@ -0,0 +1,45 @@
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
+ // eslint-disable-next-line node/no-extraneous-require
18
+ const semver = require('semver');
19
+ const { METHODS } = require('http');
20
+
21
+ function getFastifyMethods(version) {
22
+ return [
23
+ 'DELETE',
24
+ 'GET',
25
+ 'HEAD',
26
+ 'PATCH',
27
+ 'POST',
28
+ 'PUT',
29
+ 'OPTIONS',
30
+ ...semver.gte(version, '4.4.0') ?
31
+ [
32
+ 'PROPFIND',
33
+ 'PROPPATCH',
34
+ 'MKCOL',
35
+ 'COPY',
36
+ 'MOVE',
37
+ 'LOCK',
38
+ 'UNLOCK',
39
+ 'TRACE',
40
+ 'SEARCH'
41
+ ] : []
42
+ ];
43
+ }
44
+
45
+ module.exports = { getFastifyMethods, METHODS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrast/route-coverage",
3
- "version": "1.20.6",
3
+ "version": "1.21.0",
4
4
  "description": "Handles route discovery and observation",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Contrast Security <nodejs@contrastsecurity.com> (https://www.contrastsecurity.com)",
@@ -17,7 +17,7 @@
17
17
  "test": "../scripts/test.sh"
18
18
  },
19
19
  "dependencies": {
20
- "@contrast/common": "1.21.2",
20
+ "@contrast/common": "1.22.0",
21
21
  "@contrast/fn-inspect": "^4.0.0"
22
22
  }
23
23
  }