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
3 changes: 3 additions & 0 deletions .agents/skills/openshell-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,9 @@ openshell settings delete work-session --key ocsf_json_enabled

openshell settings get --global --json
openshell settings set --global --key providers_v2_enabled --value true

# OCSF schema version downgrade for SIEM compatibility (allowed: "1.1", "1.3")
openshell settings set --global --key ocsf_schema_version --value "1.1"
```

Global mutations prompt for confirmation. Use `--yes` only in reviewed automation.
Expand Down
52 changes: 49 additions & 3 deletions crates/openshell-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ pub const PROPOSAL_APPROVAL_MODE_KEY: &str = "proposal_approval_mode";
/// fail-closes on unknown persisted values for defense in depth.
pub const PROPOSAL_APPROVAL_MODE_VALUES: &[&str] = &["manual", "auto"];

/// Allowed values for `ocsf_schema_version`.
///
/// Only versions with actual downgrade transforms in
/// `openshell_ocsf::format::downgrade` are accepted. Empty string disables
/// downgrade (equivalent to unsetting the key). Malformed or unsupported
/// versions (e.g. `"banana"`, `"1.6"`) are rejected at configure time.
pub const OCSF_SCHEMA_VERSION_VALUES: &[&str] = &["", "1.1", "1.3"];

pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[
// Gateway-level opt-in for provider profile policy composition. Defaults
// to false when unset.
Expand All @@ -123,6 +131,14 @@ pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[
kind: SettingValueKind::Bool,
allowed_string_values: None,
},
// Target OCSF schema version for JSONL downgrade. When set (e.g. "1.1"
// or "1.3"), the JSONL layer strips fields and profiles that don't exist
// in the target version. Empty or unset means no downgrade.
RegisteredSetting {
key: "ocsf_schema_version",
kind: SettingValueKind::String,
allowed_string_values: Some(OCSF_SCHEMA_VERSION_VALUES),
},
// Sandbox-level opt-in for the agent-driven policy proposal surface.
// See AGENT_POLICY_PROPOSALS_ENABLED_KEY for details. Defaults to false.
RegisteredSetting {
Expand Down Expand Up @@ -168,9 +184,9 @@ pub fn parse_bool_like(raw: &str) -> Option<bool> {
#[cfg(test)]
mod tests {
use super::{
PROPOSAL_APPROVAL_MODE_KEY, PROPOSAL_APPROVAL_MODE_VALUES, PROVIDERS_V2_ENABLED_KEY,
REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind, parse_bool_like,
registered_keys_csv, setting_for_key,
OCSF_SCHEMA_VERSION_VALUES, PROPOSAL_APPROVAL_MODE_KEY, PROPOSAL_APPROVAL_MODE_VALUES,
PROVIDERS_V2_ENABLED_KEY, REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind,
parse_bool_like, registered_keys_csv, setting_for_key,
};

#[test]
Expand Down Expand Up @@ -237,6 +253,36 @@ mod tests {
}
}

// ---- ocsf_schema_version validation ----

#[test]
fn ocsf_schema_version_accepts_supported_versions() {
let setting = setting_for_key("ocsf_schema_version")
.expect("ocsf_schema_version should be registered");
assert_eq!(setting.kind, SettingValueKind::String);
assert_eq!(
setting.allowed_string_values,
Some(OCSF_SCHEMA_VERSION_VALUES)
);
assert!(setting.validate_string_value("").is_ok());
assert!(setting.validate_string_value("1.1").is_ok());
assert!(setting.validate_string_value("1.3").is_ok());
}

#[test]
fn ocsf_schema_version_rejects_malformed_and_unsupported() {
let setting = setting_for_key("ocsf_schema_version")
.expect("ocsf_schema_version should be registered");
for bad in [
"banana", "1.6", "1.5", "1.7", "1.7.0", "1.1.0", "2.0", " 1.1", "1.1 ", "v1.1",
] {
let err = setting
.validate_string_value(bad)
.expect_err(&format!("expected '{bad}' to be rejected"));
assert_eq!(err, OCSF_SCHEMA_VERSION_VALUES);
}
}

// ---- parse_bool_like ----

#[test]
Expand Down
183 changes: 183 additions & 0 deletions crates/openshell-ocsf/src/format/downgrade.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! OCSF schema version downgrade filter.
//!
//! Transforms serialized OCSF JSON events to conform to older schema versions
//! by stripping fields and profiles that don't exist in the target version.

use serde_json::Value;

/// Fields to strip when downgrading to v1.3.0 or earlier.
const STRIP_FOR_V1_3: &[&str] = &["ai_model", "container", "observation_point_id"];

/// Profile names to remove from `metadata.profiles` when downgrading to v1.3.0 or earlier.
const STRIP_PROFILES_V1_3: &[&str] = &["ai_operation", "container"];

/// Downgrade a serialized OCSF event to the target schema version.
///
/// Modifies the JSON in place: strips fields that don't exist in the target
/// version, removes unknown profile names from `metadata.profiles`, and
/// rewrites `metadata.version` to match.
///
/// Returns `true` if the event was modified, `false` if no changes were needed
/// (target is current version or newer).
pub fn downgrade_event(event: &mut Value, target_version: &str) -> bool {
let target = parse_version(target_version);
let v1_3 = (1, 3, 0);

if target >= parse_version(crate::OCSF_VERSION) {
return false;
}

let Some(obj) = event.as_object_mut() else {
return false;
};

let mut modified = false;

if target <= v1_3 {
for field in STRIP_FOR_V1_3 {
if obj.remove(*field).is_some() {
modified = true;
}
}

if let Some(profiles) = obj
.get_mut("metadata")
.and_then(Value::as_object_mut)
.and_then(|m| m.get_mut("profiles"))
.and_then(Value::as_array_mut)
{
let before = profiles.len();
profiles.retain(|p| !p.as_str().is_some_and(|s| STRIP_PROFILES_V1_3.contains(&s)));
if profiles.len() != before {
modified = true;
}
}
}

if modified && let Some(metadata) = obj.get_mut("metadata").and_then(Value::as_object_mut) {
metadata.insert(
"version".to_string(),
Value::String(target_version.to_string()),
);
}

modified
}

fn parse_version(v: &str) -> (u32, u32, u32) {
let parts: Vec<u32> = v.split('.').filter_map(|s| s.parse().ok()).collect();
(
parts.first().copied().unwrap_or(0),
parts.get(1).copied().unwrap_or(0),
parts.get(2).copied().unwrap_or(0),
)
}

#[cfg(test)]
mod tests {
use super::*;

fn test_event() -> Value {
serde_json::json!({
"class_uid": 4002,
"class_name": "HTTP Activity",
"time": 1_234_567_890,
"severity_id": 1,
"metadata": {
"version": "1.7.0",
"profiles": ["security_control", "network_proxy", "container", "host"]
},
"device": {"hostname": "sandbox-1"},
"container": {"name": "test-sandbox"},
"observation_point_id": 2,
"unmapped": {"key": "value"}
})
}

#[test]
fn test_downgrade_to_v1_3_strips_fields() {
let mut event = test_event();
let modified = downgrade_event(&mut event, "1.3.0");

assert!(modified);
assert!(event.get("container").is_none());
assert!(event.get("observation_point_id").is_none());
assert!(event.get("device").is_some());
assert!(event.get("unmapped").is_some());
}

#[test]
fn test_downgrade_to_v1_1_strips_fields() {
let mut event = test_event();
let modified = downgrade_event(&mut event, "1.1.0");

assert!(modified);
assert!(event.get("container").is_none());
assert!(event.get("observation_point_id").is_none());
}

#[test]
fn test_downgrade_strips_profiles() {
let mut event = test_event();
downgrade_event(&mut event, "1.3.0");

let profiles = event["metadata"]["profiles"].as_array().unwrap();
assert!(!profiles.iter().any(|p| p == "container"));
assert!(profiles.iter().any(|p| p == "security_control"));
assert!(profiles.iter().any(|p| p == "host"));
}

#[test]
fn test_downgrade_rewrites_version() {
let mut event = test_event();
downgrade_event(&mut event, "1.1.0");

assert_eq!(event["metadata"]["version"], "1.1.0");
}

#[test]
fn test_no_downgrade_for_current_version() {
let mut event = test_event();
let modified = downgrade_event(&mut event, "1.7.0");

assert!(!modified);
assert_eq!(event["metadata"]["version"], "1.7.0");
}

#[test]
fn test_no_downgrade_for_newer_version() {
let mut event = test_event();
let modified = downgrade_event(&mut event, "1.9.0");

assert!(!modified);
}

#[test]
fn test_downgrade_strips_ai_model_when_present() {
let mut event = serde_json::json!({
"class_uid": 6003,
"metadata": {
"version": "1.8.0",
"profiles": ["container", "host", "ai_operation"]
},
"ai_model": {"name": "claude-3-haiku", "ai_provider": "anthropic"},
"unmapped": {"latency_ms": 701}
});
let modified = downgrade_event(&mut event, "1.3.0");

assert!(modified);
assert!(event.get("ai_model").is_none());
assert!(
!event["metadata"]["profiles"]
.as_array()
.unwrap()
.iter()
.any(|p| p == "ai_operation")
);
assert_eq!(event["metadata"]["version"], "1.3.0");
assert!(event.get("unmapped").is_some());
}
}
1 change: 1 addition & 0 deletions crates/openshell-ocsf/src/format/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@

//! OCSF event formatters: shorthand (human-readable) and JSONL.

pub mod downgrade;
pub mod jsonl;
pub mod shorthand;
40 changes: 39 additions & 1 deletion crates/openshell-ocsf/src/tracing_layers/jsonl_layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use tracing::Subscriber;
use tracing_subscriber::Layer;
use tracing_subscriber::layer::Context;

use crate::format::downgrade::downgrade_event;
use crate::tracing_layers::event_bridge::{OCSF_TARGET, clone_current_event};

/// A tracing `Layer` that intercepts OCSF events and writes JSONL output.
Expand All @@ -23,9 +24,15 @@ use crate::tracing_layers::event_bridge::{OCSF_TARGET, clone_current_event};
/// `false`, the layer short-circuits without writing. This allows the sandbox
/// to hot-toggle OCSF JSONL output at runtime via the `ocsf_json_enabled`
/// setting without rebuilding the subscriber.
///
/// An optional target schema version can be set via
/// [`with_target_version`](Self::with_target_version). When set, events are
/// downgraded to the target version before writing (stripping fields and
/// profiles that don't exist in older schema versions).
pub struct OcsfJsonlLayer<W: Write + Send + 'static> {
writer: Mutex<W>,
enabled: Option<Arc<AtomicBool>>,
target_version: Option<Arc<Mutex<String>>>,
}

impl<W: Write + Send + 'static> OcsfJsonlLayer<W> {
Expand All @@ -35,6 +42,7 @@ impl<W: Write + Send + 'static> OcsfJsonlLayer<W> {
Self {
writer: Mutex::new(writer),
enabled: None,
target_version: None,
}
}

Expand All @@ -47,6 +55,16 @@ impl<W: Write + Send + 'static> OcsfJsonlLayer<W> {
self.enabled = Some(flag);
self
}

/// Attach a shared target schema version for downgrade filtering.
///
/// When set, events are downgraded to the target version before writing.
/// The version can be changed at runtime via the shared mutex.
#[must_use]
pub fn with_target_version(mut self, version: Arc<Mutex<String>>) -> Self {
self.target_version = Some(version);
self
}
}

impl<S, W> Layer<S> for OcsfJsonlLayer<W>
Expand All @@ -67,9 +85,29 @@ where
}

if let Some(ocsf_event) = clone_current_event()
&& let Ok(line) = ocsf_event.to_json_line()
&& let Ok(mut w) = self.writer.lock()
{
let line = if let Some(ref target) = self.target_version
&& let Ok(version) = target.lock()
&& !version.is_empty()
{
let Ok(mut json) = serde_json::to_value(&ocsf_event) else {
return;
};
downgrade_event(&mut json, &version);
match serde_json::to_string(&json) {
Ok(mut s) => {
s.push('\n');
s
}
Err(_) => return,
}
} else {
match ocsf_event.to_json_line() {
Ok(l) => l,
Err(_) => return,
}
};
let _ = w.write_all(line.as_bytes());
}
}
Expand Down
Loading
Loading