From ffcafb6b6bbdddf4f55cdbebc6d59c90e7610c20 Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Tue, 20 Oct 2026 12:02:34 +0100 Subject: [PATCH 1/2] feat(audit): audit credential disclosure reads Signed-off-by: Eric Curtin --- crates/openshell-ocsf/src/builders/config.rs | 36 ++- crates/openshell-server/README.md | 9 + crates/openshell-server/src/auth/audit.rs | 296 +++++++++++++++++- crates/openshell-server/src/grpc/auth_rpc.rs | 8 +- crates/openshell-server/src/grpc/policy.rs | 93 ++++++ .../src/grpc/provider_credentials.rs | 15 + .../src/grpc/provider_credentials_tests.rs | 53 ++++ docs/observability/logging.mdx | 2 +- docs/observability/ocsf-json-export.mdx | 2 + 9 files changed, 502 insertions(+), 12 deletions(-) diff --git a/crates/openshell-ocsf/src/builders/config.rs b/crates/openshell-ocsf/src/builders/config.rs index 1bc415b612..d0e9dcbdc9 100644 --- a/crates/openshell-ocsf/src/builders/config.rs +++ b/crates/openshell-ocsf/src/builders/config.rs @@ -18,6 +18,7 @@ pub struct ConfigStateChangeBuilder<'a> { security_level: Option, prev_security_level: Option, message: Option, + correlation_uid: Option, unmapped: serde_json::Map, } @@ -33,6 +34,7 @@ impl<'a> ConfigStateChangeBuilder<'a> { security_level: None, prev_security_level: None, message: None, + correlation_uid: None, unmapped: serde_json::Map::new(), } } @@ -56,6 +58,13 @@ impl<'a> ConfigStateChangeBuilder<'a> { self } + /// Set the identifier shared by events about the same request. + #[must_use] + pub fn correlation_uid(mut self, uid: impl Into) -> Self { + self.correlation_uid = Some(uid.into()); + self + } + /// Add an unmapped field. #[must_use] pub fn unmapped(mut self, key: &str, value: impl Into) -> Self { @@ -65,6 +74,10 @@ impl<'a> ConfigStateChangeBuilder<'a> { #[must_use] pub fn build(self) -> OcsfEvent { + let mut metadata = self + .ctx + .metadata(&["security_control", "container", "host"]); + metadata.correlation_uid = self.correlation_uid; let mut base = BaseEventData::new( 5019, "Device Config State Change", @@ -73,8 +86,7 @@ impl<'a> ConfigStateChangeBuilder<'a> { 1, "Log", // activity_id=1 (Log) self.severity, - self.ctx - .metadata(&["security_control", "container", "host"]), + metadata, ); if !self.unmapped.is_empty() { base.unmapped = Some(serde_json::Value::Object(self.unmapped)); @@ -119,4 +131,24 @@ mod tests { assert_eq!(json["security_level"], "Secure"); assert_eq!(json["unmapped"]["policy_version"], "v3"); } + + #[test] + fn test_config_state_change_builder_correlation_uid() { + let ctx = test_sandbox_context(); + let event = ConfigStateChangeBuilder::new(&ctx) + .correlation_uid("6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f") + .build(); + + let json = event.to_json().unwrap(); + assert_eq!( + json["metadata"]["correlation_uid"], + "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f" + ); + let event = ConfigStateChangeBuilder::new(&ctx).build(); + assert!( + event.to_json().unwrap()["metadata"] + .get("correlation_uid") + .is_none() + ); + } } diff --git a/crates/openshell-server/README.md b/crates/openshell-server/README.md index 58f735c35c..57df94b8a1 100644 --- a/crates/openshell-server/README.md +++ b/crates/openshell-server/README.md @@ -42,6 +42,15 @@ have committed refresh effects. Missing refresh-owned stored outputs trigger refresh-on-read; missing static outputs and other credential-backend errors fail closed without falling back to inline values. +## Credential disclosure audit + +`GetSandboxProviderEnvironment` and `GetProviderCredentials` each emit one OCSF +event per attempt through `DisclosureAudit` in `auth/audit.rs`. The record names +the authenticated principal, the disclosed identifiers, and the outcome, never +a credential value or a status message. A new RPC that returns secret-bearing +material must begin a `DisclosureAudit` before it authorizes the caller, and its +tests must assert that a canary secret never appears in the emitted events. + ## Refresh coordination Explicit rotation, background refresh, and operator refresh-on-read share the diff --git a/crates/openshell-server/src/auth/audit.rs b/crates/openshell-server/src/auth/audit.rs index f78315594d..7303057641 100644 --- a/crates/openshell-server/src/auth/audit.rs +++ b/crates/openshell-server/src/auth/audit.rs @@ -1,16 +1,21 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! OCSF security events for gateway authentication outcomes. +//! OCSF security events for gateway authentication outcomes and credential +//! disclosure. //! //! Events carry a low-cardinality reason category, never presented -//! credentials or raw verification errors. +//! credentials, disclosed secrets, or raw verification errors. use openshell_ocsf::{ - ActionId, AuthProtocolId, AuthenticationBuilder, DispositionId, Endpoint, HttpMethod, - HttpRequest, Service, SeverityId, StatusId, Url, User, ocsf_emit, + ActionId, AuthProtocolId, AuthenticationBuilder, ConfigStateChangeBuilder, DispositionId, + Endpoint, HttpMethod, HttpRequest, Service, SeverityId, StateId, StatusId, Url, User, + ocsf_emit, }; use std::net::SocketAddr; +use tonic::{Code, Request, Status}; + +use super::principal::Principal; /// Where a gateway connection came from. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -49,6 +54,15 @@ impl RequestOrigin { .map(str::to_owned); Self { source, request_id } } + + /// The origin of a gRPC request, from the extensions and headers the + /// multiplexer attached to it. + pub fn from_request(request: &Request) -> Self { + Self::new( + request.extensions().get::().copied(), + request.metadata().as_ref(), + ) + } } use super::authenticator::{AuthFailureReason, AuthMechanism}; @@ -129,3 +143,277 @@ pub fn emit_authentication_failure( } ocsf_emit!(builder.build()); } + +const MAX_RESOURCE_CHARS: usize = 256; + +/// Audit record for one attempt to read secret-bearing material through a +/// gateway RPC. The read changes no state, but the disclosure must stay +/// accountable. +/// +/// Exactly one event is emitted per attempt: by [`Self::finish`], or as a +/// cancellation when the RPC future is dropped first. Any new RPC that returns +/// secret-bearing material must begin one of these before it authorizes the +/// caller. Record only identifiers, never the disclosed values. +pub struct DisclosureAudit { + operation: &'static str, + actor: String, + request_id: Option, + sandbox: Option<(String, String)>, + resource: Vec<(&'static str, String)>, + emitted: bool, +} + +impl DisclosureAudit { + /// Start the record for `operation`, naming the caller from the principal + /// the gateway authenticated rather than from the request body. + pub fn begin(operation: &'static str, request: &Request) -> Self { + Self { + operation, + actor: request + .extensions() + .get::() + .map_or_else(|| "unknown".to_string(), Principal::audit_actor), + request_id: RequestOrigin::from_request(request).request_id, + sandbox: None, + resource: Vec::new(), + emitted: false, + } + } + + /// Name the sandbox the disclosed material belongs to. + pub fn sandbox(&mut self, id: &str, name: &str) { + self.sandbox = Some((id.to_string(), name.to_string())); + } + + /// Name a disclosed resource by identifier. Never pass a credential value. + /// Values can come from the request, so they are bounded. + pub fn resource(&mut self, key: &'static str, value: impl Into) { + let mut value = value.into(); + if let Some((end, _)) = value.char_indices().nth(MAX_RESOURCE_CHARS) { + value.truncate(end); + } + self.resource.push((key, value)); + } + + /// Emit the record for the attempt's final result. + pub fn finish(mut self, result: &Result) { + let outcome = match result { + Ok(_) => DisclosureOutcome::Disclosed, + Err(status) + if matches!( + status.code(), + Code::PermissionDenied | Code::Unauthenticated + ) => + { + DisclosureOutcome::Denied(status.code()) + } + Err(status) => DisclosureOutcome::Failed(status.code()), + }; + self.emit(outcome); + } + + fn emit(&mut self, outcome: DisclosureOutcome) { + self.emitted = true; + let label = outcome.label(); + let (sandbox_id, sandbox_name) = self + .sandbox + .as_ref() + .map_or(("", ""), |(id, name)| (id, name)); + let ctx = crate::gateway_ocsf::context(sandbox_id, sandbox_name); + let mut builder = ConfigStateChangeBuilder::new(&ctx) + .state(StateId::Other, label) + .severity(outcome.severity()) + .status(match outcome { + DisclosureOutcome::Disclosed => StatusId::Success, + _ => StatusId::Failure, + }) + .message(format!("{} {label}", self.operation)) + .unmapped("operation", self.operation) + .unmapped("actor", self.actor.clone()) + .unmapped("outcome", label); + if let Some(code) = outcome.code() { + builder = builder.unmapped("status_code", format!("{code:?}")); + } + for (key, value) in &self.resource { + builder = builder.unmapped(key, value.clone()); + } + if let Some(request_id) = &self.request_id { + builder = builder.correlation_uid(request_id); + } + ocsf_emit!(builder.build()); + } +} + +#[derive(Clone, Copy)] +enum DisclosureOutcome { + Disclosed, + Denied(Code), + Failed(Code), + Cancelled, +} + +impl DisclosureOutcome { + fn label(self) -> &'static str { + match self { + Self::Disclosed => "disclosed", + Self::Denied(_) => "denied", + Self::Failed(_) => "failed", + Self::Cancelled => "cancelled", + } + } + + fn severity(self) -> SeverityId { + match self { + Self::Disclosed => SeverityId::Informational, + Self::Denied(_) => SeverityId::Medium, + Self::Failed(_) | Self::Cancelled => SeverityId::Low, + } + } + + fn code(self) -> Option { + match self { + Self::Denied(code) | Self::Failed(code) => Some(code), + Self::Disclosed | Self::Cancelled => None, + } + } +} + +impl Drop for DisclosureAudit { + fn drop(&mut self) { + if !self.emitted { + self.emit(DisclosureOutcome::Cancelled); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::identity::{Identity, IdentityProvider}; + use crate::auth::principal::UserPrincipal; + use openshell_ocsf::test_support::OcsfCapture; + + const REQUEST_ID: &str = "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"; + + fn request() -> Request<()> { + let mut request = Request::new(()); + request + .extensions_mut() + .insert(Principal::User(UserPrincipal { + identity: Identity { + subject: "reviewer".into(), + display_name: None, + roles: vec![], + scopes: vec![], + provider: IdentityProvider::Oidc, + }, + })); + request + .metadata_mut() + .insert("x-request-id", REQUEST_ID.parse().unwrap()); + request + } + + fn events(capture: &OcsfCapture) -> Vec { + capture + .events() + .iter() + .map(|event| event.to_json().unwrap()) + .collect() + } + + #[test] + fn finish_emits_one_event_naming_actor_resource_and_request() { + let capture = OcsfCapture::default(); + { + let _guard = capture.install(); + let mut audit = DisclosureAudit::begin("GetThing", &request()); + audit.sandbox("sb-1", "agent"); + audit.resource("provider", "work"); + audit.finish(&Ok::<_, Status>(())); + } + + let events = events(&capture); + assert_eq!(events.len(), 1, "{events:?}"); + let event = &events[0]; + assert_eq!(event["class_uid"], 5019); + assert_eq!(event["message"], "GetThing disclosed"); + assert_eq!(event["metadata"]["correlation_uid"], REQUEST_ID); + assert_eq!(event["unmapped"]["actor"], "user:reviewer"); + assert_eq!(event["unmapped"]["operation"], "GetThing"); + assert_eq!(event["unmapped"]["outcome"], "disclosed"); + assert_eq!(event["unmapped"]["provider"], "work"); + assert_eq!(event["container"]["uid"], "sb-1"); + assert!(event["unmapped"].get("status_code").is_none()); + } + + #[test] + fn failures_record_the_code_but_not_the_status_message() { + let capture = OcsfCapture::default(); + { + let _guard = capture.install(); + DisclosureAudit::begin("GetThing", &request()).finish(&Err::<(), _>( + Status::permission_denied("secret-in-message"), + )); + DisclosureAudit::begin("GetThing", &request()) + .finish(&Err::<(), _>(Status::internal("secret-in-message"))); + } + + let events = events(&capture); + assert_eq!(events.len(), 2, "{events:?}"); + assert_eq!(events[0]["unmapped"]["outcome"], "denied"); + assert_eq!(events[0]["unmapped"]["status_code"], "PermissionDenied"); + assert_eq!(events[0]["severity"], "Medium"); + assert_eq!(events[1]["unmapped"]["outcome"], "failed"); + assert_eq!(events[1]["unmapped"]["status_code"], "Internal"); + assert!( + !serde_json::to_string(&events) + .unwrap() + .contains("secret-in-message") + ); + } + + #[test] + fn a_dropped_attempt_is_recorded_once_as_cancelled() { + let capture = OcsfCapture::default(); + { + let _guard = capture.install(); + drop(DisclosureAudit::begin("GetThing", &request())); + } + + let events = events(&capture); + assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!(events[0]["unmapped"]["outcome"], "cancelled"); + } + + #[test] + fn an_unauthenticated_request_is_attributed_to_unknown() { + let capture = OcsfCapture::default(); + { + let _guard = capture.install(); + DisclosureAudit::begin("GetThing", &Request::new(())) + .finish(&Err::<(), _>(Status::unauthenticated("no"))); + } + + let events = events(&capture); + assert_eq!(events[0]["unmapped"]["actor"], "unknown"); + assert!(events[0]["metadata"].get("correlation_uid").is_none()); + } + + #[test] + fn request_derived_values_are_bounded() { + let capture = OcsfCapture::default(); + { + let _guard = capture.install(); + let mut audit = DisclosureAudit::begin("GetThing", &request()); + audit.resource("requested_sandbox_id", "é".repeat(10_000)); + audit.finish(&Ok::<_, Status>(())); + } + + let events = events(&capture); + let recorded = events[0]["unmapped"]["requested_sandbox_id"] + .as_str() + .unwrap(); + assert_eq!(recorded.chars().count(), MAX_RESOURCE_CHARS); + } +} diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index ea654503a7..e91f9f579a 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -15,7 +15,7 @@ //! ordinary RPCs or select another successor. use crate::ServerState; -use crate::auth::audit::{self, ConnectionSource, RequestOrigin}; +use crate::auth::audit::{self, RequestOrigin}; use crate::auth::authenticator::{AuthFailureReason, AuthMechanism, AuthRejection}; use crate::auth::identity::IdentityProvider; use crate::auth::principal::{Principal, SandboxIdentitySource}; @@ -272,10 +272,7 @@ pub async fn handle_refresh_sandbox_token( .map_err(|rejection| { audit::emit_authentication_failure( REFRESH_SANDBOX_TOKEN_PATH, - &RequestOrigin::new( - request.extensions().get::().copied(), - request.metadata().as_ref(), - ), + &RequestOrigin::from_request(&request), Some(rejection.mechanism), rejection.reason, ); @@ -495,6 +492,7 @@ async fn ensure_sandbox_exists( mod tests { use super::*; use crate::ServerState; + use crate::auth::audit::ConnectionSource; use crate::auth::identity::Identity; use crate::auth::principal::{Principal, SandboxPrincipal, UserPrincipal}; use crate::auth::sandbox_jwt::{ExtensionJwtIssuer, SandboxSessionJwtAuthority}; diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 9f3edf9a44..44e6c717db 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -28,6 +28,7 @@ pub use endpoint_status::{ }; use crate::ServerState; +use crate::auth::audit::DisclosureAudit; use crate::auth::principal::Principal; use crate::auth::workspace_authz::{ MinWorkspaceRole, require_platform_admin, selected_workspace_name, @@ -3788,6 +3789,18 @@ pub(super) async fn resolve_sandbox_create_runtime_inputs( pub(super) async fn handle_get_sandbox_provider_environment( state: &Arc, request: Request, +) -> Result, Status> { + let mut audit = DisclosureAudit::begin("GetSandboxProviderEnvironment", &request); + audit.resource("requested_sandbox_id", request.get_ref().sandbox_id.clone()); + let result = disclose_sandbox_provider_environment(state, request, &mut audit).await; + audit.finish(&result); + result +} + +async fn disclose_sandbox_provider_environment( + state: &Arc, + request: Request, + audit: &mut DisclosureAudit, ) -> Result, Status> { let sandbox_id = request.get_ref().sandbox_id.clone(); let supports_static_credential_bindings = request.get_ref().supports_static_credential_bindings; @@ -3799,6 +3812,10 @@ pub(super) async fn handle_get_sandbox_provider_environment( let sandbox = super::sandbox::fetch_and_authorize_sandbox(state, &principal, &sandbox_id).await?; + audit.sandbox(sandbox.object_id(), sandbox.object_name()); + if let Some(spec) = &sandbox.spec { + audit.resource("providers", spec.providers.join(",")); + } let environment = load_sandbox_provider_environment( state, &sandbox, @@ -14070,6 +14087,82 @@ mod tests { ); } + #[tokio::test] + async fn provider_environment_disclosure_is_audited_without_values() { + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-audited", + "audited", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["work-github".to_string()], + )) + .await + .unwrap(); + let request = |sandbox_id: &str| GetSandboxProviderEnvironmentRequest { + sandbox_id: sandbox_id.to_string(), + supports_static_credential_bindings: true, + supports_stable_placeholder_environment_keys: false, + }; + + let capture = openshell_ocsf::test_support::OcsfCapture::default(); + let _guard = capture.install(); + let response = handle_get_sandbox_provider_environment( + &state, + with_sandbox(Request::new(request("sb-audited")), "sb-audited"), + ) + .await + .unwrap() + .into_inner(); + assert_eq!( + response.environment.get("GITHUB_TOKEN"), + Some(&"ghp-test".to_string()) + ); + handle_get_sandbox_provider_environment( + &state, + with_sandbox(Request::new(request("sb-audited")), "sb-other"), + ) + .await + .unwrap_err(); + handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(request("sb-missing"))), + ) + .await + .unwrap_err(); + + let events: Vec<_> = capture + .events() + .iter() + .map(|event| event.to_json().unwrap()) + .collect(); + assert_eq!(events.len(), 3, "{events:?}"); + let outcomes: Vec<_> = events + .iter() + .map(|event| event["unmapped"]["outcome"].as_str().unwrap()) + .collect(); + assert_eq!(outcomes, ["disclosed", "denied", "failed"]); + let disclosed = &events[0]; + assert_eq!( + disclosed["unmapped"]["operation"], + "GetSandboxProviderEnvironment" + ); + assert_eq!(disclosed["unmapped"]["actor"], "sandbox:sb-audited"); + assert_eq!(disclosed["unmapped"]["providers"], "work-github"); + assert_eq!(disclosed["container"]["uid"], "sb-audited"); + assert_eq!(events[1]["unmapped"]["actor"], "sandbox:sb-other"); + assert_eq!(events[2]["unmapped"]["actor"], "user:test-user"); + assert_eq!(events[2]["unmapped"]["status_code"], "NotFound"); + let recorded = serde_json::to_string(&events).unwrap(); + assert!(!recorded.contains("ghp-test"), "{recorded}"); + } + #[tokio::test] async fn provider_readiness_snapshot_uses_baseline_static_binding_contract() { let state = test_server_state().await; diff --git a/crates/openshell-server/src/grpc/provider_credentials.rs b/crates/openshell-server/src/grpc/provider_credentials.rs index 61666d4d2c..1bf7157da8 100644 --- a/crates/openshell-server/src/grpc/provider_credentials.rs +++ b/crates/openshell-server/src/grpc/provider_credentials.rs @@ -18,6 +18,7 @@ use openshell_core::proto::{ use tonic::{Request, Response, Status}; use crate::ServerState; +use crate::auth::audit::DisclosureAudit; use crate::storage_proto::StoredProviderCredentialRefreshStateV2; #[cfg(test)] @@ -38,6 +39,17 @@ struct RefreshAudit { pub(super) async fn handle( state: &Arc, request: Request, +) -> Result, Status> { + let mut audit = DisclosureAudit::begin("GetProviderCredentials", &request); + let result = handle_audited(state, request, &mut audit).await; + audit.finish(&result); + result +} + +async fn handle_audited( + state: &Arc, + request: Request, + audit: &mut DisclosureAudit, ) -> Result, Status> { let operator = crate::auth::guard::require_operator(&request)?; let subject = operator.identity.subject.clone(); @@ -55,6 +67,9 @@ pub(super) async fn handle( return Err(error); } }; + audit.resource("workspace", workspace); + audit.resource("provider", request.name.clone()); + audit.resource("credential_keys", request.credential_keys.join(",")); // Admission survives RPC cancellation, while completion records the refresh // effects even if the final all-or-nothing delivery fails. tracing::info!( diff --git a/crates/openshell-server/src/grpc/provider_credentials_tests.rs b/crates/openshell-server/src/grpc/provider_credentials_tests.rs index c602e28461..c526782345 100644 --- a/crates/openshell-server/src/grpc/provider_credentials_tests.rs +++ b/crates/openshell-server/src/grpc/provider_credentials_tests.rs @@ -132,6 +132,59 @@ async fn audits_exports_and_invalid_attempts_without_values() { } } +#[tokio::test] +async fn emits_one_disclosure_event_per_attempt_without_values() { + let (state, _) = fixture().await; + let capture = openshell_ocsf::test_support::OcsfCapture::default(); + let _guard = capture.install(); + handle(&state, authenticated(request(&["STATIC_KEY"]))) + .await + .unwrap(); + handle( + &state, + crate::grpc::test_support::authed_request(request(&["STATIC_KEY"])), + ) + .await + .unwrap_err(); + handle( + &state, + authenticated(request(&["API_KEY=secret-from-invalid-input"])), + ) + .await + .unwrap_err(); + + let events: Vec<_> = capture + .events() + .iter() + .map(|event| event.to_json().unwrap()) + .collect(); + assert_eq!(events.len(), 3, "{events:?}"); + let outcomes: Vec<_> = events + .iter() + .map(|event| event["unmapped"]["outcome"].as_str().unwrap()) + .collect(); + assert_eq!(outcomes, ["disclosed", "denied", "failed"]); + let delivered = &events[0]["unmapped"]; + assert_eq!(delivered["operation"], "GetProviderCredentials"); + assert_eq!(delivered["actor"], "operator:operator-test"); + assert_eq!(delivered["workspace"], "other"); + assert_eq!(delivered["provider"], "provider"); + assert_eq!(delivered["credential_keys"], "STATIC_KEY"); + assert!(events[2]["unmapped"].get("provider").is_none()); + let recorded = serde_json::to_string(&events).unwrap(); + for secret in [ + "old-access-token", + "static-secret", + "must-not-export", + "secret-from-invalid-input", + ] { + assert!( + !recorded.contains(secret), + "{secret} leaked into {recorded}" + ); + } +} + #[tokio::test] async fn exports_only_selected_runtime_keys_across_workspaces() { let (state, _) = fixture().await; diff --git a/docs/observability/logging.mdx b/docs/observability/logging.mdx index acf9ce14dd..96cfef6b4b 100644 --- a/docs/observability/logging.mdx +++ b/docs/observability/logging.mdx @@ -61,7 +61,7 @@ OpenShell maps sandbox events to these OCSF classes: | `PROC:` | Process Activity | 1007 | Process start, exit, timeout, signal failures | | `FINDING:` | Detection Finding | 2004 | Security findings (nonce replay, proxy bypass, unsafe policy) | | `AUTH:` | Authentication | 3002 | Gateway authentication failures | -| `CONFIG:` | Device Config State Change | 5019 | Policy load/reload, Landlock, TLS setup, provider and settings changes | +| `CONFIG:` | Device Config State Change | 5019 | Policy load/reload, Landlock, TLS setup, provider and settings changes, credential disclosure reads | | `LIFECYCLE:` | Application Lifecycle | 6002 | Sandbox supervisor start, SSH server ready | ## Reading the Shorthand Format diff --git a/docs/observability/ocsf-json-export.mdx b/docs/observability/ocsf-json-export.mdx index aa00b90d80..6cea0d90c1 100644 --- a/docs/observability/ocsf-json-export.mdx +++ b/docs/observability/ocsf-json-export.mdx @@ -39,6 +39,8 @@ The gateway emits an Authentication (`class_uid: 3002`) event when it rejects a These events never include presented credentials, other request headers, or verification error text. +The gateway also emits a Device Config State Change (`class_uid: 5019`) event for each attempt to read secret-bearing material through `GetSandboxProviderEnvironment`, which a sandbox supervisor calls to load its provider environment, or `GetProviderCredentials`, the operator credential export. The state is `disclosed`, `denied`, `failed`, or `cancelled`. `unmapped` holds `operation`, `actor` (the authenticated principal as `type:id`, or `unknown`), `outcome`, and `status_code` for denied or failed attempts. It also names what was requested: `requested_sandbox_id` and `providers` for the sandbox environment, or `workspace`, `provider`, and `credential_keys` for the export. Events about a sandbox carry it in `container`, and `metadata.correlation_uid` holds the request ID as described above. These events never include credential values or error text. + Without JSONL enabled, gateway OCSF events still appear as shorthand in console output, subject to the diagnostic log filter. Events associated with a sandbox also appear in its gateway log stream; gateway-wide events have no sandbox association. ## Enable Sandbox JSON Export From 4e562875ac5429d1a14117956011eef26f666e6d Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Tue, 10 Nov 2026 09:42:31 +0000 Subject: [PATCH 2/2] fix(audit): keep target attribution on denied credential exports Signed-off-by: Eric Curtin --- .../src/grpc/provider_credentials.rs | 34 ++++++++++++-- .../src/grpc/provider_credentials_tests.rs | 44 ++++++++++++++++++- docs/observability/ocsf-json-export.mdx | 2 +- 3 files changed, 75 insertions(+), 5 deletions(-) diff --git a/crates/openshell-server/src/grpc/provider_credentials.rs b/crates/openshell-server/src/grpc/provider_credentials.rs index 1bf7157da8..0d41c89f93 100644 --- a/crates/openshell-server/src/grpc/provider_credentials.rs +++ b/crates/openshell-server/src/grpc/provider_credentials.rs @@ -51,6 +51,7 @@ async fn handle_audited( request: Request, audit: &mut DisclosureAudit, ) -> Result, Status> { + attribute_target(audit, request.get_ref()); let operator = crate::auth::guard::require_operator(&request)?; let subject = operator.identity.subject.clone(); let fingerprint = operator.certificate_sha256.clone(); @@ -67,9 +68,6 @@ async fn handle_audited( return Err(error); } }; - audit.resource("workspace", workspace); - audit.resource("provider", request.name.clone()); - audit.resource("credential_keys", request.credential_keys.join(",")); // Admission survives RPC cancellation, while completion records the refresh // effects even if the final all-or-nothing delivery fails. tracing::info!( @@ -97,6 +95,36 @@ async fn handle_audited( result.map(Response::new) } +/// Name the target before authorization so denied and invalid attempts stay +/// attributable. Each identifier is checked on its own; malformed input is +/// never recorded. +fn attribute_target(audit: &mut DisclosureAudit, request: &GetProviderCredentialsRequest) { + if let Ok(workspace) = + crate::auth::workspace_authz::selected_workspace_name(request.workspace_scope.as_ref()) + { + audit.resource("workspace", workspace); + } + let name = &request.name; + if !name.is_empty() + && name.len() <= 253 + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + audit.resource("provider", name.clone()); + } + let keys: Vec<&str> = request + .credential_keys + .iter() + .take(MAX_KEYS) + .map(String::as_str) + .filter(|key| key.len() <= 256 && super::provider::is_valid_env_key(key)) + .collect(); + if !keys.is_empty() { + audit.resource("credential_keys", keys.join(",")); + } +} + fn validate(request: &GetProviderCredentialsRequest) -> Result { if request.name.is_empty() || request.name.trim() != request.name || request.name.len() > 253 { return Err(Status::invalid_argument( diff --git a/crates/openshell-server/src/grpc/provider_credentials_tests.rs b/crates/openshell-server/src/grpc/provider_credentials_tests.rs index e0b1921b8c..1c79340b6c 100644 --- a/crates/openshell-server/src/grpc/provider_credentials_tests.rs +++ b/crates/openshell-server/src/grpc/provider_credentials_tests.rs @@ -166,7 +166,8 @@ async fn emits_one_disclosure_event_per_attempt_without_values() { assert_eq!(delivered["workspace"], "other"); assert_eq!(delivered["provider"], "provider"); assert_eq!(delivered["credential_keys"], "STATIC_KEY"); - assert!(events[2]["unmapped"].get("provider").is_none()); + assert_eq!(events[2]["unmapped"]["provider"], "provider"); + assert!(events[2]["unmapped"].get("credential_keys").is_none()); let recorded = serde_json::to_string(&events).unwrap(); for secret in [ "old-access-token", @@ -181,6 +182,47 @@ async fn emits_one_disclosure_event_per_attempt_without_values() { } } +#[tokio::test] +async fn denied_and_invalid_attempts_keep_safe_target_attribution() { + let (state, _) = fixture().await; + let capture = openshell_ocsf::test_support::OcsfCapture::default(); + let _guard = capture.install(); + handle( + &state, + crate::grpc::test_support::authed_request(request(&["STATIC_KEY"])), + ) + .await + .unwrap_err(); + let mut mixed = request(&["STATIC_KEY", "API_KEY=secret-from-invalid-input"]); + mixed.name = "provider secret-from-invalid-input".into(); + handle(&state, authenticated(mixed)).await.unwrap_err(); + let mut mixed = request(&["STATIC_KEY", "API_KEY=secret-from-invalid-input"]); + mixed.workspace_scope = Some(workspace_selector("Bad secret-from-invalid-input")); + handle(&state, authenticated(mixed)).await.unwrap_err(); + + let events = crate::auth::audit::test_support::disclosure_events(&capture); + assert_eq!(events.len(), 3, "{events:?}"); + let denied = &events[0]["unmapped"]; + assert_eq!(denied["outcome"], "denied"); + assert_eq!(denied["workspace"], "other"); + assert_eq!(denied["provider"], "provider"); + assert_eq!(denied["credential_keys"], "STATIC_KEY"); + let bad_name = &events[1]["unmapped"]; + assert_eq!(bad_name["outcome"], "failed"); + assert_eq!(bad_name["workspace"], "other"); + assert_eq!(bad_name["credential_keys"], "STATIC_KEY"); + assert!(bad_name.get("provider").is_none()); + let bad_workspace = &events[2]["unmapped"]; + assert_eq!(bad_workspace["provider"], "provider"); + assert_eq!(bad_workspace["credential_keys"], "STATIC_KEY"); + assert!(bad_workspace.get("workspace").is_none()); + let recorded = serde_json::to_string(&events).unwrap(); + assert!( + !recorded.contains("secret-from-invalid-input"), + "leaked into {recorded}" + ); +} + #[tokio::test] async fn exports_only_selected_runtime_keys_across_workspaces() { let (state, _) = fixture().await; diff --git a/docs/observability/ocsf-json-export.mdx b/docs/observability/ocsf-json-export.mdx index 4033ca0973..10474c312d 100644 --- a/docs/observability/ocsf-json-export.mdx +++ b/docs/observability/ocsf-json-export.mdx @@ -58,7 +58,7 @@ The gateway emits an API Activity (`class_uid: 6003`) event with Medium severity When a sandbox principal is refused with `method_not_permitted`, `cross_sandbox`, or `platform_admin_required`, the gateway also emits a High-severity Detection Finding (`class_uid: 2004`) with `finding_info.uid` `sandbox-authorization-boundary`. Supervisors never make these requests, so the sandbox's credential may be compromised. Both events carry the same `metadata.correlation_uid`. Some methods answer a refused request with "not found" so callers cannot probe for resources; the event still records the denial. -The gateway also emits a Device Config State Change (`class_uid: 5019`) event for each attempt to read secret-bearing material through `GetSandboxProviderEnvironment`, which a sandbox supervisor calls to load its provider environment, or `GetProviderCredentials`, the operator credential export. The state is `disclosed`, `denied`, `failed`, or `cancelled`. `unmapped` holds `operation`, `actor` (the authenticated principal as `type:id`, or `unknown`), `outcome`, and `status_code` for denied or failed attempts. It also names what was requested: `requested_sandbox_id` and `providers` for the sandbox environment, or `workspace`, `provider`, and `credential_keys` for the export. Events about a sandbox carry it in `container`, and `metadata.correlation_uid` holds the request ID as described above. These events never include credential values or error text. +The gateway also emits a Device Config State Change (`class_uid: 5019`) event for each attempt to read secret-bearing material through `GetSandboxProviderEnvironment`, which a sandbox supervisor calls to load its provider environment, or `GetProviderCredentials`, the operator credential export. The state is `disclosed`, `denied`, `failed`, or `cancelled`. `unmapped` holds `operation`, `actor` (the authenticated principal as `type:id`, or `unknown`), `outcome`, and `status_code` for denied or failed attempts. It also names what was requested: `requested_sandbox_id` and `providers` for the sandbox environment, or `workspace`, `provider`, and `credential_keys` for the export. The export records each target identifier that is valid on its own, even when the attempt is denied or the request is otherwise invalid. Events about a sandbox carry it in `container`, and `metadata.correlation_uid` holds the request ID as described above. These events never include credential values or error text. Without JSONL enabled, gateway OCSF events still appear as shorthand in console output, subject to the diagnostic log filter. Events associated with a sandbox also appear in its gateway log stream; gateway-wide events have no sandbox association.