@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
|
@@ -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
|
+
});
|
package/lib/install/hapi.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
}
|
package/lib/install/hapi.test.js
CHANGED
|
@@ -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'
|
package/lib/utils/route-info.js
CHANGED
|
@@ -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}',
|
|
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.
|
|
3
|
+
"version": "1.35.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.
|
|
21
|
-
"@contrast/config": "1.
|
|
22
|
-
"@contrast/dep-hooks": "1.
|
|
20
|
+
"@contrast/common": "1.29.0",
|
|
21
|
+
"@contrast/config": "1.40.0",
|
|
22
|
+
"@contrast/dep-hooks": "1.14.0",
|
|
23
23
|
"@contrast/fn-inspect": "^4.3.0",
|
|
24
|
-
"@contrast/logger": "1.
|
|
25
|
-
"@contrast/patcher": "1.
|
|
26
|
-
"@contrast/scopes": "1.
|
|
27
|
-
"semver": "^7.6.0"
|
|
24
|
+
"@contrast/logger": "1.18.0",
|
|
25
|
+
"@contrast/patcher": "1.17.0",
|
|
26
|
+
"@contrast/scopes": "1.15.0",
|
|
27
|
+
"semver": "^7.6.0",
|
|
28
|
+
"path-to-regexp": "^8.2.0"
|
|
28
29
|
}
|
|
29
30
|
}
|