@contrast/route-coverage 1.29.0 → 1.31.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
@@ -17,6 +17,7 @@
17
17
 
18
18
  const { callChildComponentMethodsSync, Event } = require('@contrast/common');
19
19
  const { routeIdentifier } = require('./utils/route-info');
20
+ const NormalizedUrlMapper = require('./normalized-url-mapper');
20
21
 
21
22
  /**
22
23
  * @param {import('.').Core & {
@@ -33,19 +34,24 @@ module.exports = function init(core) {
33
34
  /** @type {Map<string, import('@contrast/common').RouteInfo>} */
34
35
  const routeInfo = new Map();
35
36
  const recentlyObserved = new Set();
36
-
37
37
  const routeQueue = new Map();
38
+
38
39
  const routeCoverage = core.routeCoverage = {
40
+ _normalizedUrlMapper: new NormalizedUrlMapper(),
41
+
42
+ uriPathToNormalizedUrl(uriPath) {
43
+ return this._normalizedUrlMapper.map(uriPath);
44
+ },
45
+
39
46
  discover(info) {
40
47
  logger.trace({ info }, 'Discovered new route:');
41
-
42
48
  routeInfo.set(routeIdentifier(info), info);
49
+ this._normalizedUrlMapper.handleDiscover(info);
43
50
  },
44
51
 
45
52
  discoveryFinished() {
46
53
  const routes = Array.from(routeInfo.values());
47
54
  messages.emit(Event.ROUTE_COVERAGE_DISCOVERY_FINISHED, routes);
48
-
49
55
  this.discover = this.queue;
50
56
  this.discoveryFinished = this.queuingFinished;
51
57
  },
@@ -0,0 +1,174 @@
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 {
18
+ get,
19
+ set,
20
+ primordials: { StringPrototypeSubstr, StringPrototypeSplit }
21
+ } = require('@contrast/common');
22
+
23
+ class NormalizedUrlMapper {
24
+ constructor() {
25
+ this._db = {
26
+ // index by static routes e.g.
27
+ // '/' => {}
28
+ // '/home' => {}
29
+ static: new Map(),
30
+ // or segment-count for parameterized routes
31
+ // '2' => { '/users/:id', '/profile/:id' }
32
+ // '3' => { '/users/:id/orders', '/profile/:id/address' }
33
+ parameterized: {},
34
+ // regex is used instead of string path
35
+ regex: new Map(),
36
+ // dynamic beyond parameterization e.g. regex syntax, '/abc+', '/abc*', '/[v1|v1.1]/users'.
37
+ // we could key off of segment length here like we do above.
38
+ dynamic: {},
39
+ };
40
+ this._defaultDynamicRe = /\(|\?|\||\[|\*|\+|\{/;
41
+ this._hapiDynamicRe = /\(|\?|\||\[|\*|\+/;
42
+ }
43
+
44
+ _getPathSegments(uriPath) {
45
+ if (!uriPath?.length) return null;
46
+ return StringPrototypeSplit.call(StringPrototypeSubstr.call(uriPath, 1), '/');
47
+ }
48
+
49
+ _looksDynamic(segment, framework) {
50
+ if (framework == 'hapi') {
51
+ return this._hapiDynamicRe.test(segment);
52
+ }
53
+ // app.get('/::fiddle') will handle requests to '/:fiddle'
54
+ if (segment.includes('::')) return true;
55
+ return this._defaultDynamicRe.test(segment);
56
+ }
57
+
58
+ _looksParamaterized(segment, framework) {
59
+ if (framework == 'hapi') {
60
+ return segment.startsWith('{');
61
+ }
62
+
63
+ // no point iterating last character
64
+ for (let idx = 0; idx < segment.length - 1; idx++) {
65
+ // '/foo::bar' is not parameterized (fastify) - maps to '/foo:bar'
66
+ // make sure ':' appears by itself
67
+ if (
68
+ segment[idx] == ':' &&
69
+ segment[idx - 1] != ':' &&
70
+ segment[idx + 1] != ':'
71
+ ) {
72
+ return true;
73
+ }
74
+ }
75
+
76
+ return false;
77
+ }
78
+
79
+ /**
80
+ * Tries to map a raw URL path to the "normalized" value used to declare the route.
81
+ * Aims to be as performant as possible.
82
+ * @param {string} uriPath path without queries
83
+ * @returns {}
84
+ */
85
+ _query(uriPath) {
86
+ // check if static first
87
+ if (this._db.static.has(uriPath)) return this._db.static.get(uriPath);
88
+
89
+ // else check dynamic routes by segment count
90
+ const _segments = this._getPathSegments(uriPath);
91
+ const entriesToCheck = this._db.parameterized[_segments.length];
92
+
93
+ if (entriesToCheck) {
94
+ for (const [, route] of entriesToCheck.entries()) {
95
+ const { segments } = route;
96
+ if (segments.every((seg, idx) => !seg || seg == _segments[idx])) {
97
+ return route;
98
+ }
99
+ }
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Registers route discovery meta into _db.
107
+ * @param {import('@contrast/common').RouteInfo} routeInfo
108
+ */
109
+ handleDiscover(routeInfo) {
110
+ if (!routeInfo.normalizedUrl) {
111
+ // todo should log but don't have core
112
+ return;
113
+ }
114
+
115
+ let segments;
116
+ let dbIndex;
117
+ let isParameterized = false;
118
+ let isDynamic = false;
119
+ const isRegExp = routeInfo.normalizedUrl?.constructor?.name == 'RegExp';
120
+
121
+ if (!isRegExp) {
122
+ segments = this._getPathSegments(routeInfo.normalizedUrl);
123
+
124
+ for (let i = 0; i < segments.length; i++) {
125
+ const segment = segments[i];
126
+ // these heuristic checks may not scale for all frameworks. we may want to dispatch to
127
+ // a framework-specific strategy that can specialize in mapping to the _db entry index.
128
+ if (this._looksDynamic(segment, routeInfo.framework)) {
129
+ isDynamic = true;
130
+ } else if (this._looksParamaterized(segment, routeInfo.framework)) {
131
+ isParameterized = true;
132
+ // replace segments to check with undefined if parameterized
133
+ segments[i] = undefined;
134
+ }
135
+ }
136
+ }
137
+
138
+ const meta = {
139
+ normalizedUrl: routeInfo.normalizedUrl,
140
+ signature: routeInfo.signature,
141
+ };
142
+
143
+ if (isDynamic) {
144
+ dbIndex = `dynamic.${segments.length}`;
145
+ } else if (isParameterized) {
146
+ // can be both dynamic and parameterized, e.g. '/:file{.:ext}', '/api/(v1|v2)/:user'
147
+ // but that's not what we want in this case
148
+ dbIndex = `parameterized.${segments.length}`;
149
+ meta.segments = segments;
150
+ } else if (isRegExp) {
151
+ dbIndex = 'regex';
152
+ } else {
153
+ dbIndex = 'static';
154
+ }
155
+
156
+ // ensure appropriate collection is there
157
+ if (!get(this._db, dbIndex)) set(this._db, dbIndex, new Map());
158
+ get(this._db, dbIndex).set(routeInfo.normalizedUrl, meta);
159
+ }
160
+
161
+ /**
162
+ * Returns the normalizedUrl associated with raw uriPath. If the
163
+ * value can't be determined from internal _db, this return null.
164
+ * @param {string} uriPath
165
+ * @returns {string}
166
+ */
167
+ map(uriPath) {
168
+ /** @type import('@contrast/common').RouteInfo */
169
+ const record = this._query(uriPath);
170
+ return record ? record.normalizedUrl : null;
171
+ }
172
+ }
173
+
174
+ module.exports = NormalizedUrlMapper;
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const frameworkRoutingData = require('@contrast/test/data/framework-routing-data');
5
+ const NormalizedUrlMapper = require('./normalized-url-mapper');
6
+
7
+ describe('route-coverage NormalizedUrlMapper', function() {
8
+ const testData = Object.values(frameworkRoutingData()).flatMap((a) => a);
9
+
10
+ describe('.map', function() {
11
+ it('returns null if no discovery events were handled', function() {
12
+ const mapper = new NormalizedUrlMapper();
13
+ [
14
+ '/user/1',
15
+ '/user/2',
16
+ '/user/3',
17
+ '/user/4',
18
+ '/user/1/cart',
19
+ '/user/2/cart',
20
+ '/user/3/cart',
21
+ '/user/4/cart',
22
+ '/products/all',
23
+ '/products/all',
24
+ '/products/1',
25
+ '/products/2',
26
+ '/products/3',
27
+ '/products/4',
28
+ ].forEach((uriPath) => {
29
+ expect(mapper.map(uriPath)).to.be.null;
30
+ });
31
+ });
32
+
33
+ it('returns normalizedUrl mapped from generic uriPath', function() {
34
+ const mapper = new NormalizedUrlMapper();
35
+ testData.forEach((d) => mapper.handleDiscover(d.routeInfo));
36
+ testData.forEach((td) => {
37
+ const { routeInfo, paths, hasMapping } = td;
38
+
39
+ for (const uriPath of paths) {
40
+ // todo - dynamic and regex paths
41
+ if (hasMapping === false) {
42
+ expect(mapper.map(uriPath)).to.be.null;
43
+ } else {
44
+ expect(mapper.map(uriPath)).to.equal(routeInfo.normalizedUrl);
45
+ }
46
+ }
47
+ });
48
+ });
49
+ });
50
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contrast/route-coverage",
3
- "version": "1.29.0",
3
+ "version": "1.31.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)",
@@ -18,12 +18,12 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@contrast/common": "1.26.0",
21
- "@contrast/config": "1.35.0",
22
- "@contrast/dep-hooks": "1.8.0",
21
+ "@contrast/config": "1.36.0",
22
+ "@contrast/dep-hooks": "1.10.0",
23
23
  "@contrast/fn-inspect": "^4.3.0",
24
- "@contrast/logger": "1.13.0",
25
- "@contrast/patcher": "1.12.0",
26
- "@contrast/scopes": "1.9.0",
24
+ "@contrast/logger": "1.14.0",
25
+ "@contrast/patcher": "1.13.0",
26
+ "@contrast/scopes": "1.11.0",
27
27
  "semver": "^7.6.0"
28
28
  }
29
29
  }