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/package.json b/package.json index a35656b52..27801eb98 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "devDependencies": { "@aws-sdk/client-kms": "^3.366.0", "@aws-sdk/client-secrets-manager": "^3.721.0", + "@datadog/native-appsec": "*", "@datadog/pprof": "*", "@opentelemetry/api": "*", "@opentelemetry/api-logs": "*", diff --git a/scripts/move_ddtrace_dependency.js b/scripts/move_ddtrace_dependency.js index 1e2f904fc..13d11d90e 100755 --- a/scripts/move_ddtrace_dependency.js +++ b/scripts/move_ddtrace_dependency.js @@ -6,6 +6,7 @@ const file = JSON.parse(process.argv[2]); moveDependency('dd-trace') +moveDependency('@datadog/native-appsec') moveDependency('@datadog/pprof') moveDependency('@opentelemetry/api') moveDependency('@opentelemetry/api-logs') diff --git a/src/appsec/event-data-extractor.spec.ts b/src/appsec/event-data-extractor.spec.ts new file mode 100644 index 000000000..597911425 --- /dev/null +++ b/src/appsec/event-data-extractor.spec.ts @@ -0,0 +1,278 @@ +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 not include cookies when cookie header is absent", () => { + const event = { + ...baseEvent, + headers: { Host: "example.com" }, + }; + const result = extractHTTPDataFromEvent(event); + expect(result!.cookies).toBeUndefined(); + expect("cookies" in result!).toBe(false); + }); + + it("should not include route when resource is empty string", () => { + const event = { ...baseEvent, resource: "" }; + const result = extractHTTPDataFromEvent(event); + expect(result!.route).toBeUndefined(); + expect("route" in result!).toBe(false); + }); + + 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}"); + }); + + it("should not include route when routeKey is absent", () => { + const event = { ...baseEvent, routeKey: undefined }; + const result = extractHTTPDataFromEvent(event); + expect(result!.route).toBeUndefined(); + expect("route" in result!).toBe(false); + }); + + it("should not include route when routeKey produces an empty string", () => { + const event = { ...baseEvent, routeKey: "" }; + const result = extractHTTPDataFromEvent(event); + expect(result!.route).toBeUndefined(); + expect("route" in result!).toBe(false); + }); + + it("should not include cookies when cookies array is absent", () => { + const event = { ...baseEvent, cookies: undefined }; + const result = extractHTTPDataFromEvent(event); + expect(result!.cookies).toBeUndefined(); + expect("cookies" in result!).toBe(false); + }); + }); + + 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..d9b453216 --- /dev/null +++ b/src/appsec/event-data-extractor.ts @@ -0,0 +1,242 @@ +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); + + const result: ExtractedHTTPData = { + 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, + }; + + if (cookies) result.cookies = cookies; + if (event.resource) result.route = event.resource; + + return result; +} + +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(" "); + const candidate = parts.length > 1 ? parts[parts.length - 1] : parts[0]; + if (candidate) route = candidate; + } + + const result: ExtractedHTTPData = { + 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, + }; + + if (cookies) result.cookies = cookies; + if (route) result.route = route; + + return result; +} + +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; + + const result: ExtractedHTTPData = { + headers: headersNoCookies, + method: event.httpMethod || "", + path: event.path || "/", + query: mergeQueryParams(event.queryStringParameters, event.multiValueQueryStringParameters), + body: decodeBody(event.body, event.isBase64Encoded), + isBase64Encoded: !!event.isBase64Encoded, + clientIp, + }; + + if (cookies) result.cookies = cookies; + + return result; +} + +function extractFromLambdaUrl(event: any): ExtractedHTTPData { + const headers = normalizeHeaders(event.headers); + const { headersNoCookies } = separateCookies(headers); + + const cookies = parseCookieArray(event.cookies) || parseCookieHeader(headers.cookie); + + const result: ExtractedHTTPData = { + 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, + }; + + if (cookies) result.cookies = cookies; + + return result; +} + +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..12481dda6 --- /dev/null +++ b/src/appsec/index.spec.ts @@ -0,0 +1,169 @@ +const mockPublish = jest.fn(); + +jest.mock("dc-polyfill", () => ({ + channel: jest.fn(() => ({ + publish: mockPublish, + hasSubscribers: true, + })), +})); + +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..c6803f504 --- /dev/null +++ b/src/appsec/index.ts @@ -0,0 +1,47 @@ +// tslint:disable-next-line: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.spec.ts b/src/trace/listener.spec.ts index 246a907f8..55e33d7c5 100644 --- a/src/trace/listener.spec.ts +++ b/src/trace/listener.spec.ts @@ -12,6 +12,16 @@ import { } from "./context/extractor"; import { TracerWrapper } from "./tracer-wrapper"; +const mockInitAppsec = jest.fn(); +const mockProcessAppsecRequest = jest.fn(); +const mockProcessAppsecResponse = jest.fn(); + +jest.mock("../appsec", () => ({ + initAppsec: (...args: any[]) => mockInitAppsec(...args), + processAppsecRequest: (...args: any[]) => mockProcessAppsecRequest(...args), + processAppsecResponse: (...args: any[]) => mockProcessAppsecResponse(...args), +})); + const mockController: { mockSpanContext?: any; mockSpanContextWrapper?: any; @@ -87,6 +97,9 @@ describe("TraceListener", () => { }; beforeEach(() => { wrapSpy.mockClear(); + mockInitAppsec.mockClear(); + mockProcessAppsecRequest.mockClear(); + mockProcessAppsecResponse.mockClear(); mockController.mockSpanContext = undefined; mockController.mockSpanContextWrapper = undefined; mockController.mockTraceSource = undefined; @@ -608,4 +621,87 @@ describe("TraceListener", () => { currentSpanSpy.mockRestore(); } }); + + describe("AppSec integration", () => { + it("calls initAppsec on start invocation", async () => { + const listener = new TraceListener(defaultConfig); + await listener.onStartInvocation({}, context as any); + + expect(mockInitAppsec).toHaveBeenCalledTimes(1); + }); + + it("calls processAppsecRequest with event and span during onEndingInvocation", async () => { + const mockSetTag = jest.fn(); + const mockSpan = { setTag: mockSetTag }; + const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan); + + try { + const listener = new TraceListener(defaultConfig); + const event = { httpMethod: "GET", path: "/test" }; + await listener.onStartInvocation(event, context as any); + listener.onEndingInvocation(event, {}, false); + + expect(mockProcessAppsecRequest).toHaveBeenCalledTimes(1); + expect(mockProcessAppsecRequest).toHaveBeenCalledWith(event, mockSpan); + } finally { + currentSpanSpy.mockRestore(); + } + }); + + it("calls processAppsecResponse with span, statusCode, and headers during onEndingInvocation", async () => { + const mockSetTag = jest.fn(); + const mockSpan = { setTag: mockSetTag }; + const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan); + + try { + const listener = new TraceListener(defaultConfig); + const event = {}; + const result = { statusCode: 200, headers: { "content-type": "application/json" } }; + await listener.onStartInvocation(event, context as any); + listener.onEndingInvocation(event, result, false); + + expect(mockProcessAppsecResponse).toHaveBeenCalledTimes(1); + expect(mockProcessAppsecResponse).toHaveBeenCalledWith(mockSpan, "200", { "content-type": "application/json" }); + } finally { + currentSpanSpy.mockRestore(); + } + }); + + it("calls processAppsecResponse with undefined statusCode and headers when result has none", async () => { + const mockSetTag = jest.fn(); + const mockSpan = { setTag: mockSetTag }; + const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan); + + try { + const listener = new TraceListener(defaultConfig); + await listener.onStartInvocation({}, context as any); + listener.onEndingInvocation({}, {}, false); + + expect(mockProcessAppsecResponse).toHaveBeenCalledTimes(1); + expect(mockProcessAppsecResponse).toHaveBeenCalledWith(mockSpan, undefined, undefined); + } finally { + currentSpanSpy.mockRestore(); + } + }); + + it("calls processAppsecRequest before processAppsecResponse", async () => { + const callOrder: string[] = []; + mockProcessAppsecRequest.mockImplementation(() => callOrder.push("request")); + mockProcessAppsecResponse.mockImplementation(() => callOrder.push("response")); + + const mockSetTag = jest.fn(); + const mockSpan = { setTag: mockSetTag }; + const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan); + + try { + const listener = new TraceListener(defaultConfig); + await listener.onStartInvocation({}, context as any); + listener.onEndingInvocation({}, {}, false); + + expect(callOrder).toEqual(["request", "response"]); + } finally { + currentSpanSpy.mockRestore(); + } + }); + }); }); diff --git a/src/trace/listener.ts b/src/trace/listener.ts index f068c5352..ba63abba8 100644 --- a/src/trace/listener.ts +++ b/src/trace/listener.ts @@ -28,6 +28,8 @@ import { 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 { @@ -115,6 +117,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); @@ -179,6 +183,9 @@ export class TraceListener { // so we won't crash user code. if (!this.tracerWrapper.currentSpan) return false; this.wrappedCurrentSpan = new SpanWrapper(this.tracerWrapper.currentSpan, {}); + + processAppsecRequest(event, this.tracerWrapper.currentSpan); + if (this.config.captureLambdaPayload) { tagObject(this.tracerWrapper.currentSpan, "function.request", event, 0, this.config.captureLambdaPayloadMaxDepth); tagObject( @@ -213,6 +220,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); diff --git a/yarn.lock b/yarn.lock index 6af142561..29d613532 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1156,7 +1156,7 @@ resolved "https://registry.yarnpkg.com/@datadog/libdatadog/-/libdatadog-0.9.2.tgz#d7a0193ab656bd9cc40649f300ef6c54d9bea52d" integrity sha512-grOerTYuU3wHuFIOBGg3jB144A3KEthEdVEL3meeiXYo7E7fBXXGRgAOwVE42VXFXfl0r8kDKCL7KupBc511tg== -"@datadog/native-appsec@11.0.1": +"@datadog/native-appsec@*", "@datadog/native-appsec@11.0.1": version "11.0.1" resolved "https://registry.yarnpkg.com/@datadog/native-appsec/-/native-appsec-11.0.1.tgz#8b545a9d968131d9cd7b43fd9594228dfcc18f3c" integrity sha512-Y/XfknUmmJcw4hhQVhqzgdQvfjy+EGmXuUBgtVkI1r+/qS00egYu+wD/x7pOvjdbZNqN96znVszAnXvDQAzMDQ==