From bd1197acdb9718140595c2d61473cff0ad7fdf8e Mon Sep 17 00:00:00 2001 From: Julio Guerra Date: Thu, 12 Nov 2026 12:17:39 +0100 Subject: [PATCH 1/2] prototype: tag AppSec invocation spans with the AWS access key ID Cross-component prototype for JULIO-11 (CADR for AWS Lambda). Do not merge. Behind DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED (default false), when tracer AppSec monitors an HTTP invocation, set aws.lambda.access_key_id on the aws.lambda span. AWS_ACCESS_KEY_ID is read once, at the first monitored invocation, and kept only if it matches ^(ASIA|AKIA)[A-Z0-9]{16}$. AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN are never read. Co-Authored-By: Claude Opus 5.5 Co-authored-by: multica-agent --- datadog_lambda/asm.py | 34 ++++++++++ datadog_lambda/config.py | 3 + tests/test_asm.py | 132 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+) diff --git a/datadog_lambda/asm.py b/datadog_lambda/asm.py index 29648021a..6e5371707 100644 --- a/datadog_lambda/asm.py +++ b/datadog_lambda/asm.py @@ -1,4 +1,6 @@ import logging +import os +import re import urllib.parse from copy import deepcopy from typing import Any, Dict, List, Optional, Union @@ -9,6 +11,7 @@ from ddtrace.internal.utils import http as http_utils from ddtrace.trace import Span +from datadog_lambda.config import config from datadog_lambda.trigger import ( EventSubtypes, EventTypes, @@ -18,6 +21,32 @@ logger = logging.getLogger(__name__) +AWS_ACCESS_KEY_ID_TAG = "aws.lambda.access_key_id" +_AWS_ACCESS_KEY_ID_PATTERN = re.compile(r"(ASIA|AKIA)[A-Z0-9]{16}") +_UNSET = object() +_aws_access_key_id: Any = _UNSET + + +def _get_aws_access_key_id() -> Optional[str]: + """Return the execution environment's AWS access key ID, read once. + + Only AWS_ACCESS_KEY_ID is read: the secret key and the session token are + never touched. The read happens at the first monitored invocation, so on + SnapStart it runs after restore, never before the snapshot. + """ + global _aws_access_key_id + if _aws_access_key_id is _UNSET: + value = os.environ.get("AWS_ACCESS_KEY_ID", "") + if _AWS_ACCESS_KEY_ID_PATTERN.fullmatch(value): + _aws_access_key_id = value + else: + _aws_access_key_id = None + logger.debug( + "AWS_ACCESS_KEY_ID is empty or malformed, %s is not set", + AWS_ACCESS_KEY_ID_TAG, + ) + return _aws_access_key_id + def _to_single_value_headers(headers: Dict[str, List[str]]) -> Dict[str, str]: """ @@ -121,6 +150,11 @@ def asm_start_request( else: return + if config.appsec_aws_access_key_id_enabled: + access_key_id = _get_aws_access_key_id() + if access_key_id: + span.set_tag(AWS_ACCESS_KEY_ID_TAG, access_key_id) + body = event.get("body") is_base64_encoded = event.get("isBase64Encoded", False) diff --git a/datadog_lambda/config.py b/datadog_lambda/config.py index 6487c9e7e..630358b4f 100644 --- a/datadog_lambda/config.py +++ b/datadog_lambda/config.py @@ -102,6 +102,9 @@ def _resolve_env(self, key, default=None, cast=None, depends_on_tracing=False): dsm_exchange_name = _get_env("DD_DSM_EXCHANGE_NAME") appsec_enabled = _get_env("DD_APPSEC_ENABLED", "false", as_bool) sca_enabled = _get_env("DD_APPSEC_SCA_ENABLED", "false", as_bool) + appsec_aws_access_key_id_enabled = _get_env( + "DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "false", as_bool + ) is_gov_region = _get_env("AWS_REGION", "", lambda x: x.startswith("us-gov-")) diff --git a/tests/test_asm.py b/tests/test_asm.py index 3aab8dd73..1efcb6db0 100644 --- a/tests/test_asm.py +++ b/tests/test_asm.py @@ -1,8 +1,11 @@ import json +import logging +import os import pytest from unittest.mock import MagicMock, patch from datadog_lambda.asm import ( + AWS_ACCESS_KEY_ID_TAG, asm_start_request, asm_start_response, get_asm_blocked_response, @@ -427,3 +430,132 @@ def test_get_asm_blocked_response_not_blocked( event_source = _EventSource(event_type=EventTypes.API_GATEWAY) response = get_asm_blocked_response(event_source) assert response is None + + +ACCESS_KEY_ID = "ASIAEXAMPLE000000001" +SECRET_CANARY = "canary-secret-ee3f9c1b" +TOKEN_CANARY = "canary-token-7d41a0c2" + + +@pytest.fixture +def aws_credentials(monkeypatch): + import datadog_lambda.asm as asm + + monkeypatch.setattr(asm, "_aws_access_key_id", asm._UNSET) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", ACCESS_KEY_ID) + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", SECRET_CANARY) + monkeypatch.setenv("AWS_SESSION_TOKEN", TOKEN_CANARY) + return monkeypatch + + +def _start_request(file="lambda-url.json"): + with open(event_samples + file, "r") as f: + event = json.load(f) + span = MagicMock() + with patch("datadog_lambda.asm.core"): + asm_start_request( + span, + event, + parse_event_source(event), + extract_trigger_tags(event, get_mock_context()), + ) + return span + + +def _access_key_tag_calls(span): + return [ + c for c in span.set_tag.call_args_list if c.args[0] == AWS_ACCESS_KEY_ID_TAG + ] + + +@pytest.mark.parametrize( + "file", + [ + "lambda-url.json", + "api-gateway.json", + "http-api.json", + "application-load-balancer.json", + ], +) +def test_access_key_id_tag_set_when_enabled(aws_credentials, file): + aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true") + span = _start_request(file) + span.set_tag.assert_any_call(AWS_ACCESS_KEY_ID_TAG, ACCESS_KEY_ID) + assert len(_access_key_tag_calls(span)) == 1 + + +@pytest.mark.parametrize("value", [None, "false", "0"]) +def test_access_key_id_tag_off_by_default(aws_credentials, value): + if value is not None: + aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", value) + span = _start_request() + assert _access_key_tag_calls(span) == [] + + +@pytest.mark.parametrize( + "value", + [ + "", + "ASIA", + "XXXXEXAMPLE000000001", + "asiaexample000000001", + "ASIAEXAMPLE0000000012", + "ASIAEXAMPLE000000001\n", + ], +) +def test_access_key_id_tag_not_set_for_malformed_value(aws_credentials, value, caplog): + aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true") + aws_credentials.setenv("AWS_ACCESS_KEY_ID", value) + with caplog.at_level(logging.DEBUG, logger="datadog_lambda.asm"): + span = _start_request() + assert _access_key_tag_calls(span) == [] + assert all(r.levelno <= logging.DEBUG for r in caplog.records) + + +def test_access_key_id_tag_not_set_when_variable_missing(aws_credentials): + aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true") + aws_credentials.delenv("AWS_ACCESS_KEY_ID") + span = _start_request() + assert _access_key_tag_calls(span) == [] + + +def test_access_key_id_read_once(aws_credentials): + aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true") + first = _start_request() + aws_credentials.setenv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE000000002") + second = _start_request() + first.set_tag.assert_any_call(AWS_ACCESS_KEY_ID_TAG, ACCESS_KEY_ID) + second.set_tag.assert_any_call(AWS_ACCESS_KEY_ID_TAG, ACCESS_KEY_ID) + + +def test_access_key_id_tag_not_set_for_non_http_event(aws_credentials): + aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true") + span = _start_request("sqs-batch.json") + assert _access_key_tag_calls(span) == [] + + +class _GuardedEnviron(dict): + """Fails the test if the secret access key or the session token is read""" + + FORBIDDEN = ("AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN") + + def __getitem__(self, key): + assert key not in self.FORBIDDEN, f"{key} was read" + return super().__getitem__(key) + + def get(self, key, default=None): + assert key not in self.FORBIDDEN, f"{key} was read" + return super().get(key, default) + + +def test_access_key_id_never_reads_secret_or_token(aws_credentials, caplog): + import datadog_lambda.asm as asm + + aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true") + aws_credentials.setattr(asm.os, "environ", _GuardedEnviron(os.environ)) + with caplog.at_level(logging.DEBUG): + span = _start_request() + span.set_tag.assert_any_call(AWS_ACCESS_KEY_ID_TAG, ACCESS_KEY_ID) + recorded = repr(span.mock_calls) + caplog.text + assert SECRET_CANARY not in recorded + assert TOKEN_CANARY not in recorded From b1cc174951a1597b9e03b065dc73fa7b1e91541f Mon Sep 17 00:00:00 2001 From: Julio Guerra Date: Thu, 12 Nov 2026 21:29:25 +0100 Subject: [PATCH 2/2] prototype: rename the activation variable to DD_APPSEC_AWS_ACCESS_KEY_ID_TAG_ENABLED Design change from Julio on JULIO-10 (2026-11-12). The old name was DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED. Co-Authored-By: Claude Opus 5.5 Co-authored-by: multica-agent --- datadog_lambda/asm.py | 2 +- datadog_lambda/config.py | 4 ++-- tests/test_asm.py | 14 +++++++------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/datadog_lambda/asm.py b/datadog_lambda/asm.py index 6e5371707..7f291dc78 100644 --- a/datadog_lambda/asm.py +++ b/datadog_lambda/asm.py @@ -150,7 +150,7 @@ def asm_start_request( else: return - if config.appsec_aws_access_key_id_enabled: + if config.appsec_aws_access_key_id_tag_enabled: access_key_id = _get_aws_access_key_id() if access_key_id: span.set_tag(AWS_ACCESS_KEY_ID_TAG, access_key_id) diff --git a/datadog_lambda/config.py b/datadog_lambda/config.py index 630358b4f..8906e7419 100644 --- a/datadog_lambda/config.py +++ b/datadog_lambda/config.py @@ -102,8 +102,8 @@ def _resolve_env(self, key, default=None, cast=None, depends_on_tracing=False): dsm_exchange_name = _get_env("DD_DSM_EXCHANGE_NAME") appsec_enabled = _get_env("DD_APPSEC_ENABLED", "false", as_bool) sca_enabled = _get_env("DD_APPSEC_SCA_ENABLED", "false", as_bool) - appsec_aws_access_key_id_enabled = _get_env( - "DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "false", as_bool + appsec_aws_access_key_id_tag_enabled = _get_env( + "DD_APPSEC_AWS_ACCESS_KEY_ID_TAG_ENABLED", "false", as_bool ) is_gov_region = _get_env("AWS_REGION", "", lambda x: x.startswith("us-gov-")) diff --git a/tests/test_asm.py b/tests/test_asm.py index 1efcb6db0..b8a689339 100644 --- a/tests/test_asm.py +++ b/tests/test_asm.py @@ -478,7 +478,7 @@ def _access_key_tag_calls(span): ], ) def test_access_key_id_tag_set_when_enabled(aws_credentials, file): - aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true") + aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_TAG_ENABLED", "true") span = _start_request(file) span.set_tag.assert_any_call(AWS_ACCESS_KEY_ID_TAG, ACCESS_KEY_ID) assert len(_access_key_tag_calls(span)) == 1 @@ -487,7 +487,7 @@ def test_access_key_id_tag_set_when_enabled(aws_credentials, file): @pytest.mark.parametrize("value", [None, "false", "0"]) def test_access_key_id_tag_off_by_default(aws_credentials, value): if value is not None: - aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", value) + aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_TAG_ENABLED", value) span = _start_request() assert _access_key_tag_calls(span) == [] @@ -504,7 +504,7 @@ def test_access_key_id_tag_off_by_default(aws_credentials, value): ], ) def test_access_key_id_tag_not_set_for_malformed_value(aws_credentials, value, caplog): - aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true") + aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_TAG_ENABLED", "true") aws_credentials.setenv("AWS_ACCESS_KEY_ID", value) with caplog.at_level(logging.DEBUG, logger="datadog_lambda.asm"): span = _start_request() @@ -513,14 +513,14 @@ def test_access_key_id_tag_not_set_for_malformed_value(aws_credentials, value, c def test_access_key_id_tag_not_set_when_variable_missing(aws_credentials): - aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true") + aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_TAG_ENABLED", "true") aws_credentials.delenv("AWS_ACCESS_KEY_ID") span = _start_request() assert _access_key_tag_calls(span) == [] def test_access_key_id_read_once(aws_credentials): - aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true") + aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_TAG_ENABLED", "true") first = _start_request() aws_credentials.setenv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE000000002") second = _start_request() @@ -529,7 +529,7 @@ def test_access_key_id_read_once(aws_credentials): def test_access_key_id_tag_not_set_for_non_http_event(aws_credentials): - aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true") + aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_TAG_ENABLED", "true") span = _start_request("sqs-batch.json") assert _access_key_tag_calls(span) == [] @@ -551,7 +551,7 @@ def get(self, key, default=None): def test_access_key_id_never_reads_secret_or_token(aws_credentials, caplog): import datadog_lambda.asm as asm - aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true") + aws_credentials.setenv("DD_APPSEC_AWS_ACCESS_KEY_ID_TAG_ENABLED", "true") aws_credentials.setattr(asm.os, "environ", _GuardedEnviron(os.environ)) with caplog.at_level(logging.DEBUG): span = _start_request()