diff --git a/packages/datadog-instrumentations/src/pg.js b/packages/datadog-instrumentations/src/pg.js index be09ac9e928..55642d82e96 100644 --- a/packages/datadog-instrumentations/src/pg.js +++ b/packages/datadog-instrumentations/src/pg.js @@ -53,14 +53,15 @@ function wrapQuery (query) { } return asyncResource.runInAsyncScope(() => { + const abortController = new AbortController() + startCh.publish({ params: this.connectionParameters, query: pgQuery, - processId + processId, + abortController }) - arguments[0] = pgQuery - const finish = asyncResource.bind(function (error) { if (error) { errorCh.publish(error) @@ -68,6 +69,43 @@ function wrapQuery (query) { finishCh.publish() }) + if (abortController.signal.aborted) { + const error = abortController.signal.reason || new Error('Aborted') + + // eslint-disable-next-line max-len + // Based on: https://github.com/brianc/node-postgres/blob/54eb0fa216aaccd727765641e7d1cf5da2bc483d/packages/pg/lib/client.js#L510 + const reusingQuery = typeof pgQuery.submit === 'function' + const callback = arguments[arguments.length - 1] + + finish(error) + + if (reusingQuery) { + if (!pgQuery.callback && typeof callback === 'function') { + pgQuery.callback = callback + } + + if (pgQuery.callback) { + pgQuery.callback(error) + } else { + process.nextTick(() => { + pgQuery.emit('error', error) + }) + } + + return pgQuery + } + + if (typeof callback === 'function') { + callback(error) + + return + } + + return Promise.reject(error) + } + + arguments[0] = pgQuery + const retval = query.apply(this, arguments) const queryQueue = this.queryQueue || this._queryQueue const activeQuery = this.activeQuery || this._activeQuery @@ -112,8 +150,11 @@ function wrapPoolQuery (query) { const pgQuery = arguments[0] !== null && typeof arguments[0] === 'object' ? arguments[0] : { text: arguments[0] } return asyncResource.runInAsyncScope(() => { + const abortController = new AbortController() + startPoolQueryCh.publish({ - query: pgQuery + query: pgQuery, + abortController }) const finish = asyncResource.bind(function () { @@ -121,6 +162,20 @@ function wrapPoolQuery (query) { }) const cb = arguments[arguments.length - 1] + + if (abortController.signal.aborted) { + const error = abortController.signal.reason || new Error('Aborted') + finish() + + if (typeof cb === 'function') { + cb(error) + + return + } else { + return Promise.reject(error) + } + } + if (typeof cb === 'function') { arguments[arguments.length - 1] = shimmer.wrapFunction(cb, cb => function () { finish() diff --git a/packages/datadog-instrumentations/test/pg.spec.js b/packages/datadog-instrumentations/test/pg.spec.js new file mode 100644 index 00000000000..21d1bfc0951 --- /dev/null +++ b/packages/datadog-instrumentations/test/pg.spec.js @@ -0,0 +1,244 @@ +'use strict' + +const agent = require('../../dd-trace/test/plugins/agent') +const dc = require('dc-polyfill') +const { assert } = require('chai') + +const clients = { + pg: pg => pg.Client +} + +if (process.env.PG_TEST_NATIVE === 'true') { + clients['pg.native'] = pg => pg.native.Client +} + +describe('pg instrumentation', () => { + withVersions('pg', 'pg', version => { + const queryClientStartChannel = dc.channel('apm:pg:query:start') + const queryPoolStartChannel = dc.channel('datadog:pg:pool:query:start') + + let pg + let Query + + function abortQuery ({ abortController }) { + const error = new Error('Test') + abortController.abort(error) + + if (!abortController.signal.reason) { + abortController.signal.reason = error + } + } + + before(() => { + return agent.load(['pg']) + }) + + describe('pg.Client', () => { + Object.keys(clients).forEach(implementation => { + describe(implementation, () => { + let client + + beforeEach(done => { + pg = require(`../../../versions/pg@${version}`).get() + const Client = clients[implementation](pg) + Query = Client.Query + + client = new Client({ + host: '127.0.0.1', + user: 'postgres', + password: 'postgres', + database: 'postgres', + application_name: 'test' + }) + + client.connect(err => done(err)) + }) + + afterEach(() => { + client.end() + }) + + describe('abortController', () => { + afterEach(() => { + if (queryClientStartChannel.hasSubscribers) { + queryClientStartChannel.unsubscribe(abortQuery) + } + }) + + describe('using callback', () => { + it('Should not fail if it is not aborted', (done) => { + client.query('SELECT 1', (err) => { + done(err) + }) + }) + + it('Should abort query', (done) => { + queryClientStartChannel.subscribe(abortQuery) + + client.query('SELECT 1', (err) => { + assert.propertyVal(err, 'message', 'Test') + done() + }) + }) + }) + + describe('using promise', () => { + it('Should not fail if it is not aborted', async () => { + await client.query('SELECT 1') + }) + + it('Should abort query', async () => { + queryClientStartChannel.subscribe(abortQuery) + + try { + await client.query('SELECT 1') + } catch (err) { + assert.propertyVal(err, 'message', 'Test') + + return + } + + throw new Error('Query was not aborted') + }) + }) + + describe('using query object', () => { + describe('without callback', () => { + it('Should not fail if it is not aborted', (done) => { + const query = new Query('SELECT 1') + + client.query(query) + + query.on('end', () => { + done() + }) + }) + + it('Should abort query', (done) => { + queryClientStartChannel.subscribe(abortQuery) + + const query = new Query('SELECT 1') + + client.query(query) + + query.on('error', err => { + assert.propertyVal(err, 'message', 'Test') + done() + }) + + query.on('end', () => { + done(new Error('Query was not aborted')) + }) + }) + }) + + describe('with callback in query object', () => { + it('Should not fail if it is not aborted', (done) => { + const query = new Query('SELECT 1') + query.callback = (err) => { + done(err) + } + + client.query(query) + }) + + it('Should abort query', (done) => { + queryClientStartChannel.subscribe(abortQuery) + + const query = new Query('SELECT 1') + query.callback = err => { + assert.propertyVal(err, 'message', 'Test') + done() + } + + client.query(query) + }) + }) + + describe('with callback in query parameter', () => { + it('Should not fail if it is not aborted', (done) => { + const query = new Query('SELECT 1') + + client.query(query, (err) => { + done(err) + }) + }) + + it('Should abort query', (done) => { + queryClientStartChannel.subscribe(abortQuery) + + const query = new Query('SELECT 1') + + client.query(query, err => { + assert.propertyVal(err, 'message', 'Test') + done() + }) + }) + }) + }) + }) + }) + }) + }) + + describe('pg.Pool', () => { + let pool + + beforeEach(() => { + const { Pool } = require(`../../../versions/pg@${version}`).get() + + pool = new Pool({ + host: '127.0.0.1', + user: 'postgres', + password: 'postgres', + database: 'postgres', + application_name: 'test' + }) + }) + + describe('abortController', () => { + afterEach(() => { + if (queryPoolStartChannel.hasSubscribers) { + queryPoolStartChannel.unsubscribe(abortQuery) + } + }) + + describe('using callback', () => { + it('Should not fail if it is not aborted', (done) => { + pool.query('SELECT 1', (err) => { + done(err) + }) + }) + + it('Should abort query', (done) => { + queryPoolStartChannel.subscribe(abortQuery) + + pool.query('SELECT 1', (err) => { + assert.propertyVal(err, 'message', 'Test') + done() + }) + }) + }) + + describe('using promise', () => { + it('Should not fail if it is not aborted', async () => { + await pool.query('SELECT 1') + }) + + it('Should abort query', async () => { + queryPoolStartChannel.subscribe(abortQuery) + + try { + await pool.query('SELECT 1') + } catch (err) { + assert.propertyVal(err, 'message', 'Test') + return + } + + throw new Error('Query was not aborted') + }) + }) + }) + }) + }) +}) diff --git a/packages/dd-trace/src/appsec/addresses.js b/packages/dd-trace/src/appsec/addresses.js index 086052218fd..e2cf6c6940a 100644 --- a/packages/dd-trace/src/appsec/addresses.js +++ b/packages/dd-trace/src/appsec/addresses.js @@ -22,5 +22,7 @@ module.exports = { USER_ID: 'usr.id', WAF_CONTEXT_PROCESSOR: 'waf.context.processor', - HTTP_OUTGOING_URL: 'server.io.net.url' + HTTP_OUTGOING_URL: 'server.io.net.url', + DB_STATEMENT: 'server.db.statement', + DB_SYSTEM: 'server.db.system' } diff --git a/packages/dd-trace/src/appsec/channels.js b/packages/dd-trace/src/appsec/channels.js index 66781d88821..c098efd5538 100644 --- a/packages/dd-trace/src/appsec/channels.js +++ b/packages/dd-trace/src/appsec/channels.js @@ -21,6 +21,8 @@ module.exports = { responseWriteHead: dc.channel('apm:http:server:response:writeHead:start'), httpClientRequestStart: dc.channel('apm:http:client:request:start'), responseSetHeader: dc.channel('datadog:http:server:response:set-header:start'), - setUncaughtExceptionCaptureCallbackStart: dc.channel('datadog:process:setUncaughtExceptionCaptureCallback:start') - + setUncaughtExceptionCaptureCallbackStart: dc.channel('datadog:process:setUncaughtExceptionCaptureCallback:start'), + pgQueryStart: dc.channel('apm:pg:query:start'), + pgPoolQueryStart: dc.channel('datadog:pg:pool:query:start'), + wafRunFinished: dc.channel('datadog:waf:run:finish') } diff --git a/packages/dd-trace/src/appsec/rasp.js b/packages/dd-trace/src/appsec/rasp.js deleted file mode 100644 index de13c33e4e9..00000000000 --- a/packages/dd-trace/src/appsec/rasp.js +++ /dev/null @@ -1,176 +0,0 @@ -'use strict' - -const { storage } = require('../../../datadog-core') -const web = require('./../plugins/util/web') -const addresses = require('./addresses') -const { httpClientRequestStart, setUncaughtExceptionCaptureCallbackStart } = require('./channels') -const { reportStackTrace } = require('./stack_trace') -const waf = require('./waf') -const { getBlockingAction, block } = require('./blocking') -const log = require('../log') - -const RULE_TYPES = { - SSRF: 'ssrf' -} - -class DatadogRaspAbortError extends Error { - constructor (req, res, blockingAction) { - super('DatadogRaspAbortError') - this.name = 'DatadogRaspAbortError' - this.req = req - this.res = res - this.blockingAction = blockingAction - } -} - -let config, abortOnUncaughtException - -function removeAllListeners (emitter, event) { - const listeners = emitter.listeners(event) - emitter.removeAllListeners(event) - - let cleaned = false - return function () { - if (cleaned === true) { - return - } - cleaned = true - - for (let i = 0; i < listeners.length; ++i) { - emitter.on(event, listeners[i]) - } - } -} - -function findDatadogRaspAbortError (err, deep = 10) { - if (err instanceof DatadogRaspAbortError) { - return err - } - - if (err.cause && deep > 0) { - return findDatadogRaspAbortError(err.cause, deep - 1) - } -} - -function handleUncaughtExceptionMonitor (err) { - const abortError = findDatadogRaspAbortError(err) - if (!abortError) return - - const { req, res, blockingAction } = abortError - block(req, res, web.root(req), null, blockingAction) - - if (!process.hasUncaughtExceptionCaptureCallback()) { - const cleanUp = removeAllListeners(process, 'uncaughtException') - const handler = () => { - process.removeListener('uncaughtException', handler) - } - - setTimeout(() => { - process.removeListener('uncaughtException', handler) - cleanUp() - }) - - process.on('uncaughtException', handler) - } else { - // uncaughtException event is not executed when hasUncaughtExceptionCaptureCallback is true - let previousCb - const cb = ({ currentCallback, abortController }) => { - setUncaughtExceptionCaptureCallbackStart.unsubscribe(cb) - if (!currentCallback) { - abortController.abort() - return - } - - previousCb = currentCallback - } - - setUncaughtExceptionCaptureCallbackStart.subscribe(cb) - - process.setUncaughtExceptionCaptureCallback(null) - - // For some reason, previous callback was defined before the instrumentation - // We can not restore it, so we let the app decide - if (previousCb) { - process.setUncaughtExceptionCaptureCallback(() => { - process.setUncaughtExceptionCaptureCallback(null) - process.setUncaughtExceptionCaptureCallback(previousCb) - }) - } - } -} - -function enable (_config) { - config = _config - httpClientRequestStart.subscribe(analyzeSsrf) - - process.on('uncaughtExceptionMonitor', handleUncaughtExceptionMonitor) - abortOnUncaughtException = process.execArgv?.includes('--abort-on-uncaught-exception') - - if (abortOnUncaughtException) { - log.warn('The --abort-on-uncaught-exception flag is enabled. The RASP module will not block operations.') - } -} - -function disable () { - if (httpClientRequestStart.hasSubscribers) httpClientRequestStart.unsubscribe(analyzeSsrf) - - process.off('uncaughtExceptionMonitor', handleUncaughtExceptionMonitor) -} - -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 - } - - const result = waf.run({ persistent }, req, RULE_TYPES.SSRF) - - const res = store?.res - handleResult(result, req, res, ctx.abortController) -} - -function getGenerateStackTraceAction (actions) { - return actions?.generate_stack -} - -function handleResult (actions, req, res, abortController) { - const generateStackTraceAction = getGenerateStackTraceAction(actions) - if (generateStackTraceAction && config.appsec.stackTrace.enabled) { - const rootSpan = web.root(req) - reportStackTrace( - rootSpan, - generateStackTraceAction.stack_id, - config.appsec.stackTrace.maxDepth, - config.appsec.stackTrace.maxStackTraces - ) - } - - if (!abortController || abortOnUncaughtException) return - - const blockingAction = getBlockingAction(actions) - if (blockingAction) { - const rootSpan = web.root(req) - // Should block only in express - if (rootSpan?.context()._name === 'express.request') { - const abortError = new DatadogRaspAbortError(req, res, blockingAction) - abortController.abort(abortError) - - // TODO Delete this when support for node 16 is removed - if (!abortController.signal.reason) { - abortController.signal.reason = abortError - } - } - } -} - -module.exports = { - enable, - disable, - handleResult, - handleUncaughtExceptionMonitor // exported only for testing purpose -} diff --git a/packages/dd-trace/src/appsec/rasp/index.js b/packages/dd-trace/src/appsec/rasp/index.js new file mode 100644 index 00000000000..801608e54d8 --- /dev/null +++ b/packages/dd-trace/src/appsec/rasp/index.js @@ -0,0 +1,103 @@ +'use strict' + +const web = require('../../plugins/util/web') +const { setUncaughtExceptionCaptureCallbackStart } = require('../channels') +const { block } = require('../blocking') +const ssrf = require('./ssrf') +const sqli = require('./sql_injection') + +const { DatadogRaspAbortError } = require('./utils') + +function removeAllListeners (emitter, event) { + const listeners = emitter.listeners(event) + emitter.removeAllListeners(event) + + let cleaned = false + return function () { + if (cleaned === true) { + return + } + cleaned = true + + for (let i = 0; i < listeners.length; ++i) { + emitter.on(event, listeners[i]) + } + } +} + +function findDatadogRaspAbortError (err, deep = 10) { + if (err instanceof DatadogRaspAbortError) { + return err + } + + if (err.cause && deep > 0) { + return findDatadogRaspAbortError(err.cause, deep - 1) + } +} + +function handleUncaughtExceptionMonitor (err) { + const abortError = findDatadogRaspAbortError(err) + if (!abortError) return + + const { req, res, blockingAction } = abortError + block(req, res, web.root(req), null, blockingAction) + + if (!process.hasUncaughtExceptionCaptureCallback()) { + const cleanUp = removeAllListeners(process, 'uncaughtException') + const handler = () => { + process.removeListener('uncaughtException', handler) + } + + setTimeout(() => { + process.removeListener('uncaughtException', handler) + cleanUp() + }) + + process.on('uncaughtException', handler) + } else { + // uncaughtException event is not executed when hasUncaughtExceptionCaptureCallback is true + let previousCb + const cb = ({ currentCallback, abortController }) => { + setUncaughtExceptionCaptureCallbackStart.unsubscribe(cb) + if (!currentCallback) { + abortController.abort() + return + } + + previousCb = currentCallback + } + + setUncaughtExceptionCaptureCallbackStart.subscribe(cb) + + process.setUncaughtExceptionCaptureCallback(null) + + // For some reason, previous callback was defined before the instrumentation + // We can not restore it, so we let the app decide + if (previousCb) { + process.setUncaughtExceptionCaptureCallback(() => { + process.setUncaughtExceptionCaptureCallback(null) + process.setUncaughtExceptionCaptureCallback(previousCb) + }) + } + } +} + +function enable (config) { + ssrf.enable(config) + sqli.enable(config) + + process.on('uncaughtExceptionMonitor', handleUncaughtExceptionMonitor) +} + +function disable () { + ssrf.disable() + sqli.disable() + + process.off('uncaughtExceptionMonitor', handleUncaughtExceptionMonitor) +} + +module.exports = { + enable, + disable, + handleUncaughtExceptionMonitor // exported only for testing purpose +} diff --git a/packages/dd-trace/src/appsec/rasp/sql_injection.js b/packages/dd-trace/src/appsec/rasp/sql_injection.js new file mode 100644 index 00000000000..b942dd82be5 --- /dev/null +++ b/packages/dd-trace/src/appsec/rasp/sql_injection.js @@ -0,0 +1,86 @@ +'use strict' + +const { pgQueryStart, pgPoolQueryStart, wafRunFinished } = require('../channels') +const { storage } = require('../../../../datadog-core') +const addresses = require('../addresses') +const waf = require('../waf') +const { RULE_TYPES, handleResult } = require('./utils') + +const DB_SYSTEM_POSTGRES = 'postgresql' +const reqQueryMap = new WeakMap() // WeakMap> + +let config + +function enable (_config) { + config = _config + + pgQueryStart.subscribe(analyzePgSqlInjection) + pgPoolQueryStart.subscribe(analyzePgSqlInjection) + wafRunFinished.subscribe(clearQuerySet) +} + +function disable () { + if (pgQueryStart.hasSubscribers) pgQueryStart.unsubscribe(analyzePgSqlInjection) + if (pgPoolQueryStart.hasSubscribers) pgPoolQueryStart.unsubscribe(analyzePgSqlInjection) + if (wafRunFinished.hasSubscribers) wafRunFinished.unsubscribe(clearQuerySet) +} + +function analyzePgSqlInjection (ctx) { + const query = ctx.query?.text + if (!query) return + + const store = storage.getStore() + if (!store) return + + const { req, res } = store + + if (!req) return + + let executedQueries = reqQueryMap.get(req) + if (executedQueries?.has(query)) return + + // Do not waste time executing same query twice + // This also will prevent double calls in pg.Pool internal queries + if (!executedQueries) { + executedQueries = new Set() + reqQueryMap.set(req, executedQueries) + } + executedQueries.add(query) + + const persistent = { + [addresses.DB_STATEMENT]: query, + [addresses.DB_SYSTEM]: DB_SYSTEM_POSTGRES + } + + const result = waf.run({ persistent }, req, RULE_TYPES.SQL_INJECTION) + + handleResult(result, req, res, ctx.abortController, config) +} + +function hasInputAddress (payload) { + return hasAddressesObjectInputAddress(payload.ephemeral) || hasAddressesObjectInputAddress(payload.persistent) +} + +function hasAddressesObjectInputAddress (addressesObject) { + return addressesObject && Object.keys(addressesObject) + .some(address => address.startsWith('server.request') || address.startsWith('graphql.server')) +} + +function clearQuerySet ({ payload }) { + if (!payload) return + + const store = storage.getStore() + if (!store) return + + const { req } = store + if (!req) return + + const executedQueries = reqQueryMap.get(req) + if (!executedQueries) return + + if (hasInputAddress(payload)) { + executedQueries.clear() + } +} + +module.exports = { enable, disable } diff --git a/packages/dd-trace/src/appsec/rasp/ssrf.js b/packages/dd-trace/src/appsec/rasp/ssrf.js new file mode 100644 index 00000000000..ae45ed7daf2 --- /dev/null +++ b/packages/dd-trace/src/appsec/rasp/ssrf.js @@ -0,0 +1,37 @@ +'use strict' + +const { httpClientRequestStart } = require('../channels') +const { storage } = require('../../../../datadog-core') +const addresses = require('../addresses') +const waf = require('../waf') +const { RULE_TYPES, handleResult } = require('./utils') + +let config + +function enable (_config) { + config = _config + 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 + } + + const result = waf.run({ persistent }, req, RULE_TYPES.SSRF) + + const res = store?.res + handleResult(result, req, res, ctx.abortController, config) +} + +module.exports = { enable, disable } diff --git a/packages/dd-trace/src/appsec/rasp/utils.js b/packages/dd-trace/src/appsec/rasp/utils.js new file mode 100644 index 00000000000..2a46b76d6e4 --- /dev/null +++ b/packages/dd-trace/src/appsec/rasp/utils.js @@ -0,0 +1,63 @@ +'use strict' + +const web = require('../../plugins/util/web') +const { reportStackTrace } = require('../stack_trace') +const { getBlockingAction } = require('../blocking') +const log = require('../../log') + +const abortOnUncaughtException = process.execArgv?.includes('--abort-on-uncaught-exception') + +if (abortOnUncaughtException) { + log.warn('The --abort-on-uncaught-exception flag is enabled. The RASP module will not block operations.') +} + +const RULE_TYPES = { + SSRF: 'ssrf', + SQL_INJECTION: 'sql_injection' +} + +class DatadogRaspAbortError extends Error { + constructor (req, res, blockingAction) { + super('DatadogRaspAbortError') + this.name = 'DatadogRaspAbortError' + this.req = req + this.res = res + this.blockingAction = blockingAction + } +} + +function handleResult (actions, req, res, abortController, config) { + const generateStackTraceAction = actions?.generate_stack + if (generateStackTraceAction && config.appsec.stackTrace.enabled) { + const rootSpan = web.root(req) + reportStackTrace( + rootSpan, + generateStackTraceAction.stack_id, + config.appsec.stackTrace.maxDepth, + config.appsec.stackTrace.maxStackTraces + ) + } + + if (!abortController || abortOnUncaughtException) return + + const blockingAction = getBlockingAction(actions) + if (blockingAction) { + const rootSpan = web.root(req) + // Should block only in express + if (rootSpan?.context()._name === 'express.request') { + const abortError = new DatadogRaspAbortError(req, res, blockingAction) + abortController.abort(abortError) + + // TODO Delete this when support for node 16 is removed + if (!abortController.signal.reason) { + abortController.signal.reason = abortError + } + } + } +} + +module.exports = { + handleResult, + RULE_TYPES, + DatadogRaspAbortError +} diff --git a/packages/dd-trace/src/appsec/waf/waf_context_wrapper.js b/packages/dd-trace/src/appsec/waf/waf_context_wrapper.js index 4ddf00f7f20..ed946633174 100644 --- a/packages/dd-trace/src/appsec/waf/waf_context_wrapper.js +++ b/packages/dd-trace/src/appsec/waf/waf_context_wrapper.js @@ -4,6 +4,7 @@ const log = require('../../log') const Reporter = require('../reporter') const addresses = require('../addresses') const { getBlockingAction } = require('../blocking') +const { wafRunFinished } = require('../channels') // TODO: remove once ephemeral addresses are implemented const preventDuplicateAddresses = new Set([ @@ -94,6 +95,10 @@ class WAFContextWrapper { Reporter.reportSchemas(result.derivatives) + if (wafRunFinished.hasSubscribers) { + wafRunFinished.publish({ payload }) + } + return result.actions } catch (err) { log.error('Error while running the AppSec WAF') diff --git a/packages/dd-trace/test/appsec/rasp.spec.js b/packages/dd-trace/test/appsec/rasp.spec.js deleted file mode 100644 index c594a2a98c2..00000000000 --- a/packages/dd-trace/test/appsec/rasp.spec.js +++ /dev/null @@ -1,180 +0,0 @@ -'use strict' - -const proxyquire = require('proxyquire') -const { httpClientRequestStart } = require('../../src/appsec/channels') -const addresses = require('../../src/appsec/addresses') -const { handleUncaughtExceptionMonitor } = require('../../src/appsec/rasp') - -describe('RASP', () => { - let waf, rasp, datadogCore, stackTrace, web - - beforeEach(() => { - datadogCore = { - storage: { - getStore: sinon.stub() - } - } - waf = { - run: sinon.stub() - } - - stackTrace = { - reportStackTrace: sinon.stub() - } - - web = { - root: sinon.stub() - } - - rasp = proxyquire('../../src/appsec/rasp', { - '../../../datadog-core': datadogCore, - './waf': waf, - './stack_trace': stackTrace, - './../plugins/util/web': web - }) - - const config = { - appsec: { - stackTrace: { - enabled: true, - maxStackTraces: 2, - maxDepth: 42 - } - } - } - - rasp.enable(config) - }) - - afterEach(() => { - sinon.restore() - rasp.disable() - }) - - describe('handleResult', () => { - it('should report stack trace when generate_stack action is present in waf result', () => { - const req = {} - const rootSpan = {} - const stackId = 'test_stack_id' - const result = { - generate_stack: { - stack_id: stackId - } - } - - web.root.returns(rootSpan) - - rasp.handleResult(result, req) - sinon.assert.calledOnceWithExactly(stackTrace.reportStackTrace, rootSpan, stackId, 42, 2) - }) - - it('should not report stack trace when no action is present in waf result', () => { - const req = {} - const result = {} - - rasp.handleResult(result, req) - sinon.assert.notCalled(stackTrace.reportStackTrace) - }) - - it('should not report stack trace when stack trace reporting is disabled', () => { - const req = {} - const result = { - generate_stack: { - stack_id: 'stackId' - } - } - const config = { - appsec: { - stackTrace: { - enabled: false, - maxStackTraces: 2, - maxDepth: 42 - } - } - } - - rasp.enable(config) - - rasp.handleResult(result, req) - sinon.assert.notCalled(stackTrace.reportStackTrace) - }) - }) - - 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, 'ssrf') - }) - - 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) - }) - }) - - describe('handleUncaughtExceptionMonitor', () => { - it('should not break with infinite loop of cause', () => { - const err = new Error() - err.cause = err - - handleUncaughtExceptionMonitor(err) - }) - }) -}) diff --git a/packages/dd-trace/test/appsec/rasp/index.spec.js b/packages/dd-trace/test/appsec/rasp/index.spec.js new file mode 100644 index 00000000000..0dae9c527e5 --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/index.spec.js @@ -0,0 +1,34 @@ +'use strict' + +const rasp = require('../../../src/appsec/rasp') +const { handleUncaughtExceptionMonitor } = require('../../../src/appsec/rasp') + +describe('RASP', () => { + beforeEach(() => { + const config = { + appsec: { + stackTrace: { + enabled: true, + maxStackTraces: 2, + maxDepth: 42 + } + } + } + + rasp.enable(config) + }) + + afterEach(() => { + sinon.restore() + rasp.disable() + }) + + describe('handleUncaughtExceptionMonitor', () => { + it('should not break with infinite loop of cause', () => { + const err = new Error() + err.cause = err + + handleUncaughtExceptionMonitor(err) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/rasp/resources/postgress-app/index.js b/packages/dd-trace/test/appsec/rasp/resources/postgress-app/index.js new file mode 100644 index 00000000000..e60041bfe7c --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/resources/postgress-app/index.js @@ -0,0 +1,55 @@ +'use strict' + +const tracer = require('dd-trace') +tracer.init({ + flushInterval: 0 +}) + +const express = require('express') +const pg = require('pg') + +const connectionData = { + host: '127.0.0.1', + user: 'postgres', + password: 'postgres', + database: 'postgres', + application_name: 'test' +} + +const pool = new pg.Pool(connectionData) + +const app = express() +const port = process.env.APP_PORT || 3000 + +app.get('/sqli/client/uncaught-promise', async (req, res) => { + const client = new pg.Client(connectionData) + await client.connect() + + try { + await client.query(`SELECT * FROM users WHERE id = '${req.query.param}'`) + } finally { + client.end() + } + + res.end('OK') +}) + +app.get('/sqli/client/uncaught-query-error', async (req, res) => { + const client = new pg.Client(connectionData) + await client.connect() + const query = new pg.Query(`SELECT * FROM users WHERE id = '${req.query.param}'`) + client.query(query) + + query.on('end', () => { + res.end('OK') + }) +}) + +app.get('/sqli/pool/uncaught-promise', async (req, res) => { + await pool.query(`SELECT * FROM users WHERE id = '${req.query.param}'`) + res.end('OK') +}) + +app.listen(port, () => { + process.send({ port }) +}) diff --git a/packages/dd-trace/test/appsec/rasp_rules.json b/packages/dd-trace/test/appsec/rasp/resources/rasp_rules.json similarity index 53% rename from packages/dd-trace/test/appsec/rasp_rules.json rename to packages/dd-trace/test/appsec/rasp/resources/rasp_rules.json index 28930412b9a..778e4821e73 100644 --- a/packages/dd-trace/test/appsec/rasp_rules.json +++ b/packages/dd-trace/test/appsec/rasp/resources/rasp_rules.json @@ -56,6 +56,57 @@ "block", "stack_trace" ] + }, + { + "id": "rasp-sqli-rule-id-2", + "name": "SQL injection exploit", + "tags": { + "type": "sql_injection", + "category": "vulnerability_trigger", + "cwe": "89", + "capec": "1000/152/248/66", + "confidence": "0", + "module": "rasp" + }, + "conditions": [ + { + "parameters": { + "resource": [ + { + "address": "server.db.statement" + } + ], + "params": [ + { + "address": "server.request.query" + }, + { + "address": "server.request.body" + }, + { + "address": "server.request.path_params" + }, + { + "address": "graphql.server.all_resolvers" + }, + { + "address": "graphql.server.resolver" + } + ], + "db_type": [ + { + "address": "server.db.system" + } + ] + }, + "operator": "sqli_detector" + } + ], + "transformers": [], + "on_match": [ + "block", + "stack_trace" + ] } ] } diff --git a/packages/dd-trace/test/appsec/rasp/sql_injection.integration.pg.plugin.spec.js b/packages/dd-trace/test/appsec/rasp/sql_injection.integration.pg.plugin.spec.js new file mode 100644 index 00000000000..c4b92b3a2f3 --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/sql_injection.integration.pg.plugin.spec.js @@ -0,0 +1,107 @@ +'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') + +// These test are here and not in the integration tests +// because they require postgres instance +describe('RASP - sql_injection - integration', () => { + let axios, sandbox, cwd, appPort, appFile, agent, proc + + before(async function () { + this.timeout(60000) + sandbox = await createSandbox( + ['express', 'pg'], + false, + [path.join(__dirname, 'resources')]) + + appPort = await getPort() + cwd = sandbox.folder + appFile = path.join(cwd, 'resources', 'postgress-app', '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_APPSEC_RASP_ENABLED: true, + DD_APPSEC_RULES: path.join(cwd, 'resources', 'rasp_rules.json') + } + }) + }) + + afterEach(async () => { + proc.kill() + await agent.stop() + }) + + it('should block using pg.Client and unhandled promise', async () => { + try { + await axios.get('/sqli/client/uncaught-promise?param=\' OR 1 = 1 --') + } catch (e) { + if (!e.response) { + throw e + } + + assert.strictEqual(e.response.status, 403) + return await agent.assertMessageReceived(({ headers, payload }) => { + assert.property(payload[0][0].meta, '_dd.appsec.json') + assert.include(payload[0][0].meta['_dd.appsec.json'], '"rasp-sqli-rule-id-2"') + }) + } + + throw new Error('Request should be blocked') + }) + + it('should block using pg.Client and unhandled query object', async () => { + try { + await axios.get('/sqli/client/uncaught-query-error?param=\' OR 1 = 1 --') + } catch (e) { + if (!e.response) { + throw e + } + + assert.strictEqual(e.response.status, 403) + return await agent.assertMessageReceived(({ headers, payload }) => { + assert.property(payload[0][0].meta, '_dd.appsec.json') + assert.include(payload[0][0].meta['_dd.appsec.json'], '"rasp-sqli-rule-id-2"') + }) + } + + throw new Error('Request should be blocked') + }) + + it('should block using pg.Pool and unhandled promise', async () => { + try { + await axios.get('/sqli/pool/uncaught-promise?param=\' OR 1 = 1 --') + } catch (e) { + if (!e.response) { + throw e + } + + assert.strictEqual(e.response.status, 403) + return await agent.assertMessageReceived(({ headers, payload }) => { + assert.property(payload[0][0].meta, '_dd.appsec.json') + assert.include(payload[0][0].meta['_dd.appsec.json'], '"rasp-sqli-rule-id-2"') + }) + } + + throw new Error('Request should be blocked') + }) +}) diff --git a/packages/dd-trace/test/appsec/rasp/sql_injection.pg.plugin.spec.js b/packages/dd-trace/test/appsec/rasp/sql_injection.pg.plugin.spec.js new file mode 100644 index 00000000000..8f05158c22d --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/sql_injection.pg.plugin.spec.js @@ -0,0 +1,286 @@ +'use strict' + +const agent = require('../../plugins/agent') +const appsec = require('../../../src/appsec') +const { wafRunFinished } = require('../../../src/appsec/channels') +const addresses = require('../../../src/appsec/addresses') +const Config = require('../../../src/config') +const path = require('path') +const Axios = require('axios') +const { assert } = require('chai') +const { checkRaspExecutedAndNotThreat, checkRaspExecutedAndHasThreat } = require('./utils') + +describe('RASP - sql_injection', () => { + withVersions('pg', 'express', expressVersion => { + withVersions('pg', 'pg', pgVersion => { + describe('sql injection with pg', () => { + const connectionData = { + host: '127.0.0.1', + user: 'postgres', + password: 'postgres', + database: 'postgres', + application_name: 'test' + } + let server, axios, app, pg + + before(() => { + return agent.load(['express', 'http', 'pg'], { client: false }) + }) + + before(done => { + const express = require(`../../../../../versions/express@${expressVersion}`).get() + pg = require(`../../../../../versions/pg@${pgVersion}`).get() + const expressApp = express() + + expressApp.get('/', (req, res) => { + app(req, res) + }) + + appsec.enable(new Config({ + appsec: { + enabled: true, + rules: path.join(__dirname, 'resources', 'rasp_rules.json'), + rasp: { enabled: true } + } + })) + + server = expressApp.listen(0, () => { + const port = server.address().port + axios = Axios.create({ + baseURL: `http://localhost:${port}` + }) + done() + }) + }) + + after(() => { + appsec.disable() + server.close() + return agent.close({ ritmReset: false }) + }) + + describe('Test using pg.Client', () => { + let client + + beforeEach((done) => { + client = new pg.Client(connectionData) + client.connect(err => done(err)) + }) + + afterEach(() => { + client.end() + }) + + it('Should not detect threat', async () => { + app = (req, res) => { + client.query('SELECT ' + req.query.param, (err) => { + if (err) { + res.statusCode = 500 + } + + res.end() + }) + } + + axios.get('/?param=1') + + await checkRaspExecutedAndNotThreat(agent) + }) + + it('Should block query with callback', async () => { + app = (req, res) => { + client.query(`SELECT * FROM users WHERE id='${req.query.param}'`, (err) => { + if (err?.name === 'DatadogRaspAbortError') { + res.statusCode = 500 + } + res.end() + }) + } + + try { + await axios.get('/?param=\' OR 1 = 1 --') + } catch (e) { + return await checkRaspExecutedAndHasThreat(agent, 'rasp-sqli-rule-id-2') + } + + assert.fail('Request should be blocked') + }) + + it('Should block query with promise', async () => { + app = async (req, res) => { + try { + await client.query(`SELECT * FROM users WHERE id = '${req.query.param}'`) + } catch (err) { + if (err?.name === 'DatadogRaspAbortError') { + res.statusCode = 500 + } + res.end() + } + } + + try { + await axios.get('/?param=\' OR 1 = 1 --') + } catch (e) { + return checkRaspExecutedAndHasThreat(agent, 'rasp-sqli-rule-id-2') + } + + assert.fail('Request should be blocked') + }) + }) + + describe('Test using pg.Pool', () => { + let pool + + beforeEach(() => { + pool = new pg.Pool(connectionData) + }) + + it('Should not detect threat', async () => { + app = (req, res) => { + pool.query('SELECT ' + req.query.param, (err) => { + if (err) { + res.statusCode = 500 + } + + res.end() + }) + } + + axios.get('/?param=1') + + await checkRaspExecutedAndNotThreat(agent) + }) + + it('Should block query with callback', async () => { + app = (req, res) => { + pool.query(`SELECT * FROM users WHERE id='${req.query.param}'`, (err) => { + if (err?.name === 'DatadogRaspAbortError') { + res.statusCode = 500 + } + res.end() + }) + } + + try { + await axios.get('/?param=\' OR 1 = 1 --') + } catch (e) { + return checkRaspExecutedAndHasThreat(agent, 'rasp-sqli-rule-id-2') + } + + assert.fail('Request should be blocked') + }) + + it('Should block query with promise', async () => { + app = async (req, res) => { + try { + await pool.query(`SELECT * FROM users WHERE id = '${req.query.param}'`) + } catch (err) { + if (err?.name === 'DatadogRaspAbortError') { + res.statusCode = 500 + } + res.end() + } + } + + try { + await axios.get('/?param=\' OR 1 = 1 --') + } catch (e) { + return checkRaspExecutedAndHasThreat(agent, 'rasp-sqli-rule-id-2') + } + + assert.fail('Request should be blocked') + }) + + describe('double calls', () => { + const WAFContextWrapper = require('../../../src/appsec/waf/waf_context_wrapper') + let run + + beforeEach(() => { + run = sinon.spy(WAFContextWrapper.prototype, 'run') + }) + + afterEach(() => { + sinon.restore() + }) + + async function runQueryAndIgnoreError (query) { + try { + await pool.query(query) + } catch (err) { + // do nothing + } + } + + it('should call to waf only once for sql injection using pg Pool', async () => { + app = async (req, res) => { + await runQueryAndIgnoreError('SELECT 1') + res.end() + } + + await axios.get('/') + + assert.equal(run.args.filter(arg => arg[1] === 'sql_injection').length, 1) + }) + + it('should call to waf twice for sql injection with two different queries in pg Pool', async () => { + app = async (req, res) => { + await runQueryAndIgnoreError('SELECT 1') + await runQueryAndIgnoreError('SELECT 2') + + res.end() + } + + await axios.get('/') + + assert.equal(run.args.filter(arg => arg[1] === 'sql_injection').length, 2) + }) + + it('should call to waf twice for sql injection and same query when input address is updated', async () => { + app = async (req, res) => { + await runQueryAndIgnoreError('SELECT 1') + + wafRunFinished.publish({ + payload: { + persistent: { + [addresses.HTTP_INCOMING_URL]: 'test' + } + } + }) + + await runQueryAndIgnoreError('SELECT 1') + + res.end() + } + + await axios.get('/') + + assert.equal(run.args.filter(arg => arg[1] === 'sql_injection').length, 2) + }) + + it('should call to waf once for sql injection and same query when input address is updated', async () => { + app = async (req, res) => { + await runQueryAndIgnoreError('SELECT 1') + + wafRunFinished.publish({ + payload: { + persistent: { + 'not-an-input': 'test' + } + } + }) + + await runQueryAndIgnoreError('SELECT 1') + + res.end() + } + + await axios.get('/') + + assert.equal(run.args.filter(arg => arg[1] === 'sql_injection').length, 1) + }) + }) + }) + }) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/rasp/sql_injection.spec.js b/packages/dd-trace/test/appsec/rasp/sql_injection.spec.js new file mode 100644 index 00000000000..5467f7ef150 --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/sql_injection.spec.js @@ -0,0 +1,116 @@ +'use strict' + +const { pgQueryStart } = require('../../../src/appsec/channels') +const addresses = require('../../../src/appsec/addresses') +const proxyquire = require('proxyquire') + +describe('RASP - sql_injection', () => { + let waf, datadogCore, sqli + + beforeEach(() => { + datadogCore = { + storage: { + getStore: sinon.stub() + } + } + + waf = { + run: sinon.stub() + } + + sqli = proxyquire('../../../src/appsec/rasp/sql_injection', { + '../../../../datadog-core': datadogCore, + '../waf': waf + }) + + const config = { + appsec: { + stackTrace: { + enabled: true, + maxStackTraces: 2, + maxDepth: 42 + } + } + } + + sqli.enable(config) + }) + + afterEach(() => { + sinon.restore() + sqli.disable() + }) + + describe('analyzePgSqlInjection', () => { + it('should analyze sql injection', () => { + const ctx = { + query: { + text: 'SELECT 1' + } + } + const req = {} + datadogCore.storage.getStore.returns({ req }) + + pgQueryStart.publish(ctx) + + const persistent = { + [addresses.DB_STATEMENT]: 'SELECT 1', + [addresses.DB_SYSTEM]: 'postgresql' + } + sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req, 'sql_injection') + }) + + it('should not analyze sql injection if rasp is disabled', () => { + sqli.disable() + + const ctx = { + query: { + text: 'SELECT 1' + } + } + const req = {} + datadogCore.storage.getStore.returns({ req }) + + pgQueryStart.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should not analyze sql injection if no store', () => { + const ctx = { + query: { + text: 'SELECT 1' + } + } + datadogCore.storage.getStore.returns(undefined) + + pgQueryStart.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should not analyze sql injection if no req', () => { + const ctx = { + query: { + text: 'SELECT 1' + } + } + datadogCore.storage.getStore.returns({}) + + pgQueryStart.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should not analyze sql injection if no query', () => { + const ctx = { + query: {} + } + datadogCore.storage.getStore.returns({}) + + pgQueryStart.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/rasp.express.plugin.spec.js b/packages/dd-trace/test/appsec/rasp/ssrf.express.plugin.spec.js similarity index 74% rename from packages/dd-trace/test/appsec/rasp.express.plugin.spec.js rename to packages/dd-trace/test/appsec/rasp/ssrf.express.plugin.spec.js index 75924c88283..26dc25219f4 100644 --- a/packages/dd-trace/test/appsec/rasp.express.plugin.spec.js +++ b/packages/dd-trace/test/appsec/rasp/ssrf.express.plugin.spec.js @@ -1,26 +1,16 @@ 'use strict' const Axios = require('axios') -const agent = require('../plugins/agent') -const appsec = require('../../src/appsec') -const Config = require('../../src/config') +const agent = require('../../plugins/agent') +const appsec = require('../../../src/appsec') +const Config = require('../../../src/config') const path = require('path') const { assert } = require('chai') +const { checkRaspExecutedAndNotThreat, checkRaspExecutedAndHasThreat } = require('./utils') function noop () {} -describe('RASP', () => { - 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('RASP - ssrf', () => { withVersions('express', 'express', expressVersion => { let app, server, axios @@ -29,7 +19,7 @@ describe('RASP', () => { }) before((done) => { - const express = require(`../../../../versions/express@${expressVersion}`).get() + const express = require(`../../../../../versions/express@${expressVersion}`).get() const expressApp = express() expressApp.get('/', (req, res) => { @@ -39,7 +29,7 @@ describe('RASP', () => { appsec.enable(new Config({ appsec: { enabled: true, - rules: path.join(__dirname, 'rasp_rules.json'), + rules: path.join(__dirname, 'resources', 'rasp_rules.json'), rasp: { enabled: true } } })) @@ -67,15 +57,8 @@ describe('RASP', () => { if (!e.response) { throw e } - return 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')) - assert.equal(span.metrics['_dd.appsec.rasp.rule.eval'], 1) - assert(span.metrics['_dd.appsec.rasp.duration'] > 0) - assert(span.metrics['_dd.appsec.rasp.duration_ext'] > 0) - assert.property(span.meta_struct, '_dd.stack') - }) + + return checkRaspExecutedAndHasThreat(agent, 'rasp-ssrf-rule-id-1') } assert.fail('Request should be blocked') @@ -92,11 +75,7 @@ describe('RASP', () => { axios.get('/?host=www.datadoghq.com') - await agent.use((traces) => { - const span = getWebSpan(traces) - assert.notProperty(span.meta, '_dd.appsec.json') - assert.notProperty(span.meta_struct || {}, '_dd.stack') - }) + return checkRaspExecutedAndNotThreat(agent) }) it('Should detect threat doing a GET request', async () => { @@ -137,7 +116,7 @@ describe('RASP', () => { let axiosToTest beforeEach(() => { - axiosToTest = require(`../../../../versions/axios@${axiosVersion}`).get() + axiosToTest = require(`../../../../../versions/axios@${axiosVersion}`).get() }) it('Should not detect threat', async () => { @@ -148,10 +127,7 @@ describe('RASP', () => { axios.get('/?host=www.datadoghq.com') - await agent.use((traces) => { - const span = getWebSpan(traces) - assert.notProperty(span.meta, '_dd.appsec.json') - }) + return checkRaspExecutedAndNotThreat(agent) }) it('Should detect threat doing a GET request', async () => { @@ -209,7 +185,7 @@ describe('RASP', () => { appsec.enable(new Config({ appsec: { enabled: true, - rules: path.join(__dirname, 'rasp_rules.json'), + rules: path.join(__dirname, 'resources', 'rasp_rules.json'), rasp: { enabled: true } } })) @@ -260,15 +236,7 @@ describe('RASP', () => { assert.equal(response.status, 200) - 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')) - assert.equal(span.metrics['_dd.appsec.rasp.rule.eval'], 1) - assert(span.metrics['_dd.appsec.rasp.duration'] > 0) - assert(span.metrics['_dd.appsec.rasp.duration_ext'] > 0) - assert.property(span.meta_struct, '_dd.stack') - }) + return checkRaspExecutedAndHasThreat(agent, 'rasp-ssrf-rule-id-1') }) }) }) diff --git a/packages/dd-trace/test/appsec/rasp/ssrf.spec.js b/packages/dd-trace/test/appsec/rasp/ssrf.spec.js new file mode 100644 index 00000000000..c40867ea254 --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/ssrf.spec.js @@ -0,0 +1,112 @@ +'use strict' + +const proxyquire = require('proxyquire') +const { httpClientRequestStart } = require('../../../src/appsec/channels') +const addresses = require('../../../src/appsec/addresses') + +describe('RASP - ssrf.js', () => { + let waf, datadogCore, ssrf + + beforeEach(() => { + datadogCore = { + storage: { + getStore: sinon.stub() + } + } + + waf = { + run: sinon.stub() + } + + ssrf = proxyquire('../../../src/appsec/rasp/ssrf', { + '../../../../datadog-core': datadogCore, + '../waf': waf + }) + + const config = { + appsec: { + stackTrace: { + enabled: true, + maxStackTraces: 2, + maxDepth: 42 + } + } + } + + ssrf.enable(config) + }) + + afterEach(() => { + sinon.restore() + ssrf.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, 'ssrf') + }) + + it('should not analyze ssrf if rasp is disabled', () => { + ssrf.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/utils.js b/packages/dd-trace/test/appsec/rasp/utils.js new file mode 100644 index 00000000000..e9353d5d815 --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/utils.js @@ -0,0 +1,41 @@ +'use strict' + +const { assert } = require('chai') + +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') +} + +function checkRaspExecutedAndNotThreat (agent) { + return agent.use((traces) => { + const span = getWebSpan(traces) + assert.notProperty(span.meta, '_dd.appsec.json') + assert.notProperty(span.meta_struct || {}, '_dd.stack') + assert.equal(span.metrics['_dd.appsec.rasp.rule.eval'], 1) + }) +} + +function checkRaspExecutedAndHasThreat (agent, ruleId) { + return agent.use((traces) => { + const span = getWebSpan(traces) + assert.property(span.meta, '_dd.appsec.json') + assert(span.meta['_dd.appsec.json'].includes(ruleId)) + assert.equal(span.metrics['_dd.appsec.rasp.rule.eval'], 1) + assert(span.metrics['_dd.appsec.rasp.duration'] > 0) + assert(span.metrics['_dd.appsec.rasp.duration_ext'] > 0) + assert.property(span.meta_struct, '_dd.stack') + }) +} + +module.exports = { + getWebSpan, + checkRaspExecutedAndNotThreat, + checkRaspExecutedAndHasThreat +} diff --git a/packages/dd-trace/test/appsec/rasp/utils.spec.js b/packages/dd-trace/test/appsec/rasp/utils.spec.js new file mode 100644 index 00000000000..255f498a117 --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/utils.spec.js @@ -0,0 +1,79 @@ +'use strict' + +const proxyquire = require('proxyquire') + +describe('RASP - utils.js', () => { + let web, utils, stackTrace, config + + beforeEach(() => { + web = { + root: sinon.stub() + } + + stackTrace = { + reportStackTrace: sinon.stub() + } + + utils = proxyquire('../../../src/appsec/rasp/utils', { + '../../plugins/util/web': web, + '../stack_trace': stackTrace + }) + + config = { + appsec: { + stackTrace: { + enabled: true, + maxStackTraces: 2, + maxDepth: 42 + } + } + } + }) + + describe('handleResult', () => { + it('should report stack trace when generate_stack action is present in waf result', () => { + const req = {} + const rootSpan = {} + const stackId = 'test_stack_id' + const result = { + generate_stack: { + stack_id: stackId + } + } + + web.root.returns(rootSpan) + + utils.handleResult(result, req, undefined, undefined, config) + sinon.assert.calledOnceWithExactly(stackTrace.reportStackTrace, rootSpan, stackId, 42, 2) + }) + + it('should not report stack trace when no action is present in waf result', () => { + const req = {} + const result = {} + + utils.handleResult(result, req, undefined, undefined, config) + sinon.assert.notCalled(stackTrace.reportStackTrace) + }) + + it('should not report stack trace when stack trace reporting is disabled', () => { + const req = {} + const result = { + generate_stack: { + stack_id: 'stackId' + } + } + const config = { + appsec: { + stackTrace: { + enabled: false, + maxStackTraces: 2, + maxDepth: 42 + } + } + } + + utils.handleResult(result, req, undefined, undefined, config) + sinon.assert.notCalled(stackTrace.reportStackTrace) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/waf/waf_context_wrapper.spec.js b/packages/dd-trace/test/appsec/waf/waf_context_wrapper.spec.js index 81f110a9950..cffe9718ee2 100644 --- a/packages/dd-trace/test/appsec/waf/waf_context_wrapper.spec.js +++ b/packages/dd-trace/test/appsec/waf/waf_context_wrapper.spec.js @@ -3,6 +3,7 @@ const proxyquire = require('proxyquire') const WAFContextWrapper = require('../../../src/appsec/waf/waf_context_wrapper') const addresses = require('../../../src/appsec/addresses') +const { wafRunFinished } = require('../../../src/appsec/channels') describe('WAFContextWrapper', () => { const knownAddresses = new Set([ @@ -28,7 +29,7 @@ describe('WAFContextWrapper', () => { expect(ddwafContext.run).to.have.been.calledOnceWithExactly(payload, 1000) }) - it('Should send ephemeral addreses every time', () => { + it('Should send ephemeral addresses every time', () => { const ddwafContext = { run: sinon.stub() } @@ -77,6 +78,28 @@ describe('WAFContextWrapper', () => { expect(ddwafContext.run).to.have.not.been.called }) + it('should publish the payload in the dc channel', () => { + const ddwafContext = { + run: sinon.stub().returns([]) + } + const wafContextWrapper = new WAFContextWrapper(ddwafContext, 1000, '1.14.0', '1.8.0', knownAddresses) + const payload = { + persistent: { + [addresses.HTTP_INCOMING_QUERY]: { key: 'value' } + }, + ephemeral: { + [addresses.HTTP_INCOMING_GRAPHQL_RESOLVER]: { anotherKey: 'anotherValue' } + } + } + const finishedCallback = sinon.stub() + + wafRunFinished.subscribe(finishedCallback) + wafContextWrapper.run(payload) + wafRunFinished.unsubscribe(finishedCallback) + + expect(finishedCallback).to.be.calledOnceWith({ payload }) + }) + describe('Disposal context check', () => { let log let ddwafContext diff --git a/packages/dd-trace/test/plugins/externals.json b/packages/dd-trace/test/plugins/externals.json index 80b3b2147f2..27a9041cb86 100644 --- a/packages/dd-trace/test/plugins/externals.json +++ b/packages/dd-trace/test/plugins/externals.json @@ -337,6 +337,10 @@ { "name": "pg-native", "versions": ["3.0.0"] + }, + { + "name": "express", + "versions": [">=4"] } ], "pino": [