Skip to content

API Security -Add support for trace tagging rules #6075

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,43 @@
"operator": "match_regex"
}
]
},
{
"id": "trace_tagging_rule",
"name": "Trace tagging test rule",
"tags": {
"type": "security_scanner",
"category": "attack_attempt"
},
"conditions": [
{
"parameters": {
"inputs": [
{
"address": "server.request.headers.no_cookies",
"key_path": [
"user-agent"
]
}
],
"regex": "^TraceTaggingTest\\/v"
},
"operator": "match_regex"
}
],
"output": {
"event": false,
"keep": true,
"attributes": {
"_dd.appsec.trace.integer": {
"value": 1234
},
"_dd.appsec.trace.agent": {
"address": "server.request.headers.no_cookies",
"key_path": ["user-agent"]
}
}
}
}
]
}
95 changes: 95 additions & 0 deletions integration-tests/appsec/trace-tagging.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
'use strict'

const { assert } = require('chai')
const path = require('path')
const Axios = require('axios')

const {
createSandbox,
FakeAgent,
spawnProc
} = require('../helpers')

describe('ASM Trace Tagging rules', () => {
let axios, sandbox, cwd, appFile, agent, proc

function startServer () {
beforeEach(async () => {
agent = await new FakeAgent().start()

const env = {
DD_TRACE_AGENT_PORT: agent.port,
DD_APPSEC_ENABLED: true,
DD_APPSEC_RULES: path.join(cwd, 'appsec', 'data-collection', 'data-collection-rules.json')
}

proc = await spawnProc(appFile, { cwd, env, execArgv: [] })
axios = Axios.create({ baseURL: proc.url })
})

afterEach(async () => {
proc.kill()
await agent.stop()
})
}

describe('express', () => {
before(async () => {
sandbox = await createSandbox(['express'])
cwd = sandbox.folder
appFile = path.join(cwd, 'appsec/data-collection/index.js')
})

after(async () => {
await sandbox.remove()
})

startServer()

it('should report waf attributes', async () => {
await axios.get('/', { headers: { 'User-Agent': 'TraceTaggingTest/v1' } })

await agent.assertMessageReceived(({ _, payload }) => {
assert.property(payload[0][0].meta, '_dd.appsec.trace.agent')
assert.strictEqual(payload[0][0].meta['_dd.appsec.trace.agent'], 'TraceTaggingTest/v1')
assert.property(payload[0][0].metrics, '_dd.appsec.trace.integer')
assert.strictEqual(payload[0][0].metrics['_dd.appsec.trace.integer'], 1234)
})
})
})

describe('fastify', () => {
before(async () => {
sandbox = await createSandbox(['fastify'])
cwd = sandbox.folder
appFile = path.join(cwd, 'appsec/data-collection/fastify.js')
})

after(async () => {
await sandbox.remove()
})

startServer()

it('should report waf attributes', async () => {
let fastifyRequestReceived = false

await axios.get('/', { headers: { 'User-Agent': 'TraceTaggingTest/v1' } })

await agent.assertMessageReceived(({ _, payload }) => {
if (payload[0][0].name !== 'fastify.request') {
throw new Error('Not the span we are looking for')
}

fastifyRequestReceived = true

assert.property(payload[0][0].meta, '_dd.appsec.trace.agent')
assert.strictEqual(payload[0][0].meta['_dd.appsec.trace.agent'], 'TraceTaggingTest/v1')
assert.property(payload[0][0].metrics, '_dd.appsec.trace.integer')
assert.strictEqual(payload[0][0].metrics['_dd.appsec.trace.integer'], 1234)
}, 30000, 10, true)

assert.isTrue(fastifyRequestReceived)
})
})
})
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
],
"dependencies": {
"@datadog/libdatadog": "0.7.0",
"@datadog/native-appsec": "10.0.0",
"@datadog/native-appsec": "10.0.1",
"@datadog/native-iast-taint-tracking": "4.0.0",
"@datadog/native-metrics": "3.1.1",
"@datadog/pprof": "5.9.0",
Expand Down
18 changes: 3 additions & 15 deletions packages/dd-trace/src/appsec/reporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
const dc = require('dc-polyfill')
const zlib = require('zlib')

const Limiter = require('../rate_limiter')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sampling priority relies now in the keep field returned by the waf and not in the event presence. Due to this, appsec rate limiter has been moved to waf.

const { storage } = require('../../../datadog-core')
const web = require('../plugins/util/web')
const { ipHeaderList } = require('../plugins/util/ip_extractor')
Expand All @@ -15,7 +14,6 @@ const {
updateWafRequestsMetricTags,
updateRaspRequestsMetricTags,
updateRaspRuleSkippedMetricTags,
updateRateLimitedMetric,
getRequestMetrics
} = require('./telemetry')
const { keepTrace } = require('../priority_sampler')
Expand All @@ -31,9 +29,6 @@ const COLLECTED_REQUEST_BODY_MAX_ELEMENTS_PER_NODE = 256

const telemetryLogCh = dc.channel('datadog:telemetry:log')

// default limiter, configurable with setRateLimit()
let limiter = new Limiter(100)

const config = {
headersExtendedCollectionEnabled: false,
maxHeadersCollected: 0,
Expand Down Expand Up @@ -91,7 +86,6 @@ const NON_EXTENDED_REQUEST_HEADERS = new Set([...requestHeadersList, ...eventHea
const NON_EXTENDED_RESPONSE_HEADERS = new Set(contentHeaderList)

function init (_config) {
limiter = new Limiter(_config.rateLimit)
config.headersExtendedCollectionEnabled = _config.extendedHeadersCollection.enabled
config.maxHeadersCollected = _config.extendedHeadersCollection.maxHeaders
config.headersRedaction = _config.extendedHeadersCollection.redaction
Expand Down Expand Up @@ -325,12 +319,6 @@ function reportAttack (attackData) {
'appsec.event': 'true'
}

if (limiter.isAllowed()) {
keepTrace(rootSpan, ASM)
} else {
updateRateLimitedMetric(req)
}

// TODO: maybe add this to format.js later (to take decision as late as possible)
if (!currentTags['_dd.origin']) {
newTags['_dd.origin'] = 'appsec'
Expand Down Expand Up @@ -430,8 +418,8 @@ function isRaspAttack (events) {
return events.some(e => e.rule?.tags?.module === 'rasp')
}

function isFingerprintAttribute (attribute) {
return attribute.startsWith('_dd.appsec.fp')
function isSchemaAttribute (attribute) {
return attribute.startsWith('_dd.appsec.s.')
}

function reportAttributes (attributes) {
Expand All @@ -444,7 +432,7 @@ function reportAttributes (attributes) {

const tags = {}
for (let [tag, value] of Object.entries(attributes)) {
if (!isFingerprintAttribute(tag)) {
if (isSchemaAttribute(tag)) {
Copy link
Contributor Author

@CarlesDD CarlesDD Jul 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conditional has been flipped in order to only apply the special format (gizp + base64 encoding) to schema attributes (aka derivatives)

const gzippedValue = zlib.gzipSync(JSON.stringify(value))
value = gzippedValue.toString('base64')
}
Expand Down
21 changes: 20 additions & 1 deletion packages/dd-trace/src/appsec/waf/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
const { storage } = require('../../../../datadog-core')
const log = require('../../log')
const Reporter = require('../reporter')
const Limiter = require('../../rate_limiter')
const { keepTrace } = require('../../priority_sampler')
const { ASM } = require('../../standalone/product')
const web = require('../../plugins/util/web')
const { updateRateLimitedMetric } = require('../telemetry')

class WafUpdateError extends Error {
constructor (diagnosticErrors) {
Expand All @@ -12,6 +17,8 @@ class WafUpdateError extends Error {
}
}

let limiter = new Limiter(100)

const waf = {
wafManager: null,
init,
Expand All @@ -27,6 +34,8 @@ const waf = {
function init (rules, config) {
destroy()

limiter = new Limiter(config.rateLimit)

// dirty require to make startup faster for serverless
const WAFManager = require('./waf_manager')

Expand Down Expand Up @@ -99,8 +108,18 @@ function run (data, req, raspRule) {
}

const wafContext = waf.wafManager.getWAFContext(req)
const result = wafContext.run(data, raspRule)

if (result?.keep) {
if (limiter.isAllowed()) {
const rootSpan = web.root(req)
keepTrace(rootSpan, ASM)
} else {
updateRateLimitedMetric(req)
}
}

return wafContext.run(data, raspRule)
return result
}

function disposeContext (req) {
Expand Down
3 changes: 2 additions & 1 deletion packages/dd-trace/src/remote_config/capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ module.exports = {
ASM_NETWORK_FINGERPRINT: 1n << 34n,
ASM_HEADER_FINGERPRINT: 1n << 35n,
ASM_RASP_CMDI: 1n << 37n,
ASM_DD_MULTICONFIG: 1n << 42n
ASM_DD_MULTICONFIG: 1n << 42n,
ASM_TRACE_TAGGING_RULES: 1n << 43n,
}
2 changes: 2 additions & 0 deletions packages/dd-trace/src/remote_config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ function enableWafUpdate (appsecConfig) {
rc.updateCapabilities(RemoteConfigCapabilities.ASM_NETWORK_FINGERPRINT, true)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_HEADER_FINGERPRINT, true)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_DD_MULTICONFIG, true)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_TRACE_TAGGING_RULES, true)

if (appsecConfig.rasp?.enabled) {
rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_SQLI, true)
Expand Down Expand Up @@ -130,6 +131,7 @@ function disableWafUpdate () {
rc.updateCapabilities(RemoteConfigCapabilities.ASM_NETWORK_FINGERPRINT, false)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_HEADER_FINGERPRINT, false)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_DD_MULTICONFIG, false)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_TRACE_TAGGING_RULES, false)

rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_SQLI, false)
rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_SSRF, false)
Expand Down
49 changes: 4 additions & 45 deletions packages/dd-trace/test/appsec/reporter.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -396,8 +396,6 @@ describe('reporter', () => {
'_dd.origin': 'appsec',
'_dd.appsec.json': '{"triggers":[{"rule":{},"rule_matches":[{}]}]}'
})
expect(prioritySampler.setPriority).to.have.been.calledOnceWithExactly(span, USER_KEEP, ASM)
expect(telemetry.updateRateLimitedMetric).to.not.have.been.called
})

it('should add tags to request span', () => {
Expand All @@ -416,40 +414,6 @@ describe('reporter', () => {
'_dd.appsec.json': '{"triggers":[{"rule":{},"rule_matches":[{}]}]}',
'network.client.ip': '8.8.8.8'
})
expect(prioritySampler.setPriority).to.have.been.calledOnceWithExactly(span, USER_KEEP, ASM)
expect(telemetry.updateRateLimitedMetric).to.not.have.been.called
})

it('should not add manual.keep when rate limit is reached', (done) => {
const addTags = span.addTags

expect(Reporter.reportAttack([])).to.not.be.false
expect(Reporter.reportAttack([])).to.not.be.false
expect(Reporter.reportAttack([])).to.not.be.false

expect(prioritySampler.setPriority).to.have.callCount(3)
expect(telemetry.updateRateLimitedMetric).to.not.have.been.called

const reporterConfigWithRateLimit1 = Object.assign({}, defaultReporterConfig)
reporterConfigWithRateLimit1.rateLimit = 1
Reporter.init(reporterConfigWithRateLimit1)

expect(Reporter.reportAttack([])).to.not.be.false
expect(addTags.getCall(3).firstArg).to.have.property('appsec.event').that.equals('true')
expect(prioritySampler.setPriority).to.have.callCount(4)
expect(telemetry.updateRateLimitedMetric).to.not.have.been.called

expect(Reporter.reportAttack([])).to.not.be.false
expect(addTags.getCall(4).firstArg).to.have.property('appsec.event').that.equals('true')
expect(prioritySampler.setPriority).to.have.callCount(4)
expect(telemetry.updateRateLimitedMetric).to.be.calledOnceWithExactly(req)

setTimeout(() => {
expect(Reporter.reportAttack([])).to.not.be.false
expect(prioritySampler.setPriority).to.have.callCount(5)
expect(telemetry.updateRateLimitedMetric).to.be.calledOnceWithExactly(req)
done()
}, 1020)
})

it('should not overwrite origin tag', () => {
Expand All @@ -464,8 +428,6 @@ describe('reporter', () => {
'_dd.appsec.json': '{"triggers":[]}',
'network.client.ip': '8.8.8.8'
})
expect(prioritySampler.setPriority).to.have.been.calledOnceWithExactly(span, USER_KEEP, ASM)
expect(telemetry.updateRateLimitedMetric).to.not.have.been.called
})

it('should merge attacks json', () => {
Expand All @@ -489,8 +451,6 @@ describe('reporter', () => {
'_dd.appsec.json': '{"triggers":[{"rule":{},"rule_matches":[{}]},{"rule":{}},{"rule":{},"rule_matches":[{}]}]}',
'network.client.ip': '8.8.8.8'
})
expect(prioritySampler.setPriority).to.have.been.calledOnceWithExactly(span, USER_KEEP, ASM)
expect(telemetry.updateRateLimitedMetric).to.not.have.been.called
})

it('should call standalone sample', () => {
Expand All @@ -514,9 +474,6 @@ describe('reporter', () => {
'_dd.appsec.json': '{"triggers":[{"rule":{},"rule_matches":[{}]},{"rule":{}},{"rule":{},"rule_matches":[{}]}]}',
'network.client.ip': '8.8.8.8'
})

expect(prioritySampler.setPriority).to.have.been.calledOnceWithExactly(span, USER_KEEP, ASM)
expect(telemetry.updateRateLimitedMetric).to.not.have.been.called
})

describe('extended collection', () => {
Expand Down Expand Up @@ -719,7 +676,8 @@ describe('reporter', () => {
'_dd.appsec.s.req.params': schemaValue,
'_dd.appsec.s.req.cookies': schemaValue,
'_dd.appsec.s.req.body': schemaValue,
'custom.processor.output': schemaValue
'custom.processor.output': 'custom_attribute',
'custom.processor.output_int': 42
}

Reporter.reportAttributes(attributes)
Expand All @@ -735,7 +693,8 @@ describe('reporter', () => {
'_dd.appsec.s.req.params': schemaEncoded,
'_dd.appsec.s.req.cookies': schemaEncoded,
'_dd.appsec.s.req.body': schemaEncoded,
'custom.processor.output': schemaEncoded
'custom.processor.output': 'custom_attribute',
'custom.processor.output_int': 42
})
})
})
Expand Down
Loading