@contrast/route-coverage 1.51.0 → 1.53.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.
@@ -0,0 +1,67 @@
1
+ /*
2
+ * Copyright: 2025 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 { RouteType } = require('@contrast/common');
18
+ const { patchType, formatHandler } = require('./../../utils/route-info');
19
+ const isArray = (arr) => Array.isArray(arr);
20
+ module.exports = function init(core) {
21
+ const { patcher, depHooks, routeCoverage } = core;
22
+
23
+ return core.routeCoverage.fastifyMiddie = {
24
+ install() {
25
+ depHooks.resolve({ name: '@fastify/middie', version: '*', file: 'lib/engine.js' }, (middie) => patcher.patch(middie, {
26
+ name: 'fastifyMiddie',
27
+ patchType,
28
+ post(data) {
29
+ patcher.patch(data.result, 'use', {
30
+ name: 'use',
31
+ patchType,
32
+ pre(data) {
33
+ const [url, fn] = data.args;
34
+ if (!url || !fn || !core.config.getEffectiveValue('assess.report_middleware_routes')) return;
35
+
36
+ const middleware = isArray(fn) ? fn : [fn];
37
+ const formattedPath = isArray(url) ? `[${url.join(', ')}]` : url;
38
+ const patchedMiddleware = middleware.map((f) => {
39
+ const formattedHandler = formatHandler(f);
40
+ const signature = `fastify.use(${formattedPath}, ${formattedHandler})`;
41
+
42
+ const routeInfo = {
43
+ signature,
44
+ url: formattedPath,
45
+ method: 'use',
46
+ normalizedUrl: formattedPath,
47
+ type: RouteType.MIDDLEWARE,
48
+ framework: 'fastify'
49
+ };
50
+ routeCoverage.discover(routeInfo);
51
+
52
+ return patcher.patch(f, {
53
+ name: 'middleware',
54
+ patchType,
55
+ post() {
56
+ routeCoverage.observe(routeInfo);
57
+ }
58
+ });
59
+ });
60
+ data.args[1] = patchedMiddleware;
61
+ }
62
+ });
63
+ }
64
+ }));
65
+ }
66
+ };
67
+ };
@@ -14,12 +14,12 @@
14
14
  */
15
15
  'use strict';
16
16
 
17
- const { getFastifyMethods } = require('../utils/methods');
17
+ const { getFastifyMethods } = require('../../utils/methods');
18
18
  const {
19
19
  primordials: { StringPrototypeToLowerCase, StringPrototypeSplit },
20
20
  RouteType,
21
21
  } = require('@contrast/common');
22
- const { patchType } = require('./../utils/route-info');
22
+ const { patchType, formatHandler } = require('./../../utils/route-info');
23
23
 
24
24
  // Spec: https://contrast.atlassian.net/wiki/spaces/NOD/pages/3454861621/Node.js+Agent+Route+Signatures#Fastify
25
25
  module.exports = function init(core) {
@@ -38,12 +38,12 @@ module.exports = function init(core) {
38
38
  return route?.[kRoutePrefix];
39
39
  }
40
40
 
41
- function createRouteInfo(method, url, fullyDeclared, type) {
41
+ function createRouteInfo(method, url, fullyDeclared, type, handler) {
42
42
  method = StringPrototypeToLowerCase.call(method);
43
43
 
44
44
  const signature = fullyDeclared
45
- ? `fastify.route({ method: '${method}', url: '${url}', handler: [Function] })`
46
- : `fastify.${method}('${url}', [Function])`;
45
+ ? `fastify.route({ method: ${method}, url: ${url}, handler: ${formatHandler(handler)} })`
46
+ : `fastify.${method}(${url}, ${formatHandler(handler)})`;
47
47
 
48
48
  const routeInfo = {
49
49
  signature,
@@ -89,30 +89,30 @@ module.exports = function init(core) {
89
89
  }
90
90
  }
91
91
 
92
- function discoverAndPatch(method, path, routeObj, handle, methods, fullyDeclared) {
92
+ function discoverAndPatch(method, path, routeObj, handle, handler, methods, fullyDeclared) {
93
93
  const type = routeObj?.options?.websocket ? RouteType.MESSAGE_BROKER : RouteType.HTTP;
94
94
 
95
95
  if (Array.isArray(method)) {
96
96
  // If all valid methods are included in `method` then .all shorthand was most likely used
97
97
  if (methods.every(m => method.includes(m))) {
98
- const routeInfo = createRouteInfo('all', path, fullyDeclared, type);
98
+ const routeInfo = createRouteInfo('all', path, fullyDeclared, type, handler);
99
99
  routeCoverage.discover(routeInfo);
100
100
  patchHandler(routeObj, handle, routeInfo);
101
101
  } else {
102
102
  method.forEach((verb) => {
103
- const routeInfo = createRouteInfo(verb, path, fullyDeclared, type);
103
+ const routeInfo = createRouteInfo(verb, path, fullyDeclared, type, handler);
104
104
  routeCoverage.discover(routeInfo);
105
105
  patchHandler(routeObj, handle, routeInfo);
106
106
  });
107
107
  }
108
108
  } else {
109
- const routeInfo = createRouteInfo(method, path, fullyDeclared, type);
109
+ const routeInfo = createRouteInfo(method, path, fullyDeclared, type, handler);
110
110
  routeCoverage.discover(routeInfo);
111
111
  patchHandler(routeObj, handle, routeInfo);
112
112
  }
113
113
  }
114
114
 
115
- return core.routeCoverage.fastify = {
115
+ return core.routeCoverage.fastifyCore = {
116
116
  install() {
117
117
  /**
118
118
  * There are some subtle differences between fastify minor versions the instrumentation must account for
@@ -158,7 +158,7 @@ module.exports = function init(core) {
158
158
  let handle = 'handler';
159
159
  if (!handler && typeof options === 'function') handle = 'options';
160
160
 
161
- discoverAndPatch(method, path, routeObj ? data.args[0] : data.args, handle, fastifyMethods, false);
161
+ discoverAndPatch(method, path, routeObj ? data.args[0] : data.args, handle, options || handler, fastifyMethods, false);
162
162
  }
163
163
  });
164
164
 
@@ -175,7 +175,7 @@ module.exports = function init(core) {
175
175
 
176
176
  const prefix = getPrefix(data.obj);
177
177
  const path = prefix ? prefix + url : url;
178
- discoverAndPatch(method, path, routeArgs, 'handler', fastifyMethods, true);
178
+ discoverAndPatch(method, path, routeArgs, 'handler', handler, fastifyMethods, true);
179
179
  }
180
180
  });
181
181
  }
@@ -18,14 +18,15 @@
18
18
  const { callChildComponentMethodsSync } = require('@contrast/common');
19
19
 
20
20
  module.exports = function(core) {
21
- const expressRouteCoverage = core.routeCoverage.express = {
21
+ const fastifyRouteCoverage = core.routeCoverage.fastify = {
22
22
  install() {
23
- callChildComponentMethodsSync(expressRouteCoverage, 'install');
23
+ callChildComponentMethodsSync(fastifyRouteCoverage, 'install');
24
24
  },
25
25
  };
26
26
 
27
- require('./express4')(core);
28
- require('./express5')(core);
27
+ require('./fastify')(core);
28
+ require('./fastify-express')(core);
29
+ require('./fastify-middie')(core);
29
30
 
30
- return expressRouteCoverage;
31
+ return fastifyRouteCoverage;
31
32
  };
@@ -12,76 +12,139 @@
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
15
16
  'use strict';
16
17
 
17
- const { primordials: { StringPrototypeToLowerCase } } = require('@contrast/common');
18
- const { patchType } = require('./../utils/route-info');
18
+ const { AsyncLocalStorage } = require('node:async_hooks');
19
+ const { RouteType, set } = require('@contrast/common');
20
+ const { Core } = require('@contrast/core/lib/ioc/core');
21
+ const { formatHandler, patchType } = require('../utils/route-info');
19
22
 
20
- // Spec: https://contrast.atlassian.net/wiki/spaces/NOD/pages/3454861621/Node.js+Agent+Route+Signatures#Hapi
21
- module.exports = function init(core) {
22
- const { patcher, depHooks, routeCoverage } = core;
23
+ /**
24
+ * The hapi `Route` class from lib/route.js is not defined or exported.
25
+ * @typedef {Object} Route
26
+ * @property {boolean} _special internal hapi property for special routes
27
+ * @property {string} method
28
+ * @property {string} path
29
+ * @property {Object} settings
30
+ * @property {(request: { method: string, path: string}) => any} settings.handler set by the Route constructor
31
+ */
32
+
33
+ class HapiRouteCoverage {
34
+ /**
35
+ * @param {import('..').Core & {
36
+ * routeCoverage: import('..').RouteCoverage;
37
+ * }} core
38
+ */
39
+ constructor(core) {
40
+ set(core, 'routeCoverage.hapi', this);
41
+ this.core = core;
42
+ this.depHooks = core.depHooks;
43
+ this.patcher = core.patcher;
44
+ this.routeCoverage = core.routeCoverage;
45
+ this.registerScope = new AsyncLocalStorage();
46
+ }
23
47
 
24
- const createSignature = (method, url) => `server.route({ method: '${method}', path: '${url}', handler: [Function] })`;
25
- function emitRouteCoverage(url, method) {
26
- method = StringPrototypeToLowerCase.call(method);
27
- const event = {
28
- signature: createSignature(method, url),
29
- url,
30
- method,
31
- normalizedUrl: url,
32
- framework: 'hapi'
33
- };
34
- routeCoverage.discover(event);
48
+ install() {
49
+ this.depHooks.resolve(
50
+ { name: '@hapi/hapi', version: '>=18 <22', file: 'lib/server.js' },
51
+ /** @param {typeof import('@hapi/hapi').Server} Server */
52
+ (Server) => this.patchServer(Server),
53
+ );
54
+ this.depHooks.resolve(
55
+ { name: '@hapi/hapi', version: '>=18 <22', file: 'lib/route.js' },
56
+ /** @param {abstract new () => Route} Route */
57
+ (Route) => this.patchRoute(Route),
58
+ );
35
59
  }
36
60
 
37
- return core.routeCoverage.hapi = {
38
- install() {
39
- return depHooks.resolve({ name: '@hapi/hapi', version: '>=18 <22' }, (hapi) => {
40
- ['server', 'Server'].forEach((server) => {
41
- patcher.patch(hapi, server, {
42
- name: `hapi.${server}`,
43
- patchType,
44
- post(data) {
45
- patcher.patch(data.result._core.router, 'add', {
46
- name: '_core.router.add',
47
- patchType,
48
- post(data) {
49
- if (!data.args[0] || !data.result) return;
61
+ /**
62
+ * Spec: https://contrast.atlassian.net/wiki/spaces/NOD/pages/3454861621/Node.js+Agent+Route+Signatures#Hapi
63
+ * @param {Route} route
64
+ */
65
+ createSignature(route) {
66
+ const handler = formatHandler(this.patcher.unwrap(route.settings.handler));
67
+ return `server.route({ method: '${route.method}', path: '${route.path}', handler: ${handler} })`;
68
+ }
69
+
70
+ /** @param {typeof import('@hapi/hapi').Server} Server */
71
+ patchServer(Server) {
72
+ const self = this;
73
+ return this.patcher.patch(Server, {
74
+ name: 'hapi.Server',
75
+ patchType,
76
+ post({ result: server }) {
77
+ self.patcher.patch(server, 'register', {
78
+ name: 'server.register',
79
+ patchType,
80
+ around(next) {
81
+ if (self.registerScope.getStore()) return next();
82
+ return self.registerScope.run({ isMiddleware: true }, next);
83
+ },
84
+ });
85
+ },
86
+ });
87
+ }
50
88
 
51
- const [{ method, path }] = data.args;
52
- if (!method || !path) return;
89
+ /**
90
+ * @param {Route} route
91
+ * @param {string} signature
92
+ * @param {RouteType} type
93
+ */
94
+ patchRouteHandler(route, signature, type) {
95
+ const self = this;
96
+ this.patcher.patch(route.settings, 'handler', {
97
+ name: 'route.settings.handler',
98
+ patchType,
99
+ // this needs to be in a pre-hook so that the route
100
+ // data is in the store before our dataflow hooks run
101
+ pre({ args: [request] }) {
102
+ self.routeCoverage.observe({
103
+ signature,
104
+ method: request.method,
105
+ url: request.path,
106
+ normalizedUrl: route.path, // should also be defined at `request.route.path`
107
+ framework: 'hapi',
108
+ type,
109
+ });
110
+ },
111
+ });
112
+ }
53
113
 
54
- if (Array.isArray(method)) {
55
- method.forEach((verb) => {
56
- emitRouteCoverage(path, verb);
57
- });
58
- } else {
59
- emitRouteCoverage(path, method);
60
- }
114
+ /**
115
+ * @param {abstract new () => Route} Route
116
+ */
117
+ patchRoute(Route) {
118
+ const self = this;
119
+ return this.patcher.patch(Route, {
120
+ name: 'hapi.Route',
121
+ patchType,
122
+ post({ result: route }) {
123
+ if (route._special) return; // skip special internal routes
124
+ const signature = self.createSignature(route);
125
+ const type = self.registerScope.getStore()?.isMiddleware ? RouteType.MIDDLEWARE : RouteType.HTTP;
61
126
 
62
- patcher.patch(data.result.route.settings, 'handler', {
63
- name: 'route.settings.handler',
64
- patchType,
65
- // this needs to be in a pre-hook so that the route
66
- // data is in the store before our dataflow hooks run
67
- pre({ args }) {
68
- const [{ method, path: url, route }] = args;
69
- //TODO: Will this signature always be associated with an existing route?
70
- const signature = createSignature(method, path);
71
- routeCoverage.observe({
72
- signature,
73
- url,
74
- method: StringPrototypeToLowerCase.call(method),
75
- normalizedUrl: route.path,
76
- });
77
- }
78
- });
79
- }
80
- });
81
- }
82
- });
127
+ self.routeCoverage.discover({
128
+ signature,
129
+ method: route.method,
130
+ url: route.path,
131
+ normalizedUrl: route.path,
132
+ framework: 'hapi',
133
+ type,
83
134
  });
84
- });
85
- }
86
- };
87
- };
135
+
136
+ self.patchRouteHandler(route, signature, type);
137
+ },
138
+ });
139
+ }
140
+ }
141
+
142
+ module.exports = Core.makeComponent({
143
+ name: 'routeCoverage.hapi',
144
+ /**
145
+ * @param {import('..').Core & {
146
+ * routeCoverage: import('..').RouteCoverage;
147
+ * }} core
148
+ */
149
+ factory: (core) => new HapiRouteCoverage(core),
150
+ });
@@ -15,28 +15,15 @@
15
15
  'use strict';
16
16
 
17
17
  const { METHODS } = require('./../utils/methods');
18
- const { isString, primordials: { StringPrototypeToLowerCase, StringPrototypeSplit } } = require('@contrast/common');
19
- const { createSignature, patchType } = require('./../utils/route-info');
18
+ const { isString, RouteType, primordials: { StringPrototypeToLowerCase, StringPrototypeSplit } } = require('@contrast/common');
19
+ const { patchType, formatHandler } = require('./../utils/route-info');
20
20
 
21
21
  // Spec: https://contrast.atlassian.net/wiki/spaces/NOD/pages/3454861621/Node.js+Agent+Route+Signatures#Koa
22
22
  module.exports = function init(core) {
23
23
  const { patcher, depHooks, routeCoverage } = core;
24
-
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'
33
- };
34
- return routeInfo;
35
- }
36
-
37
24
  return core.routeCoverage.koa = {
38
25
  install() {
39
- depHooks.resolve({ name: 'koa', version: '>=2.3.0 <4' }, (Koa) => {
26
+ depHooks.resolve({ name: 'koa', version: '>=2.3.0 <4' }, (Koa, pkgMeta) => {
40
27
  // Koa uses its own routing library @koa/router to define routes before
41
28
  // mounting them on the app with .use so instrumenting use and traversing
42
29
  // the constructed routes is the more technically correct approach than
@@ -48,40 +35,47 @@ module.exports = function init(core) {
48
35
  if (args?.length === 0) return;
49
36
  const [router] = args;
50
37
 
51
- if (!router?.router) return;
38
+ if (!router?.router) {
39
+ core.logger.debug('no routes detected in koa router stack: %s@%s', pkgMeta.name, pkgMeta.version);
40
+ return;
41
+ }
52
42
 
53
43
  router.router.stack.forEach((Layer) => {
54
- const { methods, path } = Layer;
55
- if (!path || !isString(path)) return;
44
+ const { methods, path, stack } = Layer;
45
+ if (!path || !isString(path) || !stack || stack.length === 0) return;
56
46
 
57
- let routeInfo;
58
- if (methods.length === 0) {
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);
47
+ const patchedMiddleware = [];
48
+ stack.forEach((handler) => {
49
+ const method = methods.length === 0 ? 'use' : METHODS.every(m => methods.includes(m)) ? 'all' : StringPrototypeToLowerCase.call(methods[methods.length - 1]);
50
+ if (method === 'use' && !core.config.getEffectiveValue('assess.report_middleware_routes')) return;
51
+ const routeInfo = {
52
+ signature: `Router.${method}(${path}, ${formatHandler(handler)})`,
53
+ method,
54
+ url: path,
55
+ normalizedUrl: path,
56
+ framework: 'koa',
57
+ type: method === 'use' ? RouteType.MIDDLEWARE : RouteType.HTTP
58
+ };
65
59
  routeCoverage.discover(routeInfo);
66
- } else {
67
- methods.forEach((method) => {
68
- routeInfo = createRouteInfo(method, path);
69
- routeCoverage.discover(routeInfo);
70
- });
71
- }
60
+ const patchedHandler = patcher.patch(handler, {
61
+ name: 'handler',
62
+ patchType,
63
+ pre(data) {
64
+ const { request } = data.args[0];
65
+ if (!request) return;
72
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] = StringPrototypeSplit.call(reqUrl, /\?/);
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];
67
+ const { method, url } = request;
68
+ if (!method | !url) return;
69
+ routeCoverage.observe({
70
+ ...routeInfo,
71
+ method: StringPrototypeToLowerCase.call(method),
72
+ url: StringPrototypeSplit.call(url, /\?/)[0]
73
+ });
74
+ }
75
+ });
76
+ patchedMiddleware.push(patchedHandler);
77
+ });
78
+ Layer.stack = patchedMiddleware;
85
79
  });
86
80
  }
87
81
  });