Skip to content

Commit 235128d

Browse files
IlyasShabitlhunter
authored andcommitted
Endpoints discovery for fastify (#6258)
1 parent e83ffa0 commit 235128d

File tree

10 files changed

+595
-4
lines changed

10 files changed

+595
-4
lines changed

docs/test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ tracer.init({
121121
},
122122
apiSecurity: {
123123
enabled: true,
124+
endpointCollectionEnabled: true,
125+
endpointCollectionMessageLimit: 300
124126
},
125127
rasp: {
126128
enabled: true,

index.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,16 @@ declare namespace tracer {
712712
* @default true
713713
*/
714714
enabled?: boolean,
715+
716+
/** Whether to enable endpoint collection for API Security.
717+
* @default true
718+
*/
719+
endpointCollectionEnabled?: boolean,
720+
721+
/** Maximum number of endpoints that can be serialized per message.
722+
* @default 300
723+
*/
724+
endpointCollectionMessageLimit?: number,
715725
},
716726
/**
717727
* Configuration for RASP
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
'use strict'
2+
3+
const { createSandbox, FakeAgent, spawnProc } = require('../helpers')
4+
const path = require('path')
5+
6+
describe('Endpoints collection', () => {
7+
let sandbox, cwd
8+
9+
before(async function () {
10+
this.timeout(process.platform === 'win32' ? 90000 : 30000)
11+
12+
sandbox = await createSandbox(
13+
['fastify'],
14+
false
15+
)
16+
17+
cwd = sandbox.folder
18+
})
19+
20+
after(async function () {
21+
this.timeout(60000)
22+
await sandbox.remove()
23+
})
24+
25+
function getExpectedEndpoints (framework) {
26+
const expectedEndpoints = [
27+
// Basic routes
28+
{ method: 'GET', path: '/users' },
29+
{ method: 'HEAD', path: '/users' },
30+
{ method: 'POST', path: '/users/' },
31+
{ method: 'PUT', path: '/users/:id' },
32+
{ method: 'DELETE', path: '/users/:id' },
33+
{ method: 'PATCH', path: '/users/:id/:name' },
34+
{ method: 'OPTIONS', path: '/users/:id?' },
35+
36+
// Route with regex
37+
{ method: 'DELETE', path: '/regex/:hour(^\\d{2})h:minute(^\\d{2})m' },
38+
39+
// Additional methods
40+
{ method: 'TRACE', path: '/trace-test' },
41+
{ method: 'HEAD', path: '/head-test' },
42+
43+
// Custom method
44+
{ method: 'MKCOL', path: '/example/near/:lat-:lng/radius/:r' },
45+
46+
// Using app.route()
47+
{ method: 'POST', path: '/multi-method' },
48+
{ method: 'PUT', path: '/multi-method' },
49+
{ method: 'PATCH', path: '/multi-method' },
50+
51+
// All supported methods route
52+
{ method: 'GET', path: '/all-methods' },
53+
{ method: 'HEAD', path: '/all-methods' },
54+
{ method: 'TRACE', path: '/all-methods' },
55+
{ method: 'DELETE', path: '/all-methods' },
56+
{ method: 'OPTIONS', path: '/all-methods' },
57+
{ method: 'PATCH', path: '/all-methods' },
58+
{ method: 'PUT', path: '/all-methods' },
59+
{ method: 'POST', path: '/all-methods' },
60+
{ method: 'MKCOL', path: '/all-methods' }, // Added with addHttpMethod
61+
62+
// Nested routes with Router
63+
{ method: 'PUT', path: '/v1/nested/:id' },
64+
65+
// Deeply nested routes
66+
{ method: 'GET', path: '/api/nested' },
67+
{ method: 'HEAD', path: '/api/nested' },
68+
{ method: 'GET', path: '/api/sub/deep' },
69+
{ method: 'HEAD', path: '/api/sub/deep' },
70+
{ method: 'POST', path: '/api/sub/deep/:id' },
71+
72+
// Wildcard routes
73+
{ method: 'GET', path: '/wildcard/*' },
74+
{ method: 'HEAD', path: '/wildcard/*' },
75+
{ method: 'GET', path: '*' },
76+
{ method: 'HEAD', path: '*' },
77+
78+
{ method: 'GET', path: '/later' },
79+
{ method: 'HEAD', path: '/later' },
80+
]
81+
82+
return expectedEndpoints
83+
}
84+
85+
async function runEndpointTest (framework) {
86+
let agent, proc
87+
const appFile = path.join(cwd, 'appsec', 'endpoints-collection', `${framework}.js`)
88+
89+
try {
90+
agent = await new FakeAgent().start()
91+
92+
const expectedEndpoints = getExpectedEndpoints(framework)
93+
const endpointsFound = []
94+
const isFirstFlags = []
95+
96+
const telemetryPromise = agent.assertTelemetryReceived(({ payload }) => {
97+
isFirstFlags.push(Boolean(payload.payload.is_first))
98+
99+
if (payload.payload.endpoints) {
100+
payload.payload.endpoints.forEach(endpoint => {
101+
endpointsFound.push({
102+
method: endpoint.method,
103+
path: endpoint.path,
104+
type: endpoint.type,
105+
operation_name: endpoint.operation_name,
106+
resource_name: endpoint.resource_name
107+
})
108+
})
109+
}
110+
}, 'app-endpoints', 5_000, 4)
111+
112+
proc = await spawnProc(appFile, {
113+
cwd,
114+
env: {
115+
DD_TRACE_AGENT_PORT: agent.port,
116+
DD_TELEMETRY_HEARTBEAT_INTERVAL: 1,
117+
DD_API_SECURITY_ENDPOINT_COLLECTION_MESSAGE_LIMIT: '10'
118+
}
119+
})
120+
121+
await telemetryPromise
122+
123+
const trueCount = isFirstFlags.filter(v => v === true).length
124+
expect(trueCount).to.equal(1)
125+
126+
// Check that all expected endpoints were found
127+
expectedEndpoints.forEach(expected => {
128+
const found = endpointsFound.find(e =>
129+
e.method === expected.method && e.path === expected.path
130+
)
131+
expect(found).to.exist
132+
expect(found.type).to.equal('REST')
133+
expect(found.operation_name).to.equal('http.request')
134+
expect(found.resource_name).to.equal(`${expected.method} ${expected.path}`)
135+
})
136+
137+
// check that no additional endpoints were found
138+
expect(endpointsFound.length).to.equal(expectedEndpoints.length)
139+
} finally {
140+
proc?.kill()
141+
await agent?.stop()
142+
}
143+
}
144+
145+
it('should send fastify endpoints via telemetry', async () => {
146+
await runEndpointTest('fastify')
147+
})
148+
})
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use strict'
2+
3+
const tracer = require('dd-trace')
4+
tracer.init({
5+
flushInterval: 0
6+
})
7+
8+
const fastify = require('fastify')
9+
const app = fastify()
10+
11+
// Basic routes
12+
app.get('/users', async (_, reply) => reply.send('ok'))
13+
app.post('/users/', async (_, reply) => reply.send('ok'))
14+
app.put('/users/:id', async (_, reply) => reply.send('ok'))
15+
app.delete('/users/:id', async (_, reply) => reply.send('ok'))
16+
app.patch('/users/:id/:name', async (_, reply) => reply.send('ok'))
17+
app.options('/users/:id?', async (_, reply) => reply.send('ok'))
18+
19+
// Route with regex
20+
app.delete('/regex/:hour(^\\d{2})h:minute(^\\d{2})m', async (_, reply) => reply.send('ok'))
21+
22+
// Additional methods
23+
app.trace('/trace-test', async (_, reply) => reply.send('ok'))
24+
app.head('/head-test', async (_, reply) => reply.send('ok'))
25+
26+
// Custom method
27+
app.addHttpMethod('MKCOL', { hasBody: true })
28+
app.mkcol('/example/near/:lat-:lng/radius/:r', async (_, reply) => reply.send('ok'))
29+
30+
// Using app.route()
31+
app.route({
32+
method: ['POST', 'PUT', 'PATCH'],
33+
url: '/multi-method',
34+
handler: async (_, reply) => reply.send('ok')
35+
})
36+
37+
// All supported methods route
38+
app.all('/all-methods', async (_, reply) => reply.send('ok'))
39+
40+
// Nested routes with Router
41+
app.register(async function (router) {
42+
router.put('/nested/:id', async (_, reply) => reply.send('ok'))
43+
}, { prefix: '/v1' })
44+
45+
// Deeply nested routes
46+
app.register(async function (router) {
47+
router.get('/nested', async (_, reply) => reply.send('ok'))
48+
router.register(async function (subRouter) {
49+
subRouter.get('/deep', async (_, reply) => reply.send('ok'))
50+
subRouter.post('/deep/:id', async (_, reply) => reply.send('ok'))
51+
}, { prefix: '/sub' })
52+
}, { prefix: '/api' })
53+
54+
// Wildcard routes
55+
app.get('/wildcard/*', async (_, reply) => reply.send('ok'))
56+
app.get('*', async (_, reply) => reply.send('ok'))
57+
58+
const start = async () => {
59+
await app.listen({ port: 0, host: '127.0.0.1' })
60+
const port = app.server.address().port
61+
process.send({ port })
62+
}
63+
64+
setTimeout(() => {
65+
app.get('/later', async (_, reply) => reply.send('ok'))
66+
start()
67+
}, 2e3)

packages/dd-trace/src/config.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,8 @@ class Config {
493493
defaults.apmTracingEnabled = true
494494
defaults['appsec.apiSecurity.enabled'] = true
495495
defaults['appsec.apiSecurity.sampleDelay'] = 30
496+
defaults['appsec.apiSecurity.endpointCollectionEnabled'] = true
497+
defaults['appsec.apiSecurity.endpointCollectionMessageLimit'] = 300
496498
defaults['appsec.blockedTemplateGraphql'] = undefined
497499
defaults['appsec.blockedTemplateHtml'] = undefined
498500
defaults['appsec.blockedTemplateJson'] = undefined
@@ -690,6 +692,8 @@ class Config {
690692
DD_AGENT_HOST,
691693
DD_API_SECURITY_ENABLED,
692694
DD_API_SECURITY_SAMPLE_DELAY,
695+
DD_API_SECURITY_ENDPOINT_COLLECTION_ENABLED,
696+
DD_API_SECURITY_ENDPOINT_COLLECTION_MESSAGE_LIMIT,
693697
DD_APM_TRACING_ENABLED,
694698
DD_APPSEC_AUTO_USER_INSTRUMENTATION_MODE,
695699
DD_APPSEC_COLLECT_ALL_HEADERS,
@@ -846,6 +850,10 @@ class Config {
846850
))
847851
this._setBoolean(env, 'appsec.apiSecurity.enabled', DD_API_SECURITY_ENABLED && isTrue(DD_API_SECURITY_ENABLED))
848852
env['appsec.apiSecurity.sampleDelay'] = maybeFloat(DD_API_SECURITY_SAMPLE_DELAY)
853+
this._setBoolean(env, 'appsec.apiSecurity.endpointCollectionEnabled',
854+
DD_API_SECURITY_ENDPOINT_COLLECTION_ENABLED)
855+
env['appsec.apiSecurity.endpointCollectionMessageLimit'] =
856+
maybeInt(DD_API_SECURITY_ENDPOINT_COLLECTION_MESSAGE_LIMIT)
849857
env['appsec.blockedTemplateGraphql'] = maybeFile(DD_APPSEC_GRAPHQL_BLOCKED_TEMPLATE_JSON)
850858
env['appsec.blockedTemplateHtml'] = maybeFile(DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML)
851859
this._envUnprocessed['appsec.blockedTemplateHtml'] = DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML
@@ -1073,6 +1081,10 @@ class Config {
10731081
options.experimental?.appsec?.standalone && !options.experimental.appsec.standalone.enabled
10741082
))
10751083
this._setBoolean(opts, 'appsec.apiSecurity.enabled', options.appsec?.apiSecurity?.enabled)
1084+
this._setBoolean(opts, 'appsec.apiSecurity.endpointCollectionEnabled',
1085+
options.appsec?.apiSecurity?.endpointCollectionEnabled)
1086+
opts['appsec.apiSecurity.endpointCollectionMessageLimit'] =
1087+
maybeInt(options.appsec?.apiSecurity?.endpointCollectionMessageLimit)
10761088
opts['appsec.blockedTemplateGraphql'] = maybeFile(options.appsec?.blockedTemplateGraphql)
10771089
opts['appsec.blockedTemplateHtml'] = maybeFile(options.appsec?.blockedTemplateHtml)
10781090
this._optsUnprocessed['appsec.blockedTemplateHtml'] = options.appsec?.blockedTemplateHtml

packages/dd-trace/src/supported-configurations.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"DD_API_KEY": ["A"],
99
"DD_API_SECURITY_ENABLED": ["A"],
1010
"DD_API_SECURITY_SAMPLE_DELAY": ["A"],
11+
"DD_API_SECURITY_ENDPOINT_COLLECTION_ENABLED": ["A"],
12+
"DD_API_SECURITY_ENDPOINT_COLLECTION_MESSAGE_LIMIT": ["A"],
1113
"DD_APM_FLUSH_DEADLINE_MILLISECONDS": ["A"],
1214
"DD_APM_TRACING_ENABLED": ["A"],
1315
"DD_APPSEC_AUTO_USER_INSTRUMENTATION_MODE": ["A"],

0 commit comments

Comments
 (0)