Skip to content
Merged
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
43 changes: 42 additions & 1 deletion crates/openshell-ocsf/src/builders/authentication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub struct AuthenticationBuilder<'a> {
status_detail: Option<String>,
src_endpoint: Option<Endpoint>,
http_request: Option<HttpRequest>,
correlation_uid: Option<String>,
message: Option<String>,
}

Expand All @@ -42,6 +43,7 @@ impl<'a> AuthenticationBuilder<'a> {
status_detail: None,
src_endpoint: None,
http_request: None,
correlation_uid: None,
message: None,
}
}
Expand All @@ -68,6 +70,13 @@ impl<'a> AuthenticationBuilder<'a> {
self
}

/// Set the identifier shared by events about the same request.
#[must_use]
pub fn correlation_uid(mut self, uid: impl Into<String>) -> Self {
self.correlation_uid = Some(uid.into());
self
}

#[must_use]
pub fn src_endpoint(mut self, endpoint: Endpoint) -> Self {
self.src_endpoint = Some(endpoint);
Expand All @@ -83,6 +92,8 @@ impl<'a> AuthenticationBuilder<'a> {

#[must_use]
pub fn build(self) -> OcsfEvent {
let mut metadata = self.ctx.metadata(&["security_control", "host"]);
metadata.correlation_uid = self.correlation_uid;
let mut base = BaseEventData::new(
3002,
"Authentication",
Expand All @@ -91,7 +102,7 @@ impl<'a> AuthenticationBuilder<'a> {
1,
"Logon",
self.severity,
self.ctx.metadata(&["security_control", "host"]),
metadata,
);
if let Some(status) = self.status {
base.set_status(status);
Expand Down Expand Up @@ -224,6 +235,36 @@ mod tests {
serde_json::json!(["security_control", "host"])
);
assert!(json.get("container").is_none());
assert!(json["metadata"].get("correlation_uid").is_none());
}

#[test]
fn correlated_event_from_a_named_source_matches_vendored_schema() {
let ctx = test_sandbox_context();
let json = AuthenticationBuilder::new(
&ctx,
User::unidentified(),
Service::new("openshell-gateway"),
)
.src_endpoint(Endpoint::from_name("edge-tunnel"))
.correlation_uid("6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f")
.build()
.to_json()
.unwrap();

let endpoint = load_object_schema("network_endpoint");
validate_known_fields(&json["src_endpoint"], &endpoint);
let metadata = load_object_schema("metadata");
validate_required_fields(&json["metadata"], &metadata);
validate_known_fields(&json["metadata"], &metadata);
assert_eq!(
json["src_endpoint"],
serde_json::json!({"name": "edge-tunnel"})
);
assert_eq!(
json["metadata"]["correlation_uid"],
"6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"
);
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-ocsf/src/builders/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ impl EventContext {
profiles: profiles.iter().map(|s| (*s).to_string()).collect(),
uid: Some(uuid::Uuid::new_v4().to_string()),
log_source: None,
correlation_uid: None,
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/openshell-ocsf/src/events/api_activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ mod tests {
profiles: vec!["ai_operation".to_string()],
uid: None,
log_source: None,
correlation_uid: None,
},
);
base.set_ai_model(AiModel::new("claude-3-haiku", "anthropic"));
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-ocsf/src/events/app_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ mod tests {
profiles: vec!["container".to_string()],
uid: Some("sandbox-abc123".to_string()),
log_source: None,
correlation_uid: None,
},
);
base.set_message("Starting sandbox");
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-ocsf/src/events/base_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ mod tests {
profiles: vec!["container".to_string(), "host".to_string()],
uid: Some("sandbox-abc123".to_string()),
log_source: None,
correlation_uid: None,
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/openshell-ocsf/src/events/config_state_change.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ mod tests {
profiles: vec!["security_control".to_string()],
uid: Some("sandbox-abc123".to_string()),
log_source: None,
correlation_uid: None,
},
);
base.set_message("Policy reloaded successfully");
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-ocsf/src/events/detection_finding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ mod tests {
profiles: vec!["security_control".to_string()],
uid: Some("sandbox-abc123".to_string()),
log_source: None,
correlation_uid: None,
},
),
finding_info: FindingInfo::new("nssh1-replay-abc", "NSSH1 Nonce Replay Attack")
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-ocsf/src/events/http_activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ mod tests {
profiles: vec!["security_control".to_string()],
uid: Some("sandbox-abc123".to_string()),
log_source: None,
correlation_uid: None,
},
),
http_request: Some(HttpRequest::new(
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-ocsf/src/events/network_activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ mod tests {
profiles: vec!["security_control".to_string(), "network_proxy".to_string()],
uid: Some("sandbox-abc123".to_string()),
log_source: None,
correlation_uid: None,
},
),
src_endpoint: Some(Endpoint::from_ip_str("10.42.0.2", 54321)),
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-ocsf/src/events/process_activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ mod tests {
profiles: vec!["container".to_string()],
uid: Some("sandbox-abc123".to_string()),
log_source: None,
correlation_uid: None,
},
),
process: Process::new("python3", 42).with_cmd_line("python3 /app/main.py"),
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-ocsf/src/events/ssh_activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ mod tests {
profiles: vec!["security_control".to_string()],
uid: Some("sandbox-abc123".to_string()),
log_source: None,
correlation_uid: None,
},
),
src_endpoint: Some(Endpoint::from_ip_str("10.42.0.1", 48201)),
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-ocsf/src/format/jsonl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ mod tests {
profiles: vec!["container".to_string()],
uid: Some("sandbox-abc123".to_string()),
log_source: None,
correlation_uid: None,
},
);
base.set_time(1_742_054_400_000);
Expand Down
28 changes: 27 additions & 1 deletion crates/openshell-ocsf/src/format/shorthand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,13 @@ impl OcsfEvent {
.as_ref()
.map(|ep| {
let port = ep.port.map_or(String::new(), |p| format!(":{p}"));
format!(" from {}{port}", ep.domain_or_ip())
let host = ep
.domain
.as_deref()
.or(ep.ip.as_deref())
.or(ep.name.as_deref())
.unwrap_or("unknown");
format!(" from {}{port}", escape_context_field(host))
})
.unwrap_or_default();
let mut context = Vec::new();
Expand Down Expand Up @@ -614,6 +620,7 @@ mod tests {
profiles: vec!["security_control".to_string()],
uid: Some("sandbox-abc123".to_string()),
log_source: None,
correlation_uid: None,
}
}

Expand Down Expand Up @@ -1125,6 +1132,25 @@ mod tests {
);
}

#[test]
fn test_authentication_shorthand_names_an_unaddressed_source() {
let ctx = crate::builders::test_sandbox_context();
let event = crate::builders::AuthenticationBuilder::new(
&ctx,
User::unidentified(),
Service::new("openshell-gateway"),
)
.action(ActionId::Denied)
.severity(crate::enums::SeverityId::Low)
.src_endpoint(Endpoint::from_name("edge-tunnel"))
.build();

assert_eq!(
event.format_shorthand(),
"AUTH:LOGON [LOW] DENIED unknown from edge-tunnel"
);
}

#[test]
fn test_authentication_success_shorthand_labels_principal_type() {
let ctx = crate::builders::test_sandbox_context();
Expand Down
18 changes: 18 additions & 0 deletions crates/openshell-ocsf/src/objects/endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ pub struct Endpoint {
/// Port number.
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,

/// Short name for an endpoint with no address of its own.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}

impl Endpoint {
Expand All @@ -31,6 +35,18 @@ impl Endpoint {
domain: Some(name.to_string()),
ip: None,
port: Some(port),
name: None,
}
}

/// Create an endpoint identified only by a short name.
#[must_use]
pub fn from_name(name: &str) -> Self {
Self {
domain: None,
ip: None,
port: None,
name: Some(name.to_string()),
}
}

Expand All @@ -41,6 +57,7 @@ impl Endpoint {
domain: None,
ip: Some(addr.to_string()),
port: Some(port),
name: None,
}
}

Expand All @@ -51,6 +68,7 @@ impl Endpoint {
domain: None,
ip: Some(addr.to_string()),
port: Some(port),
name: None,
}
}

Expand Down
6 changes: 6 additions & 0 deletions crates/openshell-ocsf/src/objects/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ pub struct Metadata {
/// Log source path (e.g., "/dev/kmsg" for bypass detection).
#[serde(skip_serializing_if = "Option::is_none")]
pub log_source: Option<String>,

/// Identifier shared by events about the same request.
#[serde(skip_serializing_if = "Option::is_none")]
pub correlation_uid: Option<String>,
}

/// OCSF Product object — identifies the software generating events.
Expand Down Expand Up @@ -75,6 +79,7 @@ mod tests {
profiles: vec!["security_control".to_string(), "container".to_string()],
uid: Some("sandbox-abc123".to_string()),
log_source: None,
correlation_uid: None,
};

let json = serde_json::to_value(&metadata).unwrap();
Expand All @@ -92,6 +97,7 @@ mod tests {
profiles: vec![],
uid: None,
log_source: Some("/dev/kmsg".to_string()),
correlation_uid: None,
};

let json = serde_json::to_value(&metadata).unwrap();
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-ocsf/src/tracing_layers/event_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ mod tests {
profiles: vec![],
uid: None,
log_source: None,
correlation_uid: None,
},
),
})
Expand Down
51 changes: 49 additions & 2 deletions crates/openshell-server/src/auth/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,49 @@
//! credentials or raw verification errors.

use openshell_ocsf::{
ActionId, AuthProtocolId, AuthenticationBuilder, DispositionId, HttpMethod, HttpRequest,
Service, SeverityId, StatusId, Url, User, ocsf_emit,
ActionId, AuthProtocolId, AuthenticationBuilder, DispositionId, Endpoint, HttpMethod,
HttpRequest, Service, SeverityId, StatusId, Url, User, ocsf_emit,
};
use std::net::SocketAddr;

/// Where a gateway connection came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionSource {
/// A direct TCP connection. The address is the immediate peer, which is
/// a proxy or load balancer when one fronts the gateway.
Peer(SocketAddr),
/// A connection carried over the WebSocket tunnel endpoint.
EdgeTunnel,
}

impl ConnectionSource {
fn endpoint(self) -> Endpoint {
match self {
Self::Peer(addr) => Endpoint::from_ip(addr.ip().to_canonical(), addr.port()),
Self::EdgeTunnel => Endpoint::from_name("edge-tunnel"),
}
}
}

/// Request details recorded on every audit event about the request.
#[derive(Debug, Clone, Default)]
pub struct RequestOrigin {
source: Option<ConnectionSource>,
request_id: Option<String>,
}

impl RequestOrigin {
/// Only a canonical UUID `x-request-id` is kept: the value is
/// client-controlled and the gateway generates one when it is absent.
pub fn new(source: Option<ConnectionSource>, headers: &http::HeaderMap) -> Self {
let request_id = headers
.get("x-request-id")
.and_then(|value| value.to_str().ok())
.filter(|value| openshell_core::endpoint_failure::is_canonical_request_id(value))
.map(str::to_owned);
Self { source, request_id }
}
}

use super::authenticator::{AuthFailureReason, AuthMechanism};

Expand Down Expand Up @@ -58,6 +98,7 @@ fn with_protocol(
/// Emit an Authentication failure event for a gRPC request to `path`.
pub fn emit_authentication_failure(
path: &str,
origin: &RequestOrigin,
mechanism: Option<AuthMechanism>,
reason: AuthFailureReason,
) {
Expand All @@ -80,5 +121,11 @@ pub fn emit_authentication_failure(
if let Some(mechanism) = mechanism {
builder = with_protocol(builder, mechanism);
}
if let Some(source) = origin.source {
builder = builder.src_endpoint(source.endpoint());
}
if let Some(request_id) = &origin.request_id {
builder = builder.correlation_uid(request_id);
}
ocsf_emit!(builder.build());
}
Loading
Loading