Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions crates/openshell-ocsf/src/events/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ impl OcsfEvent {
}
}

/// Associate this event with other events produced for the same operation.
pub fn set_correlation_uid(&mut self, uid: impl Into<String>) {
self.base_mut().metadata.correlation_uid = Some(uid.into());
}

/// Returns mutable base data common to all event classes.
pub(crate) fn base_mut(&mut self) -> &mut BaseEventData {
match self {
Expand Down Expand Up @@ -281,6 +286,29 @@ mod tests {
assert_eq!(deserialized.class_uid(), 2004);
}

#[test]
fn correlation_uid_can_be_set_on_any_event_class() {
let ctx = test_sandbox_context();
let mut event = AuthenticationBuilder::new(
&ctx,
User::other("user-1", "User"),
Service::new("gateway"),
)
.status(StatusId::Success)
.build();

event.set_correlation_uid("request-123");

assert_eq!(
event.base().metadata.correlation_uid.as_deref(),
Some("request-123")
);
assert_eq!(
serde_json::to_value(&event).unwrap()["metadata"]["correlation_uid"],
"request-123"
);
}

#[test]
fn test_roundtrip_application_lifecycle() {
let ctx = test_sandbox_context();
Expand Down
3 changes: 2 additions & 1 deletion crates/openshell-otel/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ pub use ocsf::{OcsfOtlp, OcsfOtlpLayer};
pub use propagation::{
HeaderMapExtractor, MetadataMapInjector, TRACEPARENT_ENV, TRACESTATE_ENV,
TraceContextInterceptor, current_trace_context_carrier, current_trace_context_environment,
set_parent_from_environment, set_parent_from_trace_context, trace_context_environment,
set_parent_from_environment, set_parent_from_headers, set_parent_from_trace_context,
trace_context_environment,
};
pub use tls::{MAX_TLS_FILE_BYTES, OtlpTls, TlsError, TlsFileError};

Expand Down
20 changes: 19 additions & 1 deletion crates/openshell-otel/src/ocsf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const MAX_EXPORT_ATTEMPTS: usize = 4;
const INITIAL_BACKOFF: Duration = Duration::from_millis(250);
const MAX_BACKOFF: Duration = Duration::from_secs(2);
const GAP_EVENT: &str = "openshell.ocsf.gap";
const CORRELATION_UID_ATTRIBUTE: &str = "ocsf.metadata.correlation_uid";

/// Exports OCSF events to an OTLP collector as log records.
pub struct OcsfOtlp {
Expand Down Expand Up @@ -391,6 +392,9 @@ fn log_record(
if let Some(uid) = &base.metadata.uid {
record.add_attribute("ocsf.metadata.uid", uid.clone());
}
if let Some(uid) = &base.metadata.correlation_uid {
record.add_attribute(CORRELATION_UID_ATTRIBUTE, uid.clone());
}
if let Some(sandbox_id) = event.sandbox_id() {
record.add_attribute("openshell.sandbox.id", sandbox_id.to_string());
}
Expand Down Expand Up @@ -629,6 +633,16 @@ mod tests {
assert_eq!(native(&event)["resources"][0]["uid"], "requested-sandbox");
}

#[test]
fn correlation_uid_is_a_stable_otlp_attribute() {
let mut event = event("sandbox-1", SeverityId::Informational);
event.set_correlation_uid("request-123");
assert_eq!(
attribute(&record(&event), CORRELATION_UID_ATTRIBUTE),
Some(AnyValue::String("request-123".into()))
);
}

#[test]
fn events_without_a_sandbox_carry_no_sandbox_attribute() {
let record = record(&event("", SeverityId::Informational));
Expand Down Expand Up @@ -785,7 +799,11 @@ mod tests {
.build();
let subscriber = tracing_subscriber::registry()
.with(crate::layer(&tracer, "ocsf-test"))
.with(otlp.layer());
.with(
Some(otlp.layer()).with_filter(tracing_subscriber::filter::filter_fn(
|metadata| metadata.target() == OCSF_TARGET,
)),
);
let span_context = tracing::subscriber::with_default(subscriber, || {
let span = tracing::info_span!("decision");
span.in_scope(|| ocsf_emit!(event("sandbox-1", SeverityId::Medium)));
Expand Down
146 changes: 146 additions & 0 deletions crates/openshell-otel/src/propagation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,42 @@ pub fn set_parent_from_trace_context(
set_parent_from_carrier(span, &carrier);
}

/// Parent `span` under valid W3C trace context from HTTP headers.
///
/// Returns `false` when `traceparent` is absent, duplicated, invalid, or
/// unsampled. Remote context is correlation data only and does not authenticate
/// its sender. Unsampled remote context is linked rather than adopted so an
/// untrusted caller cannot suppress locally exported enforcement telemetry.
/// `tracestate` is not accepted across this trust boundary.
#[must_use]
pub fn set_parent_from_headers(span: &tracing::Span, headers: &HeaderMap) -> bool {
use opentelemetry::trace::TraceContextExt as _;

if headers.get_all("traceparent").iter().count() != 1 {
return false;
}

let Some(traceparent) = headers
.get("traceparent")
.and_then(|value| value.to_str().ok())
else {
return false;
};
let carrier = BTreeMap::from([("traceparent".to_string(), traceparent.to_string())]);
let parent = TraceContextPropagator::new().extract(&TraceContextMapExtractor(&carrier));
let parent_span = parent.span();
let parent_context = parent_span.span_context();
if !parent_context.is_valid() {
return false;
}
if !parent_context.is_sampled() {
span.add_link(parent_context.clone());
return false;
}

span.set_parent(parent).is_ok()
}

fn set_parent_from_carrier(span: &tracing::Span, carrier: &BTreeMap<String, String>) {
use opentelemetry::trace::TraceContextExt as _;

Expand Down Expand Up @@ -283,6 +319,116 @@ mod tests {
assert_eq!(orphan.parent_span_id, opentelemetry::trace::SpanId::INVALID);
}

#[test]
fn http_headers_parent_a_span_only_with_one_valid_traceparent() {
use opentelemetry::trace::TraceContextExt as _;

let _tracing_lock = crate::test_lock();
let (_provider, exporter, subscriber) = env_test_exporter();
let trace_id = "4bf92f3577b34da6a3ce929d0e0e4736";
let parent_span_id = "00f067aa0ba902b7";

tracing::subscriber::with_default(subscriber, || {
let mut headers = HeaderMap::new();
headers.insert(
"traceparent",
format!("00-{trace_id}-{parent_span_id}-01")
.parse()
.unwrap(),
);
headers.insert("tracestate", "opaque-vendor=untrusted".parse().unwrap());
let child = tracing::info_span!("http-child");
assert!(set_parent_from_headers(&child, &headers));
let context = child.context();
assert_eq!(
context.span().span_context().trace_id().to_string(),
trace_id
);
assert_eq!(context.span().span_context().trace_state().header(), "");
});

let child = exporter
.get_finished_spans()
.unwrap()
.into_iter()
.find(|span| span.name == "http-child")
.unwrap();
assert_eq!(child.parent_span_id.to_string(), parent_span_id);
}

#[test]
fn duplicate_and_malformed_http_traceparent_are_rejected() {
let _tracing_lock = crate::test_lock();
let (_provider, _exporter, subscriber) = env_test_exporter();

tracing::subscriber::with_default(subscriber, || {
let mut duplicate = HeaderMap::new();
duplicate.append(
"traceparent",
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
.parse()
.unwrap(),
);
duplicate.append(
"traceparent",
"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"
.parse()
.unwrap(),
);
assert!(!set_parent_from_headers(
&tracing::info_span!("duplicate"),
&duplicate
));

let mut malformed = HeaderMap::new();
malformed.insert("traceparent", "invalid".parse().unwrap());
assert!(!set_parent_from_headers(
&tracing::info_span!("malformed"),
&malformed
));
});
}

#[test]
fn unsampled_http_traceparent_cannot_suppress_local_span_export() {
use opentelemetry::trace::TraceContextExt as _;

let _tracing_lock = crate::test_lock();
let (_provider, exporter, subscriber) = env_test_exporter();
let incoming_trace_id = "4bf92f3577b34da6a3ce929d0e0e4736";

tracing::subscriber::with_default(subscriber, || {
let mut unsampled = HeaderMap::new();
unsampled.insert(
"traceparent",
format!("00-{incoming_trace_id}-00f067aa0ba902b7-00")
.parse()
.unwrap(),
);
let child = tracing::info_span!("unsampled-child");
assert!(!set_parent_from_headers(&child, &unsampled));
let context = child.context();
let span = context.span();
let span_context = span.span_context();
assert!(span_context.is_valid());
assert!(span_context.is_sampled());
assert_ne!(span_context.trace_id().to_string(), incoming_trace_id);
});

let child = exporter
.get_finished_spans()
.unwrap()
.into_iter()
.find(|span| span.name == "unsampled-child")
.expect("the locally sampled enforcement span is exported");
assert_eq!(child.parent_span_id, opentelemetry::trace::SpanId::INVALID);
assert_eq!(child.links.len(), 1);
assert_eq!(
child.links[0].span_context.trace_id().to_string(),
incoming_trace_id
);
}

#[test]
fn interceptor_adds_traceparent_only_inside_an_exported_span() {
use tonic::service::Interceptor as _;
Expand Down
74 changes: 57 additions & 17 deletions crates/openshell-supervisor-middleware/src/headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,25 +286,31 @@ fn is_safe_value(value: &str) -> bool {
}

fn is_request_protected(name: &str) -> bool {
matches!(
name,
"authorization"
| "proxy-authorization"
| "proxy-authenticate"
| "cookie"
| "host"
| "content-length"
| "transfer-encoding"
| "connection"
| "proxy-connection"
| "keep-alive"
| "te"
| "trailer"
| "upgrade"
) || name.starts_with("x-amz-")
is_platform_correlation_header(name)
|| matches!(
name,
"authorization"
| "proxy-authorization"
| "proxy-authenticate"
| "cookie"
| "host"
| "content-length"
| "transfer-encoding"
| "connection"
| "proxy-connection"
| "keep-alive"
| "te"
| "trailer"
| "upgrade"
)
|| name.starts_with("x-amz-")
|| name.starts_with("x-openshell-credential")
}

fn is_platform_correlation_header(name: &str) -> bool {
name == "x-openshell-request-id"
}

/// Return whether a response header can carry authentication material and must
/// never be exposed to middleware.
#[must_use]
Expand All @@ -322,7 +328,8 @@ pub fn is_response_credential_header(name: &str) -> bool {
}

fn is_response_protected(name: &str) -> bool {
is_response_credential_header(name)
is_platform_correlation_header(name)
|| is_response_credential_header(name)
|| matches!(
name,
"connection"
Expand Down Expand Up @@ -408,6 +415,39 @@ mod tests {
);
}

#[test]
fn platform_request_id_is_immutable_for_all_http_phases() {
for authority in [
HeaderAuthority::Request,
HeaderAuthority::Response,
HeaderAuthority::ResponseTrailers,
] {
let name = "X-OpenShell-Request-ID";
let write_error = apply(
authority,
&[],
&[],
&[write(name, "forged", ExistingHeaderAction::Overwrite)],
)
.expect_err("platform request ID write");
assert_eq!(
write_error,
HeaderMutationError::Protected {
name: name.to_string()
}
);

let remove_error = apply(authority, &[], &[], &[remove(name)])
.expect_err("platform request ID removal");
assert_eq!(
remove_error,
HeaderMutationError::Protected {
name: name.to_string()
}
);
}
}

#[test]
fn unsafe_header_value_is_rejected() {
let error = apply(
Expand Down
5 changes: 3 additions & 2 deletions crates/openshell-supervisor-network/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ openshell-crypto = { workspace = true }
openshell-core = { path = "../openshell-core", features = ["oauth"] }
openshell-isolation-interface = { path = "../openshell-isolation-interface" }
openshell-ocsf = { path = "../openshell-ocsf" }
openshell-otel = { path = "../openshell-otel" }
openshell-policy = { path = "../openshell-policy" }
openshell-policy-schema = { path = "../openshell-policy-schema" }
openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" }
Expand Down Expand Up @@ -70,9 +71,9 @@ openshell-driver-vm = { path = "../openshell-driver-vm", default-features = fals
openshell-otel-test-support = { path = "../openshell-otel-test-support" }
openshell-ocsf = { path = "../openshell-ocsf", features = ["test-support"] }
openshell-ocsf-validate = { path = "../openshell-ocsf-validate" }
openshell-otel = { path = "../openshell-otel" }
openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" }
opentelemetry-proto = { workspace = true }
opentelemetry-proto = { workspace = true, features = ["gen-tonic", "trace"] }
opentelemetry = { workspace = true }
opentelemetry_sdk = { workspace = true, features = ["testing"] }
temp-env = "0.3"
tokio = { workspace = true, features = ["test-util"] }
Expand Down
Loading
Loading