@contrast/sources 1.1.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 +109 -0
- package/lib/index.test.js +120 -0
- package/lib/normalized-uri-mapper.js +181 -0
- package/lib/normalized-uri-mapper.test.js +59 -0
- package/lib/req-data.js +14 -0
- package/lib/source-info.js +183 -0
- package/lib/source-info.test.js +68 -0
- package/package.json +16 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const { EventEmitter } = require('events');
|
|
19
|
+
const onFinished = require('on-finished');
|
|
20
|
+
const { set, Event } = require('@contrast/common');
|
|
21
|
+
const { Core } = require('@contrast/core/lib/ioc/core');
|
|
22
|
+
const NormalizedUriMapper = require('./normalized-uri-mapper');
|
|
23
|
+
const { HttpSourceInfo } = require('./source-info');
|
|
24
|
+
|
|
25
|
+
const componentName = 'sources';
|
|
26
|
+
|
|
27
|
+
module.exports = Core.makeComponent({
|
|
28
|
+
name: componentName,
|
|
29
|
+
factory: (core) => new Sources(core),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
class Sources {
|
|
33
|
+
constructor(core) {
|
|
34
|
+
// decorate
|
|
35
|
+
set(core, componentName, this);
|
|
36
|
+
|
|
37
|
+
this.core = core;
|
|
38
|
+
this._hooks = new EventEmitter();
|
|
39
|
+
this._normalizedUriMapper = new NormalizedUriMapper(core);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
addHook(name, handler) {
|
|
43
|
+
// only this one hook atm
|
|
44
|
+
if (name === 'onSource') this._hooks.on(name, handler);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
aroundHook(serverType) {
|
|
48
|
+
const { _hooks, _normalizedUriMapper, core } = this;
|
|
49
|
+
|
|
50
|
+
return function (next, data) {
|
|
51
|
+
const { args: [event, req, res] } = data;
|
|
52
|
+
|
|
53
|
+
if (event !== 'request') {
|
|
54
|
+
if (event === 'listening') {
|
|
55
|
+
// take a snapshot of Perf.all at this point. this will get logged
|
|
56
|
+
// at some point on the perf interval timer.
|
|
57
|
+
core.Perf.mark('listening');
|
|
58
|
+
core.messages.emit(Event.SERVER_LISTENING, { type: serverType, server: data.obj });
|
|
59
|
+
}
|
|
60
|
+
return next();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
core.Perf.requestCount += 1;
|
|
64
|
+
|
|
65
|
+
const sourceInfo = new HttpSourceInfo({
|
|
66
|
+
serverType,
|
|
67
|
+
raw: req,
|
|
68
|
+
normalizedUriMapper: _normalizedUriMapper,
|
|
69
|
+
});
|
|
70
|
+
const store = { sourceInfo };
|
|
71
|
+
|
|
72
|
+
onFinished(res, (/* err, req */) => {
|
|
73
|
+
core.messages.emit(Event.RESPONSE_FINISH, store);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return core.scopes.sources.run(store, () => {
|
|
77
|
+
if (_hooks._events.onSource) {
|
|
78
|
+
_hooks.emit('onSource', {
|
|
79
|
+
// future: non-http sources will have their own type
|
|
80
|
+
sourceType: 'HTTP',
|
|
81
|
+
store,
|
|
82
|
+
incomingMessage: req,
|
|
83
|
+
serverResponse: res,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return next();
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
install() {
|
|
93
|
+
const { instrumentation, sources } = this.core;
|
|
94
|
+
|
|
95
|
+
['http', 'https', 'spdy', 'http2'].forEach((moduleName) => {
|
|
96
|
+
instrumentation.instrument({
|
|
97
|
+
moduleName,
|
|
98
|
+
patchObjects: [{
|
|
99
|
+
name: 'Server.prototype',
|
|
100
|
+
methods: ['emit'],
|
|
101
|
+
patchType: 'sources',
|
|
102
|
+
around: sources.aroundHook(moduleName)
|
|
103
|
+
}]
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports.HttpSourceInfo = HttpSourceInfo;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require('events');
|
|
4
|
+
const { expect } = require('chai');
|
|
5
|
+
const sinon = require('sinon');
|
|
6
|
+
const { initProtectFixture } = require('@contrast/test/fixtures');
|
|
7
|
+
const mocks = require('@contrast/test/mocks');
|
|
8
|
+
const proxyquire = require('proxyquire');
|
|
9
|
+
|
|
10
|
+
describe('agentify sources', function () {
|
|
11
|
+
[
|
|
12
|
+
{
|
|
13
|
+
name: 'http',
|
|
14
|
+
expected: {
|
|
15
|
+
port: 8080,
|
|
16
|
+
protocol: 'http',
|
|
17
|
+
serverType: 'http',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'https',
|
|
22
|
+
expected: {
|
|
23
|
+
port: 8080,
|
|
24
|
+
protocol: 'https',
|
|
25
|
+
serverType: 'https',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'spdy',
|
|
30
|
+
expected: {
|
|
31
|
+
port: 8080,
|
|
32
|
+
protocol: 'https',
|
|
33
|
+
serverType: 'spdy',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'http2',
|
|
38
|
+
method: 'createServer',
|
|
39
|
+
expected: {
|
|
40
|
+
port: 8080,
|
|
41
|
+
protocol: 'https',
|
|
42
|
+
serverType: 'spdy',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'http2',
|
|
47
|
+
method: 'createSecureServer',
|
|
48
|
+
expected: {
|
|
49
|
+
port: 8080,
|
|
50
|
+
protocol: 'https',
|
|
51
|
+
serverType: 'spdy',
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
].forEach(({ name, method, expected }) => {
|
|
55
|
+
describe(`${name} sources using ${method || 'Server'}()`, function () {
|
|
56
|
+
let core, api, ServerMock, reqMock, resMock, onFinishedMock;
|
|
57
|
+
|
|
58
|
+
beforeEach(function () {
|
|
59
|
+
({ core } = initProtectFixture());
|
|
60
|
+
ServerMock = function ServerMock() {
|
|
61
|
+
this.e = new EventEmitter();
|
|
62
|
+
};
|
|
63
|
+
ServerMock.prototype.emit = function (...args) {
|
|
64
|
+
this.e.emit(...args);
|
|
65
|
+
};
|
|
66
|
+
ServerMock.prototype.on = function (...args) {
|
|
67
|
+
this.e.on(...args);
|
|
68
|
+
};
|
|
69
|
+
api = {
|
|
70
|
+
Server: ServerMock,
|
|
71
|
+
createServer() {
|
|
72
|
+
return new ServerMock();
|
|
73
|
+
},
|
|
74
|
+
createSecureServer() {
|
|
75
|
+
return new ServerMock();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
reqMock = mocks.incomingMessage();
|
|
79
|
+
// resMock = new EventEmitter();
|
|
80
|
+
onFinishedMock = sinon.stub();
|
|
81
|
+
|
|
82
|
+
core.depHooks.resolve.withArgs(sinon.match({ name: 'http' })).yields(api);
|
|
83
|
+
proxyquire('.', {
|
|
84
|
+
'on-finished': onFinishedMock,
|
|
85
|
+
})(core).install();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('"request" events run in scope with correct sourceInfo', function () {
|
|
89
|
+
const server = method ? api[method]() : new ServerMock();
|
|
90
|
+
let store;
|
|
91
|
+
|
|
92
|
+
server.on('request', function () {
|
|
93
|
+
store = core.scopes.sources.getStore();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
server.emit('request', reqMock, resMock);
|
|
97
|
+
|
|
98
|
+
expect(store.sourceInfo).to.deep.include({
|
|
99
|
+
port: 8080,
|
|
100
|
+
protocol: 'http',
|
|
101
|
+
serverType: 'http',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(onFinishedMock).to.have.been.calledWith(resMock);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('non-"request" events do not run in scope', function () {
|
|
108
|
+
const server = method ? api[method]() : new ServerMock();
|
|
109
|
+
let store;
|
|
110
|
+
|
|
111
|
+
server.on('foo', function () {
|
|
112
|
+
store = core.scopes.sources.getStore();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
server.emit('foo', reqMock, resMock);
|
|
116
|
+
expect(store).to.be.undefined;
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
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 {
|
|
18
|
+
Event,
|
|
19
|
+
get,
|
|
20
|
+
set,
|
|
21
|
+
primordials: { StringPrototypeSubstr, StringPrototypeSplit }
|
|
22
|
+
} = require('@contrast/common');
|
|
23
|
+
|
|
24
|
+
class NormalizedUriMapper {
|
|
25
|
+
constructor(core) {
|
|
26
|
+
this._db = {
|
|
27
|
+
// index by static routes e.g.
|
|
28
|
+
// '/' => {}
|
|
29
|
+
// '/home' => {}
|
|
30
|
+
static: new Map(),
|
|
31
|
+
// or segment-count for parameterized routes
|
|
32
|
+
// '2' => { '/users/:id', '/profile/:id' }
|
|
33
|
+
// '3' => { '/users/:id/orders', '/profile/:id/address' }
|
|
34
|
+
parameterized: {},
|
|
35
|
+
// regex is used instead of string path
|
|
36
|
+
regex: new Map(),
|
|
37
|
+
// dynamic beyond parameterization e.g. regex syntax, '/abc+', '/abc*', '/[v1|v1.1]/users'.
|
|
38
|
+
// we could key off of segment length here like we do above.
|
|
39
|
+
dynamic: {},
|
|
40
|
+
};
|
|
41
|
+
this._defaultDynamicRe = /\(|\?|\||\[|\*|\+|\{/;
|
|
42
|
+
this._hapiDynamicRe = /\(|\?|\||\[|\*|\+/;
|
|
43
|
+
|
|
44
|
+
core.messages.on(Event.ROUTE_COVERAGE_DISCOVERY_FINISHED, (routes) => {
|
|
45
|
+
for (const routeInfo of routes) {
|
|
46
|
+
this.handleDiscover(routeInfo);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_getPathSegments(uriPath) {
|
|
52
|
+
if (!uriPath?.length) return null;
|
|
53
|
+
return StringPrototypeSplit.call(StringPrototypeSubstr.call(uriPath, 1), '/');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_looksDynamic(segment, framework) {
|
|
57
|
+
if (framework == 'hapi') {
|
|
58
|
+
return this._hapiDynamicRe.test(segment);
|
|
59
|
+
}
|
|
60
|
+
// app.get('/::fiddle') will handle requests to '/:fiddle'
|
|
61
|
+
if (segment.includes('::')) return true;
|
|
62
|
+
return this._defaultDynamicRe.test(segment);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_looksParamaterized(segment, framework) {
|
|
66
|
+
if (framework == 'hapi') {
|
|
67
|
+
return segment.startsWith('{');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// no point iterating last character
|
|
71
|
+
for (let idx = 0; idx < segment.length - 1; idx++) {
|
|
72
|
+
// '/foo::bar' is not parameterized (fastify) - maps to '/foo:bar'
|
|
73
|
+
// make sure ':' appears by itself
|
|
74
|
+
if (
|
|
75
|
+
segment[idx] == ':' &&
|
|
76
|
+
segment[idx - 1] != ':' &&
|
|
77
|
+
segment[idx + 1] != ':'
|
|
78
|
+
) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Tries to map a raw URL path to the "normalized" value used to declare the route.
|
|
88
|
+
* Aims to be as performant as possible.
|
|
89
|
+
* @param {string} uriPath path without queries
|
|
90
|
+
* @returns {}
|
|
91
|
+
*/
|
|
92
|
+
_query(uriPath) {
|
|
93
|
+
// check if static first
|
|
94
|
+
if (this._db.static.has(uriPath)) return this._db.static.get(uriPath);
|
|
95
|
+
|
|
96
|
+
// else check dynamic routes by segment count
|
|
97
|
+
const _segments = this._getPathSegments(uriPath);
|
|
98
|
+
const entriesToCheck = this._db.parameterized[_segments.length];
|
|
99
|
+
|
|
100
|
+
if (entriesToCheck) {
|
|
101
|
+
for (const [, route] of entriesToCheck.entries()) {
|
|
102
|
+
const { segments } = route;
|
|
103
|
+
if (segments.every((seg, idx) => !seg || seg == _segments[idx])) {
|
|
104
|
+
return route;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Registers route discovery meta into _db.
|
|
114
|
+
* @param {import('@contrast/common').RouteInfo} routeInfo
|
|
115
|
+
*/
|
|
116
|
+
handleDiscover(routeInfo) {
|
|
117
|
+
if (!routeInfo.normalizedUrl) {
|
|
118
|
+
// todo should log but don't have core
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let segments;
|
|
123
|
+
let dbIndex;
|
|
124
|
+
let isParameterized = false;
|
|
125
|
+
let isDynamic = false;
|
|
126
|
+
const isRegExp = routeInfo.normalizedUrl?.constructor?.name == 'RegExp';
|
|
127
|
+
|
|
128
|
+
if (!isRegExp) {
|
|
129
|
+
segments = this._getPathSegments(routeInfo.normalizedUrl);
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < segments.length; i++) {
|
|
132
|
+
const segment = segments[i];
|
|
133
|
+
// these heuristic checks may not scale for all frameworks. we may want to dispatch to
|
|
134
|
+
// a framework-specific strategy that can specialize in mapping to the _db entry index.
|
|
135
|
+
if (this._looksDynamic(segment, routeInfo.framework)) {
|
|
136
|
+
isDynamic = true;
|
|
137
|
+
} else if (this._looksParamaterized(segment, routeInfo.framework)) {
|
|
138
|
+
isParameterized = true;
|
|
139
|
+
// replace segments to check with undefined if parameterized
|
|
140
|
+
segments[i] = undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const meta = {
|
|
146
|
+
normalizedUrl: routeInfo.normalizedUrl,
|
|
147
|
+
signature: routeInfo.signature,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (isDynamic) {
|
|
151
|
+
dbIndex = `dynamic.${segments.length}`;
|
|
152
|
+
} else if (isParameterized) {
|
|
153
|
+
// can be both dynamic and parameterized, e.g. '/:file{.:ext}', '/api/(v1|v2)/:user'
|
|
154
|
+
// but that's not what we want in this case
|
|
155
|
+
dbIndex = `parameterized.${segments.length}`;
|
|
156
|
+
meta.segments = segments;
|
|
157
|
+
} else if (isRegExp) {
|
|
158
|
+
dbIndex = 'regex';
|
|
159
|
+
} else {
|
|
160
|
+
dbIndex = 'static';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ensure appropriate collection is there
|
|
164
|
+
if (!get(this._db, dbIndex)) set(this._db, dbIndex, new Map());
|
|
165
|
+
get(this._db, dbIndex).set(routeInfo.normalizedUrl, meta);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Returns the normalizedUrl associated with raw uriPath. If the
|
|
170
|
+
* value can't be determined from internal _db, this return null.
|
|
171
|
+
* @param {string} uriPath
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
map(uriPath) {
|
|
175
|
+
/** @type import('@contrast/common').RouteInfo */
|
|
176
|
+
const record = this._query(uriPath);
|
|
177
|
+
return record ? record.normalizedUrl : null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = NormalizedUriMapper;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require('node:events');
|
|
4
|
+
const { expect } = require('chai');
|
|
5
|
+
const { Event } = require('@contrast/common');
|
|
6
|
+
const frameworkRoutingData = require('@contrast/test/data/framework-routing-data');
|
|
7
|
+
const NormalizedUrlMapper = require('./normalized-uri-mapper');
|
|
8
|
+
|
|
9
|
+
describe('route-coverage NormalizedUrlMapper', function() {
|
|
10
|
+
const testData = Object.values(frameworkRoutingData()).flatMap((a) => a);
|
|
11
|
+
let mapper;
|
|
12
|
+
let messages;
|
|
13
|
+
|
|
14
|
+
this.beforeEach(function() {
|
|
15
|
+
messages = new EventEmitter();
|
|
16
|
+
mapper = new NormalizedUrlMapper({
|
|
17
|
+
messages,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('.map', function() {
|
|
22
|
+
it('returns null if no discovery events were handled', function() {
|
|
23
|
+
[
|
|
24
|
+
'/user/1',
|
|
25
|
+
'/user/2',
|
|
26
|
+
'/user/3',
|
|
27
|
+
'/user/4',
|
|
28
|
+
'/user/1/cart',
|
|
29
|
+
'/user/2/cart',
|
|
30
|
+
'/user/3/cart',
|
|
31
|
+
'/user/4/cart',
|
|
32
|
+
'/products/all',
|
|
33
|
+
'/products/all',
|
|
34
|
+
'/products/1',
|
|
35
|
+
'/products/2',
|
|
36
|
+
'/products/3',
|
|
37
|
+
'/products/4',
|
|
38
|
+
].forEach((uriPath) => {
|
|
39
|
+
expect(mapper.map(uriPath)).to.be.null;
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns normalizedUrl mapped from generic uriPath', function() {
|
|
44
|
+
messages.emit(Event.ROUTE_COVERAGE_DISCOVERY_FINISHED, testData.map((d) => d.routeInfo));
|
|
45
|
+
testData.forEach((td) => {
|
|
46
|
+
const { routeInfo, paths, hasMapping } = td;
|
|
47
|
+
|
|
48
|
+
for (const uriPath of paths) {
|
|
49
|
+
// todo - dynamic and regex paths
|
|
50
|
+
if (hasMapping === false) {
|
|
51
|
+
expect(mapper.map(uriPath)).to.be.null;
|
|
52
|
+
} else {
|
|
53
|
+
expect(mapper.map(uriPath)).to.equal(routeInfo.normalizedUrl);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
package/lib/req-data.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
*/
|
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
primordials: {
|
|
20
|
+
RegExpPrototypeExec,
|
|
21
|
+
StringPrototypeReplace,
|
|
22
|
+
StringPrototypeSlice,
|
|
23
|
+
StringPrototypeSplit,
|
|
24
|
+
StringPrototypeToLowerCase,
|
|
25
|
+
}
|
|
26
|
+
} = require('@contrast/common');
|
|
27
|
+
|
|
28
|
+
const NormalizationPatterns = {
|
|
29
|
+
UUID: [/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, '{uuid}'],
|
|
30
|
+
NUMERICAL: [/^\d+$/i, '{n}'],
|
|
31
|
+
HASH: [/([a-fA-F0-9]{2}){16,}/, '{hash}'],
|
|
32
|
+
// we can extend these as needed
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
class HttpSourceInfo {
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} param
|
|
38
|
+
* @param {string} param.serverType
|
|
39
|
+
* @param {any} param.normalizedUriMapper
|
|
40
|
+
* @param {IncomingMessage} param.raw
|
|
41
|
+
*/
|
|
42
|
+
constructor({
|
|
43
|
+
serverType,
|
|
44
|
+
normalizedUriMapper,
|
|
45
|
+
raw,
|
|
46
|
+
}) {
|
|
47
|
+
this._headerLookupCache = {};
|
|
48
|
+
this._normalizedUri = null;
|
|
49
|
+
this._normalizedUriMasked = null;
|
|
50
|
+
this._normalizedUriSegments = [];
|
|
51
|
+
this._normalizedUriMapper = normalizedUriMapper;
|
|
52
|
+
//
|
|
53
|
+
this.httpVersion = raw.httpVersion;
|
|
54
|
+
this.ip = raw.socket.remoteAddress ? StringPrototypeReplace.call(raw.socket.remoteAddress, /::ffff:/, '') : undefined;
|
|
55
|
+
this.port = raw.socket.address?.()?.port || 0;
|
|
56
|
+
this.protocol = serverType == 'http' ? 'http' : 'https'; // todo
|
|
57
|
+
this.serverType = serverType;
|
|
58
|
+
this.time = Date.now();
|
|
59
|
+
this.method = StringPrototypeToLowerCase.call(raw.method);
|
|
60
|
+
this.rawHeaders = [];
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < raw.rawHeaders.length; i += 2) {
|
|
63
|
+
const iNext = i + 1;
|
|
64
|
+
const headerName = StringPrototypeToLowerCase.call(raw.rawHeaders[i]);
|
|
65
|
+
|
|
66
|
+
headerName == 'content-type' && (this.contentType = raw.rawHeaders[iNext]);
|
|
67
|
+
|
|
68
|
+
this.rawHeaders[i] = headerName;
|
|
69
|
+
this.rawHeaders[iNext] = headerName == 'content-type' ?
|
|
70
|
+
StringPrototypeToLowerCase.call(raw.rawHeaders[iNext]) :
|
|
71
|
+
raw.rawHeaders[iNext];
|
|
72
|
+
|
|
73
|
+
if (
|
|
74
|
+
headerName == 'upgrade' &&
|
|
75
|
+
StringPrototypeToLowerCase.call(this.rawHeaders[iNext]) == 'websocket'
|
|
76
|
+
) {
|
|
77
|
+
this.protocol = 'ws';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const idx = raw.url.indexOf('?');
|
|
82
|
+
if (idx >= 0) {
|
|
83
|
+
this.uriPath = StringPrototypeSlice.call(raw.url, 0, idx);
|
|
84
|
+
this.queries = StringPrototypeSlice.call(raw.url, idx + 1);
|
|
85
|
+
} else {
|
|
86
|
+
this.uriPath = raw.url;
|
|
87
|
+
this.queries = '';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Looks through rawHeaders to find it. Caches results to avoid subsequent lookups.
|
|
93
|
+
* @param {string} name needs to be lowercase
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
getHeader(name) {
|
|
97
|
+
if (name in this._headerLookupCache) return this._headerLookupCache[name];
|
|
98
|
+
|
|
99
|
+
for (let i = 0; i < this.rawHeaders.length; i += 2) {
|
|
100
|
+
if (name == this.rawHeaders[i]) {
|
|
101
|
+
return (this._headerLookupCache[name] = this.rawHeaders[i + 1]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The normalizedUri is a computed field
|
|
110
|
+
*/
|
|
111
|
+
get normalizedUri() {
|
|
112
|
+
const r = Reflect.get(this, '_normalizedUri');
|
|
113
|
+
if (!r) this.generateNormalizedUri();
|
|
114
|
+
return Reflect.get(this, '_normalizedUri');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
set normalizedUri(value) {
|
|
118
|
+
Reflect.set(this, '_normalizedUri', value);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
generateNormalizedUri() {
|
|
122
|
+
let normalizedUri;
|
|
123
|
+
|
|
124
|
+
// leverage route discovery data to try to find route template
|
|
125
|
+
normalizedUri = this._normalizedUriMapper?.map?.(this.uriPath);
|
|
126
|
+
|
|
127
|
+
if (normalizedUri) {
|
|
128
|
+
// if we can map to the template we can use it for masked value too
|
|
129
|
+
this._normalizedUri = normalizedUri;
|
|
130
|
+
this._normalizedUriMasked = normalizedUri;
|
|
131
|
+
} else {
|
|
132
|
+
// if we can't find the template then test against common
|
|
133
|
+
// regular expressions to normalize/mask each segment per spec
|
|
134
|
+
const arr = StringPrototypeSplit.call(this.uriPath, '/');
|
|
135
|
+
let maskedUri = '';
|
|
136
|
+
|
|
137
|
+
normalizedUri = '';
|
|
138
|
+
|
|
139
|
+
for (let idx = 1; idx < arr.length; idx++) {
|
|
140
|
+
let normalSeg = arr[idx];
|
|
141
|
+
let maskedSeg = normalSeg;
|
|
142
|
+
|
|
143
|
+
let isPattern;
|
|
144
|
+
|
|
145
|
+
for (const [rx, substitution] of Object.values(NormalizationPatterns)) {
|
|
146
|
+
isPattern = !!RegExpPrototypeExec.call(rx, normalSeg);
|
|
147
|
+
if (isPattern) {
|
|
148
|
+
normalSeg = maskedSeg = substitution;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!isPattern) {
|
|
154
|
+
if (idx > 1) {
|
|
155
|
+
maskedSeg = `${StringPrototypeSlice.call(normalSeg, 0, 2)}xxxx`;
|
|
156
|
+
} else {
|
|
157
|
+
// no masking/normalizing for first seg (called "context" in spec)
|
|
158
|
+
maskedSeg = arr[idx];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
maskedUri += `/${maskedSeg}`;
|
|
163
|
+
normalizedUri += `/${normalSeg}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this._normalizedUri = normalizedUri;
|
|
167
|
+
this._normalizedUriMasked = maskedUri;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
get normalizedUriMasked() {
|
|
172
|
+
const r = Reflect.get(this, '_normalizedUriMasked');
|
|
173
|
+
if (!r) this.generateNormalizedUri();
|
|
174
|
+
return Reflect.get(this, '_normalizedUriMasked');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
set normalizedUriMasked(value) {
|
|
178
|
+
Reflect.set(this, '_normalizedUriMasked', value);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports.HttpSourceInfo = HttpSourceInfo;
|
|
183
|
+
module.exports.NORMALIZE_PATTERNS = NormalizationPatterns;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { expect } = require('chai');
|
|
4
|
+
const mocks = require('@contrast/test/mocks');
|
|
5
|
+
|
|
6
|
+
const { HttpSourceInfo } = require('./source-info');
|
|
7
|
+
|
|
8
|
+
describe('sources SourceInfo', function () {
|
|
9
|
+
[
|
|
10
|
+
{
|
|
11
|
+
uriPath: '/index',
|
|
12
|
+
expectedNormalized: '/index',
|
|
13
|
+
expectedNormalizedMasked: '/index',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
uriPath: '/orders/abc-123',
|
|
17
|
+
expectedNormalized: '/orders/abc-123',
|
|
18
|
+
expectedNormalizedMasked: '/orders/abxxxx',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
uriPath: '/orders/abc-123/item/123',
|
|
22
|
+
expectedNormalized: '/orders/abc-123/item/{n}',
|
|
23
|
+
expectedNormalizedMasked: '/orders/abxxxx/itxxxx/{n}',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
uriPath: '/orders/1234',
|
|
27
|
+
expectedNormalized: '/orders/{n}',
|
|
28
|
+
expectedNormalizedMasked: '/orders/{n}',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
uriPath: '/orders/93a0862a-09be-4292-bc6a-50d38dded69c',
|
|
32
|
+
expectedNormalized: '/orders/{uuid}',
|
|
33
|
+
expectedNormalizedMasked: '/orders/{uuid}',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
uriPath: '/orders/0f1e2d3c4b5a6f7e8d9c0b1a2f3e4d5c',
|
|
37
|
+
expectedNormalized: '/orders/{hash}',
|
|
38
|
+
expectedNormalizedMasked: '/orders/{hash}',
|
|
39
|
+
},
|
|
40
|
+
].forEach(({
|
|
41
|
+
uriPath,
|
|
42
|
+
expectedNormalized,
|
|
43
|
+
expectedNormalizedMasked,
|
|
44
|
+
}) => {
|
|
45
|
+
it(`normalizedUri and normalizedUriMasked are built correctly: ${uriPath}`, function () {
|
|
46
|
+
const req = mocks.incomingMessage();
|
|
47
|
+
req.url = `${uriPath}?${req.queries}`;
|
|
48
|
+
|
|
49
|
+
const info = new HttpSourceInfo({
|
|
50
|
+
serverType: 'http',
|
|
51
|
+
raw: req
|
|
52
|
+
});
|
|
53
|
+
expect(info.normalizedUri).to.equal(expectedNormalized);
|
|
54
|
+
expect(info.normalizedUriMasked).to.equal(expectedNormalizedMasked);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('trims IPv6 prefixes from mapped IPv4 addresses', function () {
|
|
59
|
+
const req = mocks.incomingMessage();
|
|
60
|
+
req.socket.remoteAddress = '::ffff:127.0.0.1';
|
|
61
|
+
|
|
62
|
+
const info = new HttpSourceInfo({
|
|
63
|
+
serverType: 'http',
|
|
64
|
+
raw: req
|
|
65
|
+
});
|
|
66
|
+
expect(info.ip).to.equal('127.0.0.1');
|
|
67
|
+
});
|
|
68
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@contrast/sources",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Instruments to have incoming messages run in async-local request scope.",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
|
+
},
|
|
9
|
+
"author": "",
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@contrast/common": "1.35.0",
|
|
13
|
+
"@contrast/core": "1.55.0",
|
|
14
|
+
"on-finished": "^2.4.1"
|
|
15
|
+
}
|
|
16
|
+
}
|