@contrast/route-coverage 1.32.0 → 1.34.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,31 @@
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
+
16
+ 'use strict';
17
+
18
+ const { callChildComponentMethodsSync } = require('@contrast/common');
19
+
20
+ module.exports = function(core) {
21
+ const expressRouteCoverage = core.routeCoverage.express = {
22
+ install() {
23
+ callChildComponentMethodsSync(expressRouteCoverage, 'install');
24
+ },
25
+ };
26
+
27
+ require('./express4')(core);
28
+ require('./express5')(core);
29
+
30
+ return expressRouteCoverage;
31
+ };
@@ -0,0 +1,115 @@
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 { primordials: { ArrayPrototypeJoin } } = require('@contrast/common');
18
+ const { patchType } = require('./../utils/route-info');
19
+
20
+ module.exports = function init(core) {
21
+ const {
22
+ patcher,
23
+ depHooks,
24
+ routeCoverage,
25
+ scopes,
26
+ } = core;
27
+
28
+ function instrument(config) {
29
+ // discover from basis type fields that can resolve
30
+ [
31
+ 'query',
32
+ 'mutation'
33
+ ].forEach((basisType) => {
34
+ if (!config?.[basisType]) return;
35
+
36
+ const { name, _fields } = config[basisType];
37
+ if (!_fields) return;
38
+
39
+ for (const [field, fieldConfig] of Object.entries(_fields)) {
40
+ if (!(typeof fieldConfig?.resolve == 'function')) continue;
41
+
42
+ // these are built out more below; values are somewhat arbitrary - do best we can
43
+ let signature = `GraphQL ${name} ${field}`;
44
+ let normalizedUrl = `/${name}/${field}`;
45
+
46
+ if (fieldConfig.args) {
47
+ signature += '(';
48
+ signature += ArrayPrototypeJoin.call(
49
+ fieldConfig.args.map((a) => {
50
+ const _type = a.type?.ofType?.name ?? a.type.name;
51
+ normalizedUrl += `/{${a.name}}`;
52
+ return `${a.name}: ${_type}`;
53
+ }),
54
+ ', ');
55
+ signature += ')';
56
+ }
57
+
58
+ // queries can come from body or querystring, although app may not support both
59
+ ['get', 'post'].forEach((method) => {
60
+ routeCoverage.discover({
61
+ method,
62
+ normalizedUrl,
63
+ signature,
64
+ framework: 'graphql',
65
+ });
66
+ });
67
+
68
+ patcher.patch(fieldConfig, 'resolve', {
69
+ name: 'graphql.GraphQLSchema._field.resolve',
70
+ patchType,
71
+ pre() {
72
+ try {
73
+ const store = scopes.sources.getStore();
74
+ if (!store.sourceInfo?.method) return;
75
+
76
+ routeCoverage.observe({
77
+ method: store.sourceInfo?.method,
78
+ normalizedUrl,
79
+ signature,
80
+ url: normalizedUrl,
81
+ framework: 'graphql',
82
+ });
83
+ } catch (err) {
84
+ core.logger.error({ err }, 'error occurred while handling GraphQLSchema resolver for route observation');
85
+ }
86
+ }
87
+ });
88
+ }
89
+ });
90
+ }
91
+
92
+ return core.routeCoverage.graphql = {
93
+ // the first non-zero major version of graphql is 14.
94
+ install() {
95
+ depHooks.resolve(
96
+ { name: 'graphql', version: '>=14 <17', file: './type/schema.js' },
97
+ /**
98
+ * @param {import('graphql')} xports
99
+ */
100
+ (xports) => {
101
+ xports.GraphQLSchema = class GraphQLSchema extends xports.GraphQLSchema {
102
+ constructor(...args) {
103
+ super(...args);
104
+ try {
105
+ instrument(args[0]);
106
+ } catch (err) {
107
+ core.logger.error({ err }, 'error occurred while instrumenting GraphQLSchema');
108
+ }
109
+ }
110
+ };
111
+ }
112
+ );
113
+ }
114
+ };
115
+ };
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const sinon = require('sinon');
5
+ const { initCoreFixture } = require('@contrast/test/fixtures');
6
+
7
+ describe('route-coverage graphql', function () {
8
+ let core;
9
+ let simulateRequestScope;
10
+ let MockSchema;
11
+
12
+ beforeEach(function () {
13
+ ({ core, simulateRequestScope } = initCoreFixture());
14
+ require('..')(core);
15
+ sinon.spy(core.routeCoverage, 'discover');
16
+ sinon.spy(core.routeCoverage, 'observe');
17
+
18
+ const xport = {
19
+ GraphQLSchema: class GraphQLSchema {
20
+ constructor() {}
21
+ }
22
+ };
23
+
24
+ core.depHooks.resolve.withArgs({ name: 'graphql', version: '>=14 <17', file: './type/schema.js' }).yields(xport);
25
+ require('./graphql')(core).install();
26
+ MockSchema = xport.GraphQLSchema;
27
+ });
28
+
29
+ it('reports discovery and observation by instrumenting config object passed to GraphQLSchema constructor', function () {
30
+ const config = {
31
+ query: {
32
+ name: 'Query',
33
+ _fields: {
34
+ getThis: {
35
+ name: 'getThis',
36
+ args: [{
37
+ name: 'id',
38
+ type: class GetThisType {},
39
+ }],
40
+ resolve() {},
41
+ },
42
+ getThat: {
43
+ name: 'getThat',
44
+ resolve() {},
45
+ },
46
+ }
47
+ },
48
+ mutation: {
49
+ name: 'Mutation',
50
+ _fields: {
51
+ createThis: {
52
+ name: 'createThis',
53
+ args: [{
54
+ name: 'id',
55
+ type: class CreateThisType {},
56
+ }],
57
+ resolve() {},
58
+ },
59
+ updateThat: {
60
+ name: 'updateThat',
61
+ resolve() {},
62
+ }
63
+ }
64
+ }
65
+ };
66
+ new MockSchema(config);
67
+
68
+ [
69
+ {
70
+ method: 'get',
71
+ normalizedUrl: '/Query/getThis/{id}',
72
+ signature: 'GraphQL Query getThis(id: GetThisType)',
73
+ framework: 'graphql'
74
+ },
75
+ {
76
+ method: 'post',
77
+ normalizedUrl: '/Query/getThis/{id}',
78
+ signature: 'GraphQL Query getThis(id: GetThisType)',
79
+ framework: 'graphql'
80
+ },
81
+ {
82
+ method: 'get',
83
+ normalizedUrl: '/Query/getThat',
84
+ signature: 'GraphQL Query getThat',
85
+ framework: 'graphql'
86
+ },
87
+ {
88
+ method: 'post',
89
+ normalizedUrl: '/Query/getThat',
90
+ signature: 'GraphQL Query getThat',
91
+ framework: 'graphql'
92
+ },
93
+ {
94
+ method: 'get',
95
+ normalizedUrl: '/Mutation/createThis/{id}',
96
+ signature: 'GraphQL Mutation createThis(id: CreateThisType)',
97
+ framework: 'graphql'
98
+ },
99
+
100
+ {
101
+ method: 'post',
102
+ normalizedUrl: '/Mutation/createThis/{id}',
103
+ signature: 'GraphQL Mutation createThis(id: CreateThisType)',
104
+ framework: 'graphql'
105
+ },
106
+ {
107
+ method: 'get',
108
+ normalizedUrl: '/Mutation/updateThat',
109
+ signature: 'GraphQL Mutation updateThat',
110
+ framework: 'graphql'
111
+ },
112
+ {
113
+ method: 'post',
114
+ normalizedUrl: '/Mutation/updateThat',
115
+ signature: 'GraphQL Mutation updateThat',
116
+ framework: 'graphql'
117
+ }
118
+ ].forEach((discovery) => {
119
+ try {
120
+ expect(core.routeCoverage.discover).to.have.been.calledWithMatch(discovery);
121
+ } catch (err) {
122
+ console.log('not reported:', discovery);
123
+ console.log(core.routeCoverage.discover.getCalls().map((c) => c.args[0]));
124
+ throw err;
125
+ }
126
+ });
127
+
128
+ simulateRequestScope(() => {
129
+ // exercise resolvers to trigger observation
130
+ config.query._fields.getThis.resolve();
131
+ config.query._fields.getThat.resolve();
132
+ config.mutation._fields.createThis.resolve();
133
+ config.mutation._fields.updateThat.resolve();
134
+
135
+ [
136
+ {
137
+ method: 'get',
138
+ normalizedUrl: '/Query/getThis/{id}',
139
+ signature: 'GraphQL Query getThis(id: GetThisType)',
140
+ url: '/Query/getThis/{id}',
141
+ framework: 'graphql'
142
+ },
143
+ {
144
+ method: 'get',
145
+ normalizedUrl: '/Query/getThat',
146
+ signature: 'GraphQL Query getThat',
147
+ url: '/Query/getThat',
148
+ framework: 'graphql'
149
+ },
150
+ {
151
+ method: 'get',
152
+ normalizedUrl: '/Mutation/createThis/{id}',
153
+ signature: 'GraphQL Mutation createThis(id: CreateThisType)',
154
+ url: '/Mutation/createThis/{id}',
155
+ framework: 'graphql'
156
+ },
157
+ {
158
+ method: 'get',
159
+ normalizedUrl: '/Mutation/updateThat',
160
+ signature: 'GraphQL Mutation updateThat',
161
+ url: '/Mutation/updateThat',
162
+ framework: 'graphql'
163
+ }
164
+ ].forEach((observation) => {
165
+ try {
166
+ expect(core.routeCoverage.observe).to.have.been.calledWithMatch(observation);
167
+ } catch (err) {
168
+ console.log('not reported:', observation);
169
+ console.log(core.routeCoverage.observe.getCalls().map((c) => c.args[0]));
170
+ throw err;
171
+ }
172
+ });
173
+ });
174
+ });
175
+ });
@@ -21,10 +21,11 @@ const { patchType } = require('./../utils/route-info');
21
21
  module.exports = function init(core) {
22
22
  const { patcher, depHooks, routeCoverage } = core;
23
23
 
24
+ const createSignature = (method, url) => `server.route({ method: '${method}', path: '${url}', handler: [Function] })`;
24
25
  function emitRouteCoverage(url, method) {
25
26
  method = StringPrototypeToLowerCase.call(method);
26
27
  const event = {
27
- signature: `server.route({ method: '${method}', path: '${url}', handler: [Function] })`,
28
+ signature: createSignature(method, url),
28
29
  url,
29
30
  method,
30
31
  normalizedUrl: url,
@@ -62,8 +63,10 @@ module.exports = function init(core) {
62
63
  name: 'route.settings.handler',
63
64
  patchType,
64
65
  post({ args }) {
65
- const [{ method, path, route }] = args;
66
- routeCoverage.observe({ url: path, method: StringPrototypeToLowerCase.call(method), normalizedUrl: route.path });
66
+ const [{ method, path: url, route }] = args;
67
+ //TODO: Will this signature always be associated with an existing route?
68
+ const signature = createSignature(method, path);
69
+ routeCoverage.observe({ signature, url, method: StringPrototypeToLowerCase.call(method), normalizedUrl: route.path });
67
70
  }
68
71
  });
69
72
  }
@@ -105,6 +105,7 @@ describe('route-coverage hapi', function () {
105
105
  const { route } = hapiServer._core.router.add({ method: 'get', path: '/foo' });
106
106
  route.settings.handler({ method: 'get', path: '/foo', route: { path: '/foo' } });
107
107
  expect(core.routeCoverage.observe).to.have.been.calledWith({
108
+ signature: "server.route({ method: 'get', path: '/foo', handler: [Function] })",
108
109
  url: '/foo',
109
110
  normalizedUrl: '/foo',
110
111
  method: 'get'
@@ -116,6 +117,7 @@ describe('route-coverage hapi', function () {
116
117
  const { route } = hapiServer._core.router.add({ method: 'get', path: '/foo/{id}' });
117
118
  route.settings.handler({ method: 'get', path: '/foo/1', route: { path: '/foo/{id}' } });
118
119
  expect(core.routeCoverage.observe).to.have.been.calledWith({
120
+ signature: "server.route({ method: 'get', path: '/foo/{id}', handler: [Function] })",
119
121
  url: '/foo/1',
120
122
  normalizedUrl: '/foo/{id}',
121
123
  method: 'get'
@@ -20,17 +20,12 @@ const patchType = 'route-coverage';
20
20
  * Creates a formatted "signature" for a route
21
21
  * @param {string} path
22
22
  * @param {string} method
23
+ * @param {string} obj
24
+ * @param {string} handler
23
25
  * @return {string} formatted signature
24
26
  */
25
- function createSignature(path, method = '', obj = 'Router') {
26
- return `${obj}.${method}('${path}', [Function])`;
27
+ function createSignature(path, method = '', obj = 'Router', handler = '[Function]') {
28
+ return `${obj}.${method}('${path}', ${handler})`;
27
29
  }
28
30
 
29
- /**
30
- * Creates a route identifier based on the method name and the url
31
- * @param {Pick<import('../index').RouteInfo, 'method' | 'url'>} info
32
- * @return {string}
33
- */
34
- const routeIdentifier = (info) => `${info.method}.${info.normalizedUrl}`;
35
-
36
- module.exports = { createSignature, routeIdentifier, patchType };
31
+ module.exports = { createSignature, patchType };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrast/route-coverage",
3
- "version": "1.32.0",
3
+ "version": "1.34.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,13 +17,14 @@
17
17
  "test": "../scripts/test.sh"
18
18
  },
19
19
  "dependencies": {
20
- "@contrast/common": "1.27.0",
21
- "@contrast/config": "1.37.0",
22
- "@contrast/dep-hooks": "1.11.0",
20
+ "@contrast/common": "1.29.0",
21
+ "@contrast/config": "1.39.0",
22
+ "@contrast/dep-hooks": "1.13.0",
23
23
  "@contrast/fn-inspect": "^4.3.0",
24
- "@contrast/logger": "1.15.0",
25
- "@contrast/patcher": "1.14.0",
26
- "@contrast/scopes": "1.12.0",
27
- "semver": "^7.6.0"
24
+ "@contrast/logger": "1.17.0",
25
+ "@contrast/patcher": "1.16.0",
26
+ "@contrast/scopes": "1.14.0",
27
+ "semver": "^7.6.0",
28
+ "path-to-regexp": "^8.2.0"
28
29
  }
29
30
  }