From 6287c24fa2e9d3827efe45b049c95b762c294edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Thu, 15 Jan 2026 11:59:04 +0100 Subject: [PATCH] Lamda: Attack wave detection & proxy event v2 --- library/sources/FunctionsFramework.ts | 11 + .../sources/Lambda.shouldBlockRequest.test.ts | 6 +- library/sources/Lambda.test.ts | 259 +++++++++++++++++- library/sources/Lambda.ts | 98 ++----- library/sources/lambda/gateway.ts | 157 +++++++++++ 5 files changed, 453 insertions(+), 78 deletions(-) create mode 100644 library/sources/lambda/gateway.ts diff --git a/library/sources/FunctionsFramework.ts b/library/sources/FunctionsFramework.ts index 711cde2cd..f0fd837db 100644 --- a/library/sources/FunctionsFramework.ts +++ b/library/sources/FunctionsFramework.ts @@ -118,6 +118,17 @@ function incrementStatsAndDiscoverAPISpec( if (shouldDiscover) { agent.onRouteExecute(context); } + + if ( + context.remoteAddress && + !context.blockedDueToIPOrBot && + agent.getAttackWaveDetector().check(context) + ) { + agent.onDetectedAttackWave({ + request: context, + }); + agent.getInspectionStatistics().onAttackWaveDetected(); + } } const stats = agent.getInspectionStatistics(); diff --git a/library/sources/Lambda.shouldBlockRequest.test.ts b/library/sources/Lambda.shouldBlockRequest.test.ts index 73a606985..d32024ccb 100644 --- a/library/sources/Lambda.shouldBlockRequest.test.ts +++ b/library/sources/Lambda.shouldBlockRequest.test.ts @@ -4,11 +4,13 @@ import * as t from "tap"; import { Token } from "../agent/api/Token"; import { createTestAgent } from "../helpers/createTestAgent"; import { shouldBlockRequest } from "../middleware/shouldBlockRequest"; -import { APIGatewayProxyEvent, createLambdaWrapper } from "./Lambda"; +import { createLambdaWrapper } from "./Lambda"; import { wrap } from "../helpers/wrap"; +import type { APIGatewayProxyEventV1 } from "./lambda/gateway"; -const gatewayEvent: APIGatewayProxyEvent = { +const gatewayEvent: APIGatewayProxyEventV1 = { resource: "/dev/{proxy+}", + path: "/dev/some/path", body: "body", httpMethod: "GET", queryStringParameters: { diff --git a/library/sources/Lambda.test.ts b/library/sources/Lambda.test.ts index c182e7207..31c2f3295 100644 --- a/library/sources/Lambda.test.ts +++ b/library/sources/Lambda.test.ts @@ -9,20 +9,24 @@ import { getContext } from "../agent/Context"; import { createTestAgent } from "../helpers/createTestAgent"; import { wrap } from "../helpers/wrap"; import { - APIGatewayProxyEvent, createLambdaWrapper, getFlushEveryMS, getTimeoutInMS, SQSEvent, } from "./Lambda"; +import type { + APIGatewayProxyEventV1, + APIGatewayProxyEventV2, +} from "./lambda/gateway"; t.beforeEach(async () => { delete process.env.AIKIDO_LAMBDA_FLUSH_EVERY_MS; delete process.env.AIKIDO_LAMBDA_TIMEOUT_MS; }); -const gatewayEvent: APIGatewayProxyEvent = { +const gatewayEvent: APIGatewayProxyEventV1 = { resource: "/dev/{proxy+}", + path: "/dev/some/path", body: "body", httpMethod: "GET", queryStringParameters: { @@ -42,6 +46,31 @@ const gatewayEvent: APIGatewayProxyEvent = { }, }; +const gatewayEventV2: APIGatewayProxyEventV2 = { + rawPath: "/dev/some/path", + body: "body", + rawQueryString: "query=value", + queryStringParameters: { + query: "value", + }, + pathParameters: { + parameter: "value", + }, + headers: { + "content-type": "application/json", + cookie: "cookie=value", + }, + requestContext: { + http: { + path: "/dev/some/path", + protocol: "HTTP/1.1", + userAgent: "agent", + sourceIp: "1.2.3.4", + method: "GET", + }, + }, +}; + const lambdaContext: Context = { awsRequestId: "", callbackWaitsForEmptyEventLoop: false, @@ -73,6 +102,7 @@ t.test("it transforms callback handler to async handler", async (t) => { t.same(JSON.parse(result.body), { method: "GET", + url: "/dev/some/path?query=value", remoteAddress: "1.2.3.4", headers: { "content-type": "application/json", @@ -92,6 +122,42 @@ t.test("it transforms callback handler to async handler", async (t) => { }); }); +t.test("it also works with event v2", async (t) => { + const handler = createLambdaWrapper((event, context, callback) => { + callback(null, { + body: JSON.stringify(getContext()), + statusCode: 200, + }); + }); + + const result = (await handler( + gatewayEventV2, + lambdaContext, + () => {} + )) as unknown as { body: string }; + + t.same(JSON.parse(result.body), { + method: "GET", + url: "/dev/some/path?query=value", + remoteAddress: "1.2.3.4", + headers: { + "content-type": "application/json", + cookie: "cookie=value", + }, + query: { + query: "value", + }, + cookies: { + cookie: "value", + }, + routeParams: { + parameter: "value", + }, + source: "lambda/gateway", + route: "/dev/some/path", + }); +}); + t.test("callback handler throws error", async () => { const handler = createLambdaWrapper((event, context, callback) => { callback(new Error("error")); @@ -141,6 +207,7 @@ t.test("json header is missing for gateway event", async (t) => { t.same(JSON.parse(result.body), { method: "GET", + url: "/dev/some/path?query=value", remoteAddress: "1.2.3.4", headers: {}, query: { query: "value" }, @@ -435,7 +502,7 @@ t.test("undefined values", async () => { ); t.same(result, { - url: undefined, + url: "/dev/some/path", route: undefined, method: "GET", remoteAddress: undefined, @@ -632,3 +699,189 @@ t.test("getTimeoutInMS", async (t) => { "should return 1 second at minimum threshold" ); }); + +t.test("it detects attack waves", async (t) => { + const api = new ReportingAPIForTesting(); + const agent = createTestAgent({ + block: false, + token: new Token("token"), + serverless: "lambda", + api, + }); + agent.start([]); + + const handler = createLambdaWrapper(async (event, context) => { + return getContext(); + }); + + const paths = [ + "/.env", + "/wp-config.php", + "/.git/config", + "/.htaccess", + "/.aws/credentials", + "/docker-compose.yml", + "/../etc/passwd", + "/.bash_history", + "/config/.env", + "/app/docker-compose.yml", + "/.gitignore", + "/.ssh/id_rsa", + "/../.env", + "/.htpasswd", + "/.vscode/settings.json", + "/config.php", + "/.idea/workspace.xml", + "/.DS_Store", + "/.env.local", + "/secrets/.env", + ]; + + for (const path of paths) { + const attackWaveEvent: APIGatewayProxyEventV1 = { + resource: path, + body: "", + path: path, + httpMethod: "GET", + queryStringParameters: {}, + pathParameters: {}, + headers: {}, + requestContext: { + identity: { + sourceIp: "4.3.2.1", + }, + }, + }; + + const result = await handler(attackWaveEvent, lambdaContext, () => {}); + + t.same(result, { + url: path, + method: "GET", + remoteAddress: "4.3.2.1", + body: undefined, + headers: {}, + query: {}, + cookies: {}, + routeParams: {}, + source: "lambda/gateway", + route: path, + }); + } + + const event = api + .getEvents() + .filter((e: Event) => e.type === "detected_attack_wave")[0]; + + t.match( + event, + + { + type: "detected_attack_wave", + request: { + ipAddress: "4.3.2.1", + userAgent: undefined, + source: "lambda/gateway", + }, + } + ); + + t.ok( + event.attack.metadata.samples.includes("/.git/config"), + "should include one of the attack paths" + ); +}); + +t.test("it detects attack waves using Gateway Event v2", async (t) => { + const api = new ReportingAPIForTesting(); + const agent = createTestAgent({ + block: false, + token: new Token("token"), + serverless: "lambda", + api, + }); + agent.start([]); + + const handler = createLambdaWrapper(async (event, context) => { + return getContext(); + }); + + const paths = [ + "/.env", + "/wp-config.php", + "/.git/config", + "/.htaccess", + "/.aws/credentials", + "/docker-compose.yml", + "/../etc/passwd", + "/.bash_history", + "/config/.env", + "/app/docker-compose.yml", + "/.gitignore", + "/.ssh/id_rsa", + "/../.env", + "/.htpasswd", + "/.vscode/settings.json", + "/config.php", + "/.idea/workspace.xml", + "/.DS_Store", + "/.env.local", + "/secrets/.env", + ]; + + for (const path of paths) { + const attackWaveEvent: APIGatewayProxyEventV2 = { + body: "", + rawPath: path, + rawQueryString: "", + queryStringParameters: {}, + pathParameters: {}, + headers: {}, + requestContext: { + http: { + path: path, + protocol: "HTTP/1.1", + userAgent: "agent", + method: "GET", + sourceIp: "4.3.2.2", + }, + }, + }; + + const result = await handler(attackWaveEvent, lambdaContext, () => {}); + + t.match(result, { + url: path, + method: "GET", + remoteAddress: "4.3.2.2", + body: undefined, + headers: {}, + query: {}, + cookies: {}, + routeParams: {}, + source: "lambda/gateway", + }); + } + + const event = api + .getEvents() + .filter((e: Event) => e.type === "detected_attack_wave")[0]; + + t.match( + event, + + { + type: "detected_attack_wave", + request: { + ipAddress: "4.3.2.2", + userAgent: undefined, + source: "lambda/gateway", + }, + } + ); + + t.ok( + event.attack.metadata.samples.includes("/.git/config"), + "should include one of the attack paths" + ); +}); diff --git a/library/sources/Lambda.ts b/library/sources/Lambda.ts index 04c11b7fe..85f2cf324 100644 --- a/library/sources/Lambda.ts +++ b/library/sources/Lambda.ts @@ -3,10 +3,10 @@ import { Agent } from "../agent/Agent"; import { getInstance } from "../agent/AgentSingleton"; import { runWithContext, Context as AgentContext } from "../agent/Context"; import { envToBool } from "../helpers/envToBool"; -import { isJsonContentType } from "../helpers/isJsonContentType"; import { isPlainObject } from "../helpers/isPlainObject"; -import { parse } from "../helpers/parseCookies"; import { shouldDiscoverRoute } from "./http-server/shouldDiscoverRoute"; +import { getContextForGatewayEvent, isGatewayEvent } from "./lambda/gateway"; +import { tryParseJSON } from "../helpers/tryParseJSON"; type CallbackHandler = ( event: TEvent, @@ -54,51 +54,6 @@ function convertToAsyncFunction( }; } -function normalizeHeaders(headers: Record) { - const normalized: Record = {}; - for (const key in headers) { - normalized[key.toLowerCase()] = headers[key]; - } - - return normalized; -} - -function tryParseAsJSON(json: string) { - try { - return JSON.parse(json); - } catch { - return undefined; - } -} - -function parseBody(event: APIGatewayProxyEvent) { - const headers = event.headers ? normalizeHeaders(event.headers) : {}; - - if (!event.body || !isJsonContentType(headers["content-type"] || "")) { - return undefined; - } - - return tryParseAsJSON(event.body); -} - -export type APIGatewayProxyEvent = { - resource: string; - httpMethod: string; - headers: Record; - queryStringParameters?: Record; - pathParameters?: Record; - requestContext?: { - identity?: { - sourceIp?: string; - }; - }; - body?: string; -}; - -function isGatewayEvent(event: unknown): event is APIGatewayProxyEvent { - return isPlainObject(event) && "httpMethod" in event && "headers" in event; -} - type GatewayResponse = { statusCode: number; }; @@ -170,7 +125,7 @@ export function createLambdaWrapper(handler: Handler): Handler { if (isSQSEvent(event)) { const body: unknown[] = event.Records.map((record) => - tryParseAsJSON(record.body) + tryParseJSON(record.body) ).filter((body) => body); agentContext = { @@ -190,18 +145,7 @@ export function createLambdaWrapper(handler: Handler): Handler { route: undefined, }; } else if (isGatewayEvent(event)) { - agentContext = { - url: undefined, - method: event.httpMethod, - remoteAddress: event.requestContext?.identity?.sourceIp, - body: parseBody(event), - headers: event.headers, - routeParams: event.pathParameters ? event.pathParameters : {}, - query: event.queryStringParameters ? event.queryStringParameters : {}, - cookies: event.headers?.cookie ? parse(event.headers.cookie) : {}, - source: "lambda/gateway", - route: event.resource ? event.resource : undefined, - }; + agentContext = getContextForGatewayEvent(event); } if (!agentContext) { @@ -251,20 +195,28 @@ function incrementStatsAndDiscoverAPISpec( return; } - if ( - isGatewayEvent(event) && - isGatewayResponse(result) && - agentContext.route && - agentContext.method - ) { - const shouldDiscover = shouldDiscoverRoute({ - statusCode: result.statusCode, - method: agentContext.method, - route: agentContext.route, - }); + if (isGatewayEvent(event) && agentContext.route && agentContext.method) { + if (isGatewayResponse(result)) { + const shouldDiscover = shouldDiscoverRoute({ + statusCode: result.statusCode, + method: agentContext.method, + route: agentContext.route, + }); - if (shouldDiscover) { - agent.onRouteExecute(agentContext); + if (shouldDiscover) { + agent.onRouteExecute(agentContext); + } + } + + if ( + agentContext.remoteAddress && + !agentContext.blockedDueToIPOrBot && + agent.getAttackWaveDetector().check(agentContext) + ) { + agent.onDetectedAttackWave({ + request: agentContext, + }); + agent.getInspectionStatistics().onAttackWaveDetected(); } } diff --git a/library/sources/lambda/gateway.ts b/library/sources/lambda/gateway.ts new file mode 100644 index 000000000..708dd8b7c --- /dev/null +++ b/library/sources/lambda/gateway.ts @@ -0,0 +1,157 @@ +import { Context } from "../../agent/Context"; +import { buildRouteFromURL } from "../../helpers/buildRouteFromURL"; +import { isJsonContentType } from "../../helpers/isJsonContentType"; +import { isPlainObject } from "../../helpers/isPlainObject"; +import { tryParseJSON } from "../../helpers/tryParseJSON"; +import { parse as parseCookies } from "../../helpers/parseCookies"; + +// Based on https://docs.aws.amazon.com/powertools/typescript/2.30.1/api/variables/_aws-lambda-powertools_parser.schemas.APIGatewayProxyEventSchema.html +export type APIGatewayProxyEventV1 = { + resource: string; + httpMethod: string; + headers: Record; + queryStringParameters?: Record; + pathParameters?: Record; + path: string; + requestContext?: { + identity?: { + sourceIp?: string; + }; + }; + body?: string; +}; + +// Based on https://docs.aws.amazon.com/powertools/typescript/2.30.1/api/variables/_aws-lambda-powertools_parser.schemas.APIGatewayProxyEventV2Schema.html +export type APIGatewayProxyEventV2 = { + headers: Record; + queryStringParameters?: Record; + pathParameters?: Record; + rawPath: string; + rawQueryString: string; + requestContext: { + http?: { + method: string; + path: string; + protocol: string; + sourceIp: string; + userAgent: string; + }; + }; + body?: string; +}; + +export type APIGatewayProxyEvent = + | APIGatewayProxyEventV1 + | APIGatewayProxyEventV2; + +export function isGatewayEvent(event: unknown): event is APIGatewayProxyEvent { + if (!isPlainObject(event)) { + return false; + } + return isGatewayEventV1(event) || isGatewayEventV2(event); +} + +export function isGatewayEventV1( + event: Record +): event is APIGatewayProxyEventV1 { + return "httpMethod" in event && "headers" in event; +} + +export function isGatewayEventV2( + event: Record +): event is APIGatewayProxyEventV2 { + return "requestContext" in event && "headers" in event; +} + +export function getUrlFromGatewayEvent( + event: APIGatewayProxyEvent +): string | undefined { + const queryString = getQueryStringFromGatewayEvent(event); + + const path = "rawPath" in event ? event.rawPath : event.path; + if (path === undefined) { + return undefined; + } + + if (queryString) { + return `${path}?${queryString}`; + } + + return path; +} + +export function getQueryStringFromGatewayEvent( + event: APIGatewayProxyEvent +): string | undefined { + if ("rawQueryString" in event && event.rawQueryString) { + return event.rawQueryString; + } + + const query = event.queryStringParameters || {}; + const queryString = Object.keys(query) + .map( + (key) => + `${encodeURIComponent(key)}=${encodeURIComponent(query[key] || "")}` + ) + .join("&"); + + if (queryString) { + return queryString; + } + + return undefined; +} + +function normalizeHeaders(headers: Record) { + const normalized: Record = {}; + for (const key in headers) { + normalized[key.toLowerCase()] = headers[key]; + } + + return normalized; +} + +function parseBody(event: APIGatewayProxyEvent) { + const headers = event.headers ? normalizeHeaders(event.headers) : {}; + + if (!event.body || !isJsonContentType(headers["content-type"] || "")) { + return undefined; + } + + return tryParseJSON(event.body); +} + +export function getContextForGatewayEvent( + event: APIGatewayProxyEvent +): Context | undefined { + if (isGatewayEventV1(event)) { + return { + url: getUrlFromGatewayEvent(event), + method: event.httpMethod, + remoteAddress: event.requestContext?.identity?.sourceIp, + body: parseBody(event), + headers: event.headers, + routeParams: event.pathParameters ? event.pathParameters : {}, + query: event.queryStringParameters ? event.queryStringParameters : {}, + cookies: event.headers?.cookie ? parseCookies(event.headers.cookie) : {}, + source: "lambda/gateway", + route: event.resource ? event.resource : undefined, + }; + } + + if (isGatewayEventV2(event)) { + const url = getUrlFromGatewayEvent(event); + return { + url: url, + method: event.requestContext?.http?.method, + remoteAddress: event.requestContext?.http?.sourceIp, + body: parseBody(event), + headers: event.headers, + routeParams: event.pathParameters ? event.pathParameters : {}, + query: event.queryStringParameters ? event.queryStringParameters : {}, + cookies: event.headers?.cookie ? parseCookies(event.headers.cookie) : {}, + source: "lambda/gateway", + route: url ? buildRouteFromURL(url) : undefined, + }; + } +}