Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add runtime guardrail for SSI #4319

Merged
merged 3 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading