From eb1da806437629b3be8b60c511cb827448b2aa27 Mon Sep 17 00:00:00 2001 From: Julio Guerra Date: Thu, 12 Nov 2026 13:07:54 +0100 Subject: [PATCH 1/6] feat(config): add DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED and a validated AWS_ACCESS_KEY_ID read Prototype for the JULIO-12 LLD (CADR for AWS Lambda, APPSEC-71674). Do not merge. The new read touches only AWS_ACCESS_KEY_ID, never the secret or the session token, and accepts only ^(ASIA|AKIA)[A-Z0-9]{16}$. Co-Authored-By: Claude Opus 5.5 Co-authored-by: multica-agent --- bottlecap/src/config/aws.rs | 51 +++++++++++++++++++++++++++++++++++++ bottlecap/src/config/mod.rs | 29 +++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/bottlecap/src/config/aws.rs b/bottlecap/src/config/aws.rs index 284f91b50..85bbdb16d 100644 --- a/bottlecap/src/config/aws.rs +++ b/bottlecap/src/config/aws.rs @@ -80,6 +80,25 @@ impl AwsCredentials { } } +/// Reads the execution environment's `AWS_ACCESS_KEY_ID`, and only that +/// variable. Returns [`None`] when it is missing or malformed. +#[must_use] +pub fn aws_access_key_id_from_env() -> Option { + env::var(AWS_ACCESS_KEY_ID) + .ok() + .filter(|v| is_valid_access_key_id(v)) +} + +/// Matches `^(ASIA|AKIA)[A-Z0-9]{16}$`. +#[must_use] +pub fn is_valid_access_key_id(value: &str) -> bool { + value.len() == 20 + && (value.starts_with("ASIA") || value.starts_with("AKIA")) + && value[4..] + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()) +} + #[must_use] pub fn get_aws_partition_by_region(region: &str) -> String { match region { @@ -94,3 +113,35 @@ pub fn build_lambda_function_arn(account_id: &str, region: &str, function_name: let aws_partition = get_aws_partition_by_region(region); format!("arn:{aws_partition}:lambda:{region}:{account_id}:function:{function_name}") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_access_key_ids() { + assert!(is_valid_access_key_id("ASIAEXAMPLE000000001")); + assert!(is_valid_access_key_id("AKIAIOSFODNN7EXAMPLE")); + } + + #[test] + fn invalid_access_key_ids() { + for value in [ + "", + "ASIA", + "AKIDEXAMPLE", + "ABIAEXAMPLE000000001", + "ASIAexample000000001", + "ASIAEXAMPLE00000000", + "ASIAEXAMPLE0000000011", + "ASIAEXAMPLE00000000-", + " ASIAEXAMPLE00000001", + "ASIAÉXAMPLE00000001", + ] { + assert!( + !is_valid_access_key_id(value), + "{value:?} should be invalid" + ); + } + } +} diff --git a/bottlecap/src/config/mod.rs b/bottlecap/src/config/mod.rs index d1a9ed258..590b07cca 100644 --- a/bottlecap/src/config/mod.rs +++ b/bottlecap/src/config/mod.rs @@ -85,6 +85,10 @@ pub struct LambdaConfig { pub appsec_waf_timeout: Duration, pub api_security_enabled: bool, pub api_security_sample_delay: Duration, + /// `DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED`: tag each AppSec-monitored + /// invocation span with the execution environment's `AWS_ACCESS_KEY_ID`. + /// Only applies when `serverless_appsec_enabled` is true. Defaults to `false`. + pub appsec_aws_access_key_id_enabled: bool, pub custom_metrics_exclude_tags: Vec, /// When true, inferred (synthetic) event-source spans report the function's @@ -152,6 +156,7 @@ impl Default for LambdaConfig { appsec_waf_timeout: Duration::from_millis(5), api_security_enabled: true, api_security_sample_delay: Duration::from_secs(30), + appsec_aws_access_key_id_enabled: false, custom_metrics_exclude_tags: Vec::new(), trace_remove_integration_service_names_enabled: false, lambda_durable_function_log_buffer_size: 5, @@ -228,6 +233,8 @@ pub struct LambdaConfigSource { pub api_security_enabled: Option, #[serde(deserialize_with = "deser_dur_secs")] pub api_security_sample_delay: Option, + #[serde(deserialize_with = "deser_opt_bool")] + pub appsec_aws_access_key_id_enabled: Option, /// `DD_LAMBDA_CUSTOMER_METRICS_EXCLUDE_TAGS` — comma-separated list of tag /// names to drop from customer `DogStatsD` metrics. Source field name @@ -299,6 +306,7 @@ impl DatadogConfigExtension for LambdaConfig { appsec_waf_timeout, api_security_enabled, api_security_sample_delay, + appsec_aws_access_key_id_enabled, trace_remove_integration_service_names_enabled, lambda_durable_function_log_buffer_size, trace_experimental_features_enabled, @@ -855,6 +863,27 @@ mod lambda_config_tests { // ---- AppSec / API Security ---- + #[test] + fn appsec_aws_access_key_id_enabled_defaults_to_false() { + let config = load(|_| Ok(())); + assert!(!config.ext.appsec_aws_access_key_id_enabled); + } + + #[test] + fn appsec_aws_access_key_id_enabled_from_env() { + let config = load(|jail| { + jail.set_env("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true"); + Ok(()) + }); + assert!(config.ext.appsec_aws_access_key_id_enabled); + + let config = load(|jail| { + jail.set_env("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "false"); + Ok(()) + }); + assert!(!config.ext.appsec_aws_access_key_id_enabled); + } + #[test] fn appsec_block_from_env() { let config = load(|jail| { From b6a97f09bf234190c785987a654591a2f1a9b5a2 Mon Sep 17 00:00:00 2001 From: Julio Guerra Date: Thu, 12 Nov 2026 13:07:54 +0100 Subject: [PATCH 2/6] feat(appsec): tag monitored aws.lambda spans with aws.lambda.access_key_id Prototype for the JULIO-12 LLD (CADR for AWS Lambda, APPSEC-71674). Do not merge. When DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED is on, the AppSec processor reads the execution environment's access key ID once, at the first monitored invocation (so after restore on SnapStart), and adds it to every span it has an AppSec context for, with or without an AppSec event. Co-Authored-By: Claude Opus 5.5 Co-authored-by: multica-agent --- .../src/appsec/processor/aws_access_key.rs | 75 +++++++++ bottlecap/src/appsec/processor/context.rs | 10 ++ bottlecap/src/appsec/processor/mod.rs | 146 ++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 bottlecap/src/appsec/processor/aws_access_key.rs diff --git a/bottlecap/src/appsec/processor/aws_access_key.rs b/bottlecap/src/appsec/processor/aws_access_key.rs new file mode 100644 index 000000000..78a897e02 --- /dev/null +++ b/bottlecap/src/appsec/processor/aws_access_key.rs @@ -0,0 +1,75 @@ +use std::cell::OnceCell; + +use tracing::debug; + +/// The execution environment's AWS access key ID, read on first use and +/// cached for the life of the extension process. +/// +/// The first read happens on the first monitored invocation, so on `SnapStart` +/// it always happens after restore. +pub(super) struct AwsAccessKeyId { + read: fn() -> Option, + value: OnceCell>, +} +impl AwsAccessKeyId { + pub(super) fn from_env() -> Self { + Self::with_reader(crate::config::aws::aws_access_key_id_from_env) + } + + pub(super) const fn with_reader(read: fn() -> Option) -> Self { + Self { + read, + value: OnceCell::new(), + } + } + + pub(super) fn get(&self) -> Option<&str> { + self.value + .get_or_init(|| { + let value = (self.read)(); + if value.is_none() { + debug!( + "aap: AWS_ACCESS_KEY_ID is missing or malformed, aws.lambda.access_key_id will not be set" + ); + } + value + }) + .as_deref() + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + #[test] + fn reads_once() { + static READS: AtomicUsize = AtomicUsize::new(0); + let subject = AwsAccessKeyId::with_reader(|| { + READS.fetch_add(1, Ordering::SeqCst); + Some("ASIAEXAMPLE000000001".to_string()) + }); + assert_eq!( + READS.load(Ordering::SeqCst), + 0, + "must not read before first use" + ); + assert_eq!(subject.get(), Some("ASIAEXAMPLE000000001")); + assert_eq!(subject.get(), Some("ASIAEXAMPLE000000001")); + assert_eq!(READS.load(Ordering::SeqCst), 1); + } + + #[test] + fn caches_absence() { + static READS: AtomicUsize = AtomicUsize::new(0); + let subject = AwsAccessKeyId::with_reader(|| { + READS.fetch_add(1, Ordering::SeqCst); + None + }); + assert_eq!(subject.get(), None); + assert_eq!(subject.get(), None); + assert_eq!(READS.load(Ordering::SeqCst), 1); + } +} diff --git a/bottlecap/src/appsec/processor/context.rs b/bottlecap/src/appsec/processor/context.rs index 19350201b..2eb73be98 100644 --- a/bottlecap/src/appsec/processor/context.rs +++ b/bottlecap/src/appsec/processor/context.rs @@ -53,6 +53,7 @@ impl Context { waf_handle: &mut Handle, ruleset_version: &str, waf_timeout: Duration, + aws_access_key_id: Option<&str>, ) -> Self { let mut trace_tags = HashMap::from([ (TagName::AppsecEnabled, TagValue::Metric(1.0)), @@ -67,6 +68,12 @@ impl Context { TagValue::Always(ruleset_version.to_string()), ); } + if let Some(aws_access_key_id) = aws_access_key_id { + trace_tags.insert( + TagName::AwsLambdaAccessKeyId, + TagValue::Always(aws_access_key_id.to_string()), + ); + } Self { rid, @@ -553,6 +560,8 @@ enum TagName { AppsecWafVersion, // Hidden span tags of relevance Origin, + // Execution environment tags + AwsLambdaAccessKeyId, // General request tags HttpUrl, HttpMethod, @@ -575,6 +584,7 @@ impl TagName { Self::AppsecWafTimeouts => "_dd.appsec.waf.timeouts", Self::AppsecWafVersion => "_dd.appsec.waf.version", Self::Origin => "_dd.origin", + Self::AwsLambdaAccessKeyId => "aws.lambda.access_key_id", Self::HttpUrl => "http.url", Self::HttpMethod => "http.method", Self::HttpRoute => "http.route", diff --git a/bottlecap/src/appsec/processor/mod.rs b/bottlecap/src/appsec/processor/mod.rs index f10a8a237..b37b4f8fc 100644 --- a/bottlecap/src/appsec/processor/mod.rs +++ b/bottlecap/src/appsec/processor/mod.rs @@ -15,6 +15,7 @@ use libddwaf::{Builder, Config as WafConfig, Handle}; use tokio::sync::Mutex; use tracing::{debug, info}; +use crate::appsec::processor::aws_access_key::AwsAccessKeyId; use crate::appsec::processor::context::Context; use crate::appsec::processor::response::ExpectedResponseFormat; use crate::appsec::{is_enabled, is_standalone}; @@ -22,6 +23,7 @@ use crate::config::Config; use crate::lifecycle::invocation::triggers::IdentifiedTrigger; mod apisec; +mod aws_access_key; pub mod context; pub mod response; mod ruleset; @@ -32,6 +34,8 @@ pub struct Processor { waf_timeout: Duration, api_sec_sampler: Option>>, // Must be [`Arc`] so [`Processor`] can be [`Send`]. context_buffer: VecDeque, + /// [`None`] when `DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED` is off. + aws_access_key_id: Option, } impl Processor { const CONTEXT_BUFFER_DEFAULT_CAPACITY: NonZero = NonZero::new(5).unwrap(); @@ -101,6 +105,10 @@ impl Processor { None }, context_buffer: VecDeque::with_capacity(capacity.get()), + aws_access_key_id: cfg + .ext + .appsec_aws_access_key_id_enabled + .then(AwsAccessKeyId::from_env), }) } @@ -244,11 +252,16 @@ impl Processor { } // Insert the new context at the back of the buffer. + let aws_access_key_id = self + .aws_access_key_id + .as_ref() + .and_then(AwsAccessKeyId::get); self.context_buffer.push_back(Context::new( rid.to_string(), &mut self.handle, &self.ruleset_version, self.waf_timeout, + aws_access_key_id, )); // Retrieve a mutable reference to it from the buffer. self.context_buffer @@ -835,6 +848,139 @@ mod tests { ); } + const TEST_ACCESS_KEY_ID: &str = "ASIAEXAMPLE000000001"; + const ACCESS_KEY_ID_TAG: &str = "aws.lambda.access_key_id"; + + fn function_url_trigger(query: Option<(&str, &str)>) -> IdentifiedTrigger { + let mut payload: serde_json::Value = serde_json::from_slice(include_bytes!( + "../../../tests/payloads/lambda_function_url_event.json" + )) + .expect("failed to parse payload"); + if let Some((key, value)) = query { + payload["rawQueryString"] = format!("{key}={value}").into(); + payload["queryStringParameters"] = serde_json::json!({ key: value }); + } + IdentifiedTrigger::LambdaFunctionUrlEvent( + serde_json::from_value(payload).expect("failed to build trigger"), + ) + } + + fn appsec_config(access_key_id_enabled: bool) -> Config { + Config { + ext: crate::config::LambdaConfig { + serverless_appsec_enabled: true, + appsec_aws_access_key_id_enabled: access_key_id_enabled, + ..Default::default() + }, + ..Config::default() + } + } + + /// Runs one monitored invocation end to end and returns its `aws.lambda` span. + async fn monitored_span( + processor: &mut Processor, + rid: &str, + trigger: &IdentifiedTrigger, + ) -> Span { + processor.process_invocation_next(rid, trigger).await; + processor + .process_invocation_result(rid, &Bytes::from_static(br#"{"statusCode":200}"#)) + .await; + let mut span = Span { + name: "aws.lambda".into(), + meta: HashMap::from([("request_id".to_string(), rid.to_string())]), + ..Default::default() + }; + let (finalized, _) = processor.process_span(&mut span); + assert!(finalized); + span + } + + #[tokio::test] + async fn access_key_id_tagged_when_enabled() { + let mut processor = Processor::new(&appsec_config(true)).expect("Should not fail"); + processor.aws_access_key_id = Some(AwsAccessKeyId::with_reader(|| { + Some(TEST_ACCESS_KEY_ID.to_string()) + })); + let span = monitored_span(&mut processor, "rid-1", &function_url_trigger(None)).await; + assert_eq!( + span.meta.get(ACCESS_KEY_ID_TAG).map(String::as_str), + Some(TEST_ACCESS_KEY_ID) + ); + assert!(!span.meta.contains_key("appsec.event")); + } + + #[tokio::test] + async fn access_key_id_not_tagged_when_disabled() { + let mut processor = Processor::new(&appsec_config(false)).expect("Should not fail"); + assert!(processor.aws_access_key_id.is_none()); + let span = monitored_span(&mut processor, "rid-1", &function_url_trigger(None)).await; + assert!(!span.meta.contains_key(ACCESS_KEY_ID_TAG)); + } + + #[tokio::test] + async fn access_key_id_not_tagged_when_missing_or_malformed() { + let mut processor = Processor::new(&appsec_config(true)).expect("Should not fail"); + processor.aws_access_key_id = Some(AwsAccessKeyId::with_reader(|| None)); + let span = monitored_span(&mut processor, "rid-1", &function_url_trigger(None)).await; + assert!(!span.meta.contains_key(ACCESS_KEY_ID_TAG)); + } + + #[tokio::test] + async fn access_key_id_read_once_across_invocations() { + static READS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let mut processor = Processor::new(&appsec_config(true)).expect("Should not fail"); + processor.aws_access_key_id = Some(AwsAccessKeyId::with_reader(|| { + READS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Some(TEST_ACCESS_KEY_ID.to_string()) + })); + for rid in ["rid-1", "rid-2"] { + let span = monitored_span(&mut processor, rid, &function_url_trigger(None)).await; + assert_eq!( + span.meta.get(ACCESS_KEY_ID_TAG).map(String::as_str), + Some(TEST_ACCESS_KEY_ID) + ); + } + assert_eq!(READS.load(std::sync::atomic::Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn access_key_id_tagged_alongside_appsec_event() { + let mut processor = Processor::new(&appsec_config(true)).expect("Should not fail"); + processor.aws_access_key_id = Some(AwsAccessKeyId::with_reader(|| { + Some(TEST_ACCESS_KEY_ID.to_string()) + })); + let trigger = function_url_trigger(Some(("file", "/proc/self/environ"))); + let span = monitored_span(&mut processor, "rid-1", &trigger).await; + let event = span + .meta + .get("_dd.appsec.json") + .expect("crs-930-120 should have matched"); + assert!(event.contains("crs-930-120"), "unexpected event: {event}"); + assert_eq!( + span.meta.get(ACCESS_KEY_ID_TAG).map(String::as_str), + Some(TEST_ACCESS_KEY_ID) + ); + } + + #[tokio::test] + async fn access_key_id_not_tagged_on_unmonitored_invocation() { + let mut processor = Processor::new(&appsec_config(true)).expect("Should not fail"); + processor.aws_access_key_id = Some(AwsAccessKeyId::with_reader(|| { + Some(TEST_ACCESS_KEY_ID.to_string()) + })); + processor + .process_invocation_next("rid-1", &IdentifiedTrigger::Unknown) + .await; + let mut span = Span { + name: "aws.lambda".into(), + meta: HashMap::from([("request_id".to_string(), "rid-1".to_string())]), + ..Default::default() + }; + processor.process_span(&mut span); + assert!(!span.meta.contains_key(ACCESS_KEY_ID_TAG)); + } + #[test] fn service_entry_span_mut_skips_placeholder_lambda_span() { let mut trace = vec![ From ee915c5d201141e8c08152c8c01d95bac2cbf196 Mon Sep 17 00:00:00 2001 From: Julio Guerra Date: Thu, 12 Nov 2026 21:26:25 +0100 Subject: [PATCH 3/6] refactor(config): rename the key to DD_APPSEC_AWS_ACCESS_KEY_ID_TAG_ENABLED Prototype for the JULIO-12 LLD (CADR for AWS Lambda, APPSEC-71674). Do not merge. Design change on JULIO-10 (2026-11-12). The old name has no effect. Co-Authored-By: Claude Opus 5.5 Co-authored-by: multica-agent --- bottlecap/src/appsec/processor/mod.rs | 6 +++--- bottlecap/src/config/mod.rs | 31 ++++++++++++++++----------- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/bottlecap/src/appsec/processor/mod.rs b/bottlecap/src/appsec/processor/mod.rs index b37b4f8fc..947b01da8 100644 --- a/bottlecap/src/appsec/processor/mod.rs +++ b/bottlecap/src/appsec/processor/mod.rs @@ -34,7 +34,7 @@ pub struct Processor { waf_timeout: Duration, api_sec_sampler: Option>>, // Must be [`Arc`] so [`Processor`] can be [`Send`]. context_buffer: VecDeque, - /// [`None`] when `DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED` is off. + /// [`None`] when `DD_APPSEC_AWS_ACCESS_KEY_ID_TAG_ENABLED` is off. aws_access_key_id: Option, } impl Processor { @@ -107,7 +107,7 @@ impl Processor { context_buffer: VecDeque::with_capacity(capacity.get()), aws_access_key_id: cfg .ext - .appsec_aws_access_key_id_enabled + .appsec_aws_access_key_id_tag_enabled .then(AwsAccessKeyId::from_env), }) } @@ -869,7 +869,7 @@ mod tests { Config { ext: crate::config::LambdaConfig { serverless_appsec_enabled: true, - appsec_aws_access_key_id_enabled: access_key_id_enabled, + appsec_aws_access_key_id_tag_enabled: access_key_id_enabled, ..Default::default() }, ..Config::default() diff --git a/bottlecap/src/config/mod.rs b/bottlecap/src/config/mod.rs index 590b07cca..8b3df9e9a 100644 --- a/bottlecap/src/config/mod.rs +++ b/bottlecap/src/config/mod.rs @@ -85,10 +85,10 @@ pub struct LambdaConfig { pub appsec_waf_timeout: Duration, pub api_security_enabled: bool, pub api_security_sample_delay: Duration, - /// `DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED`: tag each AppSec-monitored + /// `DD_APPSEC_AWS_ACCESS_KEY_ID_TAG_ENABLED`: tag each AppSec-monitored /// invocation span with the execution environment's `AWS_ACCESS_KEY_ID`. /// Only applies when `serverless_appsec_enabled` is true. Defaults to `false`. - pub appsec_aws_access_key_id_enabled: bool, + pub appsec_aws_access_key_id_tag_enabled: bool, pub custom_metrics_exclude_tags: Vec, /// When true, inferred (synthetic) event-source spans report the function's @@ -156,7 +156,7 @@ impl Default for LambdaConfig { appsec_waf_timeout: Duration::from_millis(5), api_security_enabled: true, api_security_sample_delay: Duration::from_secs(30), - appsec_aws_access_key_id_enabled: false, + appsec_aws_access_key_id_tag_enabled: false, custom_metrics_exclude_tags: Vec::new(), trace_remove_integration_service_names_enabled: false, lambda_durable_function_log_buffer_size: 5, @@ -234,7 +234,7 @@ pub struct LambdaConfigSource { #[serde(deserialize_with = "deser_dur_secs")] pub api_security_sample_delay: Option, #[serde(deserialize_with = "deser_opt_bool")] - pub appsec_aws_access_key_id_enabled: Option, + pub appsec_aws_access_key_id_tag_enabled: Option, /// `DD_LAMBDA_CUSTOMER_METRICS_EXCLUDE_TAGS` — comma-separated list of tag /// names to drop from customer `DogStatsD` metrics. Source field name @@ -306,7 +306,7 @@ impl DatadogConfigExtension for LambdaConfig { appsec_waf_timeout, api_security_enabled, api_security_sample_delay, - appsec_aws_access_key_id_enabled, + appsec_aws_access_key_id_tag_enabled, trace_remove_integration_service_names_enabled, lambda_durable_function_log_buffer_size, trace_experimental_features_enabled, @@ -864,24 +864,31 @@ mod lambda_config_tests { // ---- AppSec / API Security ---- #[test] - fn appsec_aws_access_key_id_enabled_defaults_to_false() { + fn appsec_aws_access_key_id_tag_enabled_defaults_to_false() { let config = load(|_| Ok(())); - assert!(!config.ext.appsec_aws_access_key_id_enabled); + assert!(!config.ext.appsec_aws_access_key_id_tag_enabled); } #[test] - fn appsec_aws_access_key_id_enabled_from_env() { + fn appsec_aws_access_key_id_tag_enabled_from_env() { let config = load(|jail| { - jail.set_env("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true"); + jail.set_env("DD_APPSEC_AWS_ACCESS_KEY_ID_TAG_ENABLED", "true"); + Ok(()) + }); + assert!(config.ext.appsec_aws_access_key_id_tag_enabled); + + let config = load(|jail| { + jail.set_env("DD_APPSEC_AWS_ACCESS_KEY_ID_TAG_ENABLED", "false"); Ok(()) }); - assert!(config.ext.appsec_aws_access_key_id_enabled); + assert!(!config.ext.appsec_aws_access_key_id_tag_enabled); + // The pre-rename key has no effect. let config = load(|jail| { - jail.set_env("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "false"); + jail.set_env("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true"); Ok(()) }); - assert!(!config.ext.appsec_aws_access_key_id_enabled); + assert!(!config.ext.appsec_aws_access_key_id_tag_enabled); } #[test] From e4fea9731085d0f6474d12922cc841e959646445 Mon Sep 17 00:00:00 2001 From: Julio Guerra Date: Thu, 12 Nov 2026 23:18:03 +0100 Subject: [PATCH 4/6] feat(appsec): never overwrite a tracer-set aws.lambda.access_key_id (RFC rev 2 D5) Prototype for the JULIO-12 LLD (CADR for AWS Lambda, APPSEC-71674). Do not merge. The extension now writes the tag only when the final invocation span does not carry it yet, counting the meta merged from a tracer placeholder span. Tests cover a tracer-built span (Python, Node.js) and the placeholder merge (Java, Ruby) before and after the extension's write. Also drops the config test that still set the pre-rename key. Co-Authored-By: Claude Opus 5.5 Co-authored-by: multica-agent --- bottlecap/src/appsec/processor/context.rs | 12 +- bottlecap/src/appsec/processor/mod.rs | 136 ++++++++++++++++++++++ bottlecap/src/config/mod.rs | 7 -- 3 files changed, 146 insertions(+), 9 deletions(-) diff --git a/bottlecap/src/appsec/processor/context.rs b/bottlecap/src/appsec/processor/context.rs index 2eb73be98..7cd101946 100644 --- a/bottlecap/src/appsec/processor/context.rs +++ b/bottlecap/src/appsec/processor/context.rs @@ -71,7 +71,7 @@ impl Context { if let Some(aws_access_key_id) = aws_access_key_id { trace_tags.insert( TagName::AwsLambdaAccessKeyId, - TagValue::Always(aws_access_key_id.to_string()), + TagValue::IfAbsent(aws_access_key_id.to_string()), ); } @@ -247,6 +247,11 @@ impl Context { TagValue::Always(value) => { span.meta.insert(key.to_string(), value.clone()); } + TagValue::IfAbsent(value) => { + span.meta + .entry(key.to_string()) + .or_insert_with(|| value.clone()); + } TagValue::AppSecEvents { triggers } => { span.meta.insert( key.to_string(), @@ -605,6 +610,9 @@ impl std::fmt::Display for TagName { enum TagValue { /// A tag value that is always emitted. Always(String), + /// A tag value that is emitted only if the span does not carry the tag + /// yet, so a value set by a tracer is kept. + IfAbsent(String), /// A tag value that is only emitted when an event is matched. OnEvent(serde_json::Value), /// The special key used to encode AAP events @@ -615,7 +623,7 @@ enum TagValue { impl std::fmt::Display for TagValue { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Always(str) => write!(f, "{str}"), + Self::Always(str) | Self::IfAbsent(str) => write!(f, "{str}"), Self::OnEvent(value) => write!(f, "{value}"), Self::AppSecEvents { triggers } => write!( f, diff --git a/bottlecap/src/appsec/processor/mod.rs b/bottlecap/src/appsec/processor/mod.rs index 947b01da8..7911404fb 100644 --- a/bottlecap/src/appsec/processor/mod.rs +++ b/bottlecap/src/appsec/processor/mod.rs @@ -981,6 +981,142 @@ mod tests { assert!(!span.meta.contains_key(ACCESS_KEY_ID_TAG)); } + const TRACER_ACCESS_KEY_ID: &str = "ASIATRACER0000000002"; + + /// A processor with the key on and a fixed environment value, and one + /// monitored invocation `rid-1` whose response was seen. + async fn processor_with_monitored_invocation() -> Processor { + let mut processor = Processor::new(&appsec_config(true)).expect("Should not fail"); + processor.aws_access_key_id = Some(AwsAccessKeyId::with_reader(|| { + Some(TEST_ACCESS_KEY_ID.to_string()) + })); + processor + .process_invocation_next("rid-1", &function_url_trigger(None)) + .await; + processor + .process_invocation_result("rid-1", &Bytes::from_static(br#"{"statusCode":200}"#)) + .await; + processor + } + + fn lambda_span(meta: &[(&str, &str)]) -> Span { + Span { + name: "aws.lambda".into(), + meta: meta + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(), + ..Default::default() + } + } + + /// Python and Node.js: the tracer builds the `aws.lambda` span and sets the tag. + #[tokio::test] + async fn access_key_id_keeps_tracer_value_on_tracer_built_span() { + let mut processor = processor_with_monitored_invocation().await; + let mut span = lambda_span(&[ + ("request_id", "rid-1"), + (ACCESS_KEY_ID_TAG, TRACER_ACCESS_KEY_ID), + ]); + processor.process_span(&mut span); + assert_eq!( + span.meta.get(ACCESS_KEY_ID_TAG).map(String::as_str), + Some(TRACER_ACCESS_KEY_ID) + ); + // Other security tags are still written: only the access key is kept. + assert_eq!( + span.meta.get("_dd.origin").map(String::as_str), + Some("appsec") + ); + } + + /// Java and Ruby: the placeholder's meta is merged into the extension-built + /// span before the extension writes its tags. + #[tokio::test] + async fn access_key_id_keeps_tracer_value_when_merge_precedes_write() { + use crate::lifecycle::invocation::context::ContextBuffer; + + let mut processor = processor_with_monitored_invocation().await; + let rid = "rid-1".to_string(); + let mut buffer = ContextBuffer::default(); + buffer.start_context(&rid, lambda_span(&[])); + let placeholder = Span { + resource: crate::traces::INVOCATION_SPAN_RESOURCE.into(), + ..lambda_span(&[ + ("request_id", "rid-1"), + (ACCESS_KEY_ID_TAG, TRACER_ACCESS_KEY_ID), + ]) + }; + buffer.add_tracer_span(&rid, &placeholder, false); + + let mut span = buffer.remove(&rid).expect("context").invocation_span; + processor.process_span(&mut span); + assert_eq!( + span.meta.get(ACCESS_KEY_ID_TAG).map(String::as_str), + Some(TRACER_ACCESS_KEY_ID) + ); + } + + /// The same, if the extension writes its tags before the placeholder merge. + #[tokio::test] + async fn access_key_id_keeps_tracer_value_when_merge_follows_write() { + use crate::lifecycle::invocation::context::ContextBuffer; + + let mut processor = processor_with_monitored_invocation().await; + let rid = "rid-1".to_string(); + let mut buffer = ContextBuffer::default(); + buffer.start_context(&rid, lambda_span(&[])); + processor.process_span(&mut buffer.get_mut(&rid).expect("context").invocation_span); + assert_eq!( + buffer + .get(&rid) + .expect("context") + .invocation_span + .meta + .get(ACCESS_KEY_ID_TAG) + .map(String::as_str), + Some(TEST_ACCESS_KEY_ID), + "precondition: the extension wrote its value first" + ); + + let placeholder = Span { + resource: crate::traces::INVOCATION_SPAN_RESOURCE.into(), + ..lambda_span(&[ + ("request_id", "rid-1"), + (ACCESS_KEY_ID_TAG, TRACER_ACCESS_KEY_ID), + ]) + }; + buffer.add_tracer_span(&rid, &placeholder, false); + let span = buffer.remove(&rid).expect("context").invocation_span; + assert_eq!( + span.meta.get(ACCESS_KEY_ID_TAG).map(String::as_str), + Some(TRACER_ACCESS_KEY_ID) + ); + } + + /// Go and .NET: the placeholder carries no tag, so the extension adds its own. + #[tokio::test] + async fn access_key_id_set_when_merged_placeholder_has_none() { + use crate::lifecycle::invocation::context::ContextBuffer; + + let mut processor = processor_with_monitored_invocation().await; + let rid = "rid-1".to_string(); + let mut buffer = ContextBuffer::default(); + buffer.start_context(&rid, lambda_span(&[])); + let placeholder = Span { + resource: crate::traces::INVOCATION_SPAN_RESOURCE.into(), + ..lambda_span(&[("request_id", "rid-1")]) + }; + buffer.add_tracer_span(&rid, &placeholder, false); + + let mut span = buffer.remove(&rid).expect("context").invocation_span; + processor.process_span(&mut span); + assert_eq!( + span.meta.get(ACCESS_KEY_ID_TAG).map(String::as_str), + Some(TEST_ACCESS_KEY_ID) + ); + } + #[test] fn service_entry_span_mut_skips_placeholder_lambda_span() { let mut trace = vec![ diff --git a/bottlecap/src/config/mod.rs b/bottlecap/src/config/mod.rs index 8b3df9e9a..866fff4e6 100644 --- a/bottlecap/src/config/mod.rs +++ b/bottlecap/src/config/mod.rs @@ -882,13 +882,6 @@ mod lambda_config_tests { Ok(()) }); assert!(!config.ext.appsec_aws_access_key_id_tag_enabled); - - // The pre-rename key has no effect. - let config = load(|jail| { - jail.set_env("DD_APPSEC_AWS_ACCESS_KEY_ID_ENABLED", "true"); - Ok(()) - }); - assert!(!config.ext.appsec_aws_access_key_id_tag_enabled); } #[test] From d1eb1d7ad50ad78fd09b848db4bda535b6c957fb Mon Sep 17 00:00:00 2001 From: Julio Guerra Date: Thu, 12 Nov 2026 23:18:03 +0100 Subject: [PATCH 5/6] bench(appsec): T14 micro benchmark, access key tag on against off Prototype for the JULIO-12 LLD (CADR for AWS Lambda, APPSEC-71674). Do not merge. Co-Authored-By: Claude Opus 5.5 Co-authored-by: multica-agent --- bottlecap/Cargo.lock | 152 ++++++++++++++++++++++ bottlecap/Cargo.toml | 6 + bottlecap/benches/appsec_access_key_id.rs | 68 ++++++++++ 3 files changed, 226 insertions(+) create mode 100644 bottlecap/benches/appsec_access_key_id.rs diff --git a/bottlecap/Cargo.lock b/bottlecap/Cargo.lock index b9b7c1b46..d821033db 100644 --- a/bottlecap/Cargo.lock +++ b/bottlecap/Cargo.lock @@ -35,6 +35,18 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.103" @@ -472,6 +484,7 @@ dependencies = [ "bytes", "chrono", "cookie", + "criterion", "datadog-agent-config", "datadog-agent-trace-sampler", "datadog-fips", @@ -588,6 +601,12 @@ dependencies = [ "crossbeam-channel", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.57" @@ -648,6 +667,33 @@ dependencies = [ "serde", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -659,6 +705,31 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + [[package]] name = "cmake" version = "0.1.58" @@ -772,6 +843,40 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1381,6 +1486,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.13.2" @@ -1850,6 +1966,26 @@ dependencies = [ "serde", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.11.0" @@ -2668,6 +2804,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl-probe" version = "0.1.6" @@ -4246,6 +4388,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" diff --git a/bottlecap/Cargo.toml b/bottlecap/Cargo.toml index f437d5ad4..84864379e 100644 --- a/bottlecap/Cargo.toml +++ b/bottlecap/Cargo.toml @@ -124,10 +124,16 @@ rmp-serde = { version = "1.3.1", default-features = false } flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] } # fault-intake test harness: decode the `datadog-protos` metrics payloads protobuf = { version = "3.7", default-features = false } +# T14 micro benchmark for the JULIO-12 prototype (do not merge) +criterion = { version = "0.5", default-features = false } [build-dependencies] # No external dependencies needed for the build script +[[bench]] +name = "appsec_access_key_id" +harness = false + [[bin]] name = "bottlecap" diff --git a/bottlecap/benches/appsec_access_key_id.rs b/bottlecap/benches/appsec_access_key_id.rs new file mode 100644 index 000000000..f412ff976 --- /dev/null +++ b/bottlecap/benches/appsec_access_key_id.rs @@ -0,0 +1,68 @@ +//! T14 (JULIO-12 LLD): cost of `DD_APPSEC_AWS_ACCESS_KEY_ID_TAG_ENABLED` on one +//! monitored invocation (request, response, `aws.lambda` span), key on against off. + +use std::collections::HashMap; +use std::hint::black_box; + +use bottlecap::appsec::processor::Processor; +use bottlecap::config::Config; +use bottlecap::lifecycle::invocation::triggers::IdentifiedTrigger; +use bytes::Bytes; +use criterion::{Criterion, criterion_group, criterion_main}; +use libdd_trace_protobuf::pb::Span; + +fn processor(tag_enabled: bool) -> Processor { + Processor::new(&Config { + ext: bottlecap::config::LambdaConfig { + serverless_appsec_enabled: true, + appsec_aws_access_key_id_tag_enabled: tag_enabled, + ..Default::default() + }, + ..Config::default() + }) + .expect("failed to create Processor") +} + +fn bench(c: &mut Criterion) { + // SAFETY: set once, before any other thread starts. + unsafe { std::env::set_var("AWS_ACCESS_KEY_ID", "ASIAEXAMPLE000000001") }; + + let trigger = IdentifiedTrigger::LambdaFunctionUrlEvent( + serde_json::from_slice(include_bytes!( + "../tests/payloads/lambda_function_url_event.json" + )) + .expect("failed to parse payload"), + ); + let response = Bytes::from_static(br#"{"statusCode":200}"#); + let rt = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime"); + + let mut group = c.benchmark_group("appsec_monitored_invocation"); + for (name, tag_enabled) in [("key_off", false), ("key_on", true)] { + let mut processor = processor(tag_enabled); + group.bench_function(name, |b| { + b.iter(|| { + rt.block_on(async { + processor.process_invocation_next("rid", &trigger).await; + processor.process_invocation_result("rid", &response).await; + }); + let mut span = Span { + name: "aws.lambda".into(), + meta: HashMap::from([("request_id".to_string(), "rid".to_string())]), + ..Default::default() + }; + processor.process_span(&mut span); + black_box(span) + }); + }); + } + group.finish(); +} + +criterion_group! { + name = benches; + config = Criterion::default().sample_size(200).measurement_time(std::time::Duration::from_secs(20)); + targets = bench +} +criterion_main!(benches); From 17f5fc8d918a2a7fd14bb61b7dc7b0d7e1f9e155 Mon Sep 17 00:00:00 2001 From: Julio Guerra Date: Thu, 12 Nov 2026 23:20:06 +0100 Subject: [PATCH 6/6] feat(appsec): never read the access key on a SnapStart restore (RFC rev 2 D2) Prototype for the JULIO-12 LLD (CADR for AWS Lambda, APPSEC-71674). Do not merge. On snap-start the variable is absent or belongs to the snapshotted environment, so the processor holds no reader and sets no tag. Also adds the trailing-newline cases of RFC rev 2 D3 to the validation test. Co-Authored-By: Claude Opus 5.5 Co-authored-by: multica-agent --- .../src/appsec/processor/aws_access_key.rs | 25 +++++++++++++++++++ bottlecap/src/appsec/processor/mod.rs | 10 +++++--- bottlecap/src/config/aws.rs | 2 ++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/bottlecap/src/appsec/processor/aws_access_key.rs b/bottlecap/src/appsec/processor/aws_access_key.rs index 78a897e02..58e5f6a46 100644 --- a/bottlecap/src/appsec/processor/aws_access_key.rs +++ b/bottlecap/src/appsec/processor/aws_access_key.rs @@ -12,6 +12,20 @@ pub(super) struct AwsAccessKeyId { value: OnceCell>, } impl AwsAccessKeyId { + /// Returns [`None`] when the tag is off, or on a `SnapStart` restore, where + /// `AWS_ACCESS_KEY_ID` is absent or belongs to the snapshotted environment + /// (RFC D2): the value is then never read. + pub(super) fn for_environment(tag_enabled: bool, init_type: Option<&str>) -> Option { + if !tag_enabled { + return None; + } + if init_type == Some(crate::tags::lambda::tags::SNAP_START_VALUE) { + debug!("aap: SnapStart restore, aws.lambda.access_key_id will not be set"); + return None; + } + Some(Self::from_env()) + } + pub(super) fn from_env() -> Self { Self::with_reader(crate::config::aws::aws_access_key_id_from_env) } @@ -61,6 +75,17 @@ mod tests { assert_eq!(READS.load(Ordering::SeqCst), 1); } + #[test] + fn none_when_disabled_or_snapstart() { + assert!(AwsAccessKeyId::for_environment(false, None).is_none()); + assert!(AwsAccessKeyId::for_environment(false, Some("on-demand")).is_none()); + assert!(AwsAccessKeyId::for_environment(true, Some("snap-start")).is_none()); + assert!(AwsAccessKeyId::for_environment(true, None).is_some()); + assert!(AwsAccessKeyId::for_environment(true, Some("on-demand")).is_some()); + assert!(AwsAccessKeyId::for_environment(true, Some("provisioned-concurrency")).is_some()); + assert!(AwsAccessKeyId::for_environment(true, Some("lambda-managed-instances")).is_some()); + } + #[test] fn caches_absence() { static READS: AtomicUsize = AtomicUsize::new(0); diff --git a/bottlecap/src/appsec/processor/mod.rs b/bottlecap/src/appsec/processor/mod.rs index 7911404fb..5ead28b9d 100644 --- a/bottlecap/src/appsec/processor/mod.rs +++ b/bottlecap/src/appsec/processor/mod.rs @@ -105,10 +105,12 @@ impl Processor { None }, context_buffer: VecDeque::with_capacity(capacity.get()), - aws_access_key_id: cfg - .ext - .appsec_aws_access_key_id_tag_enabled - .then(AwsAccessKeyId::from_env), + aws_access_key_id: AwsAccessKeyId::for_environment( + cfg.ext.appsec_aws_access_key_id_tag_enabled, + std::env::var(crate::tags::lambda::tags::INIT_TYPE) + .ok() + .as_deref(), + ), }) } diff --git a/bottlecap/src/config/aws.rs b/bottlecap/src/config/aws.rs index 85bbdb16d..41ba39ecf 100644 --- a/bottlecap/src/config/aws.rs +++ b/bottlecap/src/config/aws.rs @@ -135,6 +135,8 @@ mod tests { "ASIAEXAMPLE00000000", "ASIAEXAMPLE0000000011", "ASIAEXAMPLE00000000-", + "ASIAEXAMPLE000000001\n", + "ASIAEXAMPLE00000000\n", " ASIAEXAMPLE00000001", "ASIAÉXAMPLE00000001", ] {