Skip to content
Open
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ ocsf_emit!(event);

- `crate::ocsf_ctx()` returns the process-wide `EventContext`. It is always available (falls back to defaults in tests).
- `ocsf_emit!()` is non-blocking and cannot panic. It stores the event in a thread-local and emits via `tracing::info!()`.
- In `openshell-supervisor-network`, emit with `crate::telemetry::egress_emit!` instead, or `crate::telemetry::emit_decision` for the records of one decision, so denied HTTP decisions get a deny span. Clippy rejects `ocsf_emit!` there.
- In `openshell-supervisor-network`, route inspected request records through `RequestCorrelation`, which uses their existing request span without creating a denial-only span. Other egress decisions use `crate::telemetry::egress_emit!` or `crate::telemetry::emit_decision`. Clippy rejects direct `ocsf_emit!` calls there.
- The shorthand layer and JSONL layer extract the event from the thread-local. The shorthand format is derived automatically from the builder fields.
- For security findings, **dual-emit**: one domain event (e.g., `SshActivityBuilder`) AND one `DetectionFindingBuilder` for the same incident.
- Never log secrets, credentials, or query parameters in OCSF messages. The OCSF JSONL file may be shipped to external systems.
Expand Down
2 changes: 2 additions & 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
27 changes: 26 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 @@ -788,12 +802,23 @@ mod tests {
.with(otlp.layer());
let span_context = tracing::subscriber::with_default(subscriber, || {
let span = tracing::info_span!("decision");
span.in_scope(|| ocsf_emit!(event("sandbox-1", SeverityId::Medium)));
let mut event = event("sandbox-1", SeverityId::Medium);
event.set_correlation_uid("request-with-or-without-sampling");
span.in_scope(|| ocsf_emit!(event));
span.context().span().span_context().clone()
});
otlp.shutdown().await;

let records = exporter.exported();
assert_eq!(records.len(), 1);
assert_eq!(
attribute(&records[0].record, CORRELATION_UID_ATTRIBUTE),
Some(AnyValue::String("request-with-or-without-sampling".into()))
);
assert_eq!(
body(&records[0].record)["metadata"]["correlation_uid"],
"request-with-or-without-sampling"
);
let trace = records[0].record.trace_context();
if span_context.is_sampled() {
let trace = trace.expect("sampled span context is attached");
Expand Down
77 changes: 60 additions & 17 deletions crates/openshell-supervisor-middleware/src/headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,25 +366,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 @@ -402,7 +408,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 @@ -488,6 +495,42 @@ mod tests {
);
}

#[test]
fn platform_request_id_is_immutable_for_all_http_phases() {
for authority in [
HeaderAuthority::Request,
HeaderAuthority::HookRequest,
HeaderAuthority::RequestTrailers,
HeaderAuthority::Response,
HeaderAuthority::HookResponse,
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
3 changes: 3 additions & 0 deletions crates/openshell-supervisor-network/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ hex = "0.4"
hickory-proto = "0.26.1"
ipnet = "2"
miette = { workspace = true }
opentelemetry = { workspace = true }
opentelemetry_sdk = { workspace = true }
prost-types = { workspace = true }
rcgen = { workspace = true }
regorus = { version = "0.9", default-features = false, features = ["std", "arc", "glob"] }
Expand All @@ -56,6 +58,7 @@ tokio-rustls = { workspace = true }
tonic = { workspace = true }
tower-mcp-types = { workspace = true }
tracing = { workspace = true }
tracing-opentelemetry = { workspace = true }
uuid = { workspace = true }
webpki-roots = { workspace = true, optional = true }

Expand Down
26 changes: 26 additions & 0 deletions crates/openshell-supervisor-network/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,29 @@ domain event and a detection finding without credential values or placeholders.
Explicit body and WebSocket
rewrite flags authorize those locations for ordinary static credentials. SigV4
signing retains the endpoint-scoped resolver's existing trusted access.

## HTTP enforcement correlation

Each inspected HTTP request has one UUID shared by middleware, OCSF
`metadata.correlation_uid`, and the protected `x-openshell-request-id` response
header. Application correlation must use another header. The receipt is only a
join key: it does not establish identity, authorization or complete delivery.
L4 and passthrough traffic do not provide this header guarantee.

The audit state commits one primary HTTP Activity record when the final response
head is written, with an early-termination fallback. It retains the original
request and process context while consolidating middleware and credential
outcomes. Routine outcomes do not add records; existing security findings and
post-header failure records remain distinct. A process crash or indefinitely
hung request cannot be finalized by the fallback.

Request IDs and audit outcomes do not depend on OpenTelemetry, sampling or
collector availability. Inspected requests have a supervisor-owned request span
and children around stages that actually run, with valid workload W3C context
only as an untrusted link. Their records never create an extra denial-only span,
even if the request span is disabled or unsampled. Other egress paths retain the
legacy bounded deny-span behavior. End HTTP tracing before an upgraded session.
The request span ends after its owned stages, not when response headers are logged.
The `openshell.upstream.send` stage measures request writes and flush; response
handling is measured separately. Receipt filtering preserves raw HTTP bytes
and borrows unchanged header blocks.
Loading
Loading