From 002eca357a849da000bd3511bc669e8af344522b Mon Sep 17 00:00:00 2001 From: ishabi Date: Fri, 7 Mar 2025 16:36:57 +0100 Subject: [PATCH] report truncation metrics --- packages/dd-trace/src/appsec/telemetry/waf.js | 44 ++++++ .../dd-trace/test/appsec/resources/index.js | 21 +++ .../test/appsec/telemetry/waf.spec.js | 62 ++++++++- .../appsec/waf-metrics.integration.spec.js | 126 ++++++++++++++++++ 4 files changed, 248 insertions(+), 5 deletions(-) create mode 100644 packages/dd-trace/test/appsec/resources/index.js create mode 100644 packages/dd-trace/test/appsec/waf-metrics.integration.spec.js diff --git a/packages/dd-trace/src/appsec/telemetry/waf.js b/packages/dd-trace/src/appsec/telemetry/waf.js index 31b4ecafec6..a394dbfe13d 100644 --- a/packages/dd-trace/src/appsec/telemetry/waf.js +++ b/packages/dd-trace/src/appsec/telemetry/waf.js @@ -7,6 +7,12 @@ const appsecMetrics = telemetryMetrics.manager.namespace('appsec') const DD_TELEMETRY_WAF_RESULT_TAGS = Symbol('_dd.appsec.telemetry.waf.result.tags') +const TRUNCATION_FLAGS = { + LONG_STRING: 1, + LARGE_CONTAINER: 2, + DEEP_CONTAINER: 4 +} + function addWafRequestMetrics (store, { duration, durationExt, wafTimeout, errorCode }) { store[DD_TELEMETRY_REQUEST_METRICS].duration += duration || 0 store[DD_TELEMETRY_REQUEST_METRICS].durationExt += durationExt || 0 @@ -58,6 +64,11 @@ function trackWafMetrics (store, metrics) { metricTags[tags.WAF_TIMEOUT] = true } + const truncationReason = getTruncationReason(metrics) + if (truncationReason > 0) { + incrementTruncatedMetrics(metrics, truncationReason) + } + return metricTags } @@ -98,6 +109,39 @@ function incrementWafRequests (store) { } } +function incrementTruncatedMetrics (metrics, truncationReason) { + const truncationTags = { truncation_reason: truncationReason } + appsecMetrics.count('waf.input_truncated', truncationTags).inc(1) + + if (metrics?.maxTruncatedString) { + appsecMetrics.distribution('waf.truncated_value_size', + { truncation_reason: TRUNCATION_FLAGS.LONG_STRING }) + .track(metrics.maxTruncatedString) + } + + if (metrics?.maxTruncatedContainerSize) { + appsecMetrics.distribution('waf.truncated_value_size', + { truncation_reason: TRUNCATION_FLAGS.LARGE_CONTAINER }) + .track(metrics.maxTruncatedContainerSize) + } + + if (metrics?.maxTruncatedContainerDepth) { + appsecMetrics.distribution('waf.truncated_value_size', + { truncation_reason: TRUNCATION_FLAGS.DEEP_CONTAINER }) + .track(metrics.maxTruncatedContainerDepth) + } +} + +function getTruncationReason ({ maxTruncatedString, maxTruncatedContainerSize, maxTruncatedContainerDepth }) { + let reason = 0 + + if (maxTruncatedString) reason |= TRUNCATION_FLAGS.LONG_STRING + if (maxTruncatedContainerSize) reason |= TRUNCATION_FLAGS.LARGE_CONTAINER + if (maxTruncatedContainerDepth) reason |= TRUNCATION_FLAGS.DEEP_CONTAINER + + return reason +} + module.exports = { addWafRequestMetrics, trackWafMetrics, diff --git a/packages/dd-trace/test/appsec/resources/index.js b/packages/dd-trace/test/appsec/resources/index.js new file mode 100644 index 00000000000..575e4724ec7 --- /dev/null +++ b/packages/dd-trace/test/appsec/resources/index.js @@ -0,0 +1,21 @@ +'use strict' + +const tracer = require('dd-trace') +tracer.init({ + flushInterval: 1 +}) + +const express = require('express') +const body = require('body-parser') + +const app = express() +app.use(body.json()) +const port = process.env.APP_PORT || 3000 + +app.post('/', async (req, res) => { + res.end('OK') +}) + +app.listen(port, () => { + process.send({ port }) +}) diff --git a/packages/dd-trace/test/appsec/telemetry/waf.spec.js b/packages/dd-trace/test/appsec/telemetry/waf.spec.js index 3e16714c234..4eabb95a909 100644 --- a/packages/dd-trace/test/appsec/telemetry/waf.spec.js +++ b/packages/dd-trace/test/appsec/telemetry/waf.spec.js @@ -30,6 +30,11 @@ describe('Appsec Waf Telemetry metrics', () => { afterEach(sinon.restore) describe('if enabled', () => { + const metrics = { + wafVersion, + rulesVersion + } + beforeEach(() => { appsecTelemetry.enable({ enabled: true, @@ -38,11 +43,6 @@ describe('Appsec Waf Telemetry metrics', () => { }) describe('updateWafRequestsMetricTags', () => { - const metrics = { - wafVersion, - rulesVersion - } - it('should skip update if no request is provided', () => { const result = appsecTelemetry.updateWafRequestsMetricTags(metrics) @@ -260,6 +260,58 @@ describe('Appsec Waf Telemetry metrics', () => { expect(count).to.not.have.been.called }) }) + + describe('WAF Truncation metrics', () => { + it('should report truncated string metrics', () => { + appsecTelemetry.updateWafRequestsMetricTags({ maxTruncatedString: 5000 }, req) + + expect(count).to.have.been.calledWith('waf.input_truncated', { truncation_reason: 1 }) + expect(inc).to.have.been.calledWith(1) + + expect(distribution).to.have.been.calledWith('waf.truncated_value_size', { truncation_reason: 1 }) + expect(track).to.have.been.calledWith(5000) + }) + + it('should report truncated container size metrics', () => { + appsecTelemetry.updateWafRequestsMetricTags({ maxTruncatedContainerSize: 300 }, req) + + expect(count).to.have.been.calledWith('waf.input_truncated', { truncation_reason: 2 }) + expect(inc).to.have.been.calledWith(1) + + expect(distribution).to.have.been.calledWith('waf.truncated_value_size', { truncation_reason: 2 }) + expect(track).to.have.been.calledWith(300) + }) + + it('should report truncated container depth metrics', () => { + appsecTelemetry.updateWafRequestsMetricTags({ maxTruncatedContainerDepth: 20 }, req) + + expect(count).to.have.been.calledWith('waf.input_truncated', { truncation_reason: 4 }) + expect(inc).to.have.been.calledWith(1) + + expect(distribution).to.have.been.calledWith('waf.truncated_value_size', { truncation_reason: 4 }) + expect(track).to.have.been.calledWith(20) + }) + + it('should combine truncation reasons when multiple truncations occur', () => { + appsecTelemetry.updateWafRequestsMetricTags({ + maxTruncatedString: 5000, + maxTruncatedContainerSize: 300, + maxTruncatedContainerDepth: 20 + }, req) + + expect(count).to.have.been.calledWith('waf.input_truncated', { truncation_reason: 7 }) + expect(distribution).to.have.been.calledWith('waf.truncated_value_size', { truncation_reason: 1 }) + expect(distribution).to.have.been.calledWith('waf.truncated_value_size', { truncation_reason: 2 }) + expect(distribution).to.have.been.calledWith('waf.truncated_value_size', { truncation_reason: 4 }) + }) + + it('should not report truncation metrics when no truncation occurs', () => { + appsecTelemetry.updateWafRequestsMetricTags(metrics, req) + + expect(count).to.not.have.been.calledWith('waf.input_truncated') + expect(distribution).to.not.have.been.calledWith('waf.truncated_value_size') + }) + }) }) describe('if disabled', () => { diff --git a/packages/dd-trace/test/appsec/waf-metrics.integration.spec.js b/packages/dd-trace/test/appsec/waf-metrics.integration.spec.js new file mode 100644 index 00000000000..3538d133ded --- /dev/null +++ b/packages/dd-trace/test/appsec/waf-metrics.integration.spec.js @@ -0,0 +1,126 @@ +'use strict' + +const { createSandbox, FakeAgent, spawnProc } = require('../../../../integration-tests/helpers') +const getPort = require('get-port') +const path = require('path') +const Axios = require('axios') +const { assert } = require('chai') + +describe('WAF truncation metrics', () => { + let axios, sandbox, cwd, appPort, appFile, agent, proc + + before(async function () { + this.timeout(process.platform === 'win32' ? 90000 : 30000) + + sandbox = await createSandbox( + ['express'], + false, + [path.join(__dirname, 'resources')] + ) + + appPort = await getPort() + cwd = sandbox.folder + appFile = path.join(cwd, 'resources', 'index.js') + + axios = Axios.create({ + baseURL: `http://localhost:${appPort}` + }) + }) + + after(async function () { + this.timeout(60000) + await sandbox.remove() + }) + + beforeEach(async () => { + agent = await new FakeAgent().start() + proc = await spawnProc(appFile, { + cwd, + env: { + DD_TRACE_AGENT_PORT: agent.port, + APP_PORT: appPort, + DD_APPSEC_ENABLED: 'true', + DD_TELEMETRY_HEARTBEAT_INTERVAL: 1 + } + }) + }) + + afterEach(async () => { + proc.kill() + await agent.stop() + }) + + it('should report tuncation metrics', async () => { + let appsecTelemetryMetricsReceived = false + let appsecTelemetryDistributionsReceived = false + + const longValue = 'testattack'.repeat(500) + const largeObject = {} + for (let i = 0; i < 300; ++i) { + largeObject[`key${i}`] = `value${i}` + } + const deepObject = createNestedObject(25, { value: 'a' }) + const complexPayload = { + deepObject, + longValue, + largeObject + } + + await axios.post('/', { complexPayload }) + + const checkMessages = agent.assertMessageReceived(({ payload }) => { + assert.strictEqual(payload[0][0].metrics['_dd.appsec.enabled'], 1) + assert.strictEqual(payload[0][0].metrics['_dd.appsec.truncated.container_depth'], 20) + assert.strictEqual(payload[0][0].metrics['_dd.appsec.truncated.container_size'], 300) + assert.strictEqual(payload[0][0].metrics['_dd.appsec.truncated.string_length'], 5000) + }) + + const checkTelemetryMetrics = agent.assertTelemetryReceived(({ payload }) => { + const namespace = payload.payload.namespace + + if (namespace === 'appsec') { + appsecTelemetryMetricsReceived = true + const series = payload.payload.series + const inputTruncated = series.find(s => s.metric === 'waf.input_truncated') + + assert.exists(inputTruncated, 'input truncated serie should exist') + assert.strictEqual(inputTruncated.type, 'count') + assert.include(inputTruncated.tags, 'truncation_reason:7') + } + }, 30_000, 'generate-metrics', 2) + + const checkTelemetryDistributions = agent.assertTelemetryReceived(({ payload }) => { + const namespace = payload.payload.namespace + + if (namespace === 'appsec') { + appsecTelemetryDistributionsReceived = true + const series = payload.payload.series + const wafDuration = series.find(s => s.metric === 'waf.duration') + const wafDurationExt = series.find(s => s.metric === 'waf.duration_ext') + const wafTuncated = series.filter(s => s.metric === 'waf.truncated_value_size') + + assert.exists(wafDuration, 'waf duration serie should exist') + assert.exists(wafDurationExt, 'waf duration ext serie should exist') + + assert.equal(wafTuncated.length, 3) + assert.include(wafTuncated[0].tags, 'truncation_reason:1') + assert.include(wafTuncated[1].tags, 'truncation_reason:2') + assert.include(wafTuncated[2].tags, 'truncation_reason:4') + } + }, 30_000, 'distributions', 1) + + return Promise.all([checkMessages, checkTelemetryMetrics, checkTelemetryDistributions]).then(() => { + assert.equal(appsecTelemetryMetricsReceived, true) + assert.equal(appsecTelemetryDistributionsReceived, true) + + return true + }) + }) +}) + +const createNestedObject = (n, obj) => { + if (n > 0) { + return { a: createNestedObject(n - 1, obj) } + } + return obj +}