diff --git a/changelog.d/tls_client_metadata_subject_altnames.enhancement.md b/changelog.d/tls_client_metadata_subject_altnames.enhancement.md new file mode 100644 index 0000000000000..f976b72b60e51 --- /dev/null +++ b/changelog.d/tls_client_metadata_subject_altnames.enhancement.md @@ -0,0 +1,8 @@ +The `tls_client_metadata` metadata field added by TCP-based sources with `client_metadata_key` +set now includes `subject_altnames`, containing the Subject Alternative Names (DNS names, email +addresses, URIs, and IP addresses) from the client TLS certificate. Each SAN is prefixed with its +type (for example `DNS:example.com`, `email:admin@example.com`, `URI:https://example.com`, or +`IP Address:127.0.0.1`) to match the output of `openssl x509 -text`. This key is only added when +the client certificate contains Subject Alternative Names. + +authors: emillen diff --git a/lib/vector-core/src/tls/incoming.rs b/lib/vector-core/src/tls/incoming.rs index b22551b2d62da..7195aaf187f7b 100644 --- a/lib/vector-core/src/tls/incoming.rs +++ b/lib/vector-core/src/tls/incoming.rs @@ -1,7 +1,7 @@ use std::{ collections::HashMap, future::Future, - net::SocketAddr, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, pin::Pin, sync::Arc, task::{Context, Poll}, @@ -400,6 +400,7 @@ pub struct CertificateMetadata { pub organization_name: Option, pub organizational_unit_name: Option, pub common_name: Option, + pub subject_altnames: Vec, } impl CertificateMetadata { @@ -425,6 +426,10 @@ impl CertificateMetadata { } components.join(",") } + + pub fn subject_altnames(&self) -> String { + self.subject_altnames.join(",") + } } impl From for CertificateMetadata { @@ -434,6 +439,37 @@ impl From for CertificateMetadata { let data_string = entry.data().to_string().unwrap_or_default(); subject_metadata.insert(entry.object().to_string(), data_string); } + let subject_altnames = cert + .subject_alt_names() + .map(|names| { + names + .iter() + .filter_map(|name| { + if let Some(dns) = name.dnsname() { + Some(format!("DNS:{dns}")) + } else if let Some(email) = name.email() { + Some(format!("email:{email}")) + } else if let Some(uri) = name.uri() { + Some(format!("URI:{uri}")) + } else { + name.ipaddress().and_then(|ip| match ip.len() { + 4 => Some(format!( + "IP Address:{}", + IpAddr::V4(Ipv4Addr::new(ip[0], ip[1], ip[2], ip[3])) + )), + 16 => Some(format!( + "IP Address:{}", + IpAddr::V6(Ipv6Addr::from(u128::from_be_bytes( + ip.try_into().expect("ip address length checked"), + ))) + )), + _ => None, + }) + } + }) + .collect() + }) + .unwrap_or_default(); Self { country_name: subject_metadata.get("countryName").cloned(), state_or_province_name: subject_metadata.get("stateOrProvinceName").cloned(), @@ -441,6 +477,7 @@ impl From for CertificateMetadata { organization_name: subject_metadata.get("organizationName").cloned(), organizational_unit_name: subject_metadata.get("organizationalUnitName").cloned(), common_name: subject_metadata.get("commonName").cloned(), + subject_altnames, } } } @@ -472,6 +509,13 @@ impl Connected for MaybeTlsIncomingStream { #[cfg(test)] mod test { + use openssl::{ + hash::MessageDigest, + pkey::PKey, + rsa::Rsa, + x509::{X509NameBuilder, extension::SubjectAlternativeName}, + }; + use super::*; #[test] @@ -483,6 +527,7 @@ mod test { organization_name: Some("organization".to_owned()), organizational_unit_name: Some("org_unit".to_owned()), state_or_province_name: Some("state".to_owned()), + subject_altnames: vec!["DNS:example.com".to_owned()], }; let expected = format!( @@ -506,6 +551,7 @@ mod test { organization_name: Some("organization".to_owned()), organizational_unit_name: Some("org_unit".to_owned()), state_or_province_name: None, + subject_altnames: vec![], }; let expected = format!( @@ -517,4 +563,67 @@ mod test { ); assert_eq!(expected, example_meta.subject()); } + + #[test] + fn certificate_metadata_subject_altnames() { + let example_meta = CertificateMetadata { + common_name: None, + country_name: None, + locality_name: None, + organization_name: None, + organizational_unit_name: None, + state_or_province_name: None, + subject_altnames: vec![ + "DNS:example.com".to_owned(), + "IP Address:1.2.3.4".to_owned(), + ], + }; + assert_eq!( + "DNS:example.com,IP Address:1.2.3.4", + example_meta.subject_altnames() + ); + + let empty_meta = CertificateMetadata { + common_name: None, + country_name: None, + locality_name: None, + organization_name: None, + organizational_unit_name: None, + state_or_province_name: None, + subject_altnames: vec![], + }; + assert_eq!("", empty_meta.subject_altnames()); + } + + #[test] + fn certificate_metadata_from_x509() { + let key = PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(); + + let mut name = X509NameBuilder::new().unwrap(); + name.append_entry_by_text("CN", "example.com").unwrap(); + let name = name.build(); + + let mut builder = X509::builder().unwrap(); + builder.set_version(2).unwrap(); + builder.set_subject_name(&name).unwrap(); + builder.set_issuer_name(&name).unwrap(); + builder.set_pubkey(&key).unwrap(); + let san = SubjectAlternativeName::new() + .dns("example.com") + .email("admin@example.com") + .uri("https://example.com") + .ip("1.2.3.4") + .build(&builder.x509v3_context(None, None)) + .unwrap(); + builder.append_extension(san).unwrap(); + builder.sign(&key, MessageDigest::sha256()).unwrap(); + let cert = builder.build(); + + let metadata = CertificateMetadata::from(cert); + + assert_eq!( + "DNS:example.com,email:admin@example.com,URI:https://example.com,IP Address:1.2.3.4", + metadata.subject_altnames() + ); + } } diff --git a/src/sources/dnstap/tcp.rs b/src/sources/dnstap/tcp.rs index 29ae0c9f1e5e2..7c1aa857c6702 100644 --- a/src/sources/dnstap/tcp.rs +++ b/src/sources/dnstap/tcp.rs @@ -19,7 +19,7 @@ use crate::{ internal_events::{SocketEventsReceived, SocketMode}, sources::util::{ framestream::{FrameHandler, TcpFrameHandler}, - net::SocketListenAddr, + net::{SocketListenAddr, build_tls_client_metadata}, }, }; @@ -266,11 +266,7 @@ impl TcpFrameHandler for DnstapFrameHandler { } fn insert_tls_client_metadata(&mut self, metadata: Option) { - self.tls_client_metadata = metadata.map(|c| { - let mut metadata = ObjectMap::new(); - metadata.insert("subject".into(), c.subject().into()); - metadata - }); + self.tls_client_metadata = metadata.map(|c| build_tls_client_metadata(&c)); } fn allowed_origins(&self) -> Option<&[IpNet]> { diff --git a/src/sources/util/net/mod.rs b/src/sources/util/net/mod.rs index 4ae7920973b7a..f3b56b5e06a86 100644 --- a/src/sources/util/net/mod.rs +++ b/src/sources/util/net/mod.rs @@ -11,7 +11,7 @@ use vector_lib::configurable::configurable_component; #[cfg(feature = "sources-utils-net-tcp")] pub use self::tcp::{ MAX_IN_FLIGHT_EVENTS_TARGET, TcpNullAcker, TcpSource, TcpSourceAck, TcpSourceAcker, - request_limiter::RequestLimiter, try_bind_tcp_listener, + build_tls_client_metadata, request_limiter::RequestLimiter, try_bind_tcp_listener, }; #[cfg(feature = "sources-utils-net-udp")] pub use self::udp::try_bind_udp_socket; diff --git a/src/sources/util/net/tcp/mod.rs b/src/sources/util/net/tcp/mod.rs index aebce3dc9b6ee..d32a3d0fc7268 100644 --- a/src/sources/util/net/tcp/mod.rs +++ b/src/sources/util/net/tcp/mod.rs @@ -383,8 +383,7 @@ async fn handle_stream( if let Some(certificate_metadata) = &certificate_metadata { - let mut metadata = ObjectMap::new(); - metadata.insert("subject".into(), certificate_metadata.subject().into()); + let metadata = build_tls_client_metadata(certificate_metadata); for event in &mut events { let log = event.as_mut_log(); @@ -477,3 +476,62 @@ fn close_socket(socket: &MaybeTlsIncomingStream) -> bool { true } } + +const TLS_CLIENT_METADATA_SUBJECT_KEY: &str = "subject"; +const TLS_CLIENT_METADATA_SUBJECT_ALTNAMES_KEY: &str = "subject_altnames"; + +pub fn build_tls_client_metadata(certificate_metadata: &CertificateMetadata) -> ObjectMap { + let mut metadata = ObjectMap::new(); + metadata.insert( + TLS_CLIENT_METADATA_SUBJECT_KEY.into(), + certificate_metadata.subject().into(), + ); + let subject_altnames = certificate_metadata.subject_altnames(); + if !subject_altnames.is_empty() { + metadata.insert( + TLS_CLIENT_METADATA_SUBJECT_ALTNAMES_KEY.into(), + subject_altnames.into(), + ); + } + metadata +} + +#[cfg(test)] +mod test { + use super::*; + use vrl::value::Value; + + fn metadata_with_subject_altnames(subject_altnames: Vec) -> CertificateMetadata { + CertificateMetadata { + country_name: None, + state_or_province_name: None, + locality_name: None, + organization_name: None, + organizational_unit_name: None, + common_name: Some("common".to_owned()), + subject_altnames, + } + } + + #[test] + fn tls_client_metadata_with_subject_altnames() { + let metadata = build_tls_client_metadata(&metadata_with_subject_altnames(vec![ + "DNS:example.com".to_owned(), + "IP Address:1.2.3.4".to_owned(), + ])); + + assert_eq!(metadata.get("subject"), Some(&Value::from("CN=common"))); + assert_eq!( + metadata.get("subject_altnames"), + Some(&Value::from("DNS:example.com,IP Address:1.2.3.4")) + ); + } + + #[test] + fn tls_client_metadata_without_subject_altnames() { + let metadata = build_tls_client_metadata(&metadata_with_subject_altnames(vec![])); + + assert_eq!(metadata.get("subject"), Some(&Value::from("CN=common"))); + assert!(!metadata.contains_key("subject_altnames")); + } +} diff --git a/website/cue/reference/components/sources.cue b/website/cue/reference/components/sources.cue index f8da497bd3eea..f4e3a1567074f 100644 --- a/website/cue/reference/components/sources.cue +++ b/website/cue/reference/components/sources.cue @@ -332,6 +332,14 @@ components: sources: [Name=string]: { examples: ["CN=localhost,OU=Vector,O=Datadog,L=New York,ST=New York,C=US"] } } + subject_altnames: { + description: "The Subject Alternative Names (SANs) from the client TLS certificate, as a comma-separated list. Each SAN is prefixed with its type (for example `DNS:example.com`, `email:admin@example.com`, `URI:https://example.com`, or `IP Address:127.0.0.1`) to match the output of `openssl x509 -text`. Only added if `tls.client_metadata_key` is set and the client certificate contains SANs. Key name depends on configured `client_metadata_key`" + required: false + type: string: { + default: null + examples: ["DNS:localhost,IP Address:127.0.0.1"] + } + } } } } diff --git a/website/cue/reference/components/sources/dnstap.cue b/website/cue/reference/components/sources/dnstap.cue index 2299dc051a703..ce5df3008ee37 100644 --- a/website/cue/reference/components/sources/dnstap.cue +++ b/website/cue/reference/components/sources/dnstap.cue @@ -856,6 +856,7 @@ components: sources: dnstap: { } } } + client_metadata: fields._client_metadata } } diff --git a/website/layouts/partials/logs_output.html b/website/layouts/partials/logs_output.html index ef962218b1413..16c8a50192578 100644 --- a/website/layouts/partials/logs_output.html +++ b/website/layouts/partials/logs_output.html @@ -73,6 +73,54 @@ {{ end }} {{ end }} + + {{ with index $v.type "object" }} + {{ with .options }} +
+ {{ range $name, $opt := . }} +
+ + + {{ $name }} + + + + {{ if $opt.required }} + {{ partial "badge.html" (dict "word" "required" "color" "red") }} + {{ else }} + {{ partial "badge.html" (dict "word" "optional" "color" "blue") }} + {{ end }} + + {{ range $t, $tv := $opt.type }} + {{ partial "badge.html" (dict "word" $t "color" "gray") }} + {{ end }} + + + + {{ with $opt.description }} +
+ {{ . | markdownify }} +
+ {{ end }} + + {{ range $t, $tv := $opt.type }} + {{ with $tv.examples }} +
+ Examples + +
+ {{ range . }} + {{ . }} + {{ end }} +
+
+ {{ end }} + {{ end }} +
+ {{ end }} +
+ {{ end }} + {{ end }} {{ end }}