UNPKG

dd-trace

Version:

Datadog APM tracing client for JavaScript

539 lines (433 loc) 15.3 kB
'use strict' const dc = require('dc-polyfill') const zlib = require('zlib') const { storage } = require('../../../datadog-core') const web = require('../plugins/util/web') const { ipHeaderList } = require('../plugins/util/ip_extractor') const { incrementWafInitMetric, incrementWafUpdatesMetric, incrementWafConfigErrorsMetric, incrementWafRequestsMetric, updateWafRequestsMetricTags, updateRaspRequestsMetricTags, updateRaspRuleSkippedMetricTags, getRequestMetrics } = require('./telemetry') const { keepTrace } = require('../priority_sampler') const { ASM } = require('../standalone/product') const { DIAGNOSTIC_KEYS } = require('./waf/diagnostics') const REQUEST_HEADER_TAG_PREFIX = 'http.request.headers.' const RESPONSE_HEADER_TAG_PREFIX = 'http.response.headers.' const COLLECTED_REQUEST_BODY_MAX_STRING_LENGTH = 4096 const COLLECTED_REQUEST_BODY_MAX_DEPTH = 20 const COLLECTED_REQUEST_BODY_MAX_ELEMENTS_PER_NODE = 256 const telemetryLogCh = dc.channel('datadog:telemetry:log') const config = { headersExtendedCollectionEnabled: false, maxHeadersCollected: 0, headersRedaction: false, raspBodyCollection: false } const metricsQueue = new Map() // following header lists are ordered in the same way the spec orders them, it doesn't matter but it's easier to compare const contentHeaderList = [ 'content-length', 'content-type', 'content-encoding', 'content-language' ] const identificationHeaders = [ 'x-amzn-trace-id', 'cloudfront-viewer-ja3-fingerprint', 'cf-ray', 'x-cloud-trace-context', 'x-appgw-trace-id', 'x-sigsci-requestid', 'x-sigsci-tags', 'akamai-user-risk' ] const eventHeadersList = [ ...ipHeaderList, 'x-forwarded', 'forwarded', 'via', ...contentHeaderList, 'host', 'accept-encoding', 'accept-language' ] const requestHeadersList = [ 'content-type', 'user-agent', 'accept', ...identificationHeaders ] // these request headers are always collected - it breaks the expected spec orders const REQUEST_HEADERS_MAP = mapHeaderAndTags(requestHeadersList, REQUEST_HEADER_TAG_PREFIX) const EVENT_HEADERS_MAP = mapHeaderAndTags(eventHeadersList, REQUEST_HEADER_TAG_PREFIX) const RESPONSE_HEADERS_MAP = mapHeaderAndTags(contentHeaderList, RESPONSE_HEADER_TAG_PREFIX) const NON_EXTENDED_REQUEST_HEADERS = new Set([...requestHeadersList, ...eventHeadersList]) const NON_EXTENDED_RESPONSE_HEADERS = new Set(contentHeaderList) function init (_config) { config.headersExtendedCollectionEnabled = _config.extendedHeadersCollection.enabled config.maxHeadersCollected = _config.extendedHeadersCollection.maxHeaders config.headersRedaction = _config.extendedHeadersCollection.redaction config.raspBodyCollection = _config.rasp.bodyCollection } function formatHeaderName (name) { return name .trim() .slice(0, 200) .replaceAll(/[^a-zA-Z0-9_\-:/]/g, '_') .toLowerCase() } function getHeaderTag (tagPrefix, headerName) { return `${tagPrefix}${formatHeaderName(headerName)}` } function mapHeaderAndTags (headerList, tagPrefix) { return new Map(headerList.map(headerName => [headerName, getHeaderTag(tagPrefix, headerName)])) } function filterHeaders (headers, map) { const result = {} if (!headers) return result for (const [headerName, tagName] of map) { const headerValue = headers[headerName] if (headerValue) { result[tagName] = String(headerValue) } } return result } function filterExtendedHeaders (headers, excludedHeaderNames, tagPrefix, limit = 0) { const result = {} if (!headers) return result let counter = 0 for (const [headerName, headerValue] of Object.entries(headers)) { if (counter >= limit) break if (!excludedHeaderNames.has(headerName)) { result[getHeaderTag(tagPrefix, headerName)] = String(headerValue) counter++ } } return result } function getCollectedHeaders (req, res, shouldCollectEventHeaders, storedResponseHeaders = {}) { // Mandatory const mandatoryCollectedHeaders = filterHeaders(req.headers, REQUEST_HEADERS_MAP) // Basic collection if (!shouldCollectEventHeaders) return mandatoryCollectedHeaders const responseHeaders = Object.keys(storedResponseHeaders).length === 0 ? res.getHeaders() : { ...storedResponseHeaders, ...res.getHeaders() } const requestEventCollectedHeaders = filterHeaders(req.headers, EVENT_HEADERS_MAP) const responseEventCollectedHeaders = filterHeaders(responseHeaders, RESPONSE_HEADERS_MAP) if (!config.headersExtendedCollectionEnabled || config.headersRedaction) { // Standard collection return Object.assign( mandatoryCollectedHeaders, requestEventCollectedHeaders, responseEventCollectedHeaders ) } // Extended collection const requestExtendedHeadersAvailableCount = config.maxHeadersCollected - Object.keys(mandatoryCollectedHeaders).length - Object.keys(requestEventCollectedHeaders).length const requestEventExtendedCollectedHeaders = filterExtendedHeaders( req.headers, NON_EXTENDED_REQUEST_HEADERS, REQUEST_HEADER_TAG_PREFIX, requestExtendedHeadersAvailableCount ) const responseExtendedHeadersAvailableCount = config.maxHeadersCollected - Object.keys(responseEventCollectedHeaders).length const responseEventExtendedCollectedHeaders = filterExtendedHeaders( responseHeaders, NON_EXTENDED_RESPONSE_HEADERS, RESPONSE_HEADER_TAG_PREFIX, responseExtendedHeadersAvailableCount ) const headersTags = Object.assign( mandatoryCollectedHeaders, requestEventCollectedHeaders, requestEventExtendedCollectedHeaders, responseEventCollectedHeaders, responseEventExtendedCollectedHeaders ) // Check discarded headers const requestHeadersCount = Object.keys(req.headers).length if (requestHeadersCount > config.maxHeadersCollected) { headersTags['_dd.appsec.request.header_collection.discarded'] = requestHeadersCount - config.maxHeadersCollected } const responseHeadersCount = Object.keys(responseHeaders).length if (responseHeadersCount > config.maxHeadersCollected) { headersTags['_dd.appsec.response.header_collection.discarded'] = responseHeadersCount - config.maxHeadersCollected } return headersTags } function reportWafInit (wafVersion, rulesVersion, diagnosticsRules = {}, success = false) { if (success) { metricsQueue.set('_dd.appsec.waf.version', wafVersion) } incrementWafInitMetric(wafVersion, rulesVersion, success) } function logWafDiagnosticMessage (product, rcConfigId, configKey, message, level) { const tags = `log_type:rc::${product.toLowerCase()}::diagnostic,appsec_config_key:${configKey},rc_config_id:${rcConfigId}` telemetryLogCh.publish({ message, level, tags }) } function reportWafConfigUpdate (product, rcConfigId, diagnostics, wafVersion) { if (diagnostics.error) { logWafDiagnosticMessage(product, rcConfigId, '', diagnostics.error, 'ERROR') incrementWafConfigErrorsMetric(wafVersion, diagnostics.ruleset_version) } for (const configKey of DIAGNOSTIC_KEYS) { const configDiagnostics = diagnostics[configKey] if (!configDiagnostics) continue if (configDiagnostics.error) { logWafDiagnosticMessage(product, rcConfigId, configKey, configDiagnostics.error, 'ERROR') incrementWafConfigErrorsMetric(wafVersion, diagnostics.ruleset_version) continue } if (configDiagnostics.errors) { for (const [errorMessage, errorIds] of Object.entries(configDiagnostics.errors)) { logWafDiagnosticMessage( product, rcConfigId, configKey, `"${errorMessage}": ${JSON.stringify(errorIds)}`, 'ERROR' ) incrementWafConfigErrorsMetric(wafVersion, diagnostics.ruleset_version) } } if (configDiagnostics.warnings) { for (const [warningMessage, warningIds] of Object.entries(configDiagnostics.warnings)) { logWafDiagnosticMessage( product, rcConfigId, configKey, `"${warningMessage}": ${JSON.stringify(warningIds)}`, 'WARN' ) } } } } function reportMetrics (metrics, raspRule) { const store = storage('legacy').getStore() const rootSpan = store?.req && web.root(store.req) if (!rootSpan) return if (metrics.rulesVersion) { rootSpan.setTag('_dd.appsec.event_rules.version', metrics.rulesVersion) } if (raspRule) { updateRaspRequestsMetricTags(metrics, store.req, raspRule) } else { updateWafRequestsMetricTags(metrics, store.req) } reportTruncationMetrics(rootSpan, metrics) } function reportTruncationMetrics (rootSpan, metrics) { if (metrics.maxTruncatedString) { rootSpan.setTag('_dd.appsec.truncated.string_length', metrics.maxTruncatedString) } if (metrics.maxTruncatedContainerSize) { rootSpan.setTag('_dd.appsec.truncated.container_size', metrics.maxTruncatedContainerSize) } if (metrics.maxTruncatedContainerDepth) { rootSpan.setTag('_dd.appsec.truncated.container_depth', metrics.maxTruncatedContainerDepth) } } function reportAttack (attackData) { const store = storage('legacy').getStore() const req = store?.req const rootSpan = web.root(req) if (!rootSpan) return const currentTags = rootSpan.context()._tags const newTags = { 'appsec.event': 'true' } // TODO: maybe add this to format.js later (to take decision as late as possible) if (!currentTags['_dd.origin']) { newTags['_dd.origin'] = 'appsec' } const currentJson = currentTags['_dd.appsec.json'] // merge JSON arrays without parsing them const attackDataStr = JSON.stringify(attackData) newTags['_dd.appsec.json'] = currentJson ? currentJson.slice(0, -2) + ',' + attackDataStr.slice(1) + '}' : '{"triggers":' + attackDataStr + '}' if (req.socket) { newTags['network.client.ip'] = req.socket.remoteAddress } rootSpan.addTags(newTags) if (config.raspBodyCollection && isRaspAttack(attackData)) { reportRequestBody(rootSpan, req.body) } } function truncateRequestBody (target, depth = 0) { switch (typeof target) { case 'string': if (target.length > COLLECTED_REQUEST_BODY_MAX_STRING_LENGTH) { return { value: target.slice(0, COLLECTED_REQUEST_BODY_MAX_STRING_LENGTH), truncated: true } } return { value: target, truncated: false } case 'object': { if (target === null) { return { value: target, truncated: false } } if (depth >= COLLECTED_REQUEST_BODY_MAX_DEPTH) { return { truncated: true } } if (typeof target.toJSON === 'function') { try { return truncateRequestBody(target.toJSON(), depth + 1) } catch { return { truncated: false } } } if (Array.isArray(target)) { const maxArrayLength = Math.min(target.length, COLLECTED_REQUEST_BODY_MAX_ELEMENTS_PER_NODE) let wasTruncated = target.length > COLLECTED_REQUEST_BODY_MAX_ELEMENTS_PER_NODE const truncatedArray = new Array(maxArrayLength) for (let i = 0; i < maxArrayLength; i++) { const { value, truncated } = truncateRequestBody(target[i], depth + 1) if (truncated) wasTruncated = true truncatedArray[i] = value } return { value: truncatedArray, truncated: wasTruncated } } const keys = Object.keys(target) const maxKeysLength = Math.min(keys.length, COLLECTED_REQUEST_BODY_MAX_ELEMENTS_PER_NODE) let wasTruncated = keys.length > COLLECTED_REQUEST_BODY_MAX_ELEMENTS_PER_NODE const truncatedObject = {} for (let i = 0; i < maxKeysLength; i++) { const key = keys[i] const { value, truncated } = truncateRequestBody(target[key], depth + 1) if (truncated) wasTruncated = true truncatedObject[key] = value } return { value: truncatedObject, truncated: wasTruncated } } default: return { value: target, truncated: false } } } function reportRequestBody (rootSpan, requestBody) { if (!requestBody) return if (!rootSpan.meta_struct) { rootSpan.meta_struct = {} } if (!rootSpan.meta_struct['http.request.body']) { const { truncated, value } = truncateRequestBody(requestBody) rootSpan.meta_struct['http.request.body'] = value if (truncated) { rootSpan.setTag('_dd.appsec.rasp.request_body_size.exceeded', 'true') } } } function isRaspAttack (events) { return events.some(e => e.rule?.tags?.module === 'rasp') } function isSchemaAttribute (attribute) { return attribute.startsWith('_dd.appsec.s.') } function reportAttributes (attributes) { if (!attributes) return const req = storage('legacy').getStore()?.req const rootSpan = web.root(req) if (!rootSpan) return const tags = {} for (let [tag, value] of Object.entries(attributes)) { if (isSchemaAttribute(tag)) { const gzippedValue = zlib.gzipSync(JSON.stringify(value)) value = gzippedValue.toString('base64') } tags[tag] = value } rootSpan.addTags(tags) } function finishRequest (req, res, storedResponseHeaders) { const rootSpan = web.root(req) if (!rootSpan) return if (metricsQueue.size) { rootSpan.addTags(Object.fromEntries(metricsQueue)) keepTrace(rootSpan, ASM) metricsQueue.clear() } const metrics = getRequestMetrics(req) if (metrics?.duration) { rootSpan.setTag('_dd.appsec.waf.duration', metrics.duration) } if (metrics?.durationExt) { rootSpan.setTag('_dd.appsec.waf.duration_ext', metrics.durationExt) } if (metrics?.wafErrorCode) { rootSpan.setTag('_dd.appsec.waf.error', metrics.wafErrorCode) } if (metrics?.wafTimeouts) { rootSpan.setTag('_dd.appsec.waf.timeouts', metrics.wafTimeouts) } if (metrics?.raspDuration) { rootSpan.setTag('_dd.appsec.rasp.duration', metrics.raspDuration) } if (metrics?.raspDurationExt) { rootSpan.setTag('_dd.appsec.rasp.duration_ext', metrics.raspDurationExt) } if (metrics?.raspErrorCode) { rootSpan.setTag('_dd.appsec.rasp.error', metrics.raspErrorCode) } if (metrics?.raspTimeouts) { rootSpan.setTag('_dd.appsec.rasp.timeout', metrics.raspTimeouts) } if (metrics?.raspEvalCount) { rootSpan.setTag('_dd.appsec.rasp.rule.eval', metrics.raspEvalCount) } incrementWafRequestsMetric(req) const tags = rootSpan.context()._tags const newTags = getCollectedHeaders(req, res, shouldCollectEventHeaders(tags), storedResponseHeaders) if (tags['appsec.event'] === 'true' && typeof req.route?.path === 'string') { newTags['http.endpoint'] = req.route.path } rootSpan.addTags(newTags) } function shouldCollectEventHeaders (tags = {}) { if (tags['appsec.event'] === 'true') { return true } for (const tagName of Object.keys(tags)) { if (tagName.startsWith('appsec.events.')) { return true } } return false } module.exports = { metricsQueue, init, filterHeaders, filterExtendedHeaders, formatHeaderName, reportWafInit, reportWafConfigUpdate, reportMetrics, reportAttack, reportWafUpdate: incrementWafUpdatesMetric, reportRaspRuleSkipped: updateRaspRuleSkippedMetricTags, reportAttributes, finishRequest, mapHeaderAndTags, truncateRequestBody }