From 7c3157e295ee319b792b280c70211c72fc059570 Mon Sep 17 00:00:00 2001 From: Julio Gonzalez <107922352+hoolioh@users.noreply.github.com> Date: Tue, 30 Jan 2024 11:00:04 +0100 Subject: [PATCH] Shell execution integration (#3608) --------- Co-authored-by: Ugaitz Urien Co-authored-by: Igor Unanua --- .github/workflows/plugins.yml | 16 + LICENSE-3rdparty.csv | 1 + ci/init.js | 1 + package.json | 1 + .../src/child-process.js | 29 - .../src/child_process.js | 150 +++++ .../src/helpers/hooks.js | 4 +- .../test/child_process.spec.js | 379 ++++++++++++ .../datadog-plugin-child_process/src/index.js | 91 +++ .../src/scrub-cmd-params.js | 125 ++++ .../test/index.spec.js | 575 ++++++++++++++++++ .../test/scrub-cmd-params.spec.js | 79 +++ .../analyzers/command-injection-analyzer.js | 2 +- .../dd-trace/src/appsec/iast/iast-plugin.js | 5 +- packages/dd-trace/src/plugins/index.js | 1 + packages/dd-trace/src/plugins/util/exec.js | 34 -- packages/dd-trace/src/plugins/util/git.js | 6 + .../test/appsec/iast/iast-plugin.spec.js | 18 +- yarn.lock | 2 +- 19 files changed, 1450 insertions(+), 69 deletions(-) delete mode 100644 packages/datadog-instrumentations/src/child-process.js create mode 100644 packages/datadog-instrumentations/src/child_process.js create mode 100644 packages/datadog-instrumentations/test/child_process.spec.js create mode 100644 packages/datadog-plugin-child_process/src/index.js create mode 100644 packages/datadog-plugin-child_process/src/scrub-cmd-params.js create mode 100644 packages/datadog-plugin-child_process/test/index.spec.js create mode 100644 packages/datadog-plugin-child_process/test/scrub-cmd-params.spec.js delete mode 100644 packages/dd-trace/src/plugins/util/exec.js diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index a2f2e22e97a..d4c29dca137 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -334,6 +334,22 @@ jobs: uses: ./.github/actions/testagent/logs - uses: codecov/codecov-action@v3 + child_process: + runs-on: ubuntu-latest + env: + PLUGINS: child_process + steps: + - uses: actions/checkout@v2 + - uses: ./.github/actions/node/setup + - run: yarn install + - uses: ./.github/actions/node/oldest + - run: yarn test:plugins:ci + - uses: ./.github/actions/node/20 + - run: yarn test:plugins:ci + - uses: ./.github/actions/node/latest + - run: yarn test:plugins:ci + - uses: codecov/codecov-action@v2 + couchbase: runs-on: ubuntu-latest services: diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 6fcac2fa10f..467bc7a4feb 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -30,6 +30,7 @@ require,protobufjs,BSD-3-Clause,Copyright 2016 Daniel Wirtz require,tlhunter-sorted-set,MIT,Copyright (c) 2023 Datadog Inc. require,retry,MIT,Copyright 2011 Tim Koschützki Felix Geisendörfer require,semver,ISC,Copyright Isaac Z. Schlueter and Contributors +require,shell-quote,mit,Copyright (c) 2013 James Halliday dev,@types/node,MIT,Copyright Authors dev,autocannon,MIT,Copyright 2016 Matteo Collina dev,aws-sdk,Apache 2.0,Copyright 2012-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/ci/init.js b/ci/init.js index b6f0ba9961b..81849b0e1e1 100644 --- a/ci/init.js +++ b/ci/init.js @@ -44,6 +44,7 @@ if (isJestWorker) { if (shouldInit) { tracer.init(options) tracer.use('fs', false) + tracer.use('child_process', false) } module.exports = tracer diff --git a/package.json b/package.json index bd217b39203..210572ce113 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "protobufjs": "^7.2.5", "retry": "^0.13.1", "semver": "^7.5.4", + "shell-quote": "^1.8.1", "tlhunter-sorted-set": "^0.1.0" }, "devDependencies": { diff --git a/packages/datadog-instrumentations/src/child-process.js b/packages/datadog-instrumentations/src/child-process.js deleted file mode 100644 index 3dca938ed42..00000000000 --- a/packages/datadog-instrumentations/src/child-process.js +++ /dev/null @@ -1,29 +0,0 @@ -'use strict' - -const { - channel, - addHook -} = require('./helpers/instrument') -const shimmer = require('../../datadog-shimmer') - -const childProcessChannel = channel('datadog:child_process:execution:start') -const execMethods = ['exec', 'execFile', 'fork', 'spawn', 'execFileSync', 'execSync', 'spawnSync'] -const names = ['child_process', 'node:child_process'] - -addHook({ name: names }, childProcess => { - shimmer.massWrap(childProcess, execMethods, wrapChildProcessMethod()) - return childProcess -}) - -function wrapChildProcessMethod () { - function wrapMethod (childProcessMethod) { - return function () { - if (childProcessChannel.hasSubscribers && arguments.length > 0) { - const command = arguments[0] - childProcessChannel.publish({ command }) - } - return childProcessMethod.apply(this, arguments) - } - } - return wrapMethod -} diff --git a/packages/datadog-instrumentations/src/child_process.js b/packages/datadog-instrumentations/src/child_process.js new file mode 100644 index 00000000000..61eddc47049 --- /dev/null +++ b/packages/datadog-instrumentations/src/child_process.js @@ -0,0 +1,150 @@ +'use strict' + +const util = require('util') + +const { + addHook, + AsyncResource +} = require('./helpers/instrument') +const shimmer = require('../../datadog-shimmer') +const dc = require('dc-polyfill') + +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 +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)) + } + + return childProcess + }) +}) + +function normalizeArgs (args, shell) { + const childProcessInfo = { + command: args[0] + } + + if (Array.isArray(args[1])) { + childProcessInfo.command = childProcessInfo.command + ' ' + args[1].join(' ') + 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' + + return childProcessInfo +} + +function wrapChildProcessSyncMethod (shell = false) { + return function wrapMethod (childProcessMethod) { + return function () { + if (!childProcessChannel.start.hasSubscribers || arguments.length === 0) { + return childProcessMethod.apply(this, arguments) + } + + const childProcessInfo = normalizeArgs(arguments, shell) + + return childProcessChannel.traceSync( + childProcessMethod, + { + command: childProcessInfo.command, + shell: childProcessInfo.shell + }, + this, + ...arguments) + } + } +} + +function wrapChildProcessCustomPromisifyMethod (customPromisifyMethod, shell) { + return function () { + if (!childProcessChannel.start.hasSubscribers || arguments.length === 0) { + return customPromisifyMethod.apply(this, arguments) + } + + const childProcessInfo = normalizeArgs(arguments, shell) + + return childProcessChannel.tracePromise( + customPromisifyMethod, + { + command: childProcessInfo.command, + shell: childProcessInfo.shell + }, + this, + ...arguments) + } +} + +function wrapChildProcessAsyncMethod (shell = false) { + return function wrapMethod (childProcessMethod) { + function wrappedChildProcessMethod () { + if (!childProcessChannel.start.hasSubscribers || arguments.length === 0) { + return childProcessMethod.apply(this, arguments) + } + + const childProcessInfo = normalizeArgs(arguments, shell) + + const innerResource = new AsyncResource('bound-anonymous-fn') + return innerResource.runInAsyncScope(() => { + childProcessChannel.start.publish({ command: childProcessInfo.command, shell: childProcessInfo.shell }) + + const childProcess = childProcessMethod.apply(this, arguments) + if (childProcess) { + let errorExecuted = false + + childProcess.on('error', (e) => { + errorExecuted = true + childProcessChannel.error.publish(e) + }) + + childProcess.on('close', (code) => { + code = code || 0 + if (!errorExecuted && code !== 0) { + childProcessChannel.error.publish() + } + childProcessChannel.asyncEnd.publish({ + command: childProcessInfo.command, + shell: childProcessInfo.shell, + result: code + }) + }) + } + + return childProcess + }) + } + + if (childProcessMethod[util.promisify.custom]) { + const wrapedChildProcessCustomPromisifyMethod = + shimmer.wrap(childProcessMethod[util.promisify.custom], + wrapChildProcessCustomPromisifyMethod(childProcessMethod[util.promisify.custom]), shell) + + // should do it in this way because the original property is readonly + const descriptor = Object.getOwnPropertyDescriptor(childProcessMethod, util.promisify.custom) + Object.defineProperty(wrappedChildProcessMethod, + util.promisify.custom, + { + ...descriptor, + value: wrapedChildProcessCustomPromisifyMethod + }) + } + return wrappedChildProcessMethod + } +} diff --git a/packages/datadog-instrumentations/src/helpers/hooks.js b/packages/datadog-instrumentations/src/helpers/hooks.js index 2f27be5af25..2d50e3365c3 100644 --- a/packages/datadog-instrumentations/src/helpers/hooks.js +++ b/packages/datadog-instrumentations/src/helpers/hooks.js @@ -30,7 +30,7 @@ module.exports = { 'body-parser': () => require('../body-parser'), 'bunyan': () => require('../bunyan'), 'cassandra-driver': () => require('../cassandra-driver'), - 'child_process': () => require('../child-process'), + 'child_process': () => require('../child_process'), 'connect': () => require('../connect'), 'cookie': () => require('../cookie'), 'cookie-parser': () => require('../cookie-parser'), @@ -78,7 +78,7 @@ module.exports = { 'mysql2': () => require('../mysql2'), 'net': () => require('../net'), 'next': () => require('../next'), - 'node:child_process': () => require('../child-process'), + 'node:child_process': () => require('../child_process'), 'node:crypto': () => require('../crypto'), 'node:dns': () => require('../dns'), 'node:http': () => require('../http'), diff --git a/packages/datadog-instrumentations/test/child_process.spec.js b/packages/datadog-instrumentations/test/child_process.spec.js new file mode 100644 index 00000000000..c4ab71dbde5 --- /dev/null +++ b/packages/datadog-instrumentations/test/child_process.spec.js @@ -0,0 +1,379 @@ +'use strict' + +const { promisify } = require('util') +const agent = require('../../dd-trace/test/plugins/agent') +const dc = require('dc-polyfill') + +describe('child process', () => { + const modules = ['child_process', 'node:child_process'] + const execAsyncMethods = ['execFile', 'spawn'] + const execAsyncShellMethods = ['exec'] + const execSyncMethods = ['execFileSync'] + const execSyncShellMethods = ['execSync'] + + const childProcessChannel = dc.tracingChannel('datadog:child_process:execution') + + modules.forEach((childProcessModuleName) => { + describe(childProcessModuleName, () => { + let start, finish, error, childProcess, asyncFinish + + before(() => { + return agent.load(childProcessModuleName) + }) + + after(() => { + return agent.close({ ritmReset: false }) + }) + + beforeEach(() => { + start = sinon.stub() + finish = sinon.stub() + error = sinon.stub() + asyncFinish = sinon.stub() + + childProcessChannel.subscribe({ + start: start, + end: finish, + asyncEnd: asyncFinish, + error: error + }) + + childProcess = require(childProcessModuleName) + }) + + afterEach(() => { + childProcessChannel.unsubscribe({ + start: start, + end: finish, + asyncEnd: asyncFinish, + error: error + }) + }) + + describe('async methods', (done) => { + describe('command not interpreted by a shell by default', () => { + execAsyncMethods.forEach(methodName => { + describe(`method ${methodName}`, () => { + it('should execute success callbacks', (done) => { + 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(error).not.to.have.been.called + 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(asyncFinish).to.have.been.calledOnceWith({ + command: 'invalid_command_test', + shell: false, + result: -2 + }) + expect(error).to.have.been.calledOnce + done() + }) + }) + + it('should execute error callback with `exit 1` command', (done) => { + 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(asyncFinish).to.have.been.calledOnceWith({ + command: 'node -e "process.exit(1)"', + shell: true, + result: 1 + }) + expect(error).to.have.been.calledOnce + done() + }) + }) + }) + + if (methodName !== 'spawn') { + describe(`method ${methodName} with promisify`, () => { + it('should execute success callbacks', async () => { + await promisify(childProcess[methodName])('echo') + expect(start.firstCall.firstArg).to.include({ + command: 'echo', + shell: false + }) + + expect(asyncFinish).to.have.been.calledOnceWith({ + command: 'echo', + shell: false, + result: { + stdout: '\n', + stderr: '' + } + }) + expect(error).not.to.have.been.called + }) + + it('should execute error callback', async () => { + try { + await promisify(childProcess[methodName])('invalid_command_test') + } catch (e) { + expect(start).to.have.been.calledOnce + expect(start.firstCall.firstArg).to.include({ command: 'invalid_command_test', shell: false }) + + const errStub = new Error('spawn invalid_command_test ENOENT') + errStub.code = 'ENOENT' + errStub.errno = -2 + + expect(asyncFinish).to.have.been.calledOnce + expect(asyncFinish.firstCall.firstArg).to.include({ command: 'invalid_command_test', shell: false }) + expect(asyncFinish.firstCall.firstArg).to.deep.include({ + command: 'invalid_command_test', + shell: false, + error: errStub + }) + + expect(error).to.have.been.calledOnce + } + }) + + it('should execute error callback with `exit 1` command', async () => { + const errStub = new Error('Command failed: node -e "process.exit(1)"\n') + errStub.code = 1 + errStub.cmd = 'node -e "process.exit(1)"' + + try { + await promisify(childProcess[methodName])('node -e "process.exit(1)"', { shell: true }) + } catch (e) { + expect(start).to.have.been.calledOnce + expect(start.firstCall.firstArg).to.include({ command: 'node -e "process.exit(1)"', shell: true }) + + expect(asyncFinish).to.have.been.calledOnce + expect(asyncFinish.firstCall.firstArg).to.include({ + command: 'node -e "process.exit(1)"', + shell: true + }) + expect(asyncFinish.firstCall.firstArg).to.deep.include({ + command: 'node -e "process.exit(1)"', + shell: true, + error: errStub + }) + + expect(error).to.have.been.calledOnce + } + }) + }) + } + }) + }) + + describe('command interpreted by a shell by default', () => { + execAsyncShellMethods.forEach(methodName => { + describe(`method ${methodName}`, () => { + it('should execute success callbacks', (done) => { + 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(error).not.to.have.been.called + done() + }) + }) + + it('should execute error callback with `exit 1` command', (done) => { + 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(asyncFinish).to.have.been.calledOnceWith({ + command: 'node -e "process.exit(1)"', + shell: true, + result: 1 + }) + expect(error).to.have.been.called + done() + }) + }) + + it('should execute error callback', (done) => { + const res = childProcess[methodName]('invalid_command_test') + + res.once('close', () => { + expect(start).to.have.been.calledOnceWith({ command: 'invalid_command_test', shell: true }) + expect(error).to.have.been.calledOnce + expect(asyncFinish).to.have.been.calledOnceWith({ + command: 'invalid_command_test', + shell: true, + result: 127 + }) + done() + }) + }) + }) + + describe(`method ${methodName} with promisify`, () => { + it('should execute success callbacks', async () => { + await promisify(childProcess[methodName])('echo') + expect(start).to.have.been.calledOnceWith({ + command: 'echo', + shell: true + }) + expect(asyncFinish).to.have.been.calledOnceWith({ + command: 'echo', + shell: true, + result: 0 + }) + expect(error).not.to.have.been.called + }) + + it('should execute error callback', async () => { + try { + 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(asyncFinish).to.have.been.calledOnce + expect(error).to.have.been.calledOnce + } + }) + + it('should execute error callback with `exit 1` command', async () => { + try { + 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(asyncFinish).to.have.been.calledOnceWith({ + command: 'node -e "process.exit(1)"', + shell: true, + result: 1 + }) + expect(error).to.have.been.calledOnce + } + }) + }) + }) + }) + }) + + describe('sync methods', () => { + describe('command not interpreted by a shell', () => { + execSyncMethods.forEach(methodName => { + describe(`method ${methodName}`, () => { + it('should execute success callbacks', () => { + const result = childProcess[methodName]('ls') + + expect(start).to.have.been.calledOnceWith({ + command: 'ls', + shell: false, + result: result + }, + 'tracing:datadog:child_process:execution:start') + + expect(finish).to.have.been.calledOnceWith({ + command: 'ls', + shell: false, + result: result + }, + 'tracing:datadog:child_process:execution:end') + + 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 execute error callback with `exit 1` command', () => { + let childError + try { + childProcess[methodName]('node -e "process.exit(1)"', { shell: true }) + } catch (error) { + childError = error + } finally { + expect(start).to.have.been.calledOnceWith({ + command: 'node -e "process.exit(1)"', + shell: true, + error: childError + }) + expect(finish).to.have.been.calledOnce + } + }) + }) + }) + }) + + describe('command interpreted by a shell by default', () => { + execSyncShellMethods.forEach(methodName => { + describe(`method ${methodName}`, () => { + it('should execute success callbacks', () => { + const result = childProcess[methodName]('ls') + + expect(start).to.have.been.calledOnceWith({ + command: 'ls', + shell: true, + result + }) + expect(finish).to.have.been.calledOnceWith({ + command: 'ls', + shell: true, + result + }) + 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: true, + error: childError + }) + expect(finish).to.have.been.calledOnce + expect(error).to.have.been.calledOnce + } + }) + + 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: true, + error: childError + }) + expect(finish).to.have.been.calledOnce + } + }) + }) + }) + }) + }) + }) + }) +}) diff --git a/packages/datadog-plugin-child_process/src/index.js b/packages/datadog-plugin-child_process/src/index.js new file mode 100644 index 00000000000..b28e242f056 --- /dev/null +++ b/packages/datadog-plugin-child_process/src/index.js @@ -0,0 +1,91 @@ +'use strict' + +const TracingPlugin = require('../../dd-trace/src/plugins/tracing') +const scrubChildProcessCmd = require('./scrub-cmd-params') + +const MAX_ARG_SIZE = 4096 // 4kB + +function truncateCommand (cmdFields) { + let size = cmdFields[0].length + let truncated = false + for (let i = 1; i < cmdFields.length; i++) { + if (size >= MAX_ARG_SIZE) { + truncated = true + cmdFields[i] = '' + continue + } + + const argLen = cmdFields[i].length + if (size < MAX_ARG_SIZE && size + argLen > MAX_ARG_SIZE) { + cmdFields[i] = cmdFields[i].substring(0, 2) + truncated = true + } + + size += argLen + } + + return truncated +} + +class ChildProcessPlugin extends TracingPlugin { + static get id () { return 'child_process' } + static get prefix () { return 'tracing:datadog:child_process:execution' } + + get tracer () { + return this._tracer + } + + start ({ command, shell }) { + if (typeof command !== 'string') { + return + } + + const cmdFields = scrubChildProcessCmd(command) + const truncated = truncateCommand(cmdFields) + const property = (shell === true) ? 'cmd.shell' : 'cmd.exec' + + const meta = { + 'component': 'subprocess', + [property]: (shell === true) ? cmdFields.join(' ') : JSON.stringify(cmdFields) + } + + if (truncated) { + meta['cmd.truncated'] = `${truncated}` + } + + this.startSpan('command_execution', { + service: this.config.service, + resource: (shell === true) ? 'sh' : cmdFields[0], + type: 'system', + meta + }) + } + + end ({ result, error }) { + let exitCode + + if (result !== undefined) { + exitCode = result?.status || 0 + } else if (error !== undefined) { + exitCode = error?.status || error?.code || 0 + } else { + // TracingChannels call start, end synchronously. Later when the promise is resolved then asyncStart asyncEnd. + // Therefore in the case of calling end with neither result nor error means that they will come in the asyncEnd. + return + } + + this.activeSpan?.setTag('cmd.exit_code', `${exitCode}`) + this.activeSpan?.finish() + } + + error (error) { + this.addError(error) + } + + asyncEnd ({ result }) { + this.activeSpan?.setTag('cmd.exit_code', `${result}`) + this.activeSpan?.finish() + } +} + +module.exports = ChildProcessPlugin diff --git a/packages/datadog-plugin-child_process/src/scrub-cmd-params.js b/packages/datadog-plugin-child_process/src/scrub-cmd-params.js new file mode 100644 index 00000000000..3f5d85574e3 --- /dev/null +++ b/packages/datadog-plugin-child_process/src/scrub-cmd-params.js @@ -0,0 +1,125 @@ +'use strict' + +const shellParser = require('shell-quote/parse') + +const ALLOWED_ENV_VARIABLES = ['LD_PRELOAD', 'LD_LIBRARY_PATH', 'PATH'] +const PROCESS_DENYLIST = ['md5'] + +const VARNAMES_REGEX = /\$([\w\d_]*)(?:[^\w\d_]|$)/gmi +// eslint-disable-next-line max-len +const PARAM_PATTERN = '^-{0,2}(?:p(?:ass(?:w(?:or)?d)?)?|api_?key|secret|a(?:ccess|uth)_token|mysql_pwd|credentials|(?:stripe)?token)$' +const regexParam = new RegExp(PARAM_PATTERN, 'i') +const ENV_PATTERN = '^(\\w+=\\w+;)*\\w+=\\w+;?$' +const envvarRegex = new RegExp(ENV_PATTERN) +const REDACTED = '?' + +function extractVarNames (expression) { + const varNames = new Set() + let match + + while ((match = VARNAMES_REGEX.exec(expression))) { + varNames.add(match[1]) + } + + const varNamesObject = {} + for (const varName of varNames.keys()) { + varNamesObject[varName] = `$${varName}` + } + return varNamesObject +} + +function getTokensByExpression (expressionTokens) { + const expressionListTokens = [] + let wipExpressionTokens = [] + let isNewExpression = true + + expressionTokens.forEach(token => { + if (isNewExpression) { + expressionListTokens.push(wipExpressionTokens) + isNewExpression = false + } + + wipExpressionTokens.push(token) + + if (token.op) { + wipExpressionTokens = [] + isNewExpression = true + } + }) + return expressionListTokens +} + +function scrubChildProcessCmd (expression) { + const varNames = extractVarNames(expression) + const expressionTokens = shellParser(expression, varNames) + + const expressionListTokens = getTokensByExpression(expressionTokens) + + const result = [] + expressionListTokens.forEach((expressionTokens) => { + let foundBinary = false + for (let index = 0; index < expressionTokens.length; index++) { + const token = expressionTokens[index] + + if (typeof token === 'object') { + if (token.pattern) { + result.push(token.pattern) + } else if (token.op) { + result.push(token.op) + } else if (token.comment) { + result.push(`#${token.comment}`) + } + } else if (!foundBinary) { + if (envvarRegex.test(token)) { + const envSplit = token.split('=') + + if (!ALLOWED_ENV_VARIABLES.includes(envSplit[0])) { + envSplit[1] = REDACTED + + const newToken = envSplit.join('=') + expressionTokens[index] = newToken + + result.push(newToken) + } else { + result.push(token) + } + } else { + foundBinary = true + result.push(token) + + if (PROCESS_DENYLIST.includes(token)) { + for (index++; index < expressionTokens.length; index++) { + const token = expressionTokens[index] + + if (token.op) { + result.push(token.op) + } else { + expressionTokens[index] = REDACTED + result.push(REDACTED) + } + } + break + } + } + } else { + const paramKeyValue = token.split('=') + const paramKey = paramKeyValue[0] + + if (regexParam.test(paramKey)) { + if (paramKeyValue.length === 1) { + expressionTokens[index + 1] = REDACTED + result.push(token) + } else { + result.push(`${paramKey}=${REDACTED}`) + } + } else { + result.push(token) + } + } + } + }) + + return result +} + +module.exports = scrubChildProcessCmd diff --git a/packages/datadog-plugin-child_process/test/index.spec.js b/packages/datadog-plugin-child_process/test/index.spec.js new file mode 100644 index 00000000000..1f56fe26538 --- /dev/null +++ b/packages/datadog-plugin-child_process/test/index.spec.js @@ -0,0 +1,575 @@ +'use strict' + +const ChildProcessPlugin = require('../src') +const { storage } = require('../../datadog-core') +const agent = require('../../dd-trace/test/plugins/agent') +const { expectSomeSpan } = require('../../dd-trace/test/plugins/helpers') + +function noop () {} + +function normalizeArgs (methodName, command, options) { + const args = [] + if (methodName === 'exec' || methodName === 'execSync') { + args.push(command.join(' ')) + } else { + args.push(command[0], command.slice(1)) + } + + args.push(options) + + return args +} + +describe('Child process plugin', () => { + describe('unit tests', () => { + let tracerStub, configStub, spanStub + + beforeEach(() => { + spanStub = { + setTag: sinon.stub(), + finish: sinon.stub() + } + + tracerStub = { + startSpan: sinon.stub() + } + }) + + afterEach(() => { + sinon.restore() + }) + + describe('start', () => { + it('should call startSpan with proper parameters', () => { + const shellPlugin = new ChildProcessPlugin(tracerStub, configStub) + + shellPlugin.start({ command: 'ls -l' }) + + expect(tracerStub.startSpan).to.have.been.calledOnceWithExactly( + 'command_execution', + { + childOf: undefined, + tags: { + component: 'subprocess', + 'service.name': undefined, + 'resource.name': 'ls', + 'span.kind': undefined, + 'span.type': 'system', + 'cmd.exec': JSON.stringify([ 'ls', '-l' ]) + }, + integrationName: 'system' + } + ) + }) + + it('should call startSpan with cmd.shell property', () => { + const shellPlugin = new ChildProcessPlugin(tracerStub, configStub) + + shellPlugin.start({ command: 'ls -l', shell: true }) + + expect(tracerStub.startSpan).to.have.been.calledOnceWithExactly( + 'command_execution', + { + childOf: undefined, + tags: { + component: 'subprocess', + 'service.name': undefined, + 'resource.name': 'sh', + 'span.kind': undefined, + 'span.type': 'system', + 'cmd.shell': 'ls -l' + }, + integrationName: 'system' + } + ) + }) + + it('should truncate last argument', () => { + const shellPlugin = new ChildProcessPlugin(tracerStub, configStub) + const arg = 'a'.padEnd(4092, 'a') + const command = 'echo' + ' ' + arg + ' arg2' + + shellPlugin.start({ command }) + + expect(tracerStub.startSpan).to.have.been.calledOnceWithExactly( + 'command_execution', + { + childOf: undefined, + tags: { + component: 'subprocess', + 'service.name': undefined, + 'resource.name': 'echo', + 'span.kind': undefined, + 'span.type': 'system', + 'cmd.exec': JSON.stringify([ 'echo', arg, '' ]), + 'cmd.truncated': 'true' + }, + integrationName: 'system' + } + ) + }) + + it('should truncate path and blank last argument', () => { + const shellPlugin = new ChildProcessPlugin(tracerStub, configStub) + const path = '/home/'.padEnd(4096, '/') + const command = 'ls -l' + ' ' + path + ' -t' + + shellPlugin.start({ command, shell: true }) + + expect(tracerStub.startSpan).to.have.been.calledOnceWithExactly( + 'command_execution', + { + childOf: undefined, + tags: { + component: 'subprocess', + 'service.name': undefined, + 'resource.name': 'sh', + 'span.kind': undefined, + 'span.type': 'system', + 'cmd.shell': 'ls -l /h ', + 'cmd.truncated': 'true' + }, + integrationName: 'system' + } + ) + }) + + it('should truncate first argument and blank the rest', () => { + const shellPlugin = new ChildProcessPlugin(tracerStub, configStub) + const option = '-l'.padEnd(4096, 't') + const path = '/home' + const command = `ls ${option} ${path} -t` + + shellPlugin.start({ command }) + + expect(tracerStub.startSpan).to.have.been.calledOnceWithExactly( + 'command_execution', + { + childOf: undefined, + tags: { + component: 'subprocess', + 'service.name': undefined, + 'resource.name': 'ls', + 'span.kind': undefined, + 'span.type': 'system', + 'cmd.exec': JSON.stringify([ 'ls', '-l', '', '' ]), + 'cmd.truncated': 'true' + }, + integrationName: 'system' + } + ) + }) + + it('should truncate last argument', () => { + const shellPlugin = new ChildProcessPlugin(tracerStub, configStub) + const option = '-t'.padEnd(4000 * 8, 'u') + const path = '/home' + const command = 'ls' + ' -l' + ' ' + path + ' ' + option + + shellPlugin.start({ command, shell: true }) + + expect(tracerStub.startSpan).to.have.been.calledOnceWithExactly( + 'command_execution', + { + childOf: undefined, + tags: { + component: 'subprocess', + 'service.name': undefined, + 'resource.name': 'sh', + 'span.kind': undefined, + 'span.type': 'system', + 'cmd.shell': 'ls -l /home -t', + 'cmd.truncated': 'true' + }, + integrationName: 'system' + } + ) + }) + + it('should not crash if command is not a string', () => { + const shellPlugin = new ChildProcessPlugin(tracerStub, configStub) + + shellPlugin.start({ command: undefined }) + + expect(tracerStub.startSpan).not.to.have.been.called + }) + + it('should not crash if command does not exist', () => { + const shellPlugin = new ChildProcessPlugin(tracerStub, configStub) + + shellPlugin.start({}) + + expect(tracerStub.startSpan).not.to.have.been.called + }) + }) + + describe('end', () => { + it('should not call setTag if neither error nor result is passed', () => { + sinon.stub(storage, 'getStore').returns({ span: spanStub }) + const shellPlugin = new ChildProcessPlugin(tracerStub, configStub) + + shellPlugin.end({}) + + expect(spanStub.setTag).not.to.have.been.called + expect(spanStub.finish).not.to.have.been.called + }) + + it('should call setTag with proper code when result is a buffer', () => { + sinon.stub(storage, 'getStore').returns({ span: spanStub }) + const shellPlugin = new ChildProcessPlugin(tracerStub, configStub) + + shellPlugin.end({ result: Buffer.from('test') }) + + expect(spanStub.setTag).to.have.been.calledOnceWithExactly('cmd.exit_code', '0') + expect(spanStub.finish).to.have.been.calledOnceWithExactly() + }) + + it('should call setTag with proper code when result is a string', () => { + sinon.stub(storage, 'getStore').returns({ span: spanStub }) + const shellPlugin = new ChildProcessPlugin(tracerStub, configStub) + + shellPlugin.end({ result: 'test' }) + + expect(spanStub.setTag).to.have.been.calledOnceWithExactly('cmd.exit_code', '0') + expect(spanStub.finish).to.have.been.calledOnceWithExactly() + }) + + it('should call setTag with proper code when an error is thrown', () => { + sinon.stub(storage, 'getStore').returns({ span: spanStub }) + const shellPlugin = new ChildProcessPlugin(tracerStub, configStub) + + shellPlugin.end({ error: { status: -1 } }) + + expect(spanStub.setTag).to.have.been.calledOnceWithExactly('cmd.exit_code', '-1') + expect(spanStub.finish).to.have.been.calledOnceWithExactly() + }) + }) + + describe('asyncEnd', () => { + it('should call setTag with undefined code if neither error nor result is passed', () => { + sinon.stub(storage, 'getStore').returns({ span: spanStub }) + const shellPlugin = new ChildProcessPlugin(tracerStub, configStub) + + shellPlugin.asyncEnd({}) + + expect(spanStub.setTag).to.have.been.calledOnceWithExactly('cmd.exit_code', 'undefined') + expect(spanStub.finish).to.have.been.calledOnce + }) + + it('should call setTag with proper code when a proper code is returned', () => { + sinon.stub(storage, 'getStore').returns({ span: spanStub }) + const shellPlugin = new ChildProcessPlugin(tracerStub, configStub) + + shellPlugin.asyncEnd({ result: 0 }) + + expect(spanStub.setTag).to.have.been.calledOnceWithExactly('cmd.exit_code', '0') + expect(spanStub.finish).to.have.been.calledOnceWithExactly() + }) + }) + + describe('channel', () => { + it('should return proper prefix', () => { + expect(ChildProcessPlugin.prefix).to.be.equal('tracing:datadog:child_process:execution') + }) + + it('should return proper id', () => { + expect(ChildProcessPlugin.id).to.be.equal('child_process') + }) + }) + }) + + describe('Integration', () => { + describe('Methods which spawn a shell by default', () => { + const execAsyncMethods = ['exec'] + const execSyncMethods = ['execSync'] + let childProcess, tracer + + beforeEach(() => { + return agent.load('child_process', undefined, { flushInterval: 1 }).then(() => { + tracer = require('../../dd-trace') + childProcess = require('child_process') + tracer.use('child_process', { enabled: true }) + }) + }) + + afterEach(() => agent.close({ ritmReset: false })) + const parentSpanList = [true, false] + parentSpanList.forEach(parentSpan => { + describe(`${parentSpan ? 'with' : 'without'} parent span`, () => { + const methods = [ + ...execAsyncMethods.map(methodName => ({ methodName, async: true })), + ...execSyncMethods.map(methodName => ({ methodName, async: false })) + ] + if (parentSpan) { + beforeEach((done) => { + const parentSpan = tracer.startSpan('parent') + parentSpan.finish() + tracer.scope().activate(parentSpan, done) + }) + } + + methods.forEach(({ methodName, async }) => { + describe(methodName, () => { + it('should be instrumented', (done) => { + const expected = { + type: 'system', + name: 'command_execution', + error: 0, + meta: { + component: 'subprocess', + 'cmd.shell': 'ls', + 'cmd.exit_code': '0' + } + } + + expectSomeSpan(agent, expected).then(done, done) + + const res = childProcess[methodName]('ls') + if (async) { + res.on('close', noop) + } + }) + + it('command should be scrubbed', (done) => { + const expected = { + type: 'system', + name: 'command_execution', + error: 0, + meta: { + component: 'subprocess', + 'cmd.shell': 'echo password ?', + 'cmd.exit_code': '0' + } + } + expectSomeSpan(agent, expected).then(done, done) + + const args = [] + if (methodName === 'exec' || methodName === 'execSync') { + args.push('echo password 123') + } else { + args.push('echo') + args.push(['password', '123']) + } + + const res = childProcess[methodName](...args) + if (async) { + res.on('close', noop) + } + }) + + it('should be instrumented with error code', (done) => { + const command = [ 'node', '-badOption' ] + const options = { + stdio: 'pipe' + } + const expected = { + type: 'system', + name: 'command_execution', + error: 1, + meta: { + component: 'subprocess', + 'cmd.shell': 'node -badOption', + 'cmd.exit_code': '9' + } + } + + expectSomeSpan(agent, expected).then(done, done) + + const args = normalizeArgs(methodName, command, options) + + if (async) { + const res = childProcess[methodName].apply(null, args) + res.on('close', noop) + } else { + try { + childProcess[methodName].apply(null, args) + } catch { + // process exit with code 1, exceptions are expected + } + } + }) + }) + }) + }) + }) + }) + + describe('Methods which do not spawn a shell by default', () => { + const execAsyncMethods = ['execFile', 'spawn'] + const execSyncMethods = ['execFileSync', 'spawnSync'] + let childProcess, tracer + + beforeEach(() => { + return agent.load('child_process', undefined, { flushInterval: 1 }).then(() => { + tracer = require('../../dd-trace') + childProcess = require('child_process') + tracer.use('child_process', { enabled: true }) + }) + }) + + afterEach(() => agent.close({ ritmReset: false })) + const parentSpanList = [true, false] + parentSpanList.forEach(parentSpan => { + describe(`${parentSpan ? 'with' : 'without'} parent span`, () => { + const methods = [ + ...execAsyncMethods.map(methodName => ({ methodName, async: true })), + ...execSyncMethods.map(methodName => ({ methodName, async: false })) + ] + if (parentSpan) { + beforeEach((done) => { + const parentSpan = tracer.startSpan('parent') + parentSpan.finish() + tracer.scope().activate(parentSpan, done) + }) + } + + methods.forEach(({ methodName, async }) => { + describe(methodName, () => { + it('should be instrumented', (done) => { + const expected = { + type: 'system', + name: 'command_execution', + error: 0, + meta: { + component: 'subprocess', + 'cmd.exec': '["ls"]', + 'cmd.exit_code': '0' + } + } + expectSomeSpan(agent, expected).then(done, done) + + const res = childProcess[methodName]('ls') + if (async) { + res.on('close', noop) + } + }) + + it('command should be scrubbed', (done) => { + const expected = { + type: 'system', + name: 'command_execution', + error: 0, + meta: { + component: 'subprocess', + 'cmd.exec': '["echo","password","?"]', + 'cmd.exit_code': '0' + } + } + expectSomeSpan(agent, expected).then(done, done) + + const args = [] + if (methodName === 'exec' || methodName === 'execSync') { + args.push('echo password 123') + } else { + args.push('echo') + args.push(['password', '123']) + } + + const res = childProcess[methodName](...args) + if (async) { + res.on('close', noop) + } + }) + + it('should be instrumented with error code', (done) => { + const command = [ 'node', '-badOption' ] + const options = { + stdio: 'pipe' + } + + const errorExpected = { + type: 'system', + name: 'command_execution', + error: 1, + meta: { + component: 'subprocess', + 'cmd.exec': '["node","-badOption"]', + 'cmd.exit_code': '9' + } + } + + const noErrorExpected = { + type: 'system', + name: 'command_execution', + error: 0, + meta: { + component: 'subprocess', + 'cmd.exec': '["node","-badOption"]', + 'cmd.exit_code': '9' + } + } + + const args = normalizeArgs(methodName, command, options) + + if (async) { + expectSomeSpan(agent, errorExpected).then(done, done) + const res = childProcess[methodName].apply(null, args) + res.on('close', noop) + } else { + try { + if (methodName === 'spawnSync') { + expectSomeSpan(agent, noErrorExpected).then(done, done) + } else { + expectSomeSpan(agent, errorExpected).then(done, done) + } + childProcess[methodName].apply(null, args) + } catch { + // process exit with code 1, exceptions are expected + } + } + }) + + it('should be instrumented with error code (override shell default behavior)', (done) => { + const command = [ 'node', '-badOption' ] + const options = { + stdio: 'pipe', + shell: true + } + const errorExpected = { + type: 'system', + name: 'command_execution', + error: 1, + meta: { + component: 'subprocess', + 'cmd.shell': 'node -badOption', + 'cmd.exit_code': '9' + } + } + + const noErrorExpected = { + type: 'system', + name: 'command_execution', + error: 0, + meta: { + component: 'subprocess', + 'cmd.shell': 'node -badOption', + 'cmd.exit_code': '9' + } + } + + const args = normalizeArgs(methodName, command, options) + + if (async) { + expectSomeSpan(agent, errorExpected).then(done, done) + const res = childProcess[methodName].apply(null, args) + res.on('close', noop) + } else { + try { + if (methodName === 'spawnSync') { + expectSomeSpan(agent, noErrorExpected).then(done, done) + } else { + expectSomeSpan(agent, errorExpected).then(done, done) + } + childProcess[methodName].apply(null, args) + } catch { + // process exit with code 1, exceptions are expected + } + } + }) + }) + }) + }) + }) + }) + }) +}) diff --git a/packages/datadog-plugin-child_process/test/scrub-cmd-params.spec.js b/packages/datadog-plugin-child_process/test/scrub-cmd-params.spec.js new file mode 100644 index 00000000000..a76788d0742 --- /dev/null +++ b/packages/datadog-plugin-child_process/test/scrub-cmd-params.spec.js @@ -0,0 +1,79 @@ +'use strict' + +const scrubCmdParams = require('../src/scrub-cmd-params') + +describe('scrub cmds', () => { + it('Should not scrub single command', () => { + expect(scrubCmdParams('ls -la')).to.be.deep.equal(['ls', '-la']) + }) + + it('Should split correctly comments', () => { + expect(scrubCmdParams('ls #comment')).to.be.deep.equal(['ls', '#comment']) + expect(scrubCmdParams('ls #comment with spaces')).to.be.deep.equal(['ls', '#comment with spaces']) + }) + + it('Should split globs', () => { + expect(scrubCmdParams('ls node_modules/*')).to.be.deep.equal(['ls', 'node_modules/*']) + expect(scrubCmdParams('ls *')).to.be.deep.equal(['ls', '*']) + }) + + it('Should split correctly texts', () => { + expect(scrubCmdParams('echo "Hello\\ text"')).to.be.deep.equal(['echo', 'Hello\\ text']) + expect(scrubCmdParams('node -e "process.exit(1)"')).to.be.deep.equal(['node', '-e', 'process.exit(1)']) + }) + + it('Should not scrub chained command', () => { + expect(scrubCmdParams('ls -la|grep something')).to.be.deep.equal(['ls', '-la', '|', 'grep', 'something']) + }) + + it('Should scrub environment variables', () => { + expect(scrubCmdParams('ENV=XXX LD_PRELOAD=YYY ls')).to.be.deep.equal(['ENV=?', 'LD_PRELOAD=YYY', 'ls']) + expect(scrubCmdParams('DD_TEST=info SHELL=zsh ls -l')).to.be.deep.equal(['DD_TEST=?', 'SHELL=?', 'ls', '-l']) + }) + + it('Should scrub secret values', () => { + expect(scrubCmdParams('cmd --pass abc --token=def')).to.be.deep.equal(['cmd', '--pass', '?', '--token=?']) + + expect(scrubCmdParams('mysqladmin -u root password very_secret')) + .to.be.deep.equal(['mysqladmin', '-u', 'root', 'password', '?']) + + expect(scrubCmdParams('test -password very_secret -api_key 1234')) + .to.be.deep.equal(['test', '-password', '?', '-api_key', '?']) + }) + + it('Should scrub md5 commands', () => { + expect(scrubCmdParams('md5 -s pony')).to.be.deep.equal(['md5', '?', '?']) + + expect(scrubCmdParams('cat passwords.txt | while read line; do; md5 -s $line; done')).to.be.deep + .equal([ + 'cat', + 'passwords.txt', + '|', + 'while', + 'read', + 'line', + ';', + 'do', + ';', + 'md5', + '?', + '?', + ';', + 'done' + ]) + }) + + it('should scrub shell expressions', () => { + expect(scrubCmdParams('md5 -s secret ; mysqladmin -u root password 1234 | test api_key 4321')).to.be.deep.equal([ + 'md5', '?', '?', ';', 'mysqladmin', '-u', 'root', 'password', '?', '|', 'test', 'api_key', '?' + ]) + }) + + it('Should not scrub md5sum commands', () => { + expect(scrubCmdParams('md5sum file')).to.be.deep.equal(['md5sum', 'file']) + }) + + it('Should maintain var names', () => { + expect(scrubCmdParams('echo $something')).to.be.deep.equal(['echo', '$something']) + }) +}) diff --git a/packages/dd-trace/src/appsec/iast/analyzers/command-injection-analyzer.js b/packages/dd-trace/src/appsec/iast/analyzers/command-injection-analyzer.js index eccf8a3814b..fd2a230a2a8 100644 --- a/packages/dd-trace/src/appsec/iast/analyzers/command-injection-analyzer.js +++ b/packages/dd-trace/src/appsec/iast/analyzers/command-injection-analyzer.js @@ -8,7 +8,7 @@ class CommandInjectionAnalyzer extends InjectionAnalyzer { } onConfigure () { - this.addSub('datadog:child_process:execution:start', ({ command }) => this.analyze(command)) + this.addSub('tracing:datadog:child_process:execution:start', ({ command }) => this.analyze(command)) } } diff --git a/packages/dd-trace/src/appsec/iast/iast-plugin.js b/packages/dd-trace/src/appsec/iast/iast-plugin.js index 2fe9f85bed6..02eb07ebd10 100644 --- a/packages/dd-trace/src/appsec/iast/iast-plugin.js +++ b/packages/dd-trace/src/appsec/iast/iast-plugin.js @@ -127,10 +127,13 @@ class IastPlugin extends Plugin { if (!channelName && !moduleName) return if (!moduleName) { - const firstSep = channelName.indexOf(':') + let firstSep = channelName.indexOf(':') if (firstSep === -1) { moduleName = channelName } else { + if (channelName.startsWith('tracing:')) { + firstSep = channelName.indexOf(':', 'tracing:'.length + 1) + } const lastSep = channelName.indexOf(':', firstSep + 1) moduleName = channelName.substring(firstSep + 1, lastSep !== -1 ? lastSep : channelName.length) } diff --git a/packages/dd-trace/src/plugins/index.js b/packages/dd-trace/src/plugins/index.js index c7c96df0f50..b22f0475ab3 100644 --- a/packages/dd-trace/src/plugins/index.js +++ b/packages/dd-trace/src/plugins/index.js @@ -23,6 +23,7 @@ module.exports = { get 'aws-sdk' () { return require('../../../datadog-plugin-aws-sdk/src') }, get 'bunyan' () { return require('../../../datadog-plugin-bunyan/src') }, get 'cassandra-driver' () { return require('../../../datadog-plugin-cassandra-driver/src') }, + get 'child_process' () { return require('../../../datadog-plugin-child_process/src') }, get 'connect' () { return require('../../../datadog-plugin-connect/src') }, get 'couchbase' () { return require('../../../datadog-plugin-couchbase/src') }, get 'cypress' () { return require('../../../datadog-plugin-cypress/src') }, diff --git a/packages/dd-trace/src/plugins/util/exec.js b/packages/dd-trace/src/plugins/util/exec.js deleted file mode 100644 index 3e3ca3f3660..00000000000 --- a/packages/dd-trace/src/plugins/util/exec.js +++ /dev/null @@ -1,34 +0,0 @@ -const cp = require('child_process') -const log = require('../../log') -const { distributionMetric, incrementCountMetric } = require('../../ci-visibility/telemetry') - -const sanitizedExec = ( - cmd, - flags, - operationMetric, - durationMetric, - errorMetric -) => { - let startTime - if (operationMetric) { - incrementCountMetric(operationMetric.name, operationMetric.tags) - } - if (durationMetric) { - startTime = Date.now() - } - try { - const result = cp.execFileSync(cmd, flags, { stdio: 'pipe' }).toString().replace(/(\r\n|\n|\r)/gm, '') - if (durationMetric) { - distributionMetric(durationMetric.name, durationMetric.tags, Date.now() - startTime) - } - return result - } catch (e) { - if (errorMetric) { - incrementCountMetric(errorMetric.name, { ...errorMetric.tags, exitCode: e.status }) - } - log.error(e) - return '' - } -} - -module.exports = { sanitizedExec } diff --git a/packages/dd-trace/src/plugins/util/git.js b/packages/dd-trace/src/plugins/util/git.js index 885cbe5fb3c..f4aebc184a5 100644 --- a/packages/dd-trace/src/plugins/util/git.js +++ b/packages/dd-trace/src/plugins/util/git.js @@ -26,6 +26,7 @@ const { TELEMETRY_GIT_COMMAND_ERRORS } = require('../../ci-visibility/telemetry') const { filterSensitiveInfoFromRepository } = require('./url') +const { storage } = require('../../../../datadog-core') const GIT_REV_LIST_MAX_BUFFER = 8 * 1024 * 1024 // 8MB @@ -36,6 +37,9 @@ function sanitizedExec ( durationMetric, errorMetric ) { + const store = storage.getStore() + storage.enterWith({ noop: true }) + let startTime if (operationMetric) { incrementCountMetric(operationMetric.name, operationMetric.tags) @@ -55,6 +59,8 @@ function sanitizedExec ( } log.error(e) return '' + } finally { + storage.enterWith(store) } } diff --git a/packages/dd-trace/test/appsec/iast/iast-plugin.spec.js b/packages/dd-trace/test/appsec/iast/iast-plugin.spec.js index ca8a3381676..539c749abbe 100644 --- a/packages/dd-trace/test/appsec/iast/iast-plugin.spec.js +++ b/packages/dd-trace/test/appsec/iast/iast-plugin.spec.js @@ -247,7 +247,7 @@ describe('IAST Plugin', () => { expect(getTelemetryHandler).to.be.calledOnceWith(iastPlugin.pluginSubs[1]) }) - it('should register an pluginSubscription and increment a sink metric when a sink module is loaded', () => { + it('should register a pluginSubscription and increment a sink metric when a sink module is loaded', () => { iastPlugin.addSub({ moduleName: 'sink', channelName: 'datadog:sink:start', @@ -264,6 +264,22 @@ describe('IAST Plugin', () => { expect(metricAdd).to.be.calledOnceWith(1, 'injection') }) + it('should register and increment a sink metric when a sink module is loaded using a tracingChannel', () => { + iastPlugin.addSub({ + channelName: 'tracing:datadog:sink:start', + tag: 'injection', + tagKey: VULNERABILITY_TYPE + }, handler) + iastPlugin.configure(true) + + const metric = getInstrumentedMetric(VULNERABILITY_TYPE) + const metricAdd = sinon.stub(metric, 'add') + + loadChannel.publish({ name: 'sink' }) + + expect(metricAdd).to.be.calledOnceWith(1, 'injection') + }) + it('should register an pluginSubscription and increment a source metric when a source module is loaded', () => { iastPlugin.addSub({ moduleName: 'source', diff --git a/yarn.lock b/yarn.lock index 62a62511625..c0194b237bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4567,7 +4567,7 @@ shebang-regex@^3.0.0: resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -shell-quote@^1.6.1: +shell-quote@^1.6.1, shell-quote@^1.8.1: version "1.8.1" resolved "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz" integrity "sha1-bb9Nt1UVrVusY7TxiUw6FUx2ZoA= sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA=="