Skip to content

Commit

Permalink
add support to api security sampling
Browse files Browse the repository at this point in the history
  • Loading branch information
IlyasShabi committed Oct 3, 2024
1 parent fcd3ab8 commit 6987a66
Show file tree
Hide file tree
Showing 15 changed files with 257 additions and 341 deletions.
1 change: 0 additions & 1 deletion docs/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ tracer.init({
},
apiSecurity: {
enabled: true,
requestSampling: 1.0
},
rasp: {
enabled: true
Expand Down
7 changes: 1 addition & 6 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,19 +654,14 @@ declare namespace tracer {
mode?: 'safe' | 'extended' | 'disabled'
},
/**
* Configuration for Api Security sampling
* Configuration for Api Security
*/
apiSecurity?: {
/** Whether to enable Api Security.
* @default false
*/
enabled?: boolean,

/** Controls the request sampling rate (between 0 and 1) in which Api Security is triggered.
* The value will be coerced back if it's outside of the 0-1 range.
* @default 0.1
*/
requestSampling?: number
},
/**
* Configuration for RASP
Expand Down
54 changes: 22 additions & 32 deletions packages/dd-trace/src/appsec/api_security_sampler.js
Original file line number Diff line number Diff line change
@@ -1,61 +1,51 @@
'use strict'

const log = require('../log')
const ApiSecuritySamplerCache = require('./api_security_sampler_cache')
const web = require('../plugins/util/web')
const { USER_KEEP, AUTO_KEEP } = require('../../../../ext/priority')

let enabled
let requestSampling

const sampledRequests = new WeakSet()
let sampledRequests

function configure ({ apiSecurity }) {
enabled = apiSecurity.enabled
setRequestSampling(apiSecurity.requestSampling)
sampledRequests = new ApiSecuritySamplerCache(apiSecurity.sampleDelay)
}

function disable () {
enabled = false
sampledRequests?.clear()
}

function setRequestSampling (sampling) {
requestSampling = parseRequestSampling(sampling)
}

function parseRequestSampling (requestSampling) {
let parsed = parseFloat(requestSampling)

if (isNaN(parsed)) {
log.warn(`Incorrect API Security request sampling value: ${requestSampling}`)
function sampleRequest (req, res) {
if (!enabled) return false

parsed = 0
} else {
parsed = Math.min(1, Math.max(0, parsed))
}
const rootSpan = web.root(req)
if (!rootSpan) return false

return parsed
}
const priority = getSpanPriority(rootSpan)

function sampleRequest (req) {
if (!enabled || !requestSampling) {
if (priority !== AUTO_KEEP && priority !== USER_KEEP) {
return false
}

const shouldSample = Math.random() <= requestSampling
const key = sampledRequests.computeKey(req, res)
const isSampled = sampledRequests.isSampled(key)

if (shouldSample) {
sampledRequests.add(req)
}
if (isSampled) return false

sampledRequests.set(key)

return shouldSample
return true
}

function isSampled (req) {
return sampledRequests.has(req)
function getSpanPriority (span) {
const spanContext = span.context?.()
return spanContext._sampling?.priority // default ??
}

module.exports = {
configure,
disable,
setRequestSampling,
sampleRequest,
isSampled
sampleRequest
}
53 changes: 53 additions & 0 deletions packages/dd-trace/src/appsec/api_security_sampler_cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use strict'

const crypto = require('node:crypto')
const log = require('../log')

const MAX_SIZE = 4096
const DEFAULT_DELAY = 30 // 30s

class ApiSecuritySamplerCache extends Map {
constructor (delay) {
super()
this.delay = this._parseSampleDelay(delay)
}

_parseSampleDelay (delay) {
if (typeof delay === 'number' && Number.isFinite(delay) && delay > 0) {
return delay
} else {
log.warn('Invalid delay value. Delay must be a positive number.')
return DEFAULT_DELAY
}
}

computeKey (req, res) {
const route = req.url
const method = req.method.toLowerCase()
const statusCode = res.statusCode
const str = route + statusCode + method
return crypto.createHash('md5').update(str).digest('hex')
}

isSampled (key) {
if (!super.has(key)) {
return false
}
const previous = super.get(key)
return Date.now() - previous < (this.delay * 1000)
}

set (key) {
if (super.has(key)) {
super.delete(key)
}

super.set(key, Date.now())
if (super.size > MAX_SIZE) {
const oldestKey = super.keys().next().value
super.delete(oldestKey)
}
}
}

module.exports = ApiSecuritySamplerCache
9 changes: 4 additions & 5 deletions packages/dd-trace/src/appsec/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,6 @@ function incomingHttpStartTranslator ({ req, res, abortController }) {
persistent[addresses.HTTP_CLIENT_IP] = clientIp
}

if (apiSecuritySampler.sampleRequest(req)) {
persistent[addresses.WAF_CONTEXT_PROCESSOR] = { 'extract-schema': true }
}

const actions = waf.run({ persistent }, req)

handleResults(actions, req, res, rootSpan, abortController)
Expand Down Expand Up @@ -136,6 +132,10 @@ function incomingHttpEndTranslator ({ req, res }) {
persistent[addresses.HTTP_INCOMING_QUERY] = req.query
}

if (apiSecuritySampler.sampleRequest(req, res)) {
persistent[addresses.WAF_CONTEXT_PROCESSOR] = { 'extract-schema': true }
}

if (Object.keys(persistent).length) {
waf.run({ persistent }, req)
}
Expand Down Expand Up @@ -202,7 +202,6 @@ function onRequestCookieParser ({ req, res, abortController, cookies }) {

function onResponseBody ({ req, body }) {
if (!body || typeof body !== 'object') return
if (!apiSecuritySampler.isSampled(req)) return

// we don't support blocking at this point, so no results needed
waf.run({
Expand Down
1 change: 0 additions & 1 deletion packages/dd-trace/src/appsec/remote_config/capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ module.exports = {
ASM_CUSTOM_RULES: 1n << 8n,
ASM_CUSTOM_BLOCKING_RESPONSE: 1n << 9n,
ASM_TRUSTED_IPS: 1n << 10n,
ASM_API_SECURITY_SAMPLE_RATE: 1n << 11n,
APM_TRACING_SAMPLE_RATE: 1n << 12n,
APM_TRACING_LOGS_INJECTION: 1n << 13n,
APM_TRACING_HTTP_HEADER_TAGS: 1n << 14n,
Expand Down
7 changes: 0 additions & 7 deletions packages/dd-trace/src/appsec/remote_config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ const Activation = require('../activation')

const RemoteConfigManager = require('./manager')
const RemoteConfigCapabilities = require('./capabilities')
const apiSecuritySampler = require('../api_security_sampler')

let rc

Expand All @@ -24,18 +23,12 @@ function enable (config, appsec) {
rc.updateCapabilities(RemoteConfigCapabilities.ASM_ACTIVATION, true)
}

if (config.appsec.apiSecurity?.enabled) {
rc.updateCapabilities(RemoteConfigCapabilities.ASM_API_SECURITY_SAMPLE_RATE, true)
}

rc.setProductHandler('ASM_FEATURES', (action, rcConfig) => {
if (!rcConfig) return

if (activation === Activation.ONECLICK) {
enableOrDisableAppsec(action, rcConfig, config, appsec)
}

apiSecuritySampler.setRequestSampling(rcConfig.api_security?.request_sample_rate)
})
}

Expand Down
7 changes: 3 additions & 4 deletions packages/dd-trace/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,7 @@ class Config {
const defaults = setHiddenProperty(this, '_defaults', {})

this._setValue(defaults, 'appsec.apiSecurity.enabled', true)
this._setValue(defaults, 'appsec.apiSecurity.requestSampling', 0.1)
this._setValue(defaults, 'appsec.apiSecurity.sampleDelay', 30)
this._setValue(defaults, 'appsec.blockedTemplateGraphql', undefined)
this._setValue(defaults, 'appsec.blockedTemplateHtml', undefined)
this._setValue(defaults, 'appsec.blockedTemplateJson', undefined)
Expand Down Expand Up @@ -555,7 +555,7 @@ class Config {
AWS_LAMBDA_FUNCTION_NAME,
DD_AGENT_HOST,
DD_API_SECURITY_ENABLED,
DD_API_SECURITY_REQUEST_SAMPLE_RATE,
DD_API_SECURITY_SAMPLE_DELAY,
DD_APPSEC_AUTOMATED_USER_EVENTS_TRACKING,
DD_APPSEC_ENABLED,
DD_APPSEC_GRAPHQL_BLOCKED_TEMPLATE_JSON,
Expand Down Expand Up @@ -671,7 +671,7 @@ class Config {
DD_API_SECURITY_ENABLED && isTrue(DD_API_SECURITY_ENABLED),
DD_EXPERIMENTAL_API_SECURITY_ENABLED && isTrue(DD_EXPERIMENTAL_API_SECURITY_ENABLED)
))
this._setUnit(env, 'appsec.apiSecurity.requestSampling', DD_API_SECURITY_REQUEST_SAMPLE_RATE)
this._setValue(env, 'appsec.apiSecurity.sampleDelay', maybeFloat(DD_API_SECURITY_SAMPLE_DELAY))
this._setValue(env, 'appsec.blockedTemplateGraphql', maybeFile(DD_APPSEC_GRAPHQL_BLOCKED_TEMPLATE_JSON))
this._setValue(env, 'appsec.blockedTemplateHtml', maybeFile(DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML))
this._envUnprocessed['appsec.blockedTemplateHtml'] = DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML
Expand Down Expand Up @@ -838,7 +838,6 @@ class Config {
tagger.add(tags, options.tags)

this._setBoolean(opts, 'appsec.apiSecurity.enabled', options.appsec.apiSecurity?.enabled)
this._setUnit(opts, 'appsec.apiSecurity.requestSampling', options.appsec.apiSecurity?.requestSampling)
this._setValue(opts, 'appsec.blockedTemplateGraphql', maybeFile(options.appsec.blockedTemplateGraphql))
this._setValue(opts, 'appsec.blockedTemplateHtml', maybeFile(options.appsec.blockedTemplateHtml))
this._optsUnprocessed['appsec.blockedTemplateHtml'] = options.appsec.blockedTemplateHtml
Expand Down
92 changes: 48 additions & 44 deletions packages/dd-trace/test/appsec/api_security_sampler.spec.js
Original file line number Diff line number Diff line change
@@ -1,71 +1,75 @@
'use strict'

const apiSecuritySampler = require('../../src/appsec/api_security_sampler')
const proxyquire = require('proxyquire')

describe('Api Security Sampler', () => {
let config
describe('API Security Sampler', () => {
let apiSecuritySampler, webStub, clock

beforeEach(() => {
config = {
apiSecurity: {
enabled: true,
requestSampling: 1
}
}

sinon.stub(Math, 'random').returns(0.3)
webStub = { root: sinon.stub() }
clock = sinon.useFakeTimers(Date.now())

apiSecuritySampler = proxyquire('../../src/appsec/api_security_sampler', {
'../plugins/util/web': webStub
})
})

afterEach(sinon.restore)
afterEach(() => {
clock.restore()
})

describe('sampleRequest', () => {
it('should sample request if enabled and sampling 1', () => {
apiSecuritySampler.configure(config)

expect(apiSecuritySampler.sampleRequest({})).to.true
beforeEach(() => {
apiSecuritySampler.configure({ apiSecurity: { enabled: true, sampleDelay: 30 } })
})

it('should not sample request if enabled and sampling 0', () => {
config.apiSecurity.requestSampling = 0
apiSecuritySampler.configure(config)

expect(apiSecuritySampler.sampleRequest({})).to.false
it('should return false if not enabled', () => {
apiSecuritySampler.disable()
expect(apiSecuritySampler.sampleRequest({}, {})).to.be.false
})

it('should sample request if enabled and sampling greater than random', () => {
config.apiSecurity.requestSampling = 0.5

apiSecuritySampler.configure(config)

expect(apiSecuritySampler.sampleRequest({})).to.true
it('should return false if no root span', () => {
webStub.root.returns(null)
expect(apiSecuritySampler.sampleRequest({}, {})).to.be.false
})

it('should not sample request if enabled and sampling less than random', () => {
config.apiSecurity.requestSampling = 0.1

apiSecuritySampler.configure(config)

expect(apiSecuritySampler.sampleRequest()).to.false
it('should return true and put request in cache if priority is AUTO_KEEP', () => {
const rootSpan = { context: () => ({ _sampling: { priority: 2 } }) }
webStub.root.returns(rootSpan)
const req = { url: '/test', method: 'GET' }
const res = { statusCode: 200 }
expect(apiSecuritySampler.sampleRequest(req, res)).to.be.true
})

it('should not sample request if incorrect config value', () => {
config.apiSecurity.requestSampling = NaN

apiSecuritySampler.configure(config)
it('should return true and put request in cache if priority is USER_KEEP', () => {
const rootSpan = { context: () => ({ _sampling: { priority: 1 } }) }
webStub.root.returns(rootSpan)
const req = { url: '/test', method: 'GET' }
const res = { statusCode: 200 }
expect(apiSecuritySampler.sampleRequest(req, res)).to.be.true
})

expect(apiSecuritySampler.sampleRequest()).to.false
it('should return false if priority is neither AUTO_KEEP nor USER_KEEP', () => {
const rootSpan = { context: () => ({ _sampling: { priority: 0 } }) }
webStub.root.returns(rootSpan)
expect(apiSecuritySampler.sampleRequest({}, {})).to.be.false
})
})

it('should sample request according to the config', () => {
config.apiSecurity.requestSampling = 1
describe('disable', () => {
it('should set enabled to false and clear the cache', () => {
const req = { url: '/test', method: 'GET' }
const res = { statusCode: 200 }

apiSecuritySampler.configure(config)
const rootSpan = { context: () => ({ _sampling: { priority: 2 } }) }
webStub.root.returns(rootSpan)

expect(apiSecuritySampler.sampleRequest({})).to.true
apiSecuritySampler.configure({ apiSecurity: { enabled: true, sampleDelay: 30 } })
expect(apiSecuritySampler.sampleRequest(req, res)).to.be.true

apiSecuritySampler.setRequestSampling(0)
apiSecuritySampler.disable()

expect(apiSecuritySampler.sampleRequest()).to.false
expect(apiSecuritySampler.sampleRequest({}, {})).to.be.false
})
})
})
Loading

0 comments on commit 6987a66

Please sign in to comment.