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); 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..58e5f6a46 --- /dev/null +++ b/bottlecap/src/appsec/processor/aws_access_key.rs @@ -0,0 +1,100 @@ +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 { + /// 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) + } + + 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 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); + 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..7cd101946 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::IfAbsent(aws_access_key_id.to_string()), + ); + } Self { rid, @@ -240,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(), @@ -553,6 +565,8 @@ enum TagName { AppsecWafVersion, // Hidden span tags of relevance Origin, + // Execution environment tags + AwsLambdaAccessKeyId, // General request tags HttpUrl, HttpMethod, @@ -575,6 +589,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", @@ -595,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 @@ -605,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 f10a8a237..5ead28b9d 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_TAG_ENABLED` is off. + aws_access_key_id: Option, } impl Processor { const CONTEXT_BUFFER_DEFAULT_CAPACITY: NonZero = NonZero::new(5).unwrap(); @@ -101,6 +105,12 @@ impl Processor { None }, context_buffer: VecDeque::with_capacity(capacity.get()), + 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(), + ), }) } @@ -244,11 +254,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 +850,275 @@ 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_tag_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)); + } + + 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/aws.rs b/bottlecap/src/config/aws.rs index 284f91b50..41ba39ecf 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,37 @@ 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-", + "ASIAEXAMPLE000000001\n", + "ASIAEXAMPLE00000000\n", + " 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..866fff4e6 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_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_tag_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_tag_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_tag_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_tag_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_tag_enabled_defaults_to_false() { + let config = load(|_| Ok(())); + assert!(!config.ext.appsec_aws_access_key_id_tag_enabled); + } + + #[test] + fn appsec_aws_access_key_id_tag_enabled_from_env() { + let config = load(|jail| { + 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_tag_enabled); + } + #[test] fn appsec_block_from_env() { let config = load(|jail| {