From 6476d5f3aeed2cc91a38809b154058148b1ac501 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 16 Mar 2026 07:42:55 +0100 Subject: [PATCH 1/5] Remove unused native-appsec binaries + native-appsec version pinned in dd-trace-js --- Dockerfile | 12 +++++++++++- scripts/move_ddtrace_dependency.js | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 760b7ab82..cf1a2dfbb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,12 +16,13 @@ RUN cp -r dist /nodejs/node_modules/datadog-lambda-js RUN cp ./src/runtime/module_importer.js /nodejs/node_modules/datadog-lambda-js/runtime RUN cp ./src/handler.mjs /nodejs/node_modules/datadog-lambda-js -RUN rm -rf node_modules # Move dd-trace from devDependencies to production dependencies # That way it is included in our layer, while keeping it an optional dependency for npm RUN node ./scripts/move_ddtrace_dependency.js "$(cat package.json)" > package-new.json RUN mv package-new.json package.json +RUN rm -rf node_modules + # Install dependencies RUN yarn install --production=true --ignore-optional # Copy the dependencies to the modules folder @@ -46,6 +47,15 @@ RUN rm -rf /nodejs/node_modules/@datadog/pprof/prebuilds/*/node-120.node RUN rm -rf /nodejs/node_modules/@datadog/pprof/prebuilds/*/node-131.node RUN rm -rf /nodejs/node_modules/@datadog/pprof/prebuilds/*/node-141.node +# Remove unused @datadog/native-appsec prebuilds for non-Lambda platforms. +# Lambda runs on Amazon Linux 2 (glibc), on x64 or arm64. +RUN rm -rf /nodejs/node_modules/@datadog/native-appsec/prebuilds/darwin-arm64 +RUN rm -rf /nodejs/node_modules/@datadog/native-appsec/prebuilds/darwin-x64 +RUN rm -rf /nodejs/node_modules/@datadog/native-appsec/prebuilds/win32-ia32 +RUN rm -rf /nodejs/node_modules/@datadog/native-appsec/prebuilds/win32-x64 +RUN rm -rf /nodejs/node_modules/@datadog/native-appsec/prebuilds/linuxmusl-arm64 +RUN rm -rf /nodejs/node_modules/@datadog/native-appsec/prebuilds/linuxmusl-x64 + # Remove heavy files from @opentelemetry/api which aren't used in a lambda environment. # TODO: Create a completely separate Datadog scoped package for OpenTelemetry instead. RUN rm -rf /nodejs/node_modules/@opentelemetry/api/build/esm diff --git a/scripts/move_ddtrace_dependency.js b/scripts/move_ddtrace_dependency.js index 1e2f904fc..a27c28f33 100755 --- a/scripts/move_ddtrace_dependency.js +++ b/scripts/move_ddtrace_dependency.js @@ -1,4 +1,6 @@ // Moves the dd-trace dependency from devDependencies to dependencies within package.json. +// Also promotes selected dd-trace optionalDependencies to direct dependencies so they +// survive `yarn install --production=true --ignore-optional`. // This is used when building the Layer // USAGE: ./move_dd_trace_dependency.js "$(cat package.json)" > package.json @@ -10,6 +12,8 @@ moveDependency('@datadog/pprof') moveDependency('@opentelemetry/api') moveDependency('@opentelemetry/api-logs') +addOptionalFromDdTrace('@datadog/native-appsec') + console.log(JSON.stringify(file, null, 2)); function moveDependency (name) { @@ -17,3 +21,16 @@ function moveDependency (name) { delete file.devDependencies[name]; file.dependencies[name] = ddTraceVersion; } + +function addOptionalFromDdTrace (name) { + try { + const ddTracePkg = require('dd-trace/package.json') + const version = ddTracePkg.optionalDependencies?.[name] + if (version) { + file.dependencies[name] = version + } + } catch { + // dd-trace not installed yet; skip + } +} + From 512bae12fdbeea10aa3b44dfe0dafeff32706902 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 16 Mar 2026 07:44:45 +0100 Subject: [PATCH 2/5] Extract event data and pass it over dc --- src/appsec/event-data-extractor.spec.ts | 240 ++++++++++++++++++++++++ src/appsec/event-data-extractor.ts | 229 ++++++++++++++++++++++ src/appsec/index.spec.ts | 168 +++++++++++++++++ src/appsec/index.ts | 47 +++++ src/trace/listener.ts | 11 ++ 5 files changed, 695 insertions(+) create mode 100644 src/appsec/event-data-extractor.spec.ts create mode 100644 src/appsec/event-data-extractor.ts create mode 100644 src/appsec/index.spec.ts create mode 100644 src/appsec/index.ts diff --git a/src/appsec/event-data-extractor.spec.ts b/src/appsec/event-data-extractor.spec.ts new file mode 100644 index 000000000..1458f7aa8 --- /dev/null +++ b/src/appsec/event-data-extractor.spec.ts @@ -0,0 +1,240 @@ +import { extractHTTPDataFromEvent } from "./event-data-extractor"; + +describe("extractHTTPDataFromEvent", () => { + describe("non-HTTP events", () => { + it("should return undefined for SQS events", () => { + const event = { Records: [{ eventSource: "aws:sqs", body: "test" }] }; + expect(extractHTTPDataFromEvent(event)).toBeUndefined(); + }); + + it("should return undefined for S3 events", () => { + const event = { Records: [{ s3: { bucket: { name: "test" } } }] }; + expect(extractHTTPDataFromEvent(event)).toBeUndefined(); + }); + + it("should return undefined for empty events", () => { + expect(extractHTTPDataFromEvent({})).toBeUndefined(); + }); + }); + + describe("API Gateway v1", () => { + const baseEvent = { + httpMethod: "GET", + path: "/my/path", + resource: "/my/{param}", + headers: { Host: "example.com", Cookie: "session=abc; lang=en" }, + multiValueHeaders: null, + queryStringParameters: { foo: "bar" }, + multiValueQueryStringParameters: null, + pathParameters: { param: "123" }, + body: null, + isBase64Encoded: false, + requestContext: { + stage: "prod", + path: "/prod/my/path", + identity: { sourceIp: "1.2.3.4" }, + }, + }; + + it("should extract HTTP data correctly", () => { + const result = extractHTTPDataFromEvent(baseEvent); + + expect(result).toBeDefined(); + expect(result!.method).toBe("GET"); + expect(result!.path).toBe("/prod/my/path"); + expect(result!.clientIp).toBe("1.2.3.4"); + expect(result!.route).toBe("/my/{param}"); + expect(result!.pathParams).toEqual({ param: "123" }); + expect(result!.query).toEqual({ foo: "bar" }); + expect(result!.isBase64Encoded).toBe(false); + }); + + it("should separate cookies from headers", () => { + const result = extractHTTPDataFromEvent(baseEvent); + + expect(result!.headers.cookie).toBeUndefined(); + expect(result!.cookies).toEqual({ session: "abc", lang: "en" }); + }); + + it("should normalize header names to lowercase", () => { + const result = extractHTTPDataFromEvent(baseEvent); + + expect(result!.headers.host).toBe("example.com"); + }); + + it("should decode base64 body", () => { + const event = { + ...baseEvent, + body: Buffer.from('{"key":"value"}').toString("base64"), + isBase64Encoded: true, + }; + + const result = extractHTTPDataFromEvent(event); + expect(result!.body).toEqual({ key: "value" }); + expect(result!.isBase64Encoded).toBe(true); + }); + + it("should parse JSON body when not base64 encoded", () => { + const event = { + ...baseEvent, + body: '{"key":"value"}', + }; + + const result = extractHTTPDataFromEvent(event); + expect(result!.body).toEqual({ key: "value" }); + }); + + it("should return raw string body when not JSON", () => { + const event = { + ...baseEvent, + body: "plain text body", + }; + + const result = extractHTTPDataFromEvent(event); + expect(result!.body).toBe("plain text body"); + }); + + it("should merge multi-value query params", () => { + const event = { + ...baseEvent, + queryStringParameters: { foo: "bar" }, + multiValueQueryStringParameters: { foo: ["bar", "baz"], single: ["one"] }, + }; + + const result = extractHTTPDataFromEvent(event); + expect(result!.query).toEqual({ foo: ["bar", "baz"], single: "one" }); + }); + }); + + describe("API Gateway v2", () => { + const baseEvent = { + version: "2.0", + rawPath: "/my/path", + rawQueryString: "foo=bar", + headers: { host: "example.com" }, + queryStringParameters: { foo: "bar" }, + pathParameters: { id: "456" }, + body: null, + isBase64Encoded: false, + cookies: ["session=abc", "lang=en"], + routeKey: "GET /my/{id}", + requestContext: { + http: { + method: "POST", + path: "/my/path", + sourceIp: "5.6.7.8", + }, + domainName: "api.example.com", + apiId: "abc123", + stage: "$default", + }, + }; + + it("should extract HTTP data correctly", () => { + const result = extractHTTPDataFromEvent(baseEvent); + + expect(result).toBeDefined(); + expect(result!.method).toBe("POST"); + expect(result!.path).toBe("/my/path"); + expect(result!.clientIp).toBe("5.6.7.8"); + expect(result!.route).toBe("/my/{id}"); + expect(result!.pathParams).toEqual({ id: "456" }); + }); + + it("should parse cookies from the cookies array", () => { + const result = extractHTTPDataFromEvent(baseEvent); + + expect(result!.cookies).toEqual({ session: "abc", lang: "en" }); + }); + + it("should extract route from routeKey", () => { + const result = extractHTTPDataFromEvent(baseEvent); + expect(result!.route).toBe("/my/{id}"); + }); + }); + + describe("ALB", () => { + const baseEvent = { + httpMethod: "GET", + path: "/alb/path", + headers: { + host: "example.com", + "x-forwarded-for": "9.8.7.6, 10.0.0.1", + cookie: "token=xyz", + }, + queryStringParameters: { key: "val" }, + body: null, + isBase64Encoded: false, + requestContext: { + elb: { + targetGroupArn: "arn:aws:elasticloadbalancing:us-east-1:123456789:targetgroup/my-tg/abc", + }, + }, + }; + + it("should extract HTTP data correctly", () => { + const result = extractHTTPDataFromEvent(baseEvent); + + expect(result).toBeDefined(); + expect(result!.method).toBe("GET"); + expect(result!.path).toBe("/alb/path"); + }); + + it("should extract client IP from x-forwarded-for", () => { + const result = extractHTTPDataFromEvent(baseEvent); + expect(result!.clientIp).toBe("9.8.7.6"); + }); + + it("should parse cookies from the cookie header", () => { + const result = extractHTTPDataFromEvent(baseEvent); + expect(result!.cookies).toEqual({ token: "xyz" }); + expect(result!.headers.cookie).toBeUndefined(); + }); + + it("should not have route or pathParams", () => { + const result = extractHTTPDataFromEvent(baseEvent); + expect(result!.route).toBeUndefined(); + expect(result!.pathParams).toBeUndefined(); + }); + }); + + describe("Lambda Function URL", () => { + const baseEvent = { + version: "2.0", + rawPath: "/url/path", + rawQueryString: "", + headers: { host: "abc123.lambda-url.us-east-1.on.aws" }, + queryStringParameters: null, + body: null, + isBase64Encoded: false, + cookies: ["token=xyz"], + requestContext: { + domainName: "abc123.lambda-url.us-east-1.on.aws", + http: { + method: "GET", + path: "/url/path", + sourceIp: "11.12.13.14", + }, + }, + }; + + it("should extract HTTP data correctly", () => { + const result = extractHTTPDataFromEvent(baseEvent); + + expect(result).toBeDefined(); + expect(result!.method).toBe("GET"); + expect(result!.path).toBe("/url/path"); + expect(result!.clientIp).toBe("11.12.13.14"); + }); + + it("should parse cookies from the cookies array", () => { + const result = extractHTTPDataFromEvent(baseEvent); + expect(result!.cookies).toEqual({ token: "xyz" }); + }); + + it("should not have route", () => { + const result = extractHTTPDataFromEvent(baseEvent); + expect(result!.route).toBeUndefined(); + }); + }); +}); diff --git a/src/appsec/event-data-extractor.ts b/src/appsec/event-data-extractor.ts new file mode 100644 index 000000000..d4b7567d8 --- /dev/null +++ b/src/appsec/event-data-extractor.ts @@ -0,0 +1,229 @@ +import * as eventType from "../utils/event-type-guards"; + +export interface ExtractedHTTPData { + headers: Record; + method: string; + path: string; + query?: Record; + body?: string | object; + isBase64Encoded: boolean; + clientIp?: string; + pathParams?: Record; + cookies?: Record; + route?: string; +} + +export function extractHTTPDataFromEvent(event: any): ExtractedHTTPData | undefined { + if (eventType.isLambdaUrlEvent(event)) { + return extractFromLambdaUrl(event); + } + + if (eventType.isAPIGatewayEvent(event)) { + return extractFromApiGatewayV1(event); + } + + if (eventType.isAPIGatewayEventV2(event)) { + return extractFromApiGatewayV2(event); + } + + if (eventType.isALBEvent(event)) { + return extractFromALB(event); + } + + return undefined; +} + +function extractFromApiGatewayV1(event: any): ExtractedHTTPData { + const headers = normalizeHeaders(event.headers, event.multiValueHeaders); + const { cookies, headersNoCookies } = separateCookies(headers); + + return { + headers: headersNoCookies, + method: event.httpMethod || "", + path: event.requestContext?.path || event.path || "/", + query: mergeQueryParams(event.queryStringParameters, event.multiValueQueryStringParameters), + body: decodeBody(event.body, event.isBase64Encoded), + isBase64Encoded: !!event.isBase64Encoded, + clientIp: event.requestContext?.identity?.sourceIp, + pathParams: event.pathParameters || undefined, + cookies, + route: event.resource, + }; +} + +function extractFromApiGatewayV2(event: any): ExtractedHTTPData { + const headers = normalizeHeaders(event.headers); + const { headersNoCookies } = separateCookies(headers); + + const cookies = parseCookieArray(event.cookies) || parseCookieHeader(headers.cookie); + + let route: string | undefined; + if (event.routeKey) { + const parts = event.routeKey.split(" "); + route = parts.length > 1 ? parts[parts.length - 1] : parts[0]; + } + + return { + headers: headersNoCookies, + method: event.requestContext?.http?.method || "", + path: event.rawPath || event.requestContext?.http?.path || "/", + query: event.queryStringParameters || undefined, + body: decodeBody(event.body, event.isBase64Encoded), + isBase64Encoded: !!event.isBase64Encoded, + clientIp: event.requestContext?.http?.sourceIp, + pathParams: event.pathParameters || undefined, + cookies, + route, + }; +} + +function extractFromALB(event: any): ExtractedHTTPData { + const headers = normalizeHeaders(event.headers, event.multiValueHeaders); + const { cookies, headersNoCookies } = separateCookies(headers); + + const forwardedFor = headers["x-forwarded-for"]; + const clientIp = forwardedFor ? forwardedFor.split(",")[0].trim() : undefined; + + return { + headers: headersNoCookies, + method: event.httpMethod || "", + path: event.path || "/", + query: mergeQueryParams(event.queryStringParameters, event.multiValueQueryStringParameters), + body: decodeBody(event.body, event.isBase64Encoded), + isBase64Encoded: !!event.isBase64Encoded, + clientIp, + cookies, + }; +} + +function extractFromLambdaUrl(event: any): ExtractedHTTPData { + const headers = normalizeHeaders(event.headers); + const { headersNoCookies } = separateCookies(headers); + + const cookies = parseCookieArray(event.cookies) || parseCookieHeader(headers.cookie); + + return { + headers: headersNoCookies, + method: event.requestContext?.http?.method || "", + path: event.rawPath || event.requestContext?.http?.path || "/", + query: event.queryStringParameters || undefined, + body: decodeBody(event.body, event.isBase64Encoded), + isBase64Encoded: !!event.isBase64Encoded, + clientIp: event.requestContext?.http?.sourceIp, + cookies, + }; +} + +function normalizeHeaders( + headers?: Record, + multiValueHeaders?: Record, +): Record { + if (!headers && !multiValueHeaders) return {}; + + const result: Record = {}; + + if (multiValueHeaders) { + for (const [key, values] of Object.entries(multiValueHeaders)) { + if (values && values.length > 0) { + result[key.toLowerCase()] = values.join(", "); + } + } + } + + if (headers) { + for (const [key, value] of Object.entries(headers)) { + const lowerKey = key.toLowerCase(); + if (!(lowerKey in result) && value !== undefined) { + result[lowerKey] = value; + } + } + } + + return result; +} + +function separateCookies(headers: Record): { + cookies: Record | undefined; + headersNoCookies: Record; +} { + const cookies = parseCookieHeader(headers.cookie); + const headersNoCookies = { ...headers }; + delete headersNoCookies.cookie; + return { cookies, headersNoCookies }; +} + +function parseCookieHeader(cookieHeader: string | undefined): Record | undefined { + if (!cookieHeader) return undefined; + + const result: Record = {}; + for (const pair of cookieHeader.split(";")) { + const eqIdx = pair.indexOf("="); + if (eqIdx === -1) continue; + const name = pair.substring(0, eqIdx).trim(); + const value = pair.substring(eqIdx + 1).trim(); + if (name) { + result[name] = value; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + +function parseCookieArray(cookies: string[] | undefined): Record | undefined { + if (!cookies || !Array.isArray(cookies) || cookies.length === 0) return undefined; + + const result: Record = {}; + for (const cookie of cookies) { + const eqIdx = cookie.indexOf("="); + if (eqIdx === -1) continue; + const name = cookie.substring(0, eqIdx).trim(); + const value = cookie.substring(eqIdx + 1).trim(); + if (name) { + result[name] = value; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + +function mergeQueryParams( + single?: Record | null, + multi?: Record | null, +): Record | undefined { + if (!single && !multi) return undefined; + + const result: Record = {}; + + if (multi) { + for (const [key, values] of Object.entries(multi)) { + if (values && values.length > 0) { + result[key] = values.length === 1 ? values[0] : values; + } + } + } else if (single) { + for (const [key, value] of Object.entries(single)) { + if (value !== undefined && value !== null) { + result[key] = value; + } + } + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +function decodeBody(body: string | undefined | null, isBase64Encoded: boolean): string | object | undefined { + if (body === undefined || body === null) return undefined; + + let decoded = body; + if (isBase64Encoded) { + try { + decoded = Buffer.from(body, "base64").toString("utf-8"); + } catch { + return body; + } + } + + try { + return JSON.parse(decoded); + } catch { + return decoded; + } +} diff --git a/src/appsec/index.spec.ts b/src/appsec/index.spec.ts new file mode 100644 index 000000000..f1d609d44 --- /dev/null +++ b/src/appsec/index.spec.ts @@ -0,0 +1,168 @@ +const mockPublish = jest.fn(); + +jest.mock("dc-polyfill", () => ({ + channel: jest.fn(() => ({ + publish: mockPublish, + })), +})); + +import { initAppsec, processAppsecRequest, processAppsecResponse } from "./index"; + +jest.mock("./event-data-extractor", () => ({ + extractHTTPDataFromEvent: jest.fn(), +})); + +import { extractHTTPDataFromEvent } from "./event-data-extractor"; + +const mockExtract = extractHTTPDataFromEvent as jest.MockedFunction; + +describe("AppSec orchestrator", () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe("initAppsec", () => { + it("should enable when DD_APPSEC_ENABLED is true", () => { + process.env.DD_APPSEC_ENABLED = "true"; + initAppsec(); + + const span = { setTag: jest.fn() }; + mockExtract.mockReturnValue({ + headers: { host: "example.com" }, + method: "GET", + path: "/", + isBase64Encoded: false, + }); + + processAppsecRequest({}, span); + expect(mockPublish).toHaveBeenCalled(); + }); + + it("should enable when DD_APPSEC_ENABLED is 1", () => { + process.env.DD_APPSEC_ENABLED = "1"; + initAppsec(); + + const span = { setTag: jest.fn() }; + mockExtract.mockReturnValue({ + headers: {}, + method: "GET", + path: "/", + isBase64Encoded: false, + }); + + processAppsecRequest({}, span); + expect(mockPublish).toHaveBeenCalled(); + }); + + it("should not enable when DD_APPSEC_ENABLED is not set", () => { + delete process.env.DD_APPSEC_ENABLED; + initAppsec(); + + processAppsecRequest({}, {}); + expect(mockPublish).not.toHaveBeenCalled(); + }); + + it("should not enable when DD_APPSEC_ENABLED is false", () => { + process.env.DD_APPSEC_ENABLED = "false"; + initAppsec(); + + processAppsecRequest({}, {}); + expect(mockPublish).not.toHaveBeenCalled(); + }); + }); + + describe("processAppSecRequest", () => { + beforeEach(() => { + process.env.DD_APPSEC_ENABLED = "true"; + initAppsec(); + }); + + it("should not publish when span is falsy", () => { + processAppsecRequest({}, null); + expect(mockPublish).not.toHaveBeenCalled(); + }); + + it("should not publish when event is not an HTTP trigger", () => { + mockExtract.mockReturnValue(undefined as any); + + processAppsecRequest({}, {}); + expect(mockPublish).not.toHaveBeenCalled(); + }); + + it("should publish extracted HTTP data to the start-invocation channel", () => { + const span = { setTag: jest.fn() }; + const httpData = { + headers: { host: "example.com" }, + method: "POST", + path: "/api/test", + query: { foo: "bar" }, + body: { key: "value" }, + isBase64Encoded: false, + clientIp: "1.2.3.4", + pathParams: { id: "123" }, + cookies: { session: "abc" }, + route: "/api/{id}", + }; + mockExtract.mockReturnValue(httpData); + + processAppsecRequest({}, span); + + expect(mockPublish).toHaveBeenCalledWith({ + span, + headers: httpData.headers, + method: httpData.method, + path: httpData.path, + query: httpData.query, + body: httpData.body, + isBase64Encoded: httpData.isBase64Encoded, + clientIp: httpData.clientIp, + pathParams: httpData.pathParams, + cookies: httpData.cookies, + route: httpData.route, + }); + }); + }); + + describe("processAppSecResponse", () => { + beforeEach(() => { + process.env.DD_APPSEC_ENABLED = "true"; + initAppsec(); + }); + + it("should not publish when span is falsy", () => { + processAppsecResponse(null, "200"); + expect(mockPublish).not.toHaveBeenCalled(); + }); + + it("should publish response data to the end-invocation channel", () => { + const span = { setTag: jest.fn() }; + + processAppsecResponse(span, "200", { "content-type": "application/json" }); + + expect(mockPublish).toHaveBeenCalledWith({ + span, + statusCode: "200", + responseHeaders: { "content-type": "application/json" }, + }); + }); + + it("should publish with undefined statusCode and headers", () => { + const span = { setTag: jest.fn() }; + + processAppsecResponse(span); + + expect(mockPublish).toHaveBeenCalledWith({ + span, + statusCode: undefined, + responseHeaders: undefined, + }); + }); + }); +}); diff --git a/src/appsec/index.ts b/src/appsec/index.ts new file mode 100644 index 000000000..83019b5d2 --- /dev/null +++ b/src/appsec/index.ts @@ -0,0 +1,47 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const dc = require("dc-polyfill"); + +import { extractHTTPDataFromEvent } from "./event-data-extractor"; + +const startInvocationChannel = dc.channel("datadog:lambda:start-invocation"); +const endInvocationChannel = dc.channel("datadog:lambda:end-invocation"); + +let enabled = false; + +export function initAppsec(): void { + const envValue = process.env.DD_APPSEC_ENABLED; + enabled = envValue === "true" || envValue === "1"; +} + +export function processAppsecRequest(event: any, span: any): void { + if (!enabled || !span || !startInvocationChannel.hasSubscribers) return; + + const httpData = extractHTTPDataFromEvent(event); + if (!httpData ) { + return; + } + + startInvocationChannel.publish({ + span, + headers: httpData.headers, + method: httpData.method, + path: httpData.path, + query: httpData.query, + body: httpData.body, + isBase64Encoded: httpData.isBase64Encoded, + clientIp: httpData.clientIp, + pathParams: httpData.pathParams, + cookies: httpData.cookies, + route: httpData.route, + }); +} + +export function processAppsecResponse(span: any, statusCode?: string, responseHeaders?: Record): void { + if (!enabled || !span || !endInvocationChannel.hasSubscribers) return; + + endInvocationChannel.publish({ + span, + statusCode, + responseHeaders, + }); +} diff --git a/src/trace/listener.ts b/src/trace/listener.ts index d846d7d49..445380c05 100644 --- a/src/trace/listener.ts +++ b/src/trace/listener.ts @@ -24,6 +24,8 @@ import { DurableFunctionContext, extractDurableFunctionContext } from "./durable import { XrayService } from "./xray-service"; import { AUTHORIZING_REQUEST_ID_HEADER } from "./context/extractors/http"; import { getSpanPointerAttributes, SpanPointerAttributes } from "../utils/span-pointers"; +import { initAppsec, processAppsecRequest, processAppsecResponse } from "../appsec"; + export type TraceExtractor = (event: any, context: Context) => Promise | TraceContext; export interface TraceConfig { @@ -111,6 +113,8 @@ export class TraceListener { } public async onStartInvocation(event: any, context: Context) { + initAppsec(); + const tracerInitialized = this.tracerWrapper.isTracerAvailable; if (this.config.injectLogContext) { patchConsole(console, this.contextService); @@ -175,6 +179,9 @@ export class TraceListener { // so we won't crash user code. if (!this.tracerWrapper.currentSpan) return false; this.wrappedCurrentSpan = new SpanWrapper(this.tracerWrapper.currentSpan, {}); + + processAppsecResponse(event, this.tracerWrapper.currentSpan); + if (this.config.captureLambdaPayload) { tagObject(this.tracerWrapper.currentSpan, "function.request", event, 0, this.config.captureLambdaPayloadMaxDepth); tagObject( @@ -209,6 +216,10 @@ export class TraceListener { // Always clear the tree to prevent memory leaks, even if we skip span creation clearTraceTree(); } + const responseStatusCode = result?.statusCode?.toString(); + const responseHeaders = result?.headers as Record | undefined; + processAppsecResponse(this.tracerWrapper.currentSpan, responseStatusCode, responseHeaders); + if (this.triggerTags) { const statusCode = extractHTTPStatusCodeTag(this.triggerTags, result, isResponseStreamFunction); From 976a5e0d9045546fa687adda6e31079c68ca62b8 Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 16 Mar 2026 08:03:47 +0100 Subject: [PATCH 3/5] Fix linting issues --- src/appsec/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/appsec/index.ts b/src/appsec/index.ts index 83019b5d2..828cc0319 100644 --- a/src/appsec/index.ts +++ b/src/appsec/index.ts @@ -1,4 +1,4 @@ -// eslint-disable-next-line @typescript-eslint/no-var-requires +// tslint:disable-next-line:no-var-requires const dc = require("dc-polyfill"); import { extractHTTPDataFromEvent } from "./event-data-extractor"; From 5832672466ddfceeafd308020083f3558f367e7f Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 16 Mar 2026 09:10:59 +0100 Subject: [PATCH 4/5] Fix formatting issue --- src/appsec/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/appsec/index.ts b/src/appsec/index.ts index 828cc0319..c6803f504 100644 --- a/src/appsec/index.ts +++ b/src/appsec/index.ts @@ -17,7 +17,7 @@ export function processAppsecRequest(event: any, span: any): void { if (!enabled || !span || !startInvocationChannel.hasSubscribers) return; const httpData = extractHTTPDataFromEvent(event); - if (!httpData ) { + if (!httpData) { return; } From a9a47e9e2da26dad6dec6c87a3c06dd9b8b8ae4d Mon Sep 17 00:00:00 2001 From: Carles Capell Date: Mon, 16 Mar 2026 09:26:27 +0100 Subject: [PATCH 5/5] Fix test mock --- src/appsec/index.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/appsec/index.spec.ts b/src/appsec/index.spec.ts index f1d609d44..12481dda6 100644 --- a/src/appsec/index.spec.ts +++ b/src/appsec/index.spec.ts @@ -3,6 +3,7 @@ const mockPublish = jest.fn(); jest.mock("dc-polyfill", () => ({ channel: jest.fn(() => ({ publish: mockPublish, + hasSubscribers: true, })), }));