From 9b410b744567ca5828cee09de0e959e7f56d00a3 Mon Sep 17 00:00:00 2001 From: Ugaitz Urien Date: Tue, 4 Jun 2024 10:19:04 +0200 Subject: [PATCH] Exploit prevention monitoring ssrf (#4361) --------- Co-authored-by: Carles Capell <107924659+CarlesDD@users.noreply.github.com> --- docs/test.ts | 3 + index.d.ts | 9 ++ packages/dd-trace/src/appsec/addresses.js | 6 +- packages/dd-trace/src/appsec/channels.js | 3 +- packages/dd-trace/src/appsec/index.js | 8 +- packages/dd-trace/src/appsec/rasp.js | 35 ++++++ packages/dd-trace/src/config.js | 4 + packages/dd-trace/test/appsec/index.spec.js | 28 ++++- .../test/appsec/rasp.express.plugin.spec.js | 116 ++++++++++++++++++ packages/dd-trace/test/appsec/rasp.spec.js | 99 +++++++++++++++ packages/dd-trace/test/appsec/rasp_rules.json | 58 +++++++++ packages/dd-trace/test/config.spec.js | 13 ++ 12 files changed, 376 insertions(+), 6 deletions(-) create mode 100644 packages/dd-trace/src/appsec/rasp.js create mode 100644 packages/dd-trace/test/appsec/rasp.express.plugin.spec.js create mode 100644 packages/dd-trace/test/appsec/rasp.spec.js create mode 100644 packages/dd-trace/test/appsec/rasp_rules.json diff --git a/docs/test.ts b/docs/test.ts index abdac7a7daf..91fafd48734 100644 --- a/docs/test.ts +++ b/docs/test.ts @@ -116,6 +116,9 @@ tracer.init({ apiSecurity: { enabled: true, requestSampling: 1.0 + }, + rasp: { + enabled: true } } }); diff --git a/index.d.ts b/index.d.ts index ba0a3053702..51d87993ab4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -690,6 +690,15 @@ declare namespace tracer { * @default 0.1 */ requestSampling?: number + }, + /** + * Configuration for RASP + */ + rasp?: { + /** Whether to enable RASP. + * @default false + */ + enabled?: boolean } }; diff --git a/packages/dd-trace/src/appsec/addresses.js b/packages/dd-trace/src/appsec/addresses.js index c2352f14a61..086052218fd 100644 --- a/packages/dd-trace/src/appsec/addresses.js +++ b/packages/dd-trace/src/appsec/addresses.js @@ -15,10 +15,12 @@ module.exports = { HTTP_INCOMING_GRAPHQL_RESOLVERS: 'graphql.server.all_resolvers', HTTP_INCOMING_GRAPHQL_RESOLVER: 'graphql.server.resolver', - HTTP_OUTGOING_BODY: 'server.response.body', + HTTP_INCOMING_RESPONSE_BODY: 'server.response.body', HTTP_CLIENT_IP: 'http.client_ip', USER_ID: 'usr.id', - WAF_CONTEXT_PROCESSOR: 'waf.context.processor' + WAF_CONTEXT_PROCESSOR: 'waf.context.processor', + + HTTP_OUTGOING_URL: 'server.io.net.url' } diff --git a/packages/dd-trace/src/appsec/channels.js b/packages/dd-trace/src/appsec/channels.js index fe4ce2fb881..57a3c29676c 100644 --- a/packages/dd-trace/src/appsec/channels.js +++ b/packages/dd-trace/src/appsec/channels.js @@ -17,5 +17,6 @@ module.exports = { setCookieChannel: dc.channel('datadog:iast:set-cookie'), nextBodyParsed: dc.channel('apm:next:body-parsed'), nextQueryParsed: dc.channel('apm:next:query-parsed'), - responseBody: dc.channel('datadog:express:response:json:start') + responseBody: dc.channel('datadog:express:response:json:start'), + httpClientRequestStart: dc.channel('apm:http:client:request:start') } diff --git a/packages/dd-trace/src/appsec/index.js b/packages/dd-trace/src/appsec/index.js index 03f4d5143c2..76e67a0ef72 100644 --- a/packages/dd-trace/src/appsec/index.js +++ b/packages/dd-trace/src/appsec/index.js @@ -26,6 +26,7 @@ const { block, setTemplates, getBlockingAction } = require('./blocking') const { passportTrackEvent } = require('./passport') const { storage } = require('../../../datadog-core') const graphql = require('./graphql') +const rasp = require('./rasp') let isEnabled = false let config @@ -37,6 +38,10 @@ function enable (_config) { appsecTelemetry.enable(_config.telemetry) graphql.enable() + if (_config.appsec.rasp.enabled) { + rasp.enable() + } + setTemplates(_config) RuleManager.loadRules(_config.appsec) @@ -203,7 +208,7 @@ function onResponseBody ({ req, body }) { // we don't support blocking at this point, so no results needed waf.run({ persistent: { - [addresses.HTTP_OUTGOING_BODY]: body + [addresses.HTTP_INCOMING_RESPONSE_BODY]: body } }, req) } @@ -237,6 +242,7 @@ function disable () { appsecTelemetry.disable() graphql.disable() + rasp.disable() remoteConfig.disableWafUpdate() diff --git a/packages/dd-trace/src/appsec/rasp.js b/packages/dd-trace/src/appsec/rasp.js new file mode 100644 index 00000000000..1a4873718b9 --- /dev/null +++ b/packages/dd-trace/src/appsec/rasp.js @@ -0,0 +1,35 @@ +'use strict' + +const { storage } = require('../../../datadog-core') +const addresses = require('./addresses') +const { httpClientRequestStart } = require('./channels') +const waf = require('./waf') + +function enable () { + httpClientRequestStart.subscribe(analyzeSsrf) +} + +function disable () { + if (httpClientRequestStart.hasSubscribers) httpClientRequestStart.unsubscribe(analyzeSsrf) +} + +function analyzeSsrf (ctx) { + const store = storage.getStore() + const req = store?.req + const url = ctx.args.uri + + if (!req || !url) return + + const persistent = { + [addresses.HTTP_OUTGOING_URL]: url + } + // TODO: Currently this is only monitoring, we should + // block the request if SSRF attempt and + // generate stack traces + waf.run({ persistent }, req) +} + +module.exports = { + enable, + disable +} diff --git a/packages/dd-trace/src/config.js b/packages/dd-trace/src/config.js index ca0146c3af7..fb7367486e7 100644 --- a/packages/dd-trace/src/config.js +++ b/packages/dd-trace/src/config.js @@ -436,6 +436,7 @@ class Config { this._setValue(defaults, 'appsec.enabled', undefined) this._setValue(defaults, 'appsec.obfuscatorKeyRegex', defaultWafObfuscatorKeyRegex) this._setValue(defaults, 'appsec.obfuscatorValueRegex', defaultWafObfuscatorValueRegex) + this._setValue(defaults, 'appsec.rasp.enabled', false) this._setValue(defaults, 'appsec.rateLimit', 100) this._setValue(defaults, 'appsec.rules', undefined) this._setValue(defaults, 'appsec.sca.enabled', null) @@ -527,6 +528,7 @@ class Config { DD_APPSEC_OBFUSCATION_PARAMETER_VALUE_REGEXP, DD_APPSEC_RULES, DD_APPSEC_SCA_ENABLED, + DD_APPSEC_RASP_ENABLED, DD_APPSEC_TRACE_RATE_LIMIT, DD_APPSEC_WAF_TIMEOUT, DD_DATA_STREAMS_ENABLED, @@ -616,6 +618,7 @@ class Config { this._setBoolean(env, 'appsec.enabled', DD_APPSEC_ENABLED) this._setString(env, 'appsec.obfuscatorKeyRegex', DD_APPSEC_OBFUSCATION_PARAMETER_KEY_REGEXP) this._setString(env, 'appsec.obfuscatorValueRegex', DD_APPSEC_OBFUSCATION_PARAMETER_VALUE_REGEXP) + this._setBoolean(env, 'appsec.rasp.enabled', DD_APPSEC_RASP_ENABLED) this._setValue(env, 'appsec.rateLimit', maybeInt(DD_APPSEC_TRACE_RATE_LIMIT)) this._setString(env, 'appsec.rules', DD_APPSEC_RULES) // DD_APPSEC_SCA_ENABLED is never used locally, but only sent to the backend @@ -744,6 +747,7 @@ class Config { this._setBoolean(opts, 'appsec.enabled', options.appsec.enabled) this._setString(opts, 'appsec.obfuscatorKeyRegex', options.appsec.obfuscatorKeyRegex) this._setString(opts, 'appsec.obfuscatorValueRegex', options.appsec.obfuscatorValueRegex) + this._setBoolean(opts, 'appsec.rasp.enabled', options.appsec.rasp?.enabled) this._setValue(opts, 'appsec.rateLimit', maybeInt(options.appsec.rateLimit)) this._setString(opts, 'appsec.rules', options.appsec.rules) this._setValue(opts, 'appsec.wafTimeout', maybeInt(options.appsec.wafTimeout)) diff --git a/packages/dd-trace/test/appsec/index.spec.js b/packages/dd-trace/test/appsec/index.spec.js index 6f12e88871f..febac128f83 100644 --- a/packages/dd-trace/test/appsec/index.spec.js +++ b/packages/dd-trace/test/appsec/index.spec.js @@ -42,6 +42,7 @@ describe('AppSec Index', () => { let appsecTelemetry let graphql let apiSecuritySampler + let rasp const RULES = { rules: [{ a: 1 }] } @@ -63,6 +64,9 @@ describe('AppSec Index', () => { apiSecurity: { enabled: false, requestSampling: 0 + }, + rasp: { + enabled: true } } } @@ -99,6 +103,11 @@ describe('AppSec Index', () => { sinon.spy(apiSecuritySampler, 'sampleRequest') sinon.spy(apiSecuritySampler, 'isSampled') + rasp = { + enable: sinon.stub(), + disable: sinon.stub() + } + AppSec = proxyquire('../../src/appsec', { '../log': log, '../plugins/util/web': web, @@ -106,7 +115,8 @@ describe('AppSec Index', () => { './passport': passport, './telemetry': appsecTelemetry, './graphql': graphql, - './api_security_sampler': apiSecuritySampler + './api_security_sampler': apiSecuritySampler, + './rasp': rasp }) sinon.stub(fs, 'readFileSync').returns(JSON.stringify(RULES)) @@ -183,6 +193,19 @@ describe('AppSec Index', () => { expect(appsecTelemetry.enable).to.be.calledOnceWithExactly(config.telemetry) }) + + it('should call rasp enable', () => { + AppSec.enable(config) + + expect(rasp.enable).to.be.calledOnceWithExactly() + }) + + it('should not call rasp enable when rasp is disabled', () => { + config.appsec.rasp.enabled = false + AppSec.enable(config) + + expect(rasp.enable).to.not.be.called + }) }) describe('disable', () => { @@ -204,6 +227,7 @@ describe('AppSec Index', () => { .to.have.been.calledOnceWithExactly(AppSec.incomingHttpStartTranslator) expect(incomingHttpRequestEnd.unsubscribe).to.have.been.calledOnceWithExactly(AppSec.incomingHttpEndTranslator) expect(graphql.disable).to.have.been.calledOnceWithExactly() + expect(rasp.disable).to.have.been.calledOnceWithExactly() }) it('should disable AppSec when DC channels are not active', () => { @@ -592,7 +616,7 @@ describe('AppSec Index', () => { expect(apiSecuritySampler.isSampled).to.have.been.calledOnceWith(req) expect(waf.run).to.been.calledOnceWith({ persistent: { - [addresses.HTTP_OUTGOING_BODY]: body + [addresses.HTTP_INCOMING_RESPONSE_BODY]: body } }, req) }) diff --git a/packages/dd-trace/test/appsec/rasp.express.plugin.spec.js b/packages/dd-trace/test/appsec/rasp.express.plugin.spec.js new file mode 100644 index 00000000000..249af2ae727 --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp.express.plugin.spec.js @@ -0,0 +1,116 @@ +'use strict' + +const Axios = require('axios') +const agent = require('../plugins/agent') +const getPort = require('get-port') +const appsec = require('../../src/appsec') +const Config = require('../../src/config') +const path = require('path') +const { assert } = require('chai') + +withVersions('express', 'express', expressVersion => { + describe('RASP', () => { + let app, server, port, axios + + before(() => { + return agent.load(['http'], { client: false }) + }) + + before((done) => { + const express = require(`../../../../versions/express@${expressVersion}`).get() + const expressApp = express() + + expressApp.get('/', (req, res) => { + app(req, res) + }) + + appsec.enable(new Config({ + appsec: { + enabled: true, + rules: path.join(__dirname, 'rasp_rules.json'), + rasp: { enabled: true } + } + })) + + getPort().then(newPort => { + port = newPort + axios = Axios.create({ + baseURL: `http://localhost:${port}` + }) + server = expressApp.listen(port, () => { + done() + }) + }) + }) + + after(() => { + appsec.disable() + server.close() + return agent.close({ ritmReset: false }) + }) + + function getWebSpan (traces) { + for (const trace of traces) { + for (const span of trace) { + if (span.type === 'web') { + return span + } + } + } + throw new Error('web span not found') + } + + describe('ssrf', () => { + ['http', 'https'].forEach(protocol => { + describe(`Test using ${protocol}`, () => { + it('Should not detect threat', async () => { + app = (req, res) => { + require(protocol).get(`${protocol}://${req.query.host}`) + res.end('end') + } + + axios.get('/?host=www.datadoghq.com') + + await agent.use((traces) => { + const span = getWebSpan(traces) + assert.notProperty(span.meta, '_dd.appsec.json') + }) + }) + + it('Should detect threat doing a GET request', async () => { + app = (req, res) => { + require(protocol).get(`${protocol}://${req.query.host}`) + res.end('end') + } + + axios.get('/?host=ifconfig.pro') + + await agent.use((traces) => { + const span = getWebSpan(traces) + assert.property(span.meta, '_dd.appsec.json') + assert(span.meta['_dd.appsec.json'].includes('rasp-ssrf-rule-id-1')) + }) + }) + + it('Should detect threat doing a POST request', async () => { + app = (req, res) => { + const clientRequest = require(protocol) + .request(`${protocol}://${req.query.host}`, { method: 'POST' }) + clientRequest.write('dummy_post_data') + clientRequest.end() + res.end('end') + } + + axios.get('/?host=ifconfig.pro') + + await agent.use((traces) => { + const span = getWebSpan(traces) + assert.property(span.meta, '_dd.appsec.json') + assert(span.meta['_dd.appsec.json'].includes('rasp-ssrf-rule-id-1')) + }) + }) + }) + }) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/rasp.spec.js b/packages/dd-trace/test/appsec/rasp.spec.js new file mode 100644 index 00000000000..7f7d6dc4c50 --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp.spec.js @@ -0,0 +1,99 @@ +'use strict' + +const proxyquire = require('proxyquire') +const { httpClientRequestStart } = require('../../src/appsec/channels') +const addresses = require('../../src/appsec/addresses') + +describe('RASP', () => { + let waf, rasp, datadogCore + beforeEach(() => { + datadogCore = { + storage: { + getStore: sinon.stub() + } + } + waf = { + run: sinon.stub() + } + + rasp = proxyquire('../../src/appsec/rasp', { + '../../../datadog-core': datadogCore, + './waf': waf + }) + + rasp.enable() + }) + + afterEach(() => { + rasp.disable() + }) + + describe('analyzeSsrf', () => { + it('should analyze ssrf', () => { + const ctx = { + args: { + uri: 'http://example.com' + } + } + const req = {} + datadogCore.storage.getStore.returns({ req }) + + httpClientRequestStart.publish(ctx) + + const persistent = { [addresses.HTTP_OUTGOING_URL]: 'http://example.com' } + sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req) + }) + + it('should not analyze ssrf if rasp is disabled', () => { + rasp.disable() + const ctx = { + args: { + uri: 'http://example.com' + } + } + const req = {} + datadogCore.storage.getStore.returns({ req }) + + httpClientRequestStart.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should not analyze ssrf if no store', () => { + const ctx = { + args: { + uri: 'http://example.com' + } + } + datadogCore.storage.getStore.returns(undefined) + + httpClientRequestStart.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should not analyze ssrf if no req', () => { + const ctx = { + args: { + uri: 'http://example.com' + } + } + datadogCore.storage.getStore.returns({}) + + httpClientRequestStart.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should not analyze ssrf if no url', () => { + const ctx = { + args: {} + } + datadogCore.storage.getStore.returns({}) + + httpClientRequestStart.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/rasp_rules.json b/packages/dd-trace/test/appsec/rasp_rules.json new file mode 100644 index 00000000000..7b01675dcaa --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp_rules.json @@ -0,0 +1,58 @@ +{ + "version": "2.2", + "metadata": { + "rules_version": "1.99.0" + }, + "rules": [ + { + "id": "rasp-ssrf-rule-id-1", + "name": "Server-side request forgery exploit", + "enabled": true, + "tags": { + "type": "ssrf", + "category": "vulnerability_trigger", + "cwe": "918", + "capec": "1000/225/115/664", + "confidence": "0", + "module": "rasp" + }, + "conditions": [ + { + "parameters": { + "resource": [ + { + "address": "server.io.net.url" + } + ], + "params": [ + { + "address": "server.request.query" + }, + { + "address": "server.request.body" + }, + { + "address": "server.request.path_params" + }, + { + "address": "grpc.server.request.message" + }, + { + "address": "graphql.server.all_resolvers" + }, + { + "address": "graphql.server.resolver" + } + ] + }, + "operator": "ssrf_detector" + } + ], + "transformers": [], + "on_match": [ + "block", + "stack_trace" + ] + } + ] +} diff --git a/packages/dd-trace/test/config.spec.js b/packages/dd-trace/test/config.spec.js index a93c0f57483..c19a8405515 100644 --- a/packages/dd-trace/test/config.spec.js +++ b/packages/dd-trace/test/config.spec.js @@ -211,6 +211,7 @@ describe('Config', () => { expect(config).to.have.nested.property('experimental.enableGetRumData', false) expect(config).to.have.nested.property('appsec.enabled', undefined) expect(config).to.have.nested.property('appsec.rules', undefined) + expect(config).to.have.nested.property('appsec.rasp.enabled', false) expect(config).to.have.nested.property('appsec.rateLimit', 100) expect(config).to.have.nested.property('appsec.wafTimeout', 5e3) expect(config).to.have.nested.property('appsec.obfuscatorKeyRegex').with.length(155) @@ -252,6 +253,7 @@ describe('Config', () => { value: '(?i)(?:p(?:ass)?w(?:or)?d|pass(?:_?phrase)?|secret|(?:api_?|private_?|public_?|access_?|secret_?)key(?:_?id)?|token|consumer_?(?:id|key|secret)|sign(?:ed|ature)?|auth(?:entication|orization)?)(?:\\s*=[^;]|"\\s*:\\s*"[^"]+")|bearer\\s+[a-z0-9\\._\\-]+|token:[a-z0-9]{13}|gh[opsu]_[0-9a-zA-Z]{36}|ey[I-L][\\w=-]+\\.ey[I-L][\\w=-]+(?:\\.[\\w.+\\/=-]+)?|[\\-]{5}BEGIN[a-z\\s]+PRIVATE\\sKEY[\\-]{5}[^\\-]+[\\-]{5}END[a-z\\s]+PRIVATE\\sKEY|ssh-rsa\\s*[a-z0-9\\/\\.+]{100,}', origin: 'default' }, + { name: 'appsec.rasp.enabled', value: false, origin: 'default' }, { name: 'appsec.rateLimit', value: 100, origin: 'default' }, { name: 'appsec.rules', value: undefined, origin: 'default' }, { name: 'appsec.sca.enabled', value: null, origin: 'default' }, @@ -417,6 +419,7 @@ describe('Config', () => { process.env.DD_TRACE_REMOVE_INTEGRATION_SERVICE_NAMES_ENABLED = 'true' process.env.DD_TRACE_REMOVE_INTEGRATION_SERVICE_NAMES_ENABLED = true process.env.DD_APPSEC_ENABLED = 'true' + process.env.DD_APPSEC_RASP_ENABLED = 'true' process.env.DD_APPSEC_RULES = RULES_JSON_PATH process.env.DD_APPSEC_TRACE_RATE_LIMIT = '42' process.env.DD_APPSEC_WAF_TIMEOUT = '42' @@ -504,6 +507,7 @@ describe('Config', () => { expect(config).to.have.nested.property('experimental.exporter', 'log') expect(config).to.have.nested.property('experimental.enableGetRumData', true) expect(config).to.have.nested.property('appsec.enabled', true) + expect(config).to.have.nested.property('appsec.rasp.enabled', true) expect(config).to.have.nested.property('appsec.rules', RULES_JSON_PATH) expect(config).to.have.nested.property('appsec.rateLimit', 42) expect(config).to.have.nested.property('appsec.wafTimeout', 42) @@ -542,6 +546,7 @@ describe('Config', () => { { name: 'appsec.enabled', value: true, origin: 'env_var' }, { name: 'appsec.obfuscatorKeyRegex', value: '.*', origin: 'env_var' }, { name: 'appsec.obfuscatorValueRegex', value: '.*', origin: 'env_var' }, + { name: 'appsec.rasp.enabled', value: true, origin: 'env_var' }, { name: 'appsec.rateLimit', value: 42, origin: 'env_var' }, { name: 'appsec.rules', value: RULES_JSON_PATH, origin: 'env_var' }, { name: 'appsec.sca.enabled', value: true, origin: 'env_var' }, @@ -1006,6 +1011,7 @@ describe('Config', () => { process.env.DD_TRACE_EXPERIMENTAL_GET_RUM_DATA_ENABLED = 'true' process.env.DD_TRACE_EXPERIMENTAL_INTERNAL_ERRORS_ENABLED = 'true' process.env.DD_APPSEC_ENABLED = 'false' + process.env.DD_APPSEC_RASP_ENABLED = 'true' process.env.DD_APPSEC_RULES = RECOMMENDED_JSON_PATH process.env.DD_APPSEC_TRACE_RATE_LIMIT = 11 process.env.DD_APPSEC_WAF_TIMEOUT = 11 @@ -1085,6 +1091,9 @@ describe('Config', () => { apiSecurity: { enabled: true, requestSampling: 1.0 + }, + rasp: { + enabled: false } }, remoteConfig: { @@ -1124,6 +1133,7 @@ describe('Config', () => { expect(config).to.have.nested.property('experimental.exporter', 'agent') expect(config).to.have.nested.property('experimental.enableGetRumData', false) expect(config).to.have.nested.property('appsec.enabled', true) + expect(config).to.have.nested.property('appsec.rasp.enabled', false) expect(config).to.have.nested.property('appsec.rules', RULES_JSON_PATH) expect(config).to.have.nested.property('appsec.rateLimit', 42) expect(config).to.have.nested.property('appsec.wafTimeout', 42) @@ -1209,6 +1219,9 @@ describe('Config', () => { }, sca: { enabled: null + }, + rasp: { + enabled: false } }) })