diff --git a/packages/datadog-instrumentations/src/child_process.js b/packages/datadog-instrumentations/src/child_process.js index 8af4978800..f722495336 100644 --- a/packages/datadog-instrumentations/src/child_process.js +++ b/packages/datadog-instrumentations/src/child_process.js @@ -13,19 +13,38 @@ const childProcessChannel = dc.tracingChannel('datadog:child_process:execution') // ignored exec method because it calls to execFile directly const execAsyncMethods = ['execFile', 'spawn'] -const execSyncMethods = ['execFileSync', 'spawnSync'] const names = ['child_process', 'node:child_process'] // child_process and node:child_process returns the same object instance, we only want to add hooks once let patched = false + +function throwSyncError (error) { + throw error +} + +function returnSpawnSyncError (error, context) { + context.result = { + error, + status: null, + signal: null, + output: null, + stdout: null, + stderr: null, + pid: 0 + } + + return context.result +} + names.forEach(name => { addHook({ name }, childProcess => { if (!patched) { patched = true - shimmer.massWrap(childProcess, execAsyncMethods, wrapChildProcessAsyncMethod()) - shimmer.massWrap(childProcess, execSyncMethods, wrapChildProcessSyncMethod()) - shimmer.wrap(childProcess, 'execSync', wrapChildProcessSyncMethod(true)) + shimmer.massWrap(childProcess, execAsyncMethods, wrapChildProcessAsyncMethod(childProcess.ChildProcess)) + shimmer.wrap(childProcess, 'execSync', wrapChildProcessSyncMethod(throwSyncError, true)) + shimmer.wrap(childProcess, 'execFileSync', wrapChildProcessSyncMethod(throwSyncError)) + shimmer.wrap(childProcess, 'spawnSync', wrapChildProcessSyncMethod(returnSpawnSyncError)) } return childProcess @@ -34,17 +53,21 @@ names.forEach(name => { function normalizeArgs (args, shell) { const childProcessInfo = { - command: args[0] + command: args[0], + file: args[0] } if (Array.isArray(args[1])) { childProcessInfo.command = childProcessInfo.command + ' ' + args[1].join(' ') + childProcessInfo.fileArgs = args[1] + if (args[2] !== null && typeof args[2] === 'object') { childProcessInfo.options = args[2] } } else if (args[1] !== null && typeof args[1] === 'object') { childProcessInfo.options = args[1] } + childProcessInfo.shell = shell || childProcessInfo.options?.shell === true || typeof childProcessInfo.options?.shell === 'string' @@ -52,7 +75,21 @@ function normalizeArgs (args, shell) { return childProcessInfo } -function wrapChildProcessSyncMethod (shell = false) { +function createContextFromChildProcessInfo (childProcessInfo) { + const context = { + command: childProcessInfo.command, + file: childProcessInfo.file, + shell: childProcessInfo.shell + } + + if (childProcessInfo.fileArgs) { + context.fileArgs = childProcessInfo.fileArgs + } + + return context +} + +function wrapChildProcessSyncMethod (returnError, shell = false) { return function wrapMethod (childProcessMethod) { return function () { if (!childProcessChannel.start.hasSubscribers || arguments.length === 0) { @@ -63,14 +100,30 @@ function wrapChildProcessSyncMethod (shell = false) { const innerResource = new AsyncResource('bound-anonymous-fn') return innerResource.runInAsyncScope(() => { - return childProcessChannel.traceSync( - childProcessMethod, - { - command: childProcessInfo.command, - shell: childProcessInfo.shell - }, - this, - ...arguments) + const context = createContextFromChildProcessInfo(childProcessInfo) + const abortController = new AbortController() + + childProcessChannel.start.publish({ ...context, abortController }) + + try { + if (abortController.signal.aborted) { + const error = abortController.signal.reason || new Error('Aborted') + // expected behaviors on error are different + return returnError(error, context) + } + + const result = childProcessMethod.apply(this, arguments) + context.result = result + + return result + } catch (err) { + context.error = err + childProcessChannel.error.publish(context) + + throw err + } finally { + childProcessChannel.end.publish(context) + } }) } } @@ -84,18 +137,52 @@ function wrapChildProcessCustomPromisifyMethod (customPromisifyMethod, shell) { const childProcessInfo = normalizeArgs(arguments, shell) - return childProcessChannel.tracePromise( - customPromisifyMethod, - { - command: childProcessInfo.command, - shell: childProcessInfo.shell - }, - this, - ...arguments) + const context = createContextFromChildProcessInfo(childProcessInfo) + + const { start, end, asyncStart, asyncEnd, error } = childProcessChannel + const abortController = new AbortController() + + start.publish({ + ...context, + abortController + }) + + let result + if (abortController.signal.aborted) { + result = Promise.reject(abortController.signal.reason || new Error('Aborted')) + } else { + try { + result = customPromisifyMethod.apply(this, arguments) + } catch (error) { + error.publish({ ...context, error }) + throw error + } finally { + end.publish(context) + } + } + + function reject (err) { + context.error = err + error.publish(context) + asyncStart.publish(context) + + asyncEnd.publish(context) + return Promise.reject(err) + } + + function resolve (result) { + context.result = result + asyncStart.publish(context) + + asyncEnd.publish(context) + return result + } + + return Promise.prototype.then.call(result, resolve, reject) } } -function wrapChildProcessAsyncMethod (shell = false) { +function wrapChildProcessAsyncMethod (ChildProcess, shell = false) { return function wrapMethod (childProcessMethod) { function wrappedChildProcessMethod () { if (!childProcessChannel.start.hasSubscribers || arguments.length === 0) { @@ -112,9 +199,31 @@ function wrapChildProcessAsyncMethod (shell = false) { const innerResource = new AsyncResource('bound-anonymous-fn') return innerResource.runInAsyncScope(() => { - childProcessChannel.start.publish({ command: childProcessInfo.command, shell: childProcessInfo.shell }) + const context = createContextFromChildProcessInfo(childProcessInfo) + const abortController = new AbortController() + + childProcessChannel.start.publish({ ...context, abortController }) + + let childProcess + if (abortController.signal.aborted) { + childProcess = new ChildProcess() + childProcess.on('error', () => {}) // Original method does not crash when non subscribers + + process.nextTick(() => { + const error = abortController.signal.reason || new Error('Aborted') + childProcess.emit('error', error) + + const cb = arguments[arguments.length - 1] + if (typeof cb === 'function') { + cb(error) + } + + childProcess.emit('close') + }) + } else { + childProcess = childProcessMethod.apply(this, arguments) + } - const childProcess = childProcessMethod.apply(this, arguments) if (childProcess) { let errorExecuted = false @@ -129,8 +238,7 @@ function wrapChildProcessAsyncMethod (shell = false) { childProcessChannel.error.publish() } childProcessChannel.asyncEnd.publish({ - command: childProcessInfo.command, - shell: childProcessInfo.shell, + ...context, result: code }) }) diff --git a/packages/datadog-instrumentations/test/child_process.spec.js b/packages/datadog-instrumentations/test/child_process.spec.js index ffd002e8a6..f6d1942379 100644 --- a/packages/datadog-instrumentations/test/child_process.spec.js +++ b/packages/datadog-instrumentations/test/child_process.spec.js @@ -9,7 +9,7 @@ describe('child process', () => { const modules = ['child_process', 'node:child_process'] const execAsyncMethods = ['execFile', 'spawn'] const execAsyncShellMethods = ['exec'] - const execSyncMethods = ['execFileSync'] + const execSyncMethods = ['execFileSync', 'spawnSync'] const execSyncShellMethods = ['execSync'] const childProcessChannel = dc.tracingChannel('datadog:child_process:execution') @@ -51,7 +51,7 @@ describe('child process', () => { }) }) - describe('async methods', (done) => { + describe('async methods', () => { describe('command not interpreted by a shell by default', () => { execAsyncMethods.forEach(methodName => { describe(`method ${methodName}`, () => { @@ -59,20 +59,59 @@ describe('child process', () => { const childEmitter = childProcess[methodName]('ls') childEmitter.once('close', () => { - expect(start).to.have.been.calledOnceWith({ command: 'ls', shell: false }) - expect(asyncFinish).to.have.been.calledOnceWith({ command: 'ls', shell: false, result: 0 }) + expect(start).to.have.been.calledOnceWith({ + command: 'ls', + file: 'ls', + shell: false, + abortController: sinon.match.instanceOf(AbortController) + }) + expect(asyncFinish).to.have.been.calledOnceWith({ + command: 'ls', + file: 'ls', + shell: false, + result: 0 + }) expect(error).not.to.have.been.called done() }) }) + it('should publish arguments', (done) => { + const childEmitter = childProcess[methodName]('ls', ['-la']) + + childEmitter.once('close', () => { + expect(start).to.have.been.calledOnceWith({ + command: 'ls -la', + file: 'ls', + fileArgs: ['-la'], + shell: false, + abortController: sinon.match.instanceOf(AbortController) + }) + expect(asyncFinish).to.have.been.calledOnceWith({ + command: 'ls -la', + file: 'ls', + shell: false, + fileArgs: ['-la'], + result: 0 + }) + + done() + }) + }) + it('should execute error callback', (done) => { const childEmitter = childProcess[methodName]('invalid_command_test') childEmitter.once('close', () => { - expect(start).to.have.been.calledOnceWith({ command: 'invalid_command_test', shell: false }) + expect(start).to.have.been.calledOnceWith({ + command: 'invalid_command_test', + file: 'invalid_command_test', + shell: false, + abortController: sinon.match.instanceOf(AbortController) + }) expect(asyncFinish).to.have.been.calledOnceWith({ command: 'invalid_command_test', + file: 'invalid_command_test', shell: false, result: -2 }) @@ -85,13 +124,20 @@ describe('child process', () => { const childEmitter = childProcess[methodName]('node -e "process.exit(1)"', { shell: true }) childEmitter.once('close', () => { - expect(start).to.have.been.calledOnceWith({ command: 'node -e "process.exit(1)"', shell: true }) + expect(start).to.have.been.calledOnceWith({ + command: 'node -e "process.exit(1)"', + file: 'node -e "process.exit(1)"', + abortController: sinon.match.instanceOf(AbortController), + shell: true + }) expect(asyncFinish).to.have.been.calledOnceWith({ command: 'node -e "process.exit(1)"', + file: 'node -e "process.exit(1)"', shell: true, result: 1 }) expect(error).to.have.been.calledOnce + done() }) }) @@ -101,13 +147,15 @@ describe('child process', () => { describe(`method ${methodName} with promisify`, () => { it('should execute success callbacks', async () => { await promisify(childProcess[methodName])('echo') + expect(start.firstCall.firstArg).to.include({ command: 'echo', + file: 'echo', shell: false }) - expect(asyncFinish).to.have.been.calledOnceWith({ command: 'echo', + file: 'echo', shell: false, result: { stdout: '\n', @@ -177,8 +225,13 @@ describe('child process', () => { const res = childProcess[methodName]('ls') res.once('close', () => { - expect(start).to.have.been.calledOnceWith({ command: 'ls', shell: true }) - expect(asyncFinish).to.have.been.calledOnceWith({ command: 'ls', shell: true, result: 0 }) + expect(start).to.have.been.calledOnceWith({ + command: 'ls', + file: 'ls', + shell: true, + abortController: sinon.match.instanceOf(AbortController) + }) + expect(asyncFinish).to.have.been.calledOnceWith({ command: 'ls', file: 'ls', shell: true, result: 0 }) expect(error).not.to.have.been.called done() }) @@ -188,9 +241,15 @@ describe('child process', () => { const res = childProcess[methodName]('node -e "process.exit(1)"') res.once('close', () => { - expect(start).to.have.been.calledOnceWith({ command: 'node -e "process.exit(1)"', shell: true }) + expect(start).to.have.been.calledOnceWith({ + command: 'node -e "process.exit(1)"', + file: 'node -e "process.exit(1)"', + abortController: sinon.match.instanceOf(AbortController), + shell: true + }) expect(asyncFinish).to.have.been.calledOnceWith({ command: 'node -e "process.exit(1)"', + file: 'node -e "process.exit(1)"', shell: true, result: 1 }) @@ -203,10 +262,16 @@ describe('child process', () => { const res = childProcess[methodName]('invalid_command_test') res.once('close', () => { - expect(start).to.have.been.calledOnceWith({ command: 'invalid_command_test', shell: true }) + expect(start).to.have.been.calledOnceWith({ + command: 'invalid_command_test', + file: 'invalid_command_test', + abortController: sinon.match.instanceOf(AbortController), + shell: true + }) expect(error).to.have.been.calledOnce expect(asyncFinish).to.have.been.calledOnceWith({ command: 'invalid_command_test', + file: 'invalid_command_test', shell: true, result: 127 }) @@ -220,10 +285,13 @@ describe('child process', () => { await promisify(childProcess[methodName])('echo') expect(start).to.have.been.calledOnceWith({ command: 'echo', + file: 'echo', + abortController: sinon.match.instanceOf(AbortController), shell: true }) expect(asyncFinish).to.have.been.calledOnceWith({ command: 'echo', + file: 'echo', shell: true, result: 0 }) @@ -235,7 +303,12 @@ describe('child process', () => { await promisify(childProcess[methodName])('invalid_command_test') return Promise.reject(new Error('Command expected to fail')) } catch (e) { - expect(start).to.have.been.calledOnceWith({ command: 'invalid_command_test', shell: true }) + expect(start).to.have.been.calledOnceWith({ + command: 'invalid_command_test', + file: 'invalid_command_test', + abortController: sinon.match.instanceOf(AbortController), + shell: true + }) expect(asyncFinish).to.have.been.calledOnce expect(error).to.have.been.calledOnce } @@ -246,9 +319,15 @@ describe('child process', () => { await promisify(childProcess[methodName])('node -e "process.exit(1)"') return Promise.reject(new Error('Command expected to fail')) } catch (e) { - expect(start).to.have.been.calledOnceWith({ command: 'node -e "process.exit(1)"', shell: true }) + expect(start).to.have.been.calledOnceWith({ + command: 'node -e "process.exit(1)"', + file: 'node -e "process.exit(1)"', + abortController: sinon.match.instanceOf(AbortController), + shell: true + }) expect(asyncFinish).to.have.been.calledOnceWith({ command: 'node -e "process.exit(1)"', + file: 'node -e "process.exit(1)"', shell: true, result: 1 }) @@ -258,6 +337,62 @@ describe('child process', () => { }) }) }) + + describe('aborting in abortController', () => { + const abortError = new Error('AbortError') + function abort ({ abortController }) { + abortController.abort(abortError) + + if (!abortController.signal.reason) { + abortController.signal.reason = abortError + } + } + + beforeEach(() => { + childProcessChannel.subscribe({ start: abort }) + }) + + afterEach(() => { + childProcessChannel.unsubscribe({ start: abort }) + }) + + ;[...execAsyncMethods, ...execAsyncShellMethods].forEach((methodName) => { + describe(`method ${methodName}`, () => { + it('should execute callback with the error', (done) => { + childProcess[methodName]('aborted_command', (error) => { + expect(error).to.be.equal(abortError) + + done() + }) + }) + + it('should emit error and close', (done) => { + const cp = childProcess[methodName]('aborted_command') + const errorCallback = sinon.stub() + + cp.on('error', errorCallback) + cp.on('close', () => { + expect(errorCallback).to.have.been.calledWithExactly(abortError) + done() + }) + }) + + it('should emit error and close and execute the callback', (done) => { + const callback = sinon.stub() + const errorCallback = sinon.stub() + const cp = childProcess[methodName]('aborted_command', callback) + + cp.on('error', errorCallback) + cp.on('close', () => { + expect(callback).to.have.been.calledWithExactly(abortError) + expect(errorCallback).to.have.been.calledWithExactly(abortError) + + done() + }) + }) + }) + }) + }) }) describe('sync methods', () => { @@ -269,13 +404,15 @@ describe('child process', () => { expect(start).to.have.been.calledOnceWith({ command: 'ls', + file: 'ls', shell: false, - result + abortController: sinon.match.instanceOf(AbortController) }, 'tracing:datadog:child_process:execution:start') expect(finish).to.have.been.calledOnceWith({ command: 'ls', + file: 'ls', shell: false, result }, @@ -284,56 +421,105 @@ describe('child process', () => { expect(error).not.to.have.been.called }) - it('should execute error callback', () => { - let childError - try { - childProcess[methodName]('invalid_command_test') - } catch (error) { - childError = error - } finally { - expect(start).to.have.been.calledOnceWith({ - command: 'invalid_command_test', - shell: false, - error: childError - }) - expect(finish).to.have.been.calledOnce - expect(error).to.have.been.calledOnce - } - }) + it('should publish arguments', () => { + const result = childProcess[methodName]('ls', ['-la']) - it('should execute error callback with `exit 1` command', () => { - let childError - try { - childProcess[methodName]('node -e "process.exit(1)"') - } catch (error) { - childError = error - } finally { - expect(start).to.have.been.calledOnceWith({ - command: 'node -e "process.exit(1)"', - shell: false, - error: childError - }) - expect(finish).to.have.been.calledOnce - } + expect(start).to.have.been.calledOnceWith({ + command: 'ls -la', + file: 'ls', + shell: false, + fileArgs: ['-la'], + abortController: sinon.match.instanceOf(AbortController) + }) + expect(finish).to.have.been.calledOnceWith({ + command: 'ls -la', + file: 'ls', + shell: false, + fileArgs: ['-la'], + result + }) }) - if (methodName !== 'execFileSync' || NODE_MAJOR > 16) { - // when a process return an invalid code, in node <=16, in execFileSync with shell:true - // an exception is not thrown - it('should execute error callback with `exit 1` command with shell: true', () => { - let childError + + // errors are handled in a different way in spawnSync method + if (methodName !== 'spawnSync') { + it('should execute error callback', () => { + let childError, result try { - childProcess[methodName]('node -e "process.exit(1)"', { shell: true }) + result = childProcess[methodName]('invalid_command_test') } catch (error) { childError = error } finally { + childError = childError || result?.error + + const expectedContext = { + command: 'invalid_command_test', + file: 'invalid_command_test', + shell: false + } expect(start).to.have.been.calledOnceWith({ + ...expectedContext, + abortController: sinon.match.instanceOf(AbortController) + }) + expect(finish).to.have.been.calledOnceWith({ + ...expectedContext, + error: childError + }) + expect(error).to.have.been.calledOnceWith({ + ...expectedContext, + error: childError + }) + } + }) + + it('should execute error callback with `exit 1` command', () => { + let childError + try { + childProcess[methodName]('node -e "process.exit(1)"') + } catch (error) { + childError = error + } finally { + const expectedContext = { command: 'node -e "process.exit(1)"', - shell: true, + file: 'node -e "process.exit(1)"', + shell: false + } + expect(start).to.have.been.calledOnceWith({ + ...expectedContext, + abortController: sinon.match.instanceOf(AbortController) + }) + expect(finish).to.have.been.calledOnceWith({ + ...expectedContext, error: childError }) - expect(finish).to.have.been.calledOnce } }) + + if (methodName !== 'execFileSync' || NODE_MAJOR > 16) { + // when a process return an invalid code, in node <=16, in execFileSync with shell:true + // an exception is not thrown + it('should execute error callback with `exit 1` command with shell: true', () => { + let childError + try { + childProcess[methodName]('node -e "process.exit(1)"', { shell: true }) + } catch (error) { + childError = error + } finally { + const expectedContext = { + command: 'node -e "process.exit(1)"', + file: 'node -e "process.exit(1)"', + shell: true + } + expect(start).to.have.been.calledOnceWith({ + ...expectedContext, + abortController: sinon.match.instanceOf(AbortController) + }) + expect(finish).to.have.been.calledOnceWith({ + ...expectedContext, + error: childError + }) + } + }) + } } }) }) @@ -345,14 +531,17 @@ describe('child process', () => { it('should execute success callbacks', () => { const result = childProcess[methodName]('ls') - expect(start).to.have.been.calledOnceWith({ + const expectedContext = { command: 'ls', - shell: true, - result + file: 'ls', + shell: true + } + expect(start).to.have.been.calledOnceWith({ + ...expectedContext, + abortController: sinon.match.instanceOf(AbortController) }) expect(finish).to.have.been.calledOnceWith({ - command: 'ls', - shell: true, + ...expectedContext, result }) expect(error).not.to.have.been.called @@ -365,13 +554,23 @@ describe('child process', () => { } catch (error) { childError = error } finally { - expect(start).to.have.been.calledOnceWith({ + const expectedContext = { command: 'invalid_command_test', - shell: true, + file: 'invalid_command_test', + shell: true + } + expect(start).to.have.been.calledOnceWith({ + ...expectedContext, + abortController: sinon.match.instanceOf(AbortController) + }) + expect(finish).to.have.been.calledOnceWith({ + ...expectedContext, + error: childError + }) + expect(error).to.have.been.calledOnceWith({ + ...expectedContext, error: childError }) - expect(finish).to.have.been.calledOnce - expect(error).to.have.been.calledOnce } }) @@ -382,17 +581,71 @@ describe('child process', () => { } catch (error) { childError = error } finally { - expect(start).to.have.been.calledOnceWith({ + const expectedContext = { command: 'node -e "process.exit(1)"', - shell: true, + file: 'node -e "process.exit(1)"', + shell: true + } + expect(start).to.have.been.calledOnceWith({ + ...expectedContext, + abortController: sinon.match.instanceOf(AbortController) + }) + expect(finish).to.have.been.calledOnceWith({ + ...expectedContext, error: childError }) - expect(finish).to.have.been.calledOnce } }) }) }) }) + + describe('aborting in abortController', () => { + const abortError = new Error('AbortError') + function abort ({ abortController }) { + abortController.abort(abortError) + } + + beforeEach(() => { + childProcessChannel.subscribe({ start: abort }) + }) + + afterEach(() => { + childProcessChannel.unsubscribe({ start: abort }) + }) + + ;['execFileSync', 'execSync'].forEach((methodName) => { + describe(`method ${methodName}`, () => { + it('should throw the expected error', () => { + try { + childProcess[methodName]('aborted_command') + } catch (e) { + expect(e).to.be.equal(abortError) + + return + } + + throw new Error('Expected to fail') + }) + }) + }) + + describe('method spawnSync', () => { + it('should return error field', () => { + const result = childProcess.spawnSync('aborted_command') + + expect(result).to.be.deep.equal({ + error: abortError, + status: null, + signal: null, + output: null, + stdout: null, + stderr: null, + pid: 0 + }) + }) + }) + }) }) }) }) diff --git a/packages/dd-trace/src/appsec/addresses.js b/packages/dd-trace/src/appsec/addresses.js index 40c643012e..cb540bc4e6 100644 --- a/packages/dd-trace/src/appsec/addresses.js +++ b/packages/dd-trace/src/appsec/addresses.js @@ -28,6 +28,8 @@ module.exports = { DB_STATEMENT: 'server.db.statement', DB_SYSTEM: 'server.db.system', + SHELL_COMMAND: 'server.sys.shell.cmd', + LOGIN_SUCCESS: 'server.business_logic.users.login.success', LOGIN_FAILURE: 'server.business_logic.users.login.failure' } diff --git a/packages/dd-trace/src/appsec/channels.js b/packages/dd-trace/src/appsec/channels.js index 3081ed9974..897690652a 100644 --- a/packages/dd-trace/src/appsec/channels.js +++ b/packages/dd-trace/src/appsec/channels.js @@ -28,5 +28,6 @@ module.exports = { mysql2OuterQueryStart: dc.channel('datadog:mysql2:outerquery:start'), wafRunFinished: dc.channel('datadog:waf:run:finish'), fsOperationStart: dc.channel('apm:fs:operation:start'), - expressMiddlewareError: dc.channel('apm:express:middleware:error') + expressMiddlewareError: dc.channel('apm:express:middleware:error'), + childProcessExecutionTracingChannel: dc.tracingChannel('datadog:child_process:execution') } diff --git a/packages/dd-trace/src/appsec/rasp/command_injection.js b/packages/dd-trace/src/appsec/rasp/command_injection.js new file mode 100644 index 0000000000..8d6d977aac --- /dev/null +++ b/packages/dd-trace/src/appsec/rasp/command_injection.js @@ -0,0 +1,49 @@ +'use strict' + +const { childProcessExecutionTracingChannel } = require('../channels') +const { RULE_TYPES, handleResult } = require('./utils') +const { storage } = require('../../../../datadog-core') +const addresses = require('../addresses') +const waf = require('../waf') + +let config + +function enable (_config) { + config = _config + + childProcessExecutionTracingChannel.subscribe({ + start: analyzeCommandInjection + }) +} + +function disable () { + if (childProcessExecutionTracingChannel.start.hasSubscribers) { + childProcessExecutionTracingChannel.unsubscribe({ + start: analyzeCommandInjection + }) + } +} + +function analyzeCommandInjection ({ file, fileArgs, shell, abortController }) { + if (!file || !shell) return + + const store = storage.getStore() + const req = store?.req + if (!req) return + + const commandParams = fileArgs ? [file, ...fileArgs] : file + + const persistent = { + [addresses.SHELL_COMMAND]: commandParams + } + + const result = waf.run({ persistent }, req, RULE_TYPES.COMMAND_INJECTION) + + const res = store?.res + handleResult(result, req, res, abortController, config) +} + +module.exports = { + enable, + disable +} diff --git a/packages/dd-trace/src/appsec/rasp/index.js b/packages/dd-trace/src/appsec/rasp/index.js index d5a1312872..4a65518495 100644 --- a/packages/dd-trace/src/appsec/rasp/index.js +++ b/packages/dd-trace/src/appsec/rasp/index.js @@ -6,6 +6,7 @@ const { block, isBlocked } = require('../blocking') const ssrf = require('./ssrf') const sqli = require('./sql_injection') const lfi = require('./lfi') +const cmdi = require('./command_injection') const { DatadogRaspAbortError } = require('./utils') @@ -95,6 +96,7 @@ function enable (config) { ssrf.enable(config) sqli.enable(config) lfi.enable(config) + cmdi.enable(config) process.on('uncaughtExceptionMonitor', handleUncaughtExceptionMonitor) expressMiddlewareError.subscribe(blockOnDatadogRaspAbortError) @@ -104,6 +106,7 @@ function disable () { ssrf.disable() sqli.disable() lfi.disable() + cmdi.disable() process.off('uncaughtExceptionMonitor', handleUncaughtExceptionMonitor) if (expressMiddlewareError.hasSubscribers) expressMiddlewareError.unsubscribe(blockOnDatadogRaspAbortError) diff --git a/packages/dd-trace/src/appsec/rasp/utils.js b/packages/dd-trace/src/appsec/rasp/utils.js index c4ee4f55c3..bdf3596209 100644 --- a/packages/dd-trace/src/appsec/rasp/utils.js +++ b/packages/dd-trace/src/appsec/rasp/utils.js @@ -12,9 +12,10 @@ if (abortOnUncaughtException) { } const RULE_TYPES = { - SSRF: 'ssrf', + COMMAND_INJECTION: 'command_injection', + LFI: 'lfi', SQL_INJECTION: 'sql_injection', - LFI: 'lfi' + SSRF: 'ssrf' } class DatadogRaspAbortError extends Error { diff --git a/packages/dd-trace/src/appsec/remote_config/capabilities.js b/packages/dd-trace/src/appsec/remote_config/capabilities.js index 3eda140a98..18c11a9210 100644 --- a/packages/dd-trace/src/appsec/remote_config/capabilities.js +++ b/packages/dd-trace/src/appsec/remote_config/capabilities.js @@ -20,6 +20,7 @@ module.exports = { ASM_RASP_SQLI: 1n << 21n, ASM_RASP_LFI: 1n << 22n, ASM_RASP_SSRF: 1n << 23n, + ASM_RASP_SHI: 1n << 24n, APM_TRACING_SAMPLE_RULES: 1n << 29n, ASM_ENDPOINT_FINGERPRINT: 1n << 32n, ASM_NETWORK_FINGERPRINT: 1n << 34n, diff --git a/packages/dd-trace/src/appsec/remote_config/index.js b/packages/dd-trace/src/appsec/remote_config/index.js index 2b7eea57c8..9f0869351a 100644 --- a/packages/dd-trace/src/appsec/remote_config/index.js +++ b/packages/dd-trace/src/appsec/remote_config/index.js @@ -83,6 +83,7 @@ function enableWafUpdate (appsecConfig) { rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_SQLI, true) rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_SSRF, true) rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_LFI, true) + rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_SHI, true) } // TODO: delete noop handlers and kPreUpdate and replace with batched handlers @@ -114,6 +115,7 @@ function disableWafUpdate () { rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_SQLI, false) rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_SSRF, false) rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_LFI, false) + rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_SHI, false) rc.removeProductHandler('ASM_DATA') rc.removeProductHandler('ASM_DD') diff --git a/packages/dd-trace/test/appsec/rasp/command_injection.express.plugin.spec.js b/packages/dd-trace/test/appsec/rasp/command_injection.express.plugin.spec.js new file mode 100644 index 0000000000..3943bd0c3c --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/command_injection.express.plugin.spec.js @@ -0,0 +1,433 @@ +'use strict' + +const agent = require('../../plugins/agent') +const appsec = require('../../../src/appsec') +const Config = require('../../../src/config') +const path = require('path') +const Axios = require('axios') +const { getWebSpan, checkRaspExecutedAndHasThreat, checkRaspExecutedAndNotThreat } = require('./utils') +const { assert } = require('chai') + +describe('RASP - command_injection', () => { + withVersions('express', 'express', expressVersion => { + let app, server, axios + + async function testBlockingRequest () { + try { + await axios.get('/?dir=$(cat /etc/passwd 1>%262 ; echo .)') + } catch (e) { + if (!e.response) { + throw e + } + + return checkRaspExecutedAndHasThreat(agent, 'rasp-command_injection-rule-id-3') + } + + assert.fail('Request should be blocked') + } + + function checkRaspNotExecutedAndNotThreat (agent, checkRuleEval = true) { + return agent.use((traces) => { + const span = getWebSpan(traces) + + assert.notProperty(span.meta, '_dd.appsec.json') + assert.notProperty(span.meta_struct || {}, '_dd.stack') + if (checkRuleEval) { + assert.notProperty(span.metrics, '_dd.appsec.rasp.rule.eval') + } + }) + } + + function testBlockingAndSafeRequests () { + it('should block the threat', async () => { + await testBlockingRequest() + }) + + it('should not block safe request', async () => { + await axios.get('/?dir=.') + + return checkRaspExecutedAndNotThreat(agent) + }) + } + + function testSafeInNonShell () { + it('should not block the threat', async () => { + await axios.get('/?dir=$(cat /etc/passwd 1>%262 ; echo .)') + + return checkRaspNotExecutedAndNotThreat(agent) + }) + + it('should not block safe request', async () => { + await axios.get('/?dir=.') + + return checkRaspNotExecutedAndNotThreat(agent) + }) + } + + before(() => { + return agent.load(['express', 'http', 'child_process'], { 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, '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('exec', () => { + describe('with callback', () => { + beforeEach(() => { + app = (req, res) => { + const childProcess = require('child_process') + + childProcess.exec(`ls ${req.query.dir}`, function (e) { + if (e?.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + + res.end('end') + }) + } + }) + + testBlockingAndSafeRequests() + }) + + describe('with promise', () => { + beforeEach(() => { + app = async (req, res) => { + const util = require('util') + const exec = util.promisify(require('child_process').exec) + + try { + await exec(`ls ${req.query.dir}`) + } catch (e) { + if (e.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + } + + res.end('end') + } + }) + + testBlockingAndSafeRequests() + }) + + describe('with event emitter', () => { + beforeEach(() => { + app = (req, res) => { + const childProcess = require('child_process') + + const child = childProcess.exec(`ls ${req.query.dir}`) + child.on('error', (e) => { + if (e.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + }) + + child.on('close', () => { + res.end() + }) + } + }) + + testBlockingAndSafeRequests() + }) + + describe('execSync', () => { + beforeEach(() => { + app = (req, res) => { + const childProcess = require('child_process') + + try { + childProcess.execSync(`ls ${req.query.dir}`) + } catch (e) { + if (e.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + } + + res.end('end') + } + }) + + testBlockingAndSafeRequests() + }) + }) + + describe('execFile', () => { + describe('with shell: true', () => { + describe('with callback', () => { + beforeEach(() => { + app = (req, res) => { + const childProcess = require('child_process') + + childProcess.execFile('ls', [req.query.dir], { shell: true }, function (e) { + if (e?.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + + res.end('end') + }) + } + }) + + testBlockingAndSafeRequests() + }) + + describe('with promise', () => { + beforeEach(() => { + app = async (req, res) => { + const util = require('util') + const execFile = util.promisify(require('child_process').execFile) + + try { + await execFile('ls', [req.query.dir], { shell: true }) + } catch (e) { + if (e.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + } + + res.end('end') + } + }) + + testBlockingAndSafeRequests() + }) + + describe('with event emitter', () => { + beforeEach(() => { + app = (req, res) => { + const childProcess = require('child_process') + + const child = childProcess.execFile('ls', [req.query.dir], { shell: true }) + child.on('error', (e) => { + if (e.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + }) + + child.on('close', () => { + res.end() + }) + } + }) + + testBlockingAndSafeRequests() + }) + + describe('execFileSync', () => { + beforeEach(() => { + app = (req, res) => { + const childProcess = require('child_process') + + try { + childProcess.execFileSync('ls', [req.query.dir], { shell: true }) + } catch (e) { + if (e.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + } + + res.end() + } + }) + + testBlockingAndSafeRequests() + }) + }) + + describe('without shell', () => { + describe('with callback', () => { + beforeEach(() => { + app = (req, res) => { + const childProcess = require('child_process') + + childProcess.execFile('ls', [req.query.dir], function (e) { + if (e?.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + + res.end('end') + }) + } + }) + + testSafeInNonShell() + }) + + describe('with promise', () => { + beforeEach(() => { + app = async (req, res) => { + const util = require('util') + const execFile = util.promisify(require('child_process').execFile) + + try { + await execFile('ls', [req.query.dir]) + } catch (e) { + if (e.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + } + + res.end('end') + } + }) + + testSafeInNonShell() + }) + + describe('with event emitter', () => { + beforeEach(() => { + app = (req, res) => { + const childProcess = require('child_process') + + const child = childProcess.execFile('ls', [req.query.dir]) + child.on('error', (e) => { + if (e.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + }) + + child.on('close', () => { + res.end() + }) + } + }) + + testSafeInNonShell() + }) + + describe('execFileSync', () => { + beforeEach(() => { + app = (req, res) => { + const childProcess = require('child_process') + + try { + childProcess.execFileSync('ls', [req.query.dir]) + } catch (e) { + if (e.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + } + + res.end() + } + }) + + testSafeInNonShell() + }) + }) + }) + + describe('spawn', () => { + describe('with shell: true', () => { + describe('with event emitter', () => { + beforeEach(() => { + app = (req, res) => { + const childProcess = require('child_process') + + const child = childProcess.spawn('ls', [req.query.dir], { shell: true }) + child.on('error', (e) => { + if (e.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + }) + + child.on('close', () => { + res.end() + }) + } + }) + + testBlockingAndSafeRequests() + }) + + describe('spawnSync', () => { + beforeEach(() => { + app = (req, res) => { + const childProcess = require('child_process') + + const child = childProcess.spawnSync('ls', [req.query.dir], { shell: true }) + if (child.error?.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + + res.end() + } + }) + + testBlockingAndSafeRequests() + }) + }) + + describe('without shell', () => { + describe('with event emitter', () => { + beforeEach(() => { + app = (req, res) => { + const childProcess = require('child_process') + + const child = childProcess.spawn('ls', [req.query.dir]) + child.on('error', (e) => { + if (e.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + }) + + child.on('close', () => { + res.end() + }) + } + }) + + testSafeInNonShell() + }) + + describe('spawnSync', () => { + beforeEach(() => { + app = (req, res) => { + const childProcess = require('child_process') + + const child = childProcess.spawnSync('ls', [req.query.dir]) + if (child.error?.name === 'DatadogRaspAbortError') { + res.writeHead(500) + } + + res.end() + } + }) + + testSafeInNonShell() + }) + }) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/rasp/command_injection.integration.spec.js b/packages/dd-trace/test/appsec/rasp/command_injection.integration.spec.js new file mode 100644 index 0000000000..c91c49b65d --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/command_injection.integration.spec.js @@ -0,0 +1,88 @@ +'use strict' + +const { createSandbox, FakeAgent, spawnProc } = require('../../../../../integration-tests/helpers') +const getPort = require('get-port') +const path = require('path') +const Axios = require('axios') +const { assert } = require('chai') + +describe('RASP - command_injection - integration', () => { + let axios, sandbox, cwd, appPort, appFile, agent, proc + + before(async function () { + this.timeout(60000) + + sandbox = await createSandbox( + ['express'], + false, + [path.join(__dirname, 'resources')] + ) + + appPort = await getPort() + cwd = sandbox.folder + appFile = path.join(cwd, 'resources', 'shi-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, + DD_TRACE_DEBUG: 'true', + 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() + }) + + async function testRequestBlocked (url) { + try { + await axios.get(url) + } 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-command_injection-rule-id-3"') + }) + } + + throw new Error('Request should be blocked') + } + + it('should block using execFileSync and exception handled by express', async () => { + await testRequestBlocked('/shi/execFileSync?dir=$(cat /etc/passwd 1>%262 ; echo .)') + }) + + it('should block using execFileSync and unhandled exception', async () => { + await testRequestBlocked('/shi/execFileSync/out-of-express-scope?dir=$(cat /etc/passwd 1>%262 ; echo .)') + }) + + it('should block using execSync and exception handled by express', async () => { + await testRequestBlocked('/shi/execSync?dir=$(cat /etc/passwd 1>%262 ; echo .)') + }) + + it('should block using execSync and unhandled exception', async () => { + await testRequestBlocked('/shi/execSync/out-of-express-scope?dir=$(cat /etc/passwd 1>%262 ; echo .)') + }) +}) diff --git a/packages/dd-trace/test/appsec/rasp/command_injection.spec.js b/packages/dd-trace/test/appsec/rasp/command_injection.spec.js new file mode 100644 index 0000000000..785b155a11 --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/command_injection.spec.js @@ -0,0 +1,156 @@ +'use strict' + +const proxyquire = require('proxyquire') +const addresses = require('../../../src/appsec/addresses') +const { childProcessExecutionTracingChannel } = require('../../../src/appsec/channels') + +const { start } = childProcessExecutionTracingChannel + +describe('RASP - command_injection.js', () => { + let waf, datadogCore, commandInjection, utils, config + + beforeEach(() => { + datadogCore = { + storage: { + getStore: sinon.stub() + } + } + + waf = { + run: sinon.stub() + } + + utils = { + handleResult: sinon.stub() + } + + commandInjection = proxyquire('../../../src/appsec/rasp/command_injection', { + '../../../../datadog-core': datadogCore, + '../waf': waf, + './utils': utils + }) + + config = { + appsec: { + stackTrace: { + enabled: true, + maxStackTraces: 2, + maxDepth: 42 + } + } + } + + commandInjection.enable(config) + }) + + afterEach(() => { + sinon.restore() + commandInjection.disable() + }) + + describe('analyzeCommandInjection', () => { + it('should analyze command_injection without arguments', () => { + const ctx = { + file: 'cmd', + shell: true + } + const req = {} + datadogCore.storage.getStore.returns({ req }) + + start.publish(ctx) + + const persistent = { [addresses.SHELL_COMMAND]: 'cmd' } + sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req, 'command_injection') + }) + + it('should analyze command_injection with arguments', () => { + const ctx = { + file: 'cmd', + fileArgs: ['arg0', 'arg1'], + shell: true + } + const req = {} + datadogCore.storage.getStore.returns({ req }) + + start.publish(ctx) + + const persistent = { [addresses.SHELL_COMMAND]: ['cmd', 'arg0', 'arg1'] } + sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req, 'command_injection') + }) + + it('should not analyze command_injection when it is not shell', () => { + const ctx = { + file: 'cmd', + fileArgs: ['arg0', 'arg1'], + shell: false + } + const req = {} + datadogCore.storage.getStore.returns({ req }) + + start.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should not analyze command_injection if rasp is disabled', () => { + commandInjection.disable() + const ctx = { + file: 'cmd' + } + const req = {} + datadogCore.storage.getStore.returns({ req }) + + start.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should not analyze command_injection if no store', () => { + const ctx = { + file: 'cmd' + } + datadogCore.storage.getStore.returns(undefined) + + start.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should not analyze command_injection if no req', () => { + const ctx = { + file: 'cmd' + } + datadogCore.storage.getStore.returns({}) + + start.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should not analyze command_injection if no file', () => { + const ctx = { + fileArgs: ['arg0'] + } + const req = {} + datadogCore.storage.getStore.returns({ req }) + + start.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should call handleResult', () => { + const abortController = { abort: 'abort' } + const ctx = { file: 'cmd', abortController, shell: true } + const wafResult = { waf: 'waf' } + const req = { req: 'req' } + const res = { res: 'res' } + waf.run.returns(wafResult) + datadogCore.storage.getStore.returns({ req, res }) + + start.publish(ctx) + + sinon.assert.calledOnceWithExactly(utils.handleResult, wafResult, req, res, abortController, config) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/rasp/resources/rasp_rules.json b/packages/dd-trace/test/appsec/rasp/resources/rasp_rules.json index 778e4821e7..daca47d8d2 100644 --- a/packages/dd-trace/test/appsec/rasp/resources/rasp_rules.json +++ b/packages/dd-trace/test/appsec/rasp/resources/rasp_rules.json @@ -107,6 +107,55 @@ "block", "stack_trace" ] + }, + { + "id": "rasp-command_injection-rule-id-3", + "name": "Command injection exploit", + "tags": { + "type": "command_injection", + "category": "vulnerability_trigger", + "cwe": "77", + "capec": "1000/152/248/88", + "confidence": "0", + "module": "rasp" + }, + "conditions": [ + { + "parameters": { + "resource": [ + { + "address": "server.sys.shell.cmd" + } + ], + "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": "shi_detector" + } + ], + "transformers": [], + "on_match": [ + "block", + "stack_trace" + ] } ] } diff --git a/packages/dd-trace/test/appsec/rasp/resources/shi-app/index.js b/packages/dd-trace/test/appsec/rasp/resources/shi-app/index.js new file mode 100644 index 0000000000..a6714bd214 --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/resources/shi-app/index.js @@ -0,0 +1,44 @@ +'use strict' + +const tracer = require('dd-trace') +tracer.init({ + flushInterval: 1 +}) + +const express = require('express') +const childProcess = require('child_process') + +const app = express() +const port = process.env.APP_PORT || 3000 + +app.get('/shi/execFileSync', async (req, res) => { + childProcess.execFileSync('ls', [req.query.dir], { shell: true }) + + res.end('OK') +}) + +app.get('/shi/execFileSync/out-of-express-scope', async (req, res) => { + process.nextTick(() => { + childProcess.execFileSync('ls', [req.query.dir], { shell: true }) + + res.end('OK') + }) +}) + +app.get('/shi/execSync', async (req, res) => { + childProcess.execSync('ls', [req.query.dir]) + + res.end('OK') +}) + +app.get('/shi/execSync/out-of-express-scope', async (req, res) => { + process.nextTick(() => { + childProcess.execSync('ls', [req.query.dir]) + + res.end('OK') + }) +}) + +app.listen(port, () => { + process.send({ port }) +}) diff --git a/packages/dd-trace/test/appsec/remote_config/index.spec.js b/packages/dd-trace/test/appsec/remote_config/index.spec.js index dbd710d6a4..b1804e0b64 100644 --- a/packages/dd-trace/test/appsec/remote_config/index.spec.js +++ b/packages/dd-trace/test/appsec/remote_config/index.spec.js @@ -298,6 +298,8 @@ describe('Remote Config index', () => { .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SQLI, true) expect(rc.updateCapabilities) .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_LFI, true) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SHI, true) expect(rc.setProductHandler).to.have.been.calledWith('ASM_DATA') expect(rc.setProductHandler).to.have.been.calledWith('ASM_DD') @@ -340,6 +342,8 @@ describe('Remote Config index', () => { .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SQLI, true) expect(rc.updateCapabilities) .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_LFI, true) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SHI, true) expect(rc.setProductHandler).to.have.been.calledWith('ASM_DATA') expect(rc.setProductHandler).to.have.been.calledWith('ASM_DD') @@ -384,6 +388,8 @@ describe('Remote Config index', () => { .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SQLI, true) expect(rc.updateCapabilities) .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_LFI, true) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SHI, true) }) it('should not activate rasp capabilities if rasp is disabled', () => { @@ -423,6 +429,8 @@ describe('Remote Config index', () => { .to.not.have.been.calledWith(RemoteConfigCapabilities.ASM_RASP_SQLI) expect(rc.updateCapabilities) .to.not.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_LFI) + expect(rc.updateCapabilities) + .to.not.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SHI) }) }) @@ -462,6 +470,8 @@ describe('Remote Config index', () => { .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SQLI, false) expect(rc.updateCapabilities) .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_LFI, false) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SHI, false) expect(rc.removeProductHandler).to.have.been.calledWith('ASM_DATA') expect(rc.removeProductHandler).to.have.been.calledWith('ASM_DD')