Skip to content

Commit 2eec92d

Browse files
authored
Merge branch 'main' into joey/l2-coverage-parity
2 parents b2144da + b17f2f3 commit 2eec92d

6 files changed

Lines changed: 272 additions & 46 deletions

File tree

‎src/appsec/event-data-extractor.ts‎

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as eventType from "../utils/event-type-guards";
2+
import { normalizeHeaders } from "./headers";
23

34
export interface ExtractedHTTPData {
45
headers: Record<string, string>;
@@ -127,34 +128,6 @@ function extractFromLambdaUrl(event: any): ExtractedHTTPData {
127128
return result;
128129
}
129130

130-
function normalizeHeaders(
131-
headers?: Record<string, string>,
132-
multiValueHeaders?: Record<string, string[]>,
133-
): Record<string, string> {
134-
if (!headers && !multiValueHeaders) return {};
135-
136-
const result: Record<string, string> = {};
137-
138-
if (multiValueHeaders) {
139-
for (const [key, values] of Object.entries(multiValueHeaders)) {
140-
if (values && values.length > 0) {
141-
result[key.toLowerCase()] = values.join(", ");
142-
}
143-
}
144-
}
145-
146-
if (headers) {
147-
for (const [key, value] of Object.entries(headers)) {
148-
const lowerKey = key.toLowerCase();
149-
if (!(lowerKey in result) && value !== undefined) {
150-
result[lowerKey] = value;
151-
}
152-
}
153-
}
154-
155-
return result;
156-
}
157-
158131
function separateCookies(headers: Record<string, string>): {
159132
cookies: Record<string, string> | undefined;
160133
headersNoCookies: Record<string, string>;

‎src/appsec/headers.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Normalizes header names to lowercase and collapses multi-value headers into a single
3+
* comma-separated value, which is the shape the WAF expects. When a name appears in both maps the
4+
* multi-value one wins.
5+
*
6+
* API Gateway v1 and application load balancer payloads carry `headers` and `multiValueHeaders`,
7+
* both on the request event and on the handler result.
8+
*/
9+
export function normalizeHeaders(
10+
headers?: Record<string, unknown>,
11+
multiValueHeaders?: Record<string, unknown[]>,
12+
): Record<string, string> {
13+
if (!headers && !multiValueHeaders) return {};
14+
15+
const result: Record<string, string> = {};
16+
17+
if (multiValueHeaders) {
18+
for (const [key, values] of Object.entries(multiValueHeaders)) {
19+
if (Array.isArray(values) && values.length > 0) {
20+
result[key.toLowerCase()] = values.join(", ");
21+
}
22+
}
23+
}
24+
25+
if (headers) {
26+
for (const [key, value] of Object.entries(headers)) {
27+
const lowerKey = key.toLowerCase();
28+
if (!(lowerKey in result) && value !== undefined && value !== null) {
29+
result[lowerKey] = String(value);
30+
}
31+
}
32+
}
33+
34+
return result;
35+
}

‎src/appsec/index.spec.ts‎

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,10 @@ describe("AppSec orchestrator", () => {
7878
expect(mockPublish).not.toHaveBeenCalled();
7979
});
8080

81-
it("should extract status code and headers from the result and publish them", () => {
81+
it("should publish the normalized status code and the response headers", () => {
8282
const span = { setTag: jest.fn() };
8383

84-
processAppsecResponse(span, { statusCode: 200, headers: { "content-type": "application/json" } });
84+
processAppsecResponse(span, { statusCode: 200, headers: { "content-type": "application/json" } }, "200");
8585

8686
expect(mockPublish).toHaveBeenCalledWith({
8787
span,
@@ -101,5 +101,109 @@ describe("AppSec orchestrator", () => {
101101
responseHeaders: undefined,
102102
});
103103
});
104+
105+
it("should ignore the status code carried by the result", () => {
106+
const span = { setTag: jest.fn() };
107+
108+
processAppsecResponse(span, { statusCode: 200 }, "502");
109+
110+
expect(mockPublish).toHaveBeenCalledWith({
111+
span,
112+
statusCode: "502",
113+
responseHeaders: undefined,
114+
});
115+
});
116+
117+
it("should publish the normalized status code when the result carries none", () => {
118+
const span = { setTag: jest.fn() };
119+
120+
processAppsecResponse(span, { headers: { "content-type": "application/json" } }, "200");
121+
122+
expect(mockPublish).toHaveBeenCalledWith({
123+
span,
124+
statusCode: "200",
125+
responseHeaders: { "content-type": "application/json" },
126+
});
127+
});
128+
129+
it("should lowercase the response header names", () => {
130+
const span = { setTag: jest.fn() };
131+
132+
processAppsecResponse(span, { statusCode: 200, headers: { "X-Option": "test_value" } }, "200");
133+
134+
expect(mockPublish).toHaveBeenCalledWith({
135+
span,
136+
statusCode: "200",
137+
responseHeaders: { "x-option": "test_value" },
138+
});
139+
});
140+
141+
it("should merge multi value response headers", () => {
142+
const span = { setTag: jest.fn() };
143+
144+
processAppsecResponse(
145+
span,
146+
{
147+
statusCode: 200,
148+
headers: { "Content-Type": "application/json" },
149+
multiValueHeaders: { "X-Option": ["a", "b"] },
150+
},
151+
"200",
152+
);
153+
154+
expect(mockPublish).toHaveBeenCalledWith({
155+
span,
156+
statusCode: "200",
157+
responseHeaders: { "content-type": "application/json", "x-option": "a, b" },
158+
});
159+
});
160+
161+
it("should ignore multi value response headers that are not arrays", () => {
162+
const span = { setTag: jest.fn() };
163+
164+
processAppsecResponse(span, { statusCode: 200, multiValueHeaders: { "Set-Cookie": "a=b" } }, "200");
165+
166+
expect(mockPublish).toHaveBeenCalledWith({
167+
span,
168+
statusCode: "200",
169+
responseHeaders: {},
170+
});
171+
});
172+
173+
it("should stringify non string response header values", () => {
174+
const span = { setTag: jest.fn() };
175+
176+
processAppsecResponse(span, { statusCode: 200, headers: { "Content-Length": 42, "X-Flag": true } }, "200");
177+
178+
expect(mockPublish).toHaveBeenCalledWith({
179+
span,
180+
statusCode: "200",
181+
responseHeaders: { "content-length": "42", "x-flag": "true" },
182+
});
183+
});
184+
185+
it("should skip response headers with a null value", () => {
186+
const span = { setTag: jest.fn() };
187+
188+
processAppsecResponse(span, { statusCode: 200, headers: { "X-Option": null } }, "200");
189+
190+
expect(mockPublish).toHaveBeenCalledWith({
191+
span,
192+
statusCode: "200",
193+
responseHeaders: {},
194+
});
195+
});
196+
197+
it("should publish no status code when none is normalized, even if the result carries one", () => {
198+
const span = { setTag: jest.fn() };
199+
200+
processAppsecResponse(span, { statusCode: 204 }, undefined);
201+
202+
expect(mockPublish).toHaveBeenCalledWith({
203+
span,
204+
statusCode: undefined,
205+
responseHeaders: undefined,
206+
});
207+
});
104208
});
105209
});

‎src/appsec/index.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
const dc = require("dc-polyfill");
33

44
import { extractHTTPDataFromEvent } from "./event-data-extractor";
5+
import { normalizeHeaders } from "./headers";
56

67
const startInvocationChannel = dc.channel("datadog:lambda:start-invocation");
78
const endInvocationChannel = dc.channel("datadog:lambda:end-invocation");
@@ -30,12 +31,29 @@ export function processAppsecRequest(event: any, span: any): void {
3031
});
3132
}
3233

33-
export function processAppsecResponse(span: any, result: any): void {
34+
/**
35+
* @param span
36+
* @param result
37+
* @param statusCode Status code already normalized by the trigger layer.
38+
*/
39+
export function processAppsecResponse(span: any, result: any, statusCode?: string): void {
3440
if (!span || !endInvocationChannel.hasSubscribers) return;
3541

3642
endInvocationChannel.publish({
3743
span,
38-
statusCode: result?.statusCode?.toString(),
39-
responseHeaders: result?.headers as Record<string, string> | undefined,
44+
statusCode,
45+
responseHeaders: normalizeResponseHeaders(result),
4046
});
4147
}
48+
49+
/**
50+
* Response headers reach the tracer in the same shape as the request ones
51+
*/
52+
function normalizeResponseHeaders(result: any): Record<string, string> | undefined {
53+
const headers = result?.headers as Record<string, unknown> | undefined;
54+
const multiValueHeaders = result?.multiValueHeaders as Record<string, unknown[]> | undefined;
55+
56+
if (!headers && !multiValueHeaders) return undefined;
57+
58+
return normalizeHeaders(headers, multiValueHeaders);
59+
}

‎src/trace/listener.spec.ts‎

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { readFileSync } from "fs";
12
import { TraceListener } from "./listener";
23
import { ddtraceVersion, parentSpanFinishTimeHeader } from "./constants";
34
import { datadogLambdaVersion } from "../constants";
@@ -698,7 +699,102 @@ describe("TraceListener", () => {
698699
listener.onEndingInvocation(event, result, false);
699700

700701
expect(mockProcessAppsecResponse).toHaveBeenCalledTimes(1);
701-
expect(mockProcessAppsecResponse).toHaveBeenCalledWith(mockSpan, result);
702+
// Non-HTTP trigger: there is no normalized status code to hand over.
703+
expect(mockProcessAppsecResponse).toHaveBeenCalledWith(mockSpan, result, undefined);
704+
} finally {
705+
currentSpanSpy.mockRestore();
706+
}
707+
});
708+
709+
it("passes the normalized status code instead of the raw one for API Gateway v2", async () => {
710+
const mockSetTag = jest.fn();
711+
const mockSpan = { setTag: mockSetTag };
712+
const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan);
713+
714+
try {
715+
const listener = new TraceListener(defaultConfig);
716+
const event = JSON.parse(readFileSync("./event_samples/api-gateway-v2.json", "utf8"));
717+
// API Gateway v2 defaults to 200 when the handler omits the status code, so the raw
718+
// result value (undefined) is not what AppSec should see.
719+
const result = { body: "ok" };
720+
await listener.onStartInvocation(event, context as any);
721+
listener.onEndingInvocation(event, result, false);
722+
723+
expect(mockProcessAppsecResponse).toHaveBeenCalledWith(mockSpan, result, "200");
724+
} finally {
725+
currentSpanSpy.mockRestore();
726+
}
727+
});
728+
729+
it("passes the normalized 502 when a buffered function returned no result", async () => {
730+
const mockSetTag = jest.fn();
731+
const mockSpan = { setTag: mockSetTag };
732+
const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan);
733+
734+
try {
735+
const listener = new TraceListener(defaultConfig);
736+
const event = JSON.parse(readFileSync("./event_samples/application-load-balancer.json", "utf8"));
737+
await listener.onStartInvocation(event, context as any);
738+
listener.onEndingInvocation(event, undefined, false);
739+
740+
expect(mockProcessAppsecResponse).toHaveBeenCalledWith(mockSpan, undefined, "502");
741+
} finally {
742+
currentSpanSpy.mockRestore();
743+
}
744+
});
745+
746+
it("passes the normalized 200 when a streaming function returned no result", async () => {
747+
const mockSetTag = jest.fn();
748+
const mockSpan = { setTag: mockSetTag };
749+
const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan);
750+
751+
try {
752+
const listener = new TraceListener(defaultConfig);
753+
const event = JSON.parse(readFileSync("./event_samples/api-gateway-v2.json", "utf8"));
754+
await listener.onStartInvocation(event, context as any);
755+
listener.onEndingInvocation(event, undefined, true);
756+
757+
expect(mockProcessAppsecResponse).toHaveBeenCalledWith(mockSpan, undefined, "200");
758+
} finally {
759+
currentSpanSpy.mockRestore();
760+
}
761+
});
762+
763+
it("tags http.status_code on the span before calling processAppsecResponse", async () => {
764+
const callOrder: string[] = [];
765+
mockProcessAppsecResponse.mockImplementation(() => callOrder.push("appsec"));
766+
767+
const mockSetTag = jest.fn((key: string) => {
768+
if (key === "http.status_code") callOrder.push("tag");
769+
});
770+
const mockSpan = { setTag: mockSetTag };
771+
const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan);
772+
773+
try {
774+
const listener = new TraceListener(defaultConfig);
775+
const event = JSON.parse(readFileSync("./event_samples/api-gateway-v2.json", "utf8"));
776+
await listener.onStartInvocation(event, context as any);
777+
listener.onEndingInvocation(event, { statusCode: 201 }, false);
778+
779+
expect(callOrder).toEqual(["tag", "appsec"]);
780+
} finally {
781+
currentSpanSpy.mockRestore();
782+
}
783+
});
784+
785+
it("still calls processAppsecResponse on a 5xx response that short-circuits onEndingInvocation", async () => {
786+
const mockSetTag = jest.fn();
787+
const mockSpan = { setTag: mockSetTag };
788+
const currentSpanSpy = jest.spyOn(TracerWrapper.prototype, "currentSpan", "get").mockReturnValue(mockSpan);
789+
790+
try {
791+
const listener = new TraceListener(defaultConfig);
792+
const event = JSON.parse(readFileSync("./event_samples/api-gateway-v2.json", "utf8"));
793+
await listener.onStartInvocation(event, context as any);
794+
const responseIs5xxError = listener.onEndingInvocation(event, { statusCode: 500 }, false);
795+
796+
expect(responseIs5xxError).toBe(true);
797+
expect(mockProcessAppsecResponse).toHaveBeenCalledWith(mockSpan, { statusCode: 500 }, "500");
702798
} finally {
703799
currentSpanSpy.mockRestore();
704800
}

‎src/trace/listener.ts‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -231,25 +231,25 @@ export class TraceListener {
231231
// Always clear the tree to prevent memory leaks, even if we skip span creation
232232
clearTraceTree();
233233
}
234-
if (this.config.appsecEnabled) {
235-
processAppsecResponse(this.tracerWrapper.currentSpan, result);
236-
}
234+
let statusCode: string | undefined;
237235
if (this.triggerTags) {
238-
const statusCode = extractHTTPStatusCodeTag(this.triggerTags, result, isResponseStreamFunction);
236+
statusCode = extractHTTPStatusCodeTag(this.triggerTags, result, isResponseStreamFunction);
239237

240238
// Store the status tag in the listener to send to Xray on invocation completion
241239
this.triggerTags["http.status_code"] = statusCode!;
242240
if (this.tracerWrapper.currentSpan) {
243241
this.tracerWrapper.currentSpan.setTag("http.status_code", statusCode);
244242
}
245-
if (this.inferredSpan) {
246-
this.inferredSpan.setTag("http.status_code", statusCode);
247-
248-
if (statusCode?.length === 3 && statusCode?.startsWith("5")) {
249-
this.wrappedCurrentSpan.setTag("error", 1);
250-
return true;
251-
}
252-
}
243+
this.inferredSpan?.setTag("http.status_code", statusCode);
244+
}
245+
if (this.config.appsecEnabled) {
246+
processAppsecResponse(this.tracerWrapper.currentSpan, result, statusCode);
247+
}
248+
// Kept behind AppSec so 5xx responses still reach the WAF, and still nested on inferredSpan
249+
// so the early return only happens when there is an inferred span, as before.
250+
if (this.inferredSpan && statusCode?.length === 3 && statusCode?.startsWith("5")) {
251+
this.wrappedCurrentSpan.setTag("error", 1);
252+
return true;
253253
}
254254
if (this.durableFunctionContext) {
255255
logDebug("Applying durable function context to the aws.lambda span");

0 commit comments

Comments
 (0)