diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs index 3debc26628..10fa59b36e 100644 --- a/crates/openshell-supervisor-network/src/l7/middleware.rs +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -8,8 +8,7 @@ use crate::opa::PolicyGenerationGuard; use miette::{Result, miette}; use openshell_ocsf::{ ActionId, ActivityId, DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, - HttpActivityBuilder, HttpRequest, NetworkActivityBuilder, SeverityId, StatusId, Url as OcsfUrl, - ocsf_emit, + HttpActivityBuilder, HttpRequest, NetworkActivityBuilder, SeverityId, StatusId, ocsf_emit, }; use std::path::PathBuf; use tokio::io::{AsyncRead, AsyncWrite}; @@ -915,10 +914,7 @@ pub(super) fn middleware_events( } else { SeverityId::Medium }) - .http_request(HttpRequest::new( - &req.action, - OcsfUrl::new("http", &ctx.host, &req.target, ctx.port), - )) + .http_request(HttpRequest::new(&req.action, ctx.ocsf_url(&req.target))) .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) .firewall_rule(&ctx.policy_name, "middleware") .unmapped("transformed", invocation.transformed) diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index b971805ee5..fdf9ef39c4 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -102,6 +102,11 @@ impl L7EvalContext { pub(crate) fn websocket_scheme(&self) -> &'static str { if self.is_plaintext() { "ws" } else { "wss" } } + + /// OCSF URL for `target` on the inspected transport. + pub(crate) fn ocsf_url(&self, target: &str) -> OcsfUrl { + OcsfUrl::new(self.request_scheme(), &self.host, target, self.port) + } } fn request_default_port(ctx: &L7EvalContext) -> Option { @@ -1696,10 +1701,7 @@ fn build_l7_request_event( .action(action_id) .disposition(disposition_id) .severity(severity) - .http_request(HttpRequest::new( - action, - OcsfUrl::new("http", &ctx.host, redacted_target, ctx.port), - )) + .http_request(HttpRequest::new(action, ctx.ocsf_url(redacted_target))) .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) .firewall_rule(&ctx.policy_name, engine_type) .message(message) @@ -2158,7 +2160,7 @@ where .severity(severity) .http_request(HttpRequest::new( &request_info.action, - OcsfUrl::new("http", &ctx.host, &redacted_target, ctx.port), + ctx.ocsf_url(&redacted_target), )) .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) .firewall_rule(&ctx.policy_name, "l7") @@ -2582,7 +2584,7 @@ where .severity(severity) .http_request(HttpRequest::new( &request_info.action, - OcsfUrl::new("http", &ctx.host, &redacted_target, ctx.port), + ctx.ocsf_url(&redacted_target), )) .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) .firewall_rule(&ctx.policy_name, jsonrpc_engine_type(config.protocol)) @@ -2868,7 +2870,7 @@ where .severity(severity) .http_request(HttpRequest::new( &request_info.action, - OcsfUrl::new("http", &ctx.host, &redacted_target, ctx.port), + ctx.ocsf_url(&redacted_target), )) .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) .firewall_rule(&ctx.policy_name, "l7-graphql") @@ -3339,7 +3341,7 @@ fn emit_transformed_body_decision( .severity(severity) .http_request(HttpRequest::new( &request_info.action, - OcsfUrl::new("http", &ctx.host, &request_info.target, ctx.port), + ctx.ocsf_url(&request_info.target), )) .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) .firewall_rule(&ctx.policy_name, engine_type) @@ -3573,7 +3575,7 @@ where .severity(SeverityId::Informational) .http_request(HttpRequest::new( &req.action, - OcsfUrl::new("http", &ctx.host, &redacted_target, ctx.port), + ctx.ocsf_url(&redacted_target), )) .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) .message(format!( @@ -3606,7 +3608,7 @@ where req, client, ctx, - "http", + ctx.request_scheme(), openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, ) .await?; @@ -3680,7 +3682,7 @@ where let resolver = ctx.secret_resolver.as_deref(); let response_middleware = response_selection .as_ref() - .map(|exchange| exchange.response_relay(&req_with_auth, ctx, "http")); + .map(|exchange| exchange.response_relay(&req_with_auth, ctx, ctx.request_scheme())); // Forward request with credential rewriting and relay the response. // relay_http_request_with_resolver handles both directions: it sends @@ -7814,7 +7816,7 @@ network_policies: assert_eq!(denied_json["unmapped"]["failed"], false); assert_eq!( denied_http.format_shorthand(), - "HTTP:POST [MED] DENIED POST http://api.example.test:443/v1/messages \ + "HTTP:POST [MED] DENIED POST https://api.example.test/v1/messages \ [policy:rest_api engine:middleware] \ [failed:false transformed:false \ reason:middleware_denied:content-guard:content_match]" @@ -7930,6 +7932,88 @@ network_policies: .unwrap(); } + #[tokio::test] + async fn passthrough_middleware_scheme_follows_transport() { + use openshell_supervisor_middleware::{ChainRunner, MiddlewareRegistry}; + + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + for (port, transport, scheme) in [(8443, 443, "https"), (443, 80, "http")] { + seen.lock().unwrap().clear(); + let data = format!( + r#" +network_middlewares: + scheme: + middleware: test/scheme + on_error: fail_closed + endpoints: + include: ["api.example.test"] +network_policies: + passthrough_api: + name: passthrough_api + endpoints: + - host: api.example.test + port: {port} + binaries: + - {{ path: /usr/bin/curl }} +"# + ); + let engine = Arc::new(OpaEngine::from_strings(TEST_POLICY, &data).unwrap()); + let registry = MiddlewareRegistry::connect_services( + vec![Arc::new(SchemeRecorder(seen.clone()))], + Vec::new(), + ) + .await + .expect("connect named middleware services"); + engine.set_middleware_runner_for_tests(ChainRunner::from_registry(registry)); + let generation_guard = engine + .generation_guard(engine.current_generation()) + .unwrap(); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port, + request_default_port: Some(transport), + policy_name: "passthrough_api".into(), + binary_path: "/usr/bin/curl".into(), + ..Default::default() + }; + + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_passthrough_with_credentials( + &mut relay_client, + &mut relay_upstream, + &ctx, + &generation_guard, + Some(engine.as_ref()), + ) + .await + }); + + let request = format!( + "GET /v1/messages HTTP/1.1\r\nHost: api.example.test:{port}\r\nConnection: close\r\n\r\n" + ); + app.write_all(request.as_bytes()).await.unwrap(); + let mut upstream_request = [0u8; 1024]; + tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("request should reach upstream") + .unwrap(); + assert_eq!(*seen.lock().unwrap(), [scheme]); + + drop(app); + drop(upstream); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .ok(); + } + } + #[tokio::test] async fn websocket_upgrade_request_is_inspected_and_denied() { // The WebSocket upgrade handshake is an HTTP request the hook can inspect @@ -9023,6 +9107,7 @@ network_policies: let ctx = L7EvalContext { host: "gateway.example.test".into(), port: 443, + request_default_port: Some(443), policy_name: "route_api".into(), ..Default::default() }; @@ -9041,10 +9126,34 @@ network_policies: assert_eq!(event.base().severity, SeverityId::Medium); assert_eq!( event.format_shorthand(), - "HTTP:GET [MED] DENIED GET http://gateway.example.test:443/other [policy:route_api engine:l7] [reason:L7_REQUEST deny GET gateway.example.test:443/other reason=no L7 endpoint path matched request]" + "HTTP:GET [MED] DENIED GET https://gateway.example.test/other [policy:route_api engine:l7] [reason:L7_REQUEST deny GET gateway.example.test:443/other reason=no L7 endpoint path matched request]" ); } + #[test] + fn request_event_url_scheme_follows_transport() { + for (port, transport, scheme, url) in [ + (80, 80, "http", "http://api.example.test/v1"), + (443, 443, "https", "https://api.example.test/v1"), + (8443, 443, "https", "https://api.example.test:8443/v1"), + (443, 80, "http", "http://api.example.test:443/v1"), + ] { + let ctx = L7EvalContext { + host: "api.example.test".into(), + port, + request_default_port: Some(transport), + policy_name: "api".into(), + ..Default::default() + }; + + let event = build_l7_request_event(&ctx, "GET", "/v1", "allow", "l7", "ok", None); + + let json = event.to_json().unwrap(); + assert_eq!(json["http_request"]["url"]["scheme"], scheme); + assert!(event.format_shorthand().contains(url), "{url}"); + } + } + #[tokio::test] async fn route_selected_unmatched_path_emits_denied_policy_activity() { let engine = OpaEngine::from_strings(TEST_POLICY, ENCODED_SLASH_SCOPING_POLICY).unwrap(); diff --git a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs index 49338c9917..783d4863b5 100644 --- a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs +++ b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs @@ -22,7 +22,7 @@ use openshell_core::token_grant_failure::{ }; use openshell_ocsf::{ ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, SeverityId, - StatusId, Url as OcsfUrl, ctx::ctx as ocsf_ctx, ocsf_emit, + StatusId, ctx::ctx as ocsf_ctx, ocsf_emit, }; use tracing::warn; @@ -345,10 +345,7 @@ fn report_refused_grant( .disposition(DispositionId::Blocked) .severity(SeverityId::Medium) .status(StatusId::Failure) - .http_request(HttpRequest::new( - method, - OcsfUrl::new("http", &ctx.host, request_path, ctx.port), - )) + .http_request(HttpRequest::new(method, ctx.ocsf_url(request_path))) .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) .message(format!( "Token grant failed for {credential} to {}:{}", @@ -407,7 +404,7 @@ async fn inject_selected( .severity(SeverityId::Informational) .http_request(HttpRequest::new( &req.action, - OcsfUrl::new("http", &ctx.host, request_path(&req.target), ctx.port), + ctx.ocsf_url(request_path(&req.target)), )) .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) .message(format!( diff --git a/docs/observability/accessing-logs.mdx b/docs/observability/accessing-logs.mdx index b17841b20f..9e19c637bf 100644 --- a/docs/observability/accessing-logs.mdx +++ b/docs/observability/accessing-logs.mdx @@ -20,7 +20,7 @@ The CLI receives logs from the gateway over gRPC. Each line includes a timestamp ```text [1775014132.118] [sandbox] [OCSF ] [ocsf] NET:OPEN [INFO] ALLOWED /usr/bin/curl(58) -> api.github.com:443 [policy:github_api engine:opa] -[1775014132.190] [sandbox] [OCSF ] [ocsf] HTTP:GET [INFO] ALLOWED GET http://api.github.com/zen [policy:github_api] +[1775014132.190] [sandbox] [OCSF ] [ocsf] HTTP:GET [INFO] ALLOWED GET https://api.github.com/zen [policy:github_api] [1775014132.690] [sandbox] [OCSF ] [ocsf] NET:OPEN [MED] DENIED /usr/bin/curl(64) -> httpbin.org:443 [policy:- engine:opa] [1775014113.058] [sandbox] [INFO ] [openshell_sandbox] Starting sandbox ``` diff --git a/docs/observability/logging.mdx b/docs/observability/logging.mdx index 3a94bf65bd..acf9ce14dd 100644 --- a/docs/observability/logging.mdx +++ b/docs/observability/logging.mdx @@ -34,7 +34,7 @@ In the log file, OCSF events appear in a shorthand format with an `OCSF` level l 2026-04-01T04:04:13.074Z INFO openshell_sandbox: Creating OPA engine from proto policy data 2026-04-01T04:04:13.078Z OCSF CONFIG:VALIDATED [INFO] Validated 'sandbox' user exists in image 2026-04-01T04:04:32.118Z OCSF NET:OPEN [INFO] ALLOWED /usr/bin/curl(58) -> api.github.com:443 [policy:github_api engine:opa] -2026-04-01T04:04:32.190Z OCSF HTTP:GET [INFO] ALLOWED GET http://api.github.com/zen [policy:github_api engine:opa] +2026-04-01T04:04:32.190Z OCSF HTTP:GET [INFO] ALLOWED GET https://api.github.com/zen [policy:github_api engine:opa] 2026-04-01T04:04:32.690Z OCSF NET:OPEN [MED] DENIED /usr/bin/curl(64) -> httpbin.org:443 [policy:- engine:opa] [reason:no matching policy] ``` @@ -122,7 +122,7 @@ OCSF NET:OPEN [INFO] ALLOWED /usr/bin/curl(58) -> api.github.com:443 [policy:git An L7 read-only policy denying a POST: ```text -OCSF HTTP:POST [MED] DENIED POST http://api.github.com/user/repos [policy:github_api engine:opa] +OCSF HTTP:POST [MED] DENIED POST https://api.github.com/user/repos [policy:github_api engine:opa] ``` A connection denied because no policy matched: @@ -146,7 +146,7 @@ OCSF HTTP:GET [INFO] ALLOWED GET http://api.internal.corp:8080/v1/status [policy A supervisor middleware HTTP event records whether it transformed the request. If the middleware also emits a finding, that remains a separate event: ```text -OCSF HTTP:POST [INFO] ALLOWED POST http://httpbin.org:443/anything [policy:httpbin engine:middleware] [failed:false transformed:true] +OCSF HTTP:POST [INFO] ALLOWED POST https://httpbin.org/anything [policy:httpbin engine:middleware] [failed:false transformed:true] OCSF FINDING:CREATE [MED] "configured content matched" [type:content_guard.match count:1 middleware:prototype-content-guard] ``` diff --git a/docs/tutorials/first-network-policy.mdx b/docs/tutorials/first-network-policy.mdx index bf966a458d..5cdeef2200 100644 --- a/docs/tutorials/first-network-policy.mdx +++ b/docs/tutorials/first-network-policy.mdx @@ -174,7 +174,7 @@ openshell logs demo --since 5m --source sandbox ``` ```text -[1775014140.412] [sandbox] [OCSF ] [ocsf] HTTP:POST [MED] DENIED POST http://api.github.com:443/repos/octocat/hello-world/issues [policy:github_api engine:l7] [reason:L7_REQUEST deny POST api.github.com:443/repos/octocat/hello-world/issues reason=POST /repos/octocat/hello-world/issues not permitted by policy] +[1775014140.412] [sandbox] [OCSF ] [ocsf] HTTP:POST [MED] DENIED POST https://api.github.com/repos/octocat/hello-world/issues [policy:github_api engine:l7] [reason:L7_REQUEST deny POST api.github.com:443/repos/octocat/hello-world/issues reason=POST /repos/octocat/hello-world/issues not permitted by policy] ``` Policy events are INFO-level log records regardless of their severity, so do not filter them out with `--level warn`. In production, export these events to your SIEM for a complete audit trail of every request your agent makes. Refer to [Logging](/observability/logging) for the event format. diff --git a/docs/tutorials/github-push-access.mdx b/docs/tutorials/github-push-access.mdx index 53e32dc59d..826ee964a7 100644 --- a/docs/tutorials/github-push-access.mdx +++ b/docs/tutorials/github-push-access.mdx @@ -114,7 +114,7 @@ openshell logs github-demo --since 5m --source sandbox You should see a denial for a request resembling this one: ```text -[1775014140.412] [sandbox] [OCSF ] [ocsf] HTTP:POST [MED] DENIED POST http://github.com:443//.git/git-receive-pack [policy:_provider_my_github engine:l7] [reason:L7_REQUEST deny POST github.com:443//.git/git-receive-pack reason=POST //.git/git-receive-pack not permitted by policy] +[1775014140.412] [sandbox] [OCSF ] [ocsf] HTTP:POST [MED] DENIED POST https://github.com//.git/git-receive-pack [policy:_provider_my_github engine:l7] [reason:L7_REQUEST deny POST github.com:443//.git/git-receive-pack reason=POST //.git/git-receive-pack not permitted by policy] ``` `_provider_my_github` is the rule that OpenShell composes from the attached diff --git a/examples/sandbox-policy-quickstart/README.md b/examples/sandbox-policy-quickstart/README.md index 11da5f9eaa..35ad206ec4 100644 --- a/examples/sandbox-policy-quickstart/README.md +++ b/examples/sandbox-policy-quickstart/README.md @@ -192,7 +192,7 @@ openshell logs demo --since 5m --source sandbox ``` ```text -[1775014140.412] [sandbox] [OCSF ] [ocsf] HTTP:POST [MED] DENIED POST http://api.github.com:443/repos/octocat/hello-world/issues [policy:github_api engine:l7] [reason:L7_REQUEST deny POST api.github.com:443/repos/octocat/hello-world/issues reason=POST /repos/octocat/hello-world/issues not permitted by policy] +[1775014140.412] [sandbox] [OCSF ] [ocsf] HTTP:POST [MED] DENIED POST https://api.github.com/repos/octocat/hello-world/issues [policy:github_api engine:l7] [reason:L7_REQUEST deny POST api.github.com:443/repos/octocat/hello-world/issues reason=POST /repos/octocat/hello-world/issues not permitted by policy] ``` The log captures the exact HTTP method, path, and matching rule. Policy