From 11387172fb8f0ae9c2fdf586c9d811ab0f032f70 Mon Sep 17 00:00:00 2001 From: Igor Unanua Date: Tue, 8 Oct 2024 10:05:07 +0200 Subject: [PATCH] Exploit Prevention LFI (#4676) * rasp lfi and iast using rasp fs-plugin * Add rasp lfi capability in RC * Handle aborted operations in fs instrumentation * enable test without express * cleanup and console log to debug test error * Do not throw * another test * Try increasing timeout * Enable debug again * Enable debug again * increase timeout a lot * increase timeout more * New lfi test * Increase test timeout * print all errors * remote debug info * Handle the different invocation cases * Handle non string properties * specify types to be analyzed * a bunch of tests * clean up * rasp lfi subs delayed (#4715) * Delay Appsec fs plugin subscription to fs:operations until the first req is received * disable rasp in tests * fix tests recursive call * Avoid multiple subscriptions to incomingHttpRequestStart * another try * replace spy with stub * execute unsubscribe asynchronously * sinon.assert async * clarify comment * Use a constant * Do not enable rasp in some tests * Remove not needed config property * Rename properties * Test iast and rasp fs-plugin subscription order * Avoid multiple analyzeLfi subscriptions * Block synchronous operations * Include synchronous blocking integration test * Test refactor * rename test file * Cleanup --- .../datadog-instrumentations/src/express.js | 23 + packages/datadog-instrumentations/src/fs.js | 34 +- packages/dd-trace/src/appsec/addresses.js | 2 + packages/dd-trace/src/appsec/channels.js | 4 +- .../iast/analyzers/path-traversal-analyzer.js | 9 +- packages/dd-trace/src/appsec/iast/index.js | 3 + .../dd-trace/src/appsec/rasp/fs-plugin.js | 99 ++++ packages/dd-trace/src/appsec/rasp/index.js | 34 +- packages/dd-trace/src/appsec/rasp/lfi.js | 112 +++++ packages/dd-trace/src/appsec/rasp/utils.js | 3 +- .../src/appsec/remote_config/capabilities.js | 1 + .../src/appsec/remote_config/index.js | 2 + .../analyzers/path-traversal-analyzer.spec.js | 8 + .../dd-trace/test/appsec/iast/index.spec.js | 30 +- packages/dd-trace/test/appsec/index.spec.js | 5 +- .../test/appsec/rasp/fs-plugin.spec.js | 251 ++++++++++ .../dd-trace/test/appsec/rasp/index.spec.js | 73 ++- .../appsec/rasp/lfi.express.plugin.spec.js | 469 ++++++++++++++++++ .../lfi.integration.express.plugin.spec.js | 69 +++ .../dd-trace/test/appsec/rasp/lfi.spec.js | 144 ++++++ .../appsec/rasp/resources/lfi-app/index.js | 28 ++ .../appsec/rasp/resources/lfi_rasp_rules.json | 61 +++ packages/dd-trace/test/appsec/rasp/utils.js | 12 +- .../test/appsec/remote_config/index.spec.js | 10 + .../test/appsec/response_blocking.spec.js | 5 +- packages/dd-trace/test/plugins/agent.js | 4 +- 26 files changed, 1465 insertions(+), 30 deletions(-) create mode 100644 packages/dd-trace/src/appsec/rasp/fs-plugin.js create mode 100644 packages/dd-trace/src/appsec/rasp/lfi.js create mode 100644 packages/dd-trace/test/appsec/rasp/fs-plugin.spec.js create mode 100644 packages/dd-trace/test/appsec/rasp/lfi.express.plugin.spec.js create mode 100644 packages/dd-trace/test/appsec/rasp/lfi.integration.express.plugin.spec.js create mode 100644 packages/dd-trace/test/appsec/rasp/lfi.spec.js create mode 100644 packages/dd-trace/test/appsec/rasp/resources/lfi-app/index.js create mode 100644 packages/dd-trace/test/appsec/rasp/resources/lfi_rasp_rules.json diff --git a/packages/datadog-instrumentations/src/express.js b/packages/datadog-instrumentations/src/express.js index d3113821364..c47feef2468 100644 --- a/packages/datadog-instrumentations/src/express.js +++ b/packages/datadog-instrumentations/src/express.js @@ -3,6 +3,7 @@ const { createWrapRouterMethod } = require('./router') const shimmer = require('../../datadog-shimmer') const { addHook, channel } = require('./helpers/instrument') +const tracingChannel = require('dc-polyfill').tracingChannel const handleChannel = channel('apm:express:request:handle') @@ -35,6 +36,27 @@ function wrapResponseJson (json) { } } +const responseRenderChannel = tracingChannel('datadog:express:response:render') + +function wrapResponseRender (render) { + return function wrappedRender (view, options, callback) { + if (!responseRenderChannel.start.hasSubscribers) { + return render.apply(this, arguments) + } + + return responseRenderChannel.traceSync( + render, + { + req: this.req, + view, + options + }, + this, + ...arguments + ) + } +} + addHook({ name: 'express', versions: ['>=4'] }, express => { shimmer.wrap(express.application, 'handle', wrapHandle) shimmer.wrap(express.Router, 'use', wrapRouterMethod) @@ -42,6 +64,7 @@ addHook({ name: 'express', versions: ['>=4'] }, express => { shimmer.wrap(express.response, 'json', wrapResponseJson) shimmer.wrap(express.response, 'jsonp', wrapResponseJson) + shimmer.wrap(express.response, 'render', wrapResponseRender) return express }) diff --git a/packages/datadog-instrumentations/src/fs.js b/packages/datadog-instrumentations/src/fs.js index e0e57f1ebce..9ae201b9860 100644 --- a/packages/datadog-instrumentations/src/fs.js +++ b/packages/datadog-instrumentations/src/fs.js @@ -266,24 +266,44 @@ function createWrapFunction (prefix = '', override = '') { const lastIndex = arguments.length - 1 const cb = typeof arguments[lastIndex] === 'function' && arguments[lastIndex] const innerResource = new AsyncResource('bound-anonymous-fn') - const message = getMessage(method, getMethodParamsRelationByPrefix(prefix)[operation], arguments, this) + const params = getMethodParamsRelationByPrefix(prefix)[operation] + const abortController = new AbortController() + const message = { ...getMessage(method, params, arguments, this), abortController } + + const finish = innerResource.bind(function (error) { + if (error !== null && typeof error === 'object') { // fs.exists receives a boolean + errorChannel.publish(error) + } + finishChannel.publish() + }) if (cb) { const outerResource = new AsyncResource('bound-anonymous-fn') arguments[lastIndex] = shimmer.wrapFunction(cb, cb => innerResource.bind(function (e) { - if (e !== null && typeof e === 'object') { // fs.exists receives a boolean - errorChannel.publish(e) - } - - finishChannel.publish() - + finish(e) return outerResource.runInAsyncScope(() => cb.apply(this, arguments)) })) } return innerResource.runInAsyncScope(() => { startChannel.publish(message) + + if (abortController.signal.aborted) { + const error = abortController.signal.reason || new Error('Aborted') + + if (prefix === 'promises.') { + finish(error) + return Promise.reject(error) + } else if (name.includes('Sync') || !cb) { + finish(error) + throw error + } else if (cb) { + arguments[lastIndex](error) + return + } + } + try { const result = original.apply(this, arguments) if (cb) return result diff --git a/packages/dd-trace/src/appsec/addresses.js b/packages/dd-trace/src/appsec/addresses.js index e2cf6c6940a..f8ce3033d36 100644 --- a/packages/dd-trace/src/appsec/addresses.js +++ b/packages/dd-trace/src/appsec/addresses.js @@ -23,6 +23,8 @@ module.exports = { WAF_CONTEXT_PROCESSOR: 'waf.context.processor', HTTP_OUTGOING_URL: 'server.io.net.url', + FS_OPERATION_PATH: 'server.io.fs.file', + 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 a451b9ce145..729f4da0334 100644 --- a/packages/dd-trace/src/appsec/channels.js +++ b/packages/dd-trace/src/appsec/channels.js @@ -25,5 +25,7 @@ module.exports = { pgQueryStart: dc.channel('apm:pg:query:start'), pgPoolQueryStart: dc.channel('datadog:pg:pool:query:start'), mysql2OuterQueryStart: dc.channel('datadog:mysql2:outerquery:start'), - wafRunFinished: dc.channel('datadog:waf:run:finish') + wafRunFinished: dc.channel('datadog:waf:run:finish'), + fsOperationStart: dc.channel('apm:fs:operation:start'), + expressMiddlewareError: dc.channel('apm:express:middleware:error') } diff --git a/packages/dd-trace/src/appsec/iast/analyzers/path-traversal-analyzer.js b/packages/dd-trace/src/appsec/iast/analyzers/path-traversal-analyzer.js index 83bf2a87085..625dbde9150 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/path-traversal-analyzer.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/path-traversal-analyzer.js @@ -29,7 +29,14 @@ class PathTraversalAnalyzer extends InjectionAnalyzer { onConfigure () { this.addSub('apm:fs:operation:start', (obj) => { - if (ignoredOperations.includes(obj.operation)) return + const store = storage.getStore() + const outOfReqOrChild = !store?.fs?.root + + // we could filter out all the nested fs.operations based on store.fs.root + // but if we spect a store in the context to be present we are going to exclude + // all out_of_the_request fs.operations + // AppsecFsPlugin must be enabled + if (ignoredOperations.includes(obj.operation) || outOfReqOrChild) return const pathArguments = [] if (obj.dest) { diff --git a/packages/dd-trace/src/appsec/iast/index.js b/packages/dd-trace/src/appsec/iast/index.js index 0facaa39a2a..9330bfdbbb1 100644 --- a/packages/dd-trace/src/appsec/iast/index.js +++ b/packages/dd-trace/src/appsec/iast/index.js @@ -14,6 +14,7 @@ const { } = require('./taint-tracking') const { IAST_ENABLED_TAG_KEY } = require('./tags') const iastTelemetry = require('./telemetry') +const { enable: enableFsPlugin, disable: disableFsPlugin, IAST_MODULE } = require('../rasp/fs-plugin') // TODO Change to `apm:http:server:request:[start|close]` when the subscription // order of the callbacks can be enforce @@ -27,6 +28,7 @@ function enable (config, _tracer) { if (isEnabled) return iastTelemetry.configure(config, config.iast?.telemetryVerbosity) + enableFsPlugin(IAST_MODULE) enableAllAnalyzers(config) enableTaintTracking(config.iast, iastTelemetry.verbosity) requestStart.subscribe(onIncomingHttpRequestStart) @@ -44,6 +46,7 @@ function disable () { isEnabled = false iastTelemetry.stop() + disableFsPlugin(IAST_MODULE) disableAllAnalyzers() disableTaintTracking() overheadController.finishGlobalContext() diff --git a/packages/dd-trace/src/appsec/rasp/fs-plugin.js b/packages/dd-trace/src/appsec/rasp/fs-plugin.js new file mode 100644 index 00000000000..a283b4f1a61 --- /dev/null +++ b/packages/dd-trace/src/appsec/rasp/fs-plugin.js @@ -0,0 +1,99 @@ +'use strict' + +const Plugin = require('../../plugins/plugin') +const { storage } = require('../../../../datadog-core') +const log = require('../../log') + +const RASP_MODULE = 'rasp' +const IAST_MODULE = 'iast' + +const enabledFor = { + [RASP_MODULE]: false, + [IAST_MODULE]: false +} + +let fsPlugin + +function enterWith (fsProps, store = storage.getStore()) { + if (store && !store.fs?.opExcluded) { + storage.enterWith({ + ...store, + fs: { + ...store.fs, + ...fsProps, + parentStore: store + } + }) + } +} + +class AppsecFsPlugin extends Plugin { + enable () { + this.addSub('apm:fs:operation:start', this._onFsOperationStart) + this.addSub('apm:fs:operation:finish', this._onFsOperationFinishOrRenderEnd) + this.addSub('tracing:datadog:express:response:render:start', this._onResponseRenderStart) + this.addSub('tracing:datadog:express:response:render:end', this._onFsOperationFinishOrRenderEnd) + + super.configure(true) + } + + disable () { + super.configure(false) + } + + _onFsOperationStart () { + const store = storage.getStore() + if (store) { + enterWith({ root: store.fs?.root === undefined }, store) + } + } + + _onResponseRenderStart () { + enterWith({ opExcluded: true }) + } + + _onFsOperationFinishOrRenderEnd () { + const store = storage.getStore() + if (store?.fs?.parentStore) { + storage.enterWith(store.fs.parentStore) + } + } +} + +function enable (mod) { + if (enabledFor[mod] !== false) return + + enabledFor[mod] = true + + if (!fsPlugin) { + fsPlugin = new AppsecFsPlugin() + fsPlugin.enable() + } + + log.info(`Enabled AppsecFsPlugin for ${mod}`) +} + +function disable (mod) { + if (!mod || !enabledFor[mod]) return + + enabledFor[mod] = false + + const allDisabled = Object.values(enabledFor).every(val => val === false) + if (allDisabled) { + fsPlugin?.disable() + + fsPlugin = undefined + } + + log.info(`Disabled AppsecFsPlugin for ${mod}`) +} + +module.exports = { + enable, + disable, + + AppsecFsPlugin, + + RASP_MODULE, + IAST_MODULE +} diff --git a/packages/dd-trace/src/appsec/rasp/index.js b/packages/dd-trace/src/appsec/rasp/index.js index 801608e54d8..d5a1312872a 100644 --- a/packages/dd-trace/src/appsec/rasp/index.js +++ b/packages/dd-trace/src/appsec/rasp/index.js @@ -1,10 +1,11 @@ 'use strict' const web = require('../../plugins/util/web') -const { setUncaughtExceptionCaptureCallbackStart } = require('../channels') -const { block } = require('../blocking') +const { setUncaughtExceptionCaptureCallbackStart, expressMiddlewareError } = require('../channels') +const { block, isBlocked } = require('../blocking') const ssrf = require('./ssrf') const sqli = require('./sql_injection') +const lfi = require('./lfi') const { DatadogRaspAbortError } = require('./utils') @@ -30,17 +31,13 @@ function findDatadogRaspAbortError (err, deep = 10) { return err } - if (err.cause && deep > 0) { + 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) +function handleUncaughtExceptionMonitor (error) { + if (!blockOnDatadogRaspAbortError({ error })) return if (!process.hasUncaughtExceptionCaptureCallback()) { const cleanUp = removeAllListeners(process, 'uncaughtException') @@ -82,22 +79,39 @@ function handleUncaughtExceptionMonitor (err) { } } +function blockOnDatadogRaspAbortError ({ error }) { + const abortError = findDatadogRaspAbortError(error) + if (!abortError) return false + + const { req, res, blockingAction } = abortError + if (!isBlocked(res)) { + block(req, res, web.root(req), null, blockingAction) + } + + return true +} + function enable (config) { ssrf.enable(config) sqli.enable(config) + lfi.enable(config) process.on('uncaughtExceptionMonitor', handleUncaughtExceptionMonitor) + expressMiddlewareError.subscribe(blockOnDatadogRaspAbortError) } function disable () { ssrf.disable() sqli.disable() + lfi.disable() process.off('uncaughtExceptionMonitor', handleUncaughtExceptionMonitor) + if (expressMiddlewareError.hasSubscribers) expressMiddlewareError.unsubscribe(blockOnDatadogRaspAbortError) } module.exports = { enable, disable, - handleUncaughtExceptionMonitor // exported only for testing purpose + handleUncaughtExceptionMonitor, // exported only for testing purpose + blockOnDatadogRaspAbortError // exported only for testing purpose } diff --git a/packages/dd-trace/src/appsec/rasp/lfi.js b/packages/dd-trace/src/appsec/rasp/lfi.js new file mode 100644 index 00000000000..1190734064d --- /dev/null +++ b/packages/dd-trace/src/appsec/rasp/lfi.js @@ -0,0 +1,112 @@ +'use strict' + +const { fsOperationStart, incomingHttpRequestStart } = require('../channels') +const { storage } = require('../../../../datadog-core') +const { enable: enableFsPlugin, disable: disableFsPlugin, RASP_MODULE } = require('./fs-plugin') +const { FS_OPERATION_PATH } = require('../addresses') +const waf = require('../waf') +const { RULE_TYPES, handleResult } = require('./utils') +const { isAbsolute } = require('path') + +let config +let enabled +let analyzeSubscribed + +function enable (_config) { + config = _config + + if (enabled) return + + enabled = true + + incomingHttpRequestStart.subscribe(onFirstReceivedRequest) +} + +function disable () { + if (fsOperationStart.hasSubscribers) fsOperationStart.unsubscribe(analyzeLfi) + if (incomingHttpRequestStart.hasSubscribers) incomingHttpRequestStart.unsubscribe(onFirstReceivedRequest) + + disableFsPlugin(RASP_MODULE) + + enabled = false + analyzeSubscribed = false +} + +function onFirstReceivedRequest () { + // nodejs unsubscribe during publish bug: https://github.com/nodejs/node/pull/55116 + process.nextTick(() => { + incomingHttpRequestStart.unsubscribe(onFirstReceivedRequest) + }) + + enableFsPlugin(RASP_MODULE) + + if (!analyzeSubscribed) { + fsOperationStart.subscribe(analyzeLfi) + analyzeSubscribed = true + } +} + +function analyzeLfi (ctx) { + const store = storage.getStore() + if (!store) return + + const { req, fs, res } = store + if (!req || !fs) return + + getPaths(ctx, fs).forEach(path => { + const persistent = { + [FS_OPERATION_PATH]: path + } + + const result = waf.run({ persistent }, req, RULE_TYPES.LFI) + handleResult(result, req, res, ctx.abortController, config) + }) +} + +function getPaths (ctx, fs) { + // these properties could have String, Buffer, URL, Integer or FileHandle types + const pathArguments = [ + ctx.dest, + ctx.existingPath, + ctx.file, + ctx.newPath, + ctx.oldPath, + ctx.path, + ctx.prefix, + ctx.src, + ctx.target + ] + + return pathArguments + .map(path => pathToStr(path)) + .filter(path => shouldAnalyze(path, fs)) +} + +function pathToStr (path) { + if (!path) return + + if (typeof path === 'string' || + path instanceof String || + path instanceof Buffer || + path instanceof URL) { + return path.toString() + } +} + +function shouldAnalyze (path, fs) { + if (!path) return + + const notExcludedRootOp = !fs.opExcluded && fs.root + return notExcludedRootOp && (isAbsolute(path) || path.includes('../') || shouldAnalyzeURLFile(path, fs)) +} + +function shouldAnalyzeURLFile (path, fs) { + if (path.startsWith('file://')) { + return shouldAnalyze(path.substring(7), fs) + } +} + +module.exports = { + enable, + disable +} diff --git a/packages/dd-trace/src/appsec/rasp/utils.js b/packages/dd-trace/src/appsec/rasp/utils.js index 2a46b76d6e4..c4ee4f55c3f 100644 --- a/packages/dd-trace/src/appsec/rasp/utils.js +++ b/packages/dd-trace/src/appsec/rasp/utils.js @@ -13,7 +13,8 @@ if (abortOnUncaughtException) { const RULE_TYPES = { SSRF: 'ssrf', - SQL_INJECTION: 'sql_injection' + SQL_INJECTION: 'sql_injection', + LFI: 'lfi' } 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 f42d7358203..05dc96233fd 100644 --- a/packages/dd-trace/src/appsec/remote_config/capabilities.js +++ b/packages/dd-trace/src/appsec/remote_config/capabilities.js @@ -19,5 +19,6 @@ module.exports = { APM_TRACING_ENABLED: 1n << 19n, ASM_RASP_SQLI: 1n << 21n, ASM_RASP_SSRF: 1n << 23n, + ASM_RASP_LFI: 1n << 24n, APM_TRACING_SAMPLE_RULES: 1n << 29n } diff --git a/packages/dd-trace/src/appsec/remote_config/index.js b/packages/dd-trace/src/appsec/remote_config/index.js index b63b3690102..28772c60c2e 100644 --- a/packages/dd-trace/src/appsec/remote_config/index.js +++ b/packages/dd-trace/src/appsec/remote_config/index.js @@ -79,6 +79,7 @@ function enableWafUpdate (appsecConfig) { if (appsecConfig.rasp?.enabled) { rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_SQLI, true) rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_SSRF, true) + rc.updateCapabilities(RemoteConfigCapabilities.ASM_RASP_LFI, true) } // TODO: delete noop handlers and kPreUpdate and replace with batched handlers @@ -106,6 +107,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.removeProductHandler('ASM_DATA') rc.removeProductHandler('ASM_DD') diff --git a/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.spec.js b/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.spec.js index 5b46c193fbd..6c39799f916 100644 --- a/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.spec.js +++ b/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.spec.js @@ -45,6 +45,14 @@ const InjectionAnalyzer = proxyquire('../../../../src/appsec/iast/analyzers/inje }) describe('path-traversal-analyzer', () => { + before(() => { + pathTraversalAnalyzer.enable() + }) + + after(() => { + pathTraversalAnalyzer.disable() + }) + it('Analyzer should be subscribed to proper channel', () => { expect(pathTraversalAnalyzer._subscriptions).to.have.lengthOf(1) expect(pathTraversalAnalyzer._subscriptions[0]._channel.name).to.equals('apm:fs:operation:start') diff --git a/packages/dd-trace/test/appsec/iast/index.spec.js b/packages/dd-trace/test/appsec/iast/index.spec.js index 7035296d8de..f770694ede4 100644 --- a/packages/dd-trace/test/appsec/iast/index.spec.js +++ b/packages/dd-trace/test/appsec/iast/index.spec.js @@ -7,6 +7,7 @@ const iastContextFunctions = require('../../../src/appsec/iast/iast-context') const overheadController = require('../../../src/appsec/iast/overhead-controller') const vulnerabilityReporter = require('../../../src/appsec/iast/vulnerability-reporter') const { testInRequest } = require('./utils') +const { IAST_MODULE } = require('../../../src/appsec/rasp/fs-plugin') describe('IAST Index', () => { beforeEach(() => { @@ -102,6 +103,8 @@ describe('IAST Index', () => { let mockVulnerabilityReporter let mockIast let mockOverheadController + let appsecFsPlugin + let analyzers const config = new Config({ experimental: { @@ -125,9 +128,18 @@ describe('IAST Index', () => { startGlobalContext: sinon.stub(), finishGlobalContext: sinon.stub() } + appsecFsPlugin = { + enable: sinon.stub(), + disable: sinon.stub() + } + analyzers = { + enableAllAnalyzers: sinon.stub() + } mockIast = proxyquire('../../../src/appsec/iast', { './vulnerability-reporter': mockVulnerabilityReporter, - './overhead-controller': mockOverheadController + './overhead-controller': mockOverheadController, + '../rasp/fs-plugin': appsecFsPlugin, + './analyzers': analyzers }) }) @@ -136,6 +148,22 @@ describe('IAST Index', () => { mockIast.disable() }) + describe('enable', () => { + it('should enable AppsecFsPlugin', () => { + mockIast.enable(config) + expect(appsecFsPlugin.enable).to.have.been.calledOnceWithExactly(IAST_MODULE) + expect(analyzers.enableAllAnalyzers).to.have.been.calledAfter(appsecFsPlugin.enable) + }) + }) + + describe('disable', () => { + it('should disable AppsecFsPlugin', () => { + mockIast.enable(config) + mockIast.disable() + expect(appsecFsPlugin.disable).to.have.been.calledOnceWithExactly(IAST_MODULE) + }) + }) + describe('managing overhead controller global context', () => { it('should start global context refresher on iast enabled', () => { mockIast.enable(config) diff --git a/packages/dd-trace/test/appsec/index.spec.js b/packages/dd-trace/test/appsec/index.spec.js index b8a41d840b5..8548804ac38 100644 --- a/packages/dd-trace/test/appsec/index.spec.js +++ b/packages/dd-trace/test/appsec/index.spec.js @@ -1058,7 +1058,10 @@ describe('IP blocking', function () { beforeEach(() => { appsec.enable(new Config({ appsec: { - enabled: true + enabled: true, + rasp: { + enabled: false // disable rasp to not trigger lfi + } } })) diff --git a/packages/dd-trace/test/appsec/rasp/fs-plugin.spec.js b/packages/dd-trace/test/appsec/rasp/fs-plugin.spec.js new file mode 100644 index 00000000000..03b2a0acdd0 --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/fs-plugin.spec.js @@ -0,0 +1,251 @@ +'use strict' + +const proxyquire = require('proxyquire') +const { assert } = require('chai') +const path = require('path') +const dc = require('dc-polyfill') +const { storage } = require('../../../../datadog-core') +const { AppsecFsPlugin } = require('../../../src/appsec/rasp/fs-plugin') +const agent = require('../../plugins/agent') + +const opStartCh = dc.channel('apm:fs:operation:start') +const opFinishCh = dc.channel('apm:fs:operation:finish') + +describe('AppsecFsPlugin', () => { + let appsecFsPlugin + + beforeEach(() => { + appsecFsPlugin = new AppsecFsPlugin() + appsecFsPlugin.enable() + }) + + afterEach(() => { appsecFsPlugin.disable() }) + + describe('enable/disable', () => { + let fsPlugin, configure + + beforeEach(() => { + configure = sinon.stub() + class PluginClass { + addSub (channelName, handler) {} + + configure (config) { + configure(config) + } + } + + fsPlugin = proxyquire('../../../src/appsec/rasp/fs-plugin', { + '../../plugins/plugin': PluginClass + }) + }) + + afterEach(() => { sinon.restore() }) + + it('should require valid mod when calling enable', () => { + fsPlugin.enable('iast') + + sinon.assert.calledOnceWithExactly(configure, true) + }) + + it('should create only one instance', () => { + fsPlugin.enable('iast') + fsPlugin.enable('iast') + fsPlugin.enable('rasp') + + sinon.assert.calledOnceWithExactly(configure, true) + }) + + it('should discard unknown mods when enabled', () => { + fsPlugin.enable('unknown') + sinon.assert.notCalled(configure) + + fsPlugin.enable() + sinon.assert.notCalled(configure) + }) + + it('should not disable if there are still modules using the plugin', () => { + fsPlugin.enable('iast') + fsPlugin.enable('rasp') + + fsPlugin.disable('rasp') + + sinon.assert.calledOnce(configure) + }) + + it('should disable only if there are no more modules using the plugin', () => { + fsPlugin.enable('iast') + fsPlugin.enable('rasp') + + fsPlugin.disable('rasp') + fsPlugin.disable('iast') + + sinon.assert.calledTwice(configure) + assert.strictEqual(configure.secondCall.args[0], false) + }) + + it('should discard unknown mods when disabling', () => { + fsPlugin.disable('unknown') + sinon.assert.notCalled(configure) + + fsPlugin.disable() + sinon.assert.notCalled(configure) + }) + }) + + describe('_onFsOperationStart', () => { + it('should mark fs root', () => { + const origStore = {} + storage.enterWith(origStore) + + appsecFsPlugin._onFsOperationStart() + + let store = storage.getStore() + assert.property(store, 'fs') + assert.propertyVal(store.fs, 'parentStore', origStore) + assert.propertyVal(store.fs, 'root', true) + + appsecFsPlugin._onFsOperationFinishOrRenderEnd() + + store = storage.getStore() + assert.equal(store, origStore) + assert.notProperty(store, 'fs') + }) + + it('should mark fs children', () => { + const origStore = { orig: true } + storage.enterWith(origStore) + + appsecFsPlugin._onFsOperationStart() + + const rootStore = storage.getStore() + assert.property(rootStore, 'fs') + assert.propertyVal(rootStore.fs, 'parentStore', origStore) + assert.propertyVal(rootStore.fs, 'root', true) + + appsecFsPlugin._onFsOperationStart() + + let store = storage.getStore() + assert.property(store, 'fs') + assert.propertyVal(store.fs, 'parentStore', rootStore) + assert.propertyVal(store.fs, 'root', false) + assert.propertyVal(store, 'orig', true) + + appsecFsPlugin._onFsOperationFinishOrRenderEnd() + + store = storage.getStore() + assert.equal(store, rootStore) + + appsecFsPlugin._onFsOperationFinishOrRenderEnd() + store = storage.getStore() + assert.equal(store, origStore) + }) + }) + + describe('_onResponseRenderStart', () => { + it('should mark fs ops as excluded while response rendering', () => { + appsecFsPlugin.enable() + + const origStore = {} + storage.enterWith(origStore) + + appsecFsPlugin._onResponseRenderStart() + + let store = storage.getStore() + assert.property(store, 'fs') + assert.propertyVal(store.fs, 'parentStore', origStore) + assert.propertyVal(store.fs, 'opExcluded', true) + + appsecFsPlugin._onFsOperationFinishOrRenderEnd() + + store = storage.getStore() + assert.equal(store, origStore) + assert.notProperty(store, 'fs') + }) + }) + + describe('integration', () => { + describe('apm:fs:operation', () => { + let fs + + afterEach(() => agent.close({ ritmReset: false })) + + beforeEach(() => agent.load('fs', undefined, { flushInterval: 1 }).then(() => { + fs = require('fs') + })) + + it('should mark root operations', () => { + let count = 0 + const onStart = () => { + const store = storage.getStore() + assert.isNotNull(store.fs) + + count++ + assert.strictEqual(count === 1, store.fs.root) + } + + try { + const origStore = {} + storage.enterWith(origStore) + + opStartCh.subscribe(onStart) + + fs.readFileSync(path.join(__dirname, 'fs-plugin.spec.js')) + + assert.strictEqual(count, 4) + } finally { + opStartCh.unsubscribe(onStart) + } + }) + + it('should mark root even if op is excluded', () => { + let count = 0 + const onStart = () => { + const store = storage.getStore() + assert.isNotNull(store.fs) + + count++ + assert.isUndefined(store.fs.root) + } + + try { + const origStore = { + fs: { opExcluded: true } + } + storage.enterWith(origStore) + + opStartCh.subscribe(onStart) + + fs.readFileSync(path.join(__dirname, 'fs-plugin.spec.js')) + + assert.strictEqual(count, 4) + } finally { + opStartCh.unsubscribe(onStart) + } + }) + + it('should clean up store when finishing op', () => { + let count = 4 + const onFinish = () => { + const store = storage.getStore() + count-- + + if (count === 0) { + assert.isUndefined(store.fs) + } + } + try { + const origStore = {} + storage.enterWith(origStore) + + opFinishCh.subscribe(onFinish) + + fs.readFileSync(path.join(__dirname, 'fs-plugin.spec.js')) + + assert.strictEqual(count, 0) + } finally { + opFinishCh.unsubscribe(onFinish) + } + }) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/rasp/index.spec.js b/packages/dd-trace/test/appsec/rasp/index.spec.js index 0dae9c527e5..be6c602780a 100644 --- a/packages/dd-trace/test/appsec/rasp/index.spec.js +++ b/packages/dd-trace/test/appsec/rasp/index.spec.js @@ -1,9 +1,12 @@ 'use strict' -const rasp = require('../../../src/appsec/rasp') +const proxyquire = require('proxyquire') const { handleUncaughtExceptionMonitor } = require('../../../src/appsec/rasp') +const { DatadogRaspAbortError } = require('../../../src/appsec/rasp/utils') describe('RASP', () => { + let rasp, subscribe, unsubscribe, block, blocked + beforeEach(() => { const config = { appsec: { @@ -15,6 +18,25 @@ describe('RASP', () => { } } + subscribe = sinon.stub() + unsubscribe = sinon.stub() + + block = sinon.stub() + + rasp = proxyquire('../../../src/appsec/rasp', { + '../blocking': { + block, + isBlocked: sinon.stub().callsFake(() => blocked) + }, + '../channels': { + expressMiddlewareError: { + subscribe, + unsubscribe, + hasSubscribers: true + } + } + }) + rasp.enable(config) }) @@ -31,4 +53,53 @@ describe('RASP', () => { handleUncaughtExceptionMonitor(err) }) }) + + describe('enable/disable', () => { + it('should subscribe to apm:express:middleware:error', () => { + sinon.assert.calledOnce(subscribe) + }) + + it('should unsubscribe to apm:express:middleware:error', () => { + rasp.disable() + + sinon.assert.calledOnce(unsubscribe) + }) + }) + + describe('blockOnDatadogRaspAbortError', () => { + let req, res, blockingAction + + beforeEach(() => { + req = {} + res = {} + blockingAction = {} + }) + + afterEach(() => { + sinon.restore() + }) + + it('should skip non DatadogRaspAbortError', () => { + rasp.blockOnDatadogRaspAbortError({ error: new Error() }) + + sinon.assert.notCalled(block) + }) + + it('should block DatadogRaspAbortError first time', () => { + rasp.blockOnDatadogRaspAbortError({ error: new DatadogRaspAbortError(req, res, blockingAction) }) + + sinon.assert.calledOnce(block) + }) + + it('should skip calling block if blocked before', () => { + rasp.blockOnDatadogRaspAbortError({ error: new DatadogRaspAbortError(req, res, blockingAction) }) + + blocked = true + + rasp.blockOnDatadogRaspAbortError({ error: new DatadogRaspAbortError(req, res, blockingAction) }) + rasp.blockOnDatadogRaspAbortError({ error: new DatadogRaspAbortError(req, res, blockingAction) }) + + sinon.assert.calledOnce(block) + }) + }) }) diff --git a/packages/dd-trace/test/appsec/rasp/lfi.express.plugin.spec.js b/packages/dd-trace/test/appsec/rasp/lfi.express.plugin.spec.js new file mode 100644 index 00000000000..b5b825cc628 --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/lfi.express.plugin.spec.js @@ -0,0 +1,469 @@ +'use strict' + +const Axios = require('axios') +const os = require('os') +const fs = require('fs') +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') + +describe('RASP - lfi', () => { + let axios + + async function testBlockingRequest (url = '/?file=/test.file', config = undefined, ruleEvalCount = 1) { + try { + await axios.get(url, config) + } catch (e) { + if (!e.response) { + throw e + } + + assert.strictEqual(e.response.status, 418) // a teapot + + return checkRaspExecutedAndHasThreat(agent, 'rasp-lfi-rule-id-1', ruleEvalCount) + } + + assert.fail('Request should be blocked') + } + + withVersions('express', 'express', expressVersion => { + let app, server + + before(() => { + return agent.load(['http', 'express'], { 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', 'lfi_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('lfi', () => { + function getApp (fn, args, options) { + return async (req, res) => { + try { + const result = await fn(args) + options.onfinish?.(result) + } catch (e) { + if (e.message === 'DatadogRaspAbortError') { + res.writeHead(418) + } + } + res.end('end') + } + } + + function getAppSync (fn, args, options) { + return (req, res) => { + try { + const result = fn(args) + options.onfinish?.(result) + } catch (e) { + if (e.message === 'DatadogRaspAbortError') { + res.writeHead(418) + } + } + res.end('end') + } + } + + function runFsMethodTest (description, options, fn, ...args) { + const { vulnerableIndex = 0, ruleEvalCount } = options + + describe(description, () => { + const getAppFn = options.getAppFn ?? getApp + + it('should block param from the request', async () => { + app = getAppFn(fn, args, options) + + const file = args[vulnerableIndex] + return testBlockingRequest(`/?file=${file}`, undefined, ruleEvalCount) + .then(span => { + assert(span.meta['_dd.appsec.json'].includes(file)) + }) + }) + + it('should not block if param not found in the request', async () => { + app = getAppFn(fn, args, options) + + await axios.get('/?file=/test.file') + + return checkRaspExecutedAndNotThreat(agent, false) + }) + }) + } + + function runFsMethodTestThreeWay (methodName, options = {}, ...args) { + let desc = `test ${methodName} ${options.desc ?? ''}` + const { vulnerableIndex = 0 } = options + if (vulnerableIndex !== 0) { + desc += ` with vulnerable index ${vulnerableIndex}` + } + describe(desc, () => { + runFsMethodTest(`test fs.${methodName}Sync method`, { ...options, getAppFn: getAppSync }, (args) => { + return require('fs')[`${methodName}Sync`](...args) + }, ...args) + + runFsMethodTest(`test fs.${methodName} method`, options, (args) => { + return new Promise((resolve, reject) => { + require('fs')[methodName](...args, (err, res) => { + if (err) reject(err) + else resolve(res) + }) + }) + }, ...args) + + runFsMethodTest(`test fs.promises.${methodName} method`, options, async (args) => { + return require('fs').promises[methodName](...args) + }, ...args) + }) + } + + function unlink (...args) { + args.forEach(arg => { + try { + fs.unlinkSync(arg) + } catch (e) { + + } + }) + } + + describe('test access', () => { + runFsMethodTestThreeWay('access', undefined, __filename) + runFsMethodTestThreeWay('access', { desc: 'Buffer' }, Buffer.from(__filename)) + + // not supported by waf yet + // runFsMethodTestThreeWay('access', { desc: 'URL' }, new URL(`file://${__filename}`)) + }) + + describe('test appendFile', () => { + const filename = path.join(os.tmpdir(), 'test-appendfile') + + beforeEach(() => { + fs.writeFileSync(filename, '') + }) + + afterEach(() => { + fs.unlinkSync(filename) + }) + + runFsMethodTestThreeWay('appendFile', undefined, filename, 'test-content') + }) + + describe('test chmod', () => { + const filename = path.join(os.tmpdir(), 'test-chmod') + + beforeEach(() => { + fs.writeFileSync(filename, '') + }) + + afterEach(() => { + fs.unlinkSync(filename) + }) + runFsMethodTestThreeWay('chmod', undefined, filename, '666') + }) + + describe('test copyFile', () => { + const src = path.join(os.tmpdir(), 'test-copyFile-src') + const dest = path.join(os.tmpdir(), 'test-copyFile-dst') + + beforeEach(() => { + fs.writeFileSync(src, '') + }) + + afterEach(() => unlink(src, dest)) + + runFsMethodTestThreeWay('copyFile', { vulnerableIndex: 0, ruleEvalCount: 2 }, src, dest) + runFsMethodTestThreeWay('copyFile', { vulnerableIndex: 1, ruleEvalCount: 2 }, src, dest) + }) + + describe('test link', () => { + const src = path.join(os.tmpdir(), 'test-link-src') + const dest = path.join(os.tmpdir(), 'test-link-dst') + + beforeEach(() => { + fs.writeFileSync(src, '') + }) + + afterEach(() => unlink(src, dest)) + + runFsMethodTestThreeWay('copyFile', { vulnerableIndex: 0, ruleEvalCount: 2 }, src, dest) + runFsMethodTestThreeWay('copyFile', { vulnerableIndex: 1, ruleEvalCount: 2 }, src, dest) + }) + + describe('test lstat', () => { + runFsMethodTestThreeWay('lstat', undefined, __filename) + }) + + describe('test mkdir', () => { + const dirname = path.join(os.tmpdir(), 'test-mkdir') + + afterEach(() => { + try { + fs.rmdirSync(dirname) + } catch (e) { + // some ops are blocked + } + }) + runFsMethodTestThreeWay('mkdir', undefined, dirname) + }) + + describe('test mkdtemp', () => { + const dirname = path.join(os.tmpdir(), 'test-mkdtemp') + + runFsMethodTestThreeWay('mkdtemp', { + onfinish: (todelete) => { + try { + fs.rmdirSync(todelete) + } catch (e) { + // some ops are blocked + } + } + }, dirname) + }) + + describe('test open', () => { + runFsMethodTestThreeWay('open', { + onfinish: (fd) => { + if (fd && fd.close) { + fd.close() + } else { + fs.close(fd, () => {}) + } + } + }, __filename, 'r') + }) + + describe('test opendir', () => { + const dirname = path.join(os.tmpdir(), 'test-opendir') + + beforeEach(() => { + fs.mkdirSync(dirname) + }) + + afterEach(() => { + fs.rmdirSync(dirname) + }) + runFsMethodTestThreeWay('opendir', { + onfinish: (dir) => { + dir.close() + } + }, dirname) + }) + + describe('test readdir', () => { + const dirname = path.join(os.tmpdir(), 'test-opendir') + + beforeEach(() => { + fs.mkdirSync(dirname) + }) + + afterEach(() => { + fs.rmdirSync(dirname) + }) + runFsMethodTestThreeWay('readdir', undefined, dirname) + }) + + describe('test readFile', () => { + runFsMethodTestThreeWay('readFile', undefined, __filename) + }) + + describe('test readlink', () => { + const src = path.join(os.tmpdir(), 'test-readlink-src') + const dest = path.join(os.tmpdir(), 'test-readlink-dst') + + beforeEach(() => { + fs.writeFileSync(src, '') + fs.linkSync(src, dest) + }) + + afterEach(() => unlink(src, dest)) + + runFsMethodTestThreeWay('readlink', undefined, dest) + }) + + describe('test realpath', () => { + runFsMethodTestThreeWay('realpath', undefined, __filename) + + runFsMethodTest('test fs.realpath.native method', {}, (args) => { + return new Promise((resolve, reject) => { + require('fs').realpath.native(...args, (err, result) => { + if (err) reject(err) + else resolve(result) + }) + }) + }, __filename) + }) + + describe('test rename', () => { + const src = path.join(os.tmpdir(), 'test-rename-src') + const dest = path.join(os.tmpdir(), 'test-rename-dst') + + beforeEach(() => { + fs.writeFileSync(src, '') + }) + + afterEach(() => unlink(dest)) + + runFsMethodTestThreeWay('rename', { vulnerableIndex: 0, ruleEvalCount: 2 }, src, dest) + runFsMethodTestThreeWay('rename', { vulnerableIndex: 1, ruleEvalCount: 2 }, src, dest) + }) + + describe('test rmdir', () => { + const dirname = path.join(os.tmpdir(), 'test-rmdir') + + beforeEach(() => { + fs.mkdirSync(dirname) + }) + + afterEach(() => { + try { fs.rmdirSync(dirname) } catch (e) {} + }) + + runFsMethodTestThreeWay('rmdir', undefined, dirname) + }) + + describe('test stat', () => { + runFsMethodTestThreeWay('stat', undefined, __filename) + }) + + describe('test symlink', () => { + const src = path.join(os.tmpdir(), 'test-symlink-src') + const dest = path.join(os.tmpdir(), 'test-symlink-dst') + + beforeEach(() => { + fs.writeFileSync(src, '') + }) + + afterEach(() => { + unlink(src, dest) + }) + + runFsMethodTestThreeWay('symlink', { vulnerableIndex: 0, ruleEvalCount: 2 }, src, dest) + runFsMethodTestThreeWay('symlink', { vulnerableIndex: 1, ruleEvalCount: 2 }, src, dest) + }) + + describe('test truncate', () => { + const src = path.join(os.tmpdir(), 'test-truncate-src') + + beforeEach(() => { + fs.writeFileSync(src, 'aaaaaa') + }) + + afterEach(() => unlink(src)) + + runFsMethodTestThreeWay('truncate', undefined, src) + }) + + describe('test unlink', () => { + const src = path.join(os.tmpdir(), 'test-unlink-src') + + beforeEach(() => { + fs.writeFileSync(src, '') + }) + runFsMethodTestThreeWay('unlink', undefined, src) + }) + + describe('test writeFile', () => { + const src = path.join(os.tmpdir(), 'test-writeFile-src') + + afterEach(() => unlink(src)) + + runFsMethodTestThreeWay('writeFile', undefined, src, 'content') + }) + }) + }) + + describe('without express', () => { + let app, server + + before(() => { + return agent.load(['http'], { client: false }) + }) + + before((done) => { + const http = require('http') + server = http.createServer((req, res) => { + if (app) { + app(req, res) + } else { + res.end('end') + } + }) + + appsec.enable(new Config({ + appsec: { + enabled: true, + rules: path.join(__dirname, 'resources', 'lfi_rasp_rules.json'), + rasp: { enabled: true } + } + })) + + server.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 }) + }) + + it('Should detect threat but not block', async () => { + app = (req, res) => { + try { + require('fs').statSync(req.headers.file) + } catch (e) { + if (e.message === 'DatadogRaspAbortError') { + res.writeHead(500) + } else { + res.writeHead(418) + } + } + res.end('end') + } + + return testBlockingRequest('/', { + headers: { + file: '/test.file' + } + }) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/rasp/lfi.integration.express.plugin.spec.js b/packages/dd-trace/test/appsec/rasp/lfi.integration.express.plugin.spec.js new file mode 100644 index 00000000000..45dc1cac46f --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/lfi.integration.express.plugin.spec.js @@ -0,0 +1,69 @@ +'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 - lfi - integration - sync', () => { + let axios, sandbox, cwd, appPort, appFile, agent, proc + + before(async function () { + this.timeout(60000) + sandbox = await createSandbox( + ['express', 'fs'], + false, + [path.join(__dirname, 'resources')]) + + appPort = await getPort() + cwd = sandbox.folder + appFile = path.join(cwd, 'resources', 'lfi-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', 'lfi_rasp_rules.json') + } + }) + }) + + afterEach(async () => { + proc.kill() + await agent.stop() + }) + + it('should block a sync endpoint getting the error from apm:express:middleware:error', async () => { + try { + await axios.get('/lfi/sync?file=/etc/passwd') + } 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-lfi-rule-id-1"') + }) + } + + throw new Error('Request should be blocked') + }) +}) diff --git a/packages/dd-trace/test/appsec/rasp/lfi.spec.js b/packages/dd-trace/test/appsec/rasp/lfi.spec.js new file mode 100644 index 00000000000..405311ae0d3 --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/lfi.spec.js @@ -0,0 +1,144 @@ +'use strict' + +const proxyquire = require('proxyquire') +const { assert } = require('chai') +const { fsOperationStart, incomingHttpRequestStart } = require('../../../src/appsec/channels') +const { FS_OPERATION_PATH } = require('../../../src/appsec/addresses') +const { RASP_MODULE } = require('../../../src/appsec/rasp/fs-plugin') + +describe('RASP - lfi.js', () => { + let waf, datadogCore, lfi, web, blocking, appsecFsPlugin, config + + beforeEach(() => { + datadogCore = { + storage: { + getStore: sinon.stub() + } + } + + waf = { + run: sinon.stub() + } + + web = { + root: sinon.stub() + } + + blocking = { + block: sinon.stub() + } + + appsecFsPlugin = { + enable: sinon.stub(), + disable: sinon.stub() + } + + lfi = proxyquire('../../../src/appsec/rasp/lfi', { + '../../../../datadog-core': datadogCore, + '../waf': waf, + '../../plugins/util/web': web, + '../blocking': blocking, + './fs-plugin': appsecFsPlugin + }) + + config = { + appsec: { + stackTrace: { + enabled: true, + maxStackTraces: 2, + maxDepth: 42 + } + } + } + }) + + afterEach(() => { + sinon.restore() + lfi.disable() + }) + + describe('enable', () => { + it('should subscribe to first http req', () => { + const subscribe = sinon.stub(incomingHttpRequestStart, 'subscribe') + + lfi.enable(config) + + sinon.assert.calledOnce(subscribe) + }) + + it('should enable AppsecFsPlugin after the first request', () => { + const unsubscribe = sinon.stub(incomingHttpRequestStart, 'unsubscribe') + const fsOpSubscribe = sinon.stub(fsOperationStart, 'subscribe') + + lfi.enable(config) + + incomingHttpRequestStart.publish({}) + + sinon.assert.calledOnceWithExactly(appsecFsPlugin.enable, RASP_MODULE) + + assert(fsOpSubscribe.calledAfter(appsecFsPlugin.enable)) + + process.nextTick(() => { + sinon.assert.calledOnce(unsubscribe) + }) + }) + }) + + describe('disable', () => { + it('should disable AppsecFsPlugin', () => { + lfi.enable(config) + + lfi.disable() + sinon.assert.calledOnceWithExactly(appsecFsPlugin.disable, RASP_MODULE) + }) + }) + + describe('analyzeLfi', () => { + const path = '/etc/passwd' + const ctx = { path } + const req = {} + + beforeEach(() => { + lfi.enable(config) + + incomingHttpRequestStart.publish({}) + }) + + it('should analyze lfi for root fs operations', () => { + const fs = { root: true } + datadogCore.storage.getStore.returns({ req, fs }) + + fsOperationStart.publish(ctx) + + const persistent = { [FS_OPERATION_PATH]: path } + sinon.assert.calledOnceWithExactly(waf.run, { persistent }, req, 'lfi') + }) + + it('should NOT analyze lfi for child fs operations', () => { + const fs = {} + datadogCore.storage.getStore.returns({ req, fs }) + + fsOperationStart.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should NOT analyze lfi for undefined fs (AppsecFsPlugin disabled)', () => { + const fs = undefined + datadogCore.storage.getStore.returns({ req, fs }) + + fsOperationStart.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + + it('should NOT analyze lfi for excluded operations', () => { + const fs = { opExcluded: true, root: true } + datadogCore.storage.getStore.returns({ req, fs }) + + fsOperationStart.publish(ctx) + + sinon.assert.notCalled(waf.run) + }) + }) +}) diff --git a/packages/dd-trace/test/appsec/rasp/resources/lfi-app/index.js b/packages/dd-trace/test/appsec/rasp/resources/lfi-app/index.js new file mode 100644 index 00000000000..1beb4d977cb --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/resources/lfi-app/index.js @@ -0,0 +1,28 @@ +'use strict' + +const tracer = require('dd-trace') +tracer.init({ + flushInterval: 0 +}) + +const express = require('express') +const { readFileSync } = require('fs') + +const app = express() +const port = process.env.APP_PORT || 3000 + +app.get('/lfi/sync', (req, res) => { + let result + try { + result = readFileSync(req.query.file) + } catch (e) { + if (e.message === 'DatadogRaspAbortError') { + throw e + } + } + res.send(result) +}) + +app.listen(port, () => { + process.send({ port }) +}) diff --git a/packages/dd-trace/test/appsec/rasp/resources/lfi_rasp_rules.json b/packages/dd-trace/test/appsec/rasp/resources/lfi_rasp_rules.json new file mode 100644 index 00000000000..814f6c72236 --- /dev/null +++ b/packages/dd-trace/test/appsec/rasp/resources/lfi_rasp_rules.json @@ -0,0 +1,61 @@ +{ + "version": "2.2", + "metadata": { + "rules_version": "1.99.0" + }, + "rules": [ + { + "id": "rasp-lfi-rule-id-1", + "name": "Local file inclusion exploit", + "enabled": true, + "tags": { + "type": "lfi", + "category": "vulnerability_trigger", + "cwe": "22", + "capec": "1000/255/153/126", + "confidence": "0", + "module": "rasp" + }, + "conditions": [ + { + "parameters": { + "resource": [ + { + "address": "server.io.fs.file" + } + ], + "params": [ + { + "address": "server.request.query" + }, + { + "address": "server.request.body" + }, + { + "address": "server.request.path_params" + }, + { + "address": "grpc.server.request.message" + }, + { + "address": "server.request.headers.no_cookies" + }, + { + "address": "graphql.server.all_resolvers" + }, + { + "address": "graphql.server.resolver" + } + ] + }, + "operator": "lfi_detector" + } + ], + "transformers": [], + "on_match": [ + "block", + "stack_trace" + ] + } + ] +} diff --git a/packages/dd-trace/test/appsec/rasp/utils.js b/packages/dd-trace/test/appsec/rasp/utils.js index e9353d5d815..0d8a3e076a4 100644 --- a/packages/dd-trace/test/appsec/rasp/utils.js +++ b/packages/dd-trace/test/appsec/rasp/utils.js @@ -13,24 +13,28 @@ function getWebSpan (traces) { throw new Error('web span not found') } -function checkRaspExecutedAndNotThreat (agent) { +function checkRaspExecutedAndNotThreat (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') - assert.equal(span.metrics['_dd.appsec.rasp.rule.eval'], 1) + if (checkRuleEval) { + assert.equal(span.metrics['_dd.appsec.rasp.rule.eval'], 1) + } }) } -function checkRaspExecutedAndHasThreat (agent, ruleId) { +function checkRaspExecutedAndHasThreat (agent, ruleId, ruleEvalCount = 1) { 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.equal(span.metrics['_dd.appsec.rasp.rule.eval'], ruleEvalCount) 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 span }) } 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 fd923c9a92b..c3da43a17c0 100644 --- a/packages/dd-trace/test/appsec/remote_config/index.spec.js +++ b/packages/dd-trace/test/appsec/remote_config/index.spec.js @@ -290,6 +290,8 @@ describe('Remote Config index', () => { .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SSRF, true) expect(rc.updateCapabilities) .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SQLI, true) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_LFI, true) expect(rc.setProductHandler).to.have.been.calledWith('ASM_DATA') expect(rc.setProductHandler).to.have.been.calledWith('ASM_DD') @@ -324,6 +326,8 @@ describe('Remote Config index', () => { .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SSRF, true) expect(rc.updateCapabilities) .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SQLI, true) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_LFI, true) expect(rc.setProductHandler).to.have.been.calledWith('ASM_DATA') expect(rc.setProductHandler).to.have.been.calledWith('ASM_DD') @@ -360,6 +364,8 @@ describe('Remote Config index', () => { .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SSRF, true) expect(rc.updateCapabilities) .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SQLI, true) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_LFI, true) }) it('should not activate rasp capabilities if rasp is disabled', () => { @@ -391,6 +397,8 @@ describe('Remote Config index', () => { .to.not.have.been.calledWith(RemoteConfigCapabilities.ASM_RASP_SSRF) expect(rc.updateCapabilities) .to.not.have.been.calledWith(RemoteConfigCapabilities.ASM_RASP_SQLI) + expect(rc.updateCapabilities) + .to.not.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_LFI) }) }) @@ -422,6 +430,8 @@ describe('Remote Config index', () => { .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SSRF, false) expect(rc.updateCapabilities) .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_SQLI, false) + expect(rc.updateCapabilities) + .to.have.been.calledWithExactly(RemoteConfigCapabilities.ASM_RASP_LFI, false) expect(rc.removeProductHandler).to.have.been.calledWith('ASM_DATA') expect(rc.removeProductHandler).to.have.been.calledWith('ASM_DD') diff --git a/packages/dd-trace/test/appsec/response_blocking.spec.js b/packages/dd-trace/test/appsec/response_blocking.spec.js index 2868a42b05b..03541858955 100644 --- a/packages/dd-trace/test/appsec/response_blocking.spec.js +++ b/packages/dd-trace/test/appsec/response_blocking.spec.js @@ -52,7 +52,10 @@ describe('HTTP Response Blocking', () => { appsec.enable(new Config({ appsec: { enabled: true, - rules: path.join(__dirname, 'response_blocking_rules.json') + rules: path.join(__dirname, 'response_blocking_rules.json'), + rasp: { + enabled: false // disable rasp to not trigger waf.run executions due to lfi + } } })) }) diff --git a/packages/dd-trace/test/plugins/agent.js b/packages/dd-trace/test/plugins/agent.js index dc87f18dc3a..cb6f241e7d3 100644 --- a/packages/dd-trace/test/plugins/agent.js +++ b/packages/dd-trace/test/plugins/agent.js @@ -210,10 +210,10 @@ function runCallback (callback, options, handlers) { function handler () { try { - callback.apply(null, arguments) + const result = callback.apply(null, arguments) handlers.delete(handlerPayload) clearTimeout(timeout) - deferred.resolve() + deferred.resolve(result) } catch (e) { if (options && options.rejectFirst) { clearTimeout(timeout)