Skip to content

Commit

Permalink
Add runtime guardrail for SSI (#4319)
Browse files Browse the repository at this point in the history
* Add guardrails for SSI

* PR feedback

* test on a swath of versions, in CI only
  • Loading branch information
bengl authored and juan-fernandez committed Jul 10, 2024
1 parent ace344f commit 3f0e5bb
Show file tree
Hide file tree
Showing 10 changed files with 606 additions and 99 deletions.
14 changes: 14 additions & 0 deletions .github/workflows/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ jobs:
- run: sudo sysctl -w kernel.core_pattern='|/bin/false'
- run: yarn test:integration

# We'll run these separately for earlier (i.e. unsupported) versions
integration-guardrails:
strategy:
matrix:
version: [12, 14, 16]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.version }}
- run: yarn install --ignore-engines
- run: node node_modules/.bin/mocha --colors --timeout 30000 -r packages/dd-trace/test/setup/core.js integration-tests/init.spec.js

integration-ci:
strategy:
matrix:
Expand Down
41 changes: 40 additions & 1 deletion init.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,26 @@

const path = require('path')
const Module = require('module')
const telemetry = require('./packages/dd-trace/src/telemetry/init-telemetry')
const semver = require('semver')

function isTrue (envVar) {
return ['1', 'true', 'True'].includes(envVar)
}

// eslint-disable-next-line no-console
let log = { info: isTrue(process.env.DD_TRACE_DEBUG) ? console.log : () => {} }
if (semver.satisfies(process.versions.node, '>=16')) {
const Config = require('./packages/dd-trace/src/config')
log = require('./packages/dd-trace/src/log')

// eslint-disable-next-line no-new
new Config() // we need this to initialize the logger
}

let initBailout = false
let clobberBailout = false
const forced = isTrue(process.env.DD_INJECT_FORCE)

if (process.env.DD_INJECTION_ENABLED) {
// If we're running via single-step install, and we're not in the app's
Expand All @@ -19,13 +37,34 @@ if (process.env.DD_INJECTION_ENABLED) {
if (resolvedInApp) {
const ourselves = path.join(__dirname, 'index.js')
if (ourselves !== resolvedInApp) {
clobberBailout = true
}
}

// If we're running via single-step install, and the runtime doesn't match
// the engines field in package.json, then we should not initialize the tracer.
if (!clobberBailout) {
const { engines } = require('./package.json')
const version = process.versions.node
if (!semver.satisfies(version, engines.node)) {
initBailout = true
telemetry([
{ name: 'abort', tags: ['reason:incompatible_runtime'] },
{ name: 'abort.runtime', tags: [] }
])
log.info('Aborting application instrumentation due to incompatible_runtime.')
log.info(`Found incompatible runtime nodejs ${version}, Supported runtimes: nodejs ${engines.node}.`)
if (forced) {
log.info('DD_INJECT_FORCE enabled, allowing unsupported runtimes and continuing.')
}
}
}
}

if (!initBailout) {
if (!clobberBailout && (!initBailout || forced)) {
const tracer = require('.')
tracer.init()
module.exports = tracer
telemetry('complete', [`injection_forced:${forced && initBailout ? 'true' : 'false'}`])
log.info('Application instrumentation bootstrapping complete')
}
13 changes: 8 additions & 5 deletions initialize.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,12 @@ export async function getSource (...args) {
}

if (isMainThread) {
await import('./init.js')
const { register } = await import('node:module')
if (register) {
register('./loader-hook.mjs', import.meta.url)
}
// Need this IIFE for versions of Node.js without top-level await.
(async () => {
await import('./init.js')
const { register } = await import('node:module')
if (register) {
register('./loader-hook.mjs', import.meta.url)
}
})()
}
181 changes: 174 additions & 7 deletions integration-tests/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@ const msgpack = require('msgpack-lite')
const codec = msgpack.createCodec({ int64: true })
const EventEmitter = require('events')
const childProcess = require('child_process')
const { fork } = childProcess
const { fork, spawn } = childProcess
const exec = promisify(childProcess.exec)
const http = require('http')
const fs = require('fs/promises')
const fs = require('fs')
const os = require('os')
const path = require('path')
const rimraf = promisify(require('rimraf'))
const id = require('../packages/dd-trace/src/id')
const upload = require('multer')()
const assert = require('assert')

const hookFile = 'dd-trace/loader-hook.mjs'

Expand Down Expand Up @@ -162,6 +163,95 @@ class FakeAgent extends EventEmitter {
}
}

async function runAndCheckOutput (filename, cwd, expectedOut) {
const proc = spawn('node', [filename], { cwd, stdio: 'pipe' })
const pid = proc.pid
let out = await new Promise((resolve, reject) => {
proc.on('error', reject)
let out = Buffer.alloc(0)
proc.stdout.on('data', data => {
out = Buffer.concat([out, data])
})
proc.stderr.pipe(process.stdout)
proc.on('exit', () => resolve(out.toString('utf8')))
setTimeout(() => {
if (proc.exitCode === null) proc.kill()
}, 1000) // TODO this introduces flakiness. find a better way to end the process.
})
if (typeof expectedOut === 'function') {
expectedOut(out)
} else {
if (process.env.DD_TRACE_DEBUG) {
// Debug adds this, which we don't care about in these tests
out = out.replace('Flushing 0 metrics via HTTP\n', '')
}
assert.strictEqual(out, expectedOut)
}
return pid
}

// This is set by the useSandbox function
let sandbox

// This _must_ be used with the useSandbox function
async function runAndCheckWithTelemetry (filename, expectedOut, ...expectedTelemetryPoints) {
const cwd = sandbox.folder
const cleanup = telemetryForwarder(expectedTelemetryPoints)
const pid = await runAndCheckOutput(filename, cwd, expectedOut)
const msgs = await cleanup()
if (expectedTelemetryPoints.length === 0) {
// assert no telemetry sent
try {
assert.deepStrictEqual(msgs.length, 0)
} catch (e) {
// This console.log is useful for debugging telemetry. Plz don't remove.
// eslint-disable-next-line no-console
console.error('Expected no telemetry, but got:\n', msgs.map(msg => JSON.stringify(msg[1].points)).join('\n'))
throw e
}
return
}
let points = []
for (const [telemetryType, data] of msgs) {
assert.strictEqual(telemetryType, 'library_entrypoint')
assert.deepStrictEqual(data.metadata, meta(pid))
points = points.concat(data.points)
}
let expectedPoints = getPoints(...expectedTelemetryPoints)
// We now have to sort both the expected and actual telemetry points.
// This is because data can come in in any order.
// We'll just contatenate all the data together for each point and sort them.
points = points.map(p => p.name + '\t' + p.tags.join(',')).sort().join('\n')
expectedPoints = expectedPoints.map(p => p.name + '\t' + p.tags.join(',')).sort().join('\n')
assert.strictEqual(points, expectedPoints)

function getPoints (...args) {
const expectedPoints = []
let currentPoint = {}
for (const arg of args) {
if (!currentPoint.name) {
currentPoint.name = 'library_entrypoint.' + arg
} else {
currentPoint.tags = arg.split(',')
expectedPoints.push(currentPoint)
currentPoint = {}
}
}
return expectedPoints
}

function meta (pid) {
return {
language_name: 'nodejs',
language_version: process.versions.node,
runtime_name: 'nodejs',
runtime_version: process.versions.node,
tracer_version: require('../package.json').version,
pid: Number(pid)
}
}
}

function spawnProc (filename, options = {}, stdioHandler) {
const proc = fork(filename, { ...options, stdio: 'pipe' })
return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -205,9 +295,9 @@ async function createSandbox (dependencies = [], isGitRepo = false,
// We might use NODE_OPTIONS to init the tracer. We don't want this to affect this operations
const { NODE_OPTIONS, ...restOfEnv } = process.env

await fs.mkdir(folder)
await exec(`yarn pack --filename ${out}`) // TODO: cache this
await exec(`yarn add ${allDependencies.join(' ')}`, { cwd: folder, env: restOfEnv })
fs.mkdirSync(folder)
await exec(`yarn pack --filename ${out}`, { env: restOfEnv }) // TODO: cache this
await exec(`yarn add ${allDependencies.join(' ')} --ignore-engines`, { cwd: folder, env: restOfEnv })

for (const path of integrationTestsPaths) {
if (process.platform === 'win32') {
Expand All @@ -229,7 +319,7 @@ async function createSandbox (dependencies = [], isGitRepo = false,

if (isGitRepo) {
await exec('git init', { cwd: folder })
await fs.writeFile(path.join(folder, '.gitignore'), 'node_modules/', { flush: true })
fs.writeFileSync(path.join(folder, '.gitignore'), 'node_modules/', { flush: true })
await exec('git config user.email "john@doe.com"', { cwd: folder })
await exec('git config user.name "John Doe"', { cwd: folder })
await exec('git config commit.gpgsign false', { cwd: folder })
Expand All @@ -245,6 +335,54 @@ async function createSandbox (dependencies = [], isGitRepo = false,
}
}

function telemetryForwarder (expectedTelemetryPoints) {
process.env.DD_TELEMETRY_FORWARDER_PATH =
path.join(__dirname, 'telemetry-forwarder.sh')
process.env.FORWARDER_OUT = path.join(__dirname, `forwarder-${Date.now()}.out`)

let retries = 0

const tryAgain = async function () {
retries += 1
await new Promise(resolve => setTimeout(resolve, 100))
return cleanup()
}

const cleanup = function () {
let msgs
try {
msgs = fs.readFileSync(process.env.FORWARDER_OUT, 'utf8').trim().split('\n')
} catch (e) {
if (expectedTelemetryPoints.length && e.code === 'ENOENT' && retries < 10) {
return tryAgain()
}
return []
}
for (let i = 0; i < msgs.length; i++) {
const [telemetryType, data] = msgs[i].split('\t')
if (!data && retries < 10) {
return tryAgain()
}
let parsed
try {
parsed = JSON.parse(data)
} catch (e) {
if (!data && retries < 10) {
return tryAgain()
}
throw new SyntaxError(`error parsing data: ${e.message}\n${data}`)
}
msgs[i] = [telemetryType, parsed]
}
fs.unlinkSync(process.env.FORWARDER_OUT)
delete process.env.FORWARDER_OUT
delete process.env.DD_TELEMETRY_FORWARDER_PATH
return msgs
}

return cleanup
}

async function curl (url, useHttp2 = false) {
if (typeof url === 'object') {
if (url.then) {
Expand Down Expand Up @@ -313,14 +451,43 @@ async function spawnPluginIntegrationTestProc (cwd, serverFile, agentPort, stdio
}, stdioHandler)
}

function useEnv (env) {
before(() => {
Object.assign(process.env, env)
})
after(() => {
for (const key of Object.keys(env)) {
delete process.env[key]
}
})
}

function useSandbox (...args) {
before(async () => {
sandbox = await createSandbox(...args)
})
after(() => {
const oldSandbox = sandbox
sandbox = undefined
return oldSandbox.remove()
})
}
function sandboxCwd () {
return sandbox.folder
}

module.exports = {
FakeAgent,
spawnProc,
runAndCheckWithTelemetry,
createSandbox,
curl,
curlAndAssertMessage,
getCiVisAgentlessConfig,
getCiVisEvpProxyConfig,
checkSpansForServiceName,
spawnPluginIntegrationTestProc
spawnPluginIntegrationTestProc,
useEnv,
useSandbox,
sandboxCwd
}
Loading

0 comments on commit 3f0e5bb

Please sign in to comment.