diff --git a/Cargo.lock b/Cargo.lock index 2a24331f2a..79083a962b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2468,6 +2468,7 @@ checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" dependencies = [ "aho-corasick", "bstr", + "log", "regex-automata", "regex-syntax", ] @@ -4895,6 +4896,7 @@ dependencies = [ name = "openshell-policy" version = "0.0.0" dependencies = [ + "globset", "hickory-proto", "miette", "openshell-core", @@ -4969,6 +4971,7 @@ dependencies = [ "base64 0.22.1", "bytes", "clap", + "globset", "hex", "hyper-util", "ipnet", diff --git a/Cargo.toml b/Cargo.toml index 4816fc8ffe..b277a12d51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,6 +123,7 @@ include_dir = "0.7" # Glob matching glob = "0.3" +globset = "0.4" # Utilities futures = "0.3" diff --git a/crates/openshell-core/src/policy.rs b/crates/openshell-core/src/policy.rs index af474325c5..0cb0eb3333 100644 --- a/crates/openshell-core/src/policy.rs +++ b/crates/openshell-core/src/policy.rs @@ -28,6 +28,9 @@ pub struct FilesystemPolicy { /// Read-write directory allow list. pub read_write: Vec, + /// Path globs for observed workload open attempts. + pub audit_paths: Vec, + /// Automatically include the workdir as read-write. pub include_workdir: bool, } @@ -37,6 +40,7 @@ impl Default for FilesystemPolicy { Self { read_only: Vec::new(), read_write: Vec::new(), + audit_paths: Vec::new(), include_workdir: true, } } @@ -150,6 +154,7 @@ impl From for FilesystemPolicy { .into_iter() .map(|p| PathBuf::from(normalize_path(&p))) .collect(), + audit_paths: proto.audit_paths, include_workdir: proto.include_workdir, } } diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index f819d8ab38..00035e34b0 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -84,6 +84,10 @@ impl RegisteredSetting { /// still applies when this flag is on. pub const AGENT_POLICY_PROPOSALS_ENABLED_KEY: &str = "agent_policy_proposals_enabled"; +/// Opt in to launch and exit events for processes started by the Linux +/// sandbox boundary. The value is captured when the sandbox starts. +pub const PROCESS_ACTIVITY_ENABLED_KEY: &str = "process_activity_enabled"; + /// Approval mode for agent-authored policy proposals. /// /// `"manual"` (the default when unset): every proposal lands in the draft @@ -115,6 +119,11 @@ pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ kind: SettingValueKind::Bool, allowed_string_values: None, }, + RegisteredSetting { + key: PROCESS_ACTIVITY_ENABLED_KEY, + kind: SettingValueKind::Bool, + allowed_string_values: None, + }, // Deprecated and ignored: OpenShell emits OCSF 1.8.0 only. Still // registered so existing values load; the supervisor reports a set value. RegisteredSetting { @@ -187,6 +196,13 @@ mod tests { assert!(setting_for_key("providers_v2_enabled").is_none()); } + #[test] + fn process_activity_is_an_opt_in_boolean_setting() { + let setting = setting_for_key("process_activity_enabled") + .expect("process activity setting should be registered"); + assert_eq!(setting.kind, SettingValueKind::Bool); + } + // ---- RegisteredSetting::validate_string_value ---- #[test] diff --git a/crates/openshell-driver-mxc/src/driver.rs b/crates/openshell-driver-mxc/src/driver.rs index 2772f1a69c..d0404136c5 100644 --- a/crates/openshell-driver-mxc/src/driver.rs +++ b/crates/openshell-driver-mxc/src/driver.rs @@ -1319,6 +1319,7 @@ mod lifecycle_tests { include_workdir: false, read_only: Vec::new(), read_write: read_write.iter().map(ToString::to_string).collect(), + audit_paths: Vec::new(), }), ..Default::default() } diff --git a/crates/openshell-driver-mxc/src/lib.rs b/crates/openshell-driver-mxc/src/lib.rs index 4b9d328e9f..0219716415 100644 --- a/crates/openshell-driver-mxc/src/lib.rs +++ b/crates/openshell-driver-mxc/src/lib.rs @@ -21,11 +21,12 @@ mod driver; mod grpc; #[cfg(target_os = "windows")] mod mxc; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", test))] mod policy; // Embedded mapper logic (source of truth; was the `openshell-policy-mapper` -// crate). Windows-only — MXC and the policy mapper are not built for Linux/WSL. -#[cfg(target_os = "windows")] +// crate). Production code is Windows-only; tests run the pure mapper on Linux. +#[cfg(any(target_os = "windows", test))] +#[cfg_attr(not(target_os = "windows"), allow(unused_imports))] mod policy_map; // Real-time ETW → OCSF audit consumer (Plane A). Consumes the OS Sandboxing // provider MXC drives and emits OCSF through the gateway's tracing sink. diff --git a/crates/openshell-driver-mxc/src/policy.rs b/crates/openshell-driver-mxc/src/policy.rs index 30a0ca56da..f277f42281 100644 --- a/crates/openshell-driver-mxc/src/policy.rs +++ b/crates/openshell-driver-mxc/src/policy.rs @@ -206,6 +206,7 @@ mod tests { include_workdir: false, read_only: ro.iter().map(ToString::to_string).collect(), read_write: rw.iter().map(ToString::to_string).collect(), + audit_paths: Vec::new(), }), ..Default::default() } @@ -234,6 +235,26 @@ mod tests { assert!(matches!(err, MapError::Internal(_))); } + #[test] + fn embedded_rejects_file_audit_on_both_mxc_paths() { + let mapper = EmbeddedPolicyMapper; + let mut policy = fs_policy(&["C:/work/demo"], &[]); + policy.filesystem.as_mut().unwrap().audit_paths = vec!["/sandbox/private/**".to_string()]; + for egress in [None, Some("127.0.0.1:18080".parse().unwrap())] { + let ctx = MapCtx { + sandbox_id: "sb-audit".into(), + egress, + }; + let error = mapper.map(Some(&policy), &ctx).unwrap_err(); + let MapError::Unsupported(loss) = error else { + panic!("expected unsupported audit policy"); + }; + assert_eq!(loss.len(), 1); + assert_eq!(loss[0].rule_kind, "filesystem_policy.audit_paths"); + assert!(!loss[0].detail.contains("/sandbox/private")); + } + } + #[test] fn embedded_rejects_network_policy_on_isolation_session() { use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule}; diff --git a/crates/openshell-driver-mxc/src/policy_map/map.rs b/crates/openshell-driver-mxc/src/policy_map/map.rs index c1b3af94fa..ac6b028b86 100644 --- a/crates/openshell-driver-mxc/src/policy_map/map.rs +++ b/crates/openshell-driver-mxc/src/policy_map/map.rs @@ -297,6 +297,16 @@ fn map_filesystem( match &policy.filesystem { Some(fs) => { + if !fs.audit_paths.is_empty() { + add_loss( + items, + "filesystem_policy.audit_paths", + "error", + "MXC cannot observe watched file opens.", + "watched file opens", + "The sandbox would emit no file activity events for this policy.", + ); + } readwrite.clone_from(&fs.read_write); readonly.clone_from(&fs.read_only); if fs.include_workdir { diff --git a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs index 6dda646bd0..8721f21d5e 100644 --- a/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs +++ b/crates/openshell-driver-mxc/tests/policy_mapper_matrix.rs @@ -1114,6 +1114,7 @@ fn handled_fields_inventory() { include_workdir: false, read_only: vec!["/usr".into()], read_write: vec!["/work".into()], + audit_paths: Vec::new(), }), landlock: Some(LandlockPolicy { compatibility: "best_effort".into(), diff --git a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs index df800d7aea..2e53136532 100644 --- a/crates/openshell-driver-mxc/tests/wxc_exec_real.rs +++ b/crates/openshell-driver-mxc/tests/wxc_exec_real.rs @@ -292,6 +292,7 @@ fn dryrun_accepts_split_policy_output() { include_workdir: false, read_only: Vec::new(), read_write: vec![temp_path.clone()], + audit_paths: Vec::new(), }), ..Default::default() }; @@ -724,6 +725,7 @@ async fn pc_https_egress_reads_injected_ca_bundle() { include_workdir: false, read_only: Vec::new(), read_write: vec![output_dir_string], + audit_paths: Vec::new(), }), network_policies: std::collections::HashMap::from([( "https_example".to_string(), diff --git a/crates/openshell-isolation-interface/src/lib.rs b/crates/openshell-isolation-interface/src/lib.rs index 446f6cc244..505c38ffec 100644 --- a/crates/openshell-isolation-interface/src/lib.rs +++ b/crates/openshell-isolation-interface/src/lib.rs @@ -33,6 +33,8 @@ pub struct AgentSpec { /// Whether the supervisor serves the OTLP relay for this sandbox. The /// boundary points workload SDKs at the relay only while it is active. pub otlp_relay_active: bool, + /// Whether the boundary emits lifecycle events for processes it starts. + pub process_activity_enabled: bool, } pub mod contract; diff --git a/crates/openshell-ocsf-validate/schemas/v1.8.0/file_activity@container+host+security_control.json b/crates/openshell-ocsf-validate/schemas/v1.8.0/file_activity@container+host+security_control.json new file mode 100644 index 0000000000..61effaea62 --- /dev/null +++ b/crates/openshell-ocsf-validate/schemas/v1.8.0/file_activity@container+host+security_control.json @@ -0,0 +1,7018 @@ +{ + "$defs": { + "kb_article": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "src_url" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "avg_timespan": { + "$ref": "#/$defs/timespan", + "title": "Average Timespan" + }, + "bulletin": { + "title": "Patch Bulletin", + "type": "string" + }, + "classification": { + "title": "Classification", + "type": "string" + }, + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "install_state": { + "title": "Install State", + "type": "string" + }, + "install_state_id": { + "enum": [ + 0, + 1, + 2, + 3, + 99 + ], + "title": "Install State ID", + "type": "integer" + }, + "is_superseded": { + "title": "The patch is superseded.", + "type": "boolean" + }, + "os": { + "$ref": "#/$defs/os", + "title": "OS" + }, + "product": { + "$ref": "#/$defs/product", + "title": "Product" + }, + "severity": { + "title": "Severity", + "type": "string" + }, + "size": { + "title": "Size", + "type": "integer" + }, + "src_url": { + "title": "Source URL", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "title": "KB Article", + "type": "object" + }, + "long_string": { + "additionalProperties": false, + "properties": { + "is_truncated": { + "title": "Is Truncated", + "type": "boolean" + }, + "untruncated_size": { + "title": "Untruncated Size", + "type": "integer" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "value" + ], + "title": "Long String", + "type": "object" + }, + "enrichment": { + "additionalProperties": false, + "properties": { + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "data": { + "title": "Data" + }, + "desc": { + "title": "Description", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "reputation": { + "$ref": "#/$defs/reputation", + "title": "Reputation Scores" + }, + "short_desc": { + "title": "Short Description", + "type": "string" + }, + "src_url": { + "title": "Source URL", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "data", + "name", + "value" + ], + "title": "Enrichment", + "type": "object" + }, + "ldap_person": { + "additionalProperties": false, + "properties": { + "cost_center": { + "title": "Cost Center", + "type": "string" + }, + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "deleted_time": { + "title": "Deleted Time", + "type": "integer" + }, + "department": { + "title": "Department", + "type": "string" + }, + "display_name": { + "title": "Display Name", + "type": "string" + }, + "email_addrs": { + "items": { + "type": "string" + }, + "title": "Email Addresses", + "type": "array" + }, + "employee_uid": { + "title": "Employee ID", + "type": "string" + }, + "given_name": { + "title": "Given Name", + "type": "string" + }, + "hire_time": { + "title": "Hire Time", + "type": "integer" + }, + "job_title": { + "title": "Job Title", + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + }, + "last_login_time": { + "title": "Last Login", + "type": "integer" + }, + "ldap_cn": { + "title": "LDAP Common Name", + "type": "string" + }, + "ldap_dn": { + "title": "LDAP Distinguished Name", + "type": "string" + }, + "leave_time": { + "title": "Leave Time", + "type": "integer" + }, + "location": { + "$ref": "#/$defs/location", + "title": "Geo Location" + }, + "manager": { + "$ref": "#/$defs/user", + "title": "Manager" + }, + "modified_time": { + "title": "Modified Time", + "type": "integer" + }, + "office_location": { + "title": "Office Location", + "type": "string" + }, + "phone_number": { + "title": "Telephone Number", + "type": "string" + }, + "surname": { + "title": "Surname", + "type": "string" + }, + "tags": { + "items": { + "$ref": "#/$defs/key_value_object" + }, + "title": "Tags", + "type": "array" + } + }, + "title": "LDAP Person", + "type": "object" + }, + "email_auth": { + "additionalProperties": false, + "properties": { + "dkim": { + "title": "DKIM Status", + "type": "string" + }, + "dkim_domain": { + "title": "DKIM Domain", + "type": "string" + }, + "dkim_signature": { + "title": "DKIM Signature", + "type": "string" + }, + "dmarc": { + "title": "DMARC Status", + "type": "string" + }, + "dmarc_override": { + "title": "DMARC Override", + "type": "string" + }, + "dmarc_policy": { + "title": "DMARC Policy", + "type": "string" + }, + "spf": { + "title": "SPF Status", + "type": "string" + } + }, + "title": "Email Authentication", + "type": "object" + }, + "epss": { + "additionalProperties": false, + "properties": { + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "percentile": { + "title": "EPSS Percentile", + "type": "number" + }, + "score": { + "title": "EPPS Score", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "score" + ], + "title": "EPSS", + "type": "object" + }, + "device_hw_info": { + "additionalProperties": false, + "properties": { + "bios_date": { + "title": "BIOS Date", + "type": "string" + }, + "bios_manufacturer": { + "title": "BIOS Manufacturer", + "type": "string" + }, + "bios_ver": { + "title": "BIOS Version", + "type": "string" + }, + "chassis": { + "title": "Chassis", + "type": "string" + }, + "cpu_architecture": { + "title": "CPU Architecture", + "type": "string" + }, + "cpu_architecture_id": { + "enum": [ + 0, + 1, + 2, + 3, + 99 + ], + "title": "CPU Architecture ID", + "type": "integer" + }, + "cpu_bits": { + "title": "CPU Bits", + "type": "integer" + }, + "cpu_cores": { + "title": "CPU Cores", + "type": "integer" + }, + "cpu_count": { + "title": "CPU Count", + "type": "integer" + }, + "cpu_speed": { + "title": "Processor Speed", + "type": "integer" + }, + "cpu_type": { + "title": "Processor Type", + "type": "string" + }, + "desktop_display": { + "$ref": "#/$defs/display", + "title": "Desktop Display" + }, + "gpu_count": { + "title": "GPU Count", + "type": "integer" + }, + "gpu_info_list": { + "items": { + "$ref": "#/$defs/gpu_info" + }, + "title": "GPU information", + "type": "array" + }, + "keyboard_info": { + "$ref": "#/$defs/keyboard_info", + "title": "Keyboard Information" + }, + "ram_size": { + "title": "RAM Size", + "type": "integer" + }, + "serial_number": { + "title": "Serial Number", + "type": "string" + }, + "uuid": { + "title": "UUID", + "type": "string" + }, + "vendor_name": { + "title": "Vendor Name", + "type": "string" + } + }, + "title": "Device Hardware Info", + "type": "object" + }, + "data_classification": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "category_id" + ] + }, + { + "required": [ + "confidentiality_id" + ] + } + ], + "properties": { + "category": { + "title": "Category", + "type": "string" + }, + "category_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 99 + ], + "title": "Category ID", + "type": "integer" + }, + "classifier_details": { + "$ref": "#/$defs/classifier_details", + "title": "Classifier Details" + }, + "confidentiality": { + "title": "Confidentiality", + "type": "string" + }, + "confidentiality_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 99 + ], + "title": "Confidentiality ID", + "type": "integer" + }, + "discovery_details": { + "items": { + "$ref": "#/$defs/discovery_details" + }, + "title": "Discovery Details", + "type": "array" + }, + "policy": { + "$ref": "#/$defs/policy", + "title": "Policy" + }, + "size": { + "title": "Size", + "type": "integer" + }, + "src_url": { + "title": "Source URL", + "type": "string" + }, + "status": { + "title": "Status", + "type": "string" + }, + "status_details": { + "items": { + "type": "string" + }, + "title": "Status Details", + "type": "array" + }, + "status_id": { + "enum": [ + 0, + 1, + 2, + 3, + 99 + ], + "title": "Status ID", + "type": "integer" + }, + "total": { + "title": "Total", + "type": "integer" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "title": "Data Classification", + "type": "object" + }, + "display": { + "additionalProperties": false, + "properties": { + "color_depth": { + "title": "Color Depth", + "type": "integer" + }, + "physical_height": { + "title": "Physical Height", + "type": "integer" + }, + "physical_orientation": { + "title": "Physical Orientation", + "type": "integer" + }, + "physical_width": { + "title": "Physical Width", + "type": "integer" + }, + "scale_factor": { + "title": "Scale Factor", + "type": "integer" + } + }, + "title": "Display", + "type": "object" + }, + "scim": { + "additionalProperties": false, + "properties": { + "auth_protocol": { + "title": "Auth Protocol", + "type": "string" + }, + "auth_protocol_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 99 + ], + "title": "Auth Protocol ID", + "type": "integer" + }, + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "error_message": { + "title": "Last Error Message", + "type": "string" + }, + "is_group_provisioning_enabled": { + "title": "SCIM Group Provisioning Enabled", + "type": "boolean" + }, + "is_user_provisioning_enabled": { + "title": "SCIM User Provisioning Enabled", + "type": "boolean" + }, + "last_run_time": { + "title": "Last Sync Time", + "type": "integer" + }, + "modified_time": { + "title": "Modified Time", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + }, + "protocol_name": { + "title": "Supported Protocol", + "type": "string" + }, + "rate_limit": { + "title": "Rate Limit", + "type": "integer" + }, + "scim_group_schema": { + "title": "SCIM Group Schema" + }, + "scim_user_schema": { + "title": "SCIM User Schema" + }, + "state": { + "title": "State", + "type": "string" + }, + "state_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 99 + ], + "title": "State ID", + "type": "integer" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "uid_alt": { + "title": "External ID", + "type": "string" + }, + "url_string": { + "title": "SCIM Endpoint URL", + "type": "string" + }, + "vendor_name": { + "title": "Service Provider", + "type": "string" + }, + "version": { + "title": "SCIM Version", + "type": "string" + } + }, + "title": "SCIM", + "type": "object" + }, + "programmatic_credential": { + "additionalProperties": false, + "properties": { + "last_used_time": { + "title": "Last Used Time", + "type": "integer" + }, + "type": { + "title": "Type", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "required": [ + "uid" + ], + "title": "Programmatic Credential", + "type": "object" + }, + "os": { + "additionalProperties": false, + "properties": { + "build": { + "title": "OS Build", + "type": "string" + }, + "country": { + "title": "Country", + "type": "string" + }, + "cpe_name": { + "title": "The product CPE identifier", + "type": "string" + }, + "cpu_bits": { + "title": "CPU Bits", + "type": "integer" + }, + "edition": { + "title": "OS Edition", + "type": "string" + }, + "kernel_release": { + "title": "Kernel Release", + "type": "string" + }, + "lang": { + "title": "Language", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "sp_name": { + "title": "OS Service Pack", + "type": "string" + }, + "sp_ver": { + "title": "OS Service Pack Version", + "type": "integer" + }, + "type": { + "title": "Type", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 99, + 100, + 101, + 200, + 201, + 300, + 301, + 302, + 400, + 401, + 402 + ], + "title": "Type ID", + "type": "integer" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "name", + "type_id" + ], + "title": "Operating System (OS)", + "type": "object" + }, + "tactic": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "src_url": { + "title": "Source URL", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "title": "MITRE Tactic", + "type": "object" + }, + "dns_answer": { + "additionalProperties": false, + "properties": { + "class": { + "title": "Resource Record Class", + "type": "string" + }, + "flag_ids": { + "items": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 99 + ], + "type": "integer" + }, + "title": "DNS Header Flags", + "type": "array" + }, + "flags": { + "items": { + "type": "string" + }, + "title": "DNS Header Flags", + "type": "array" + }, + "packet_uid": { + "title": "Packet UID", + "type": "integer" + }, + "rdata": { + "title": "DNS RData", + "type": "string" + }, + "ttl": { + "title": "TTL", + "type": "integer" + }, + "type": { + "title": "Resource Record Type", + "type": "string" + } + }, + "required": [ + "rdata" + ], + "title": "DNS Answer", + "type": "object" + }, + "discovery_details": { + "additionalProperties": false, + "properties": { + "count": { + "title": "Count", + "type": "integer" + }, + "occurrence_details": { + "$ref": "#/$defs/occurrence_details", + "title": "Occurrence Details" + }, + "occurrences": { + "items": { + "$ref": "#/$defs/occurrence_details" + }, + "title": "Occurrences", + "type": "array" + }, + "type": { + "title": "Type", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "title": "Discovery Details", + "type": "object" + }, + "sso": { + "additionalProperties": false, + "properties": { + "auth_protocol": { + "title": "Auth Protocol", + "type": "string" + }, + "auth_protocol_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 99 + ], + "title": "Auth Protocol ID", + "type": "integer" + }, + "certificate": { + "$ref": "#/$defs/certificate", + "title": "SAML Certificate" + }, + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "duration_mins": { + "title": "SSO Session Duration", + "type": "integer" + }, + "idle_timeout": { + "title": "SSO Idle Timeout", + "type": "integer" + }, + "login_endpoint": { + "title": "SSO Login Endpoint", + "type": "string" + }, + "logout_endpoint": { + "title": "SSO Logout Endpoint", + "type": "string" + }, + "metadata_endpoint": { + "title": "SSO Metadata Endpoint", + "type": "string" + }, + "modified_time": { + "title": "Modified Time", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + }, + "protocol_name": { + "title": "Supported Protocol", + "type": "string" + }, + "scopes": { + "items": { + "type": "string" + }, + "title": "Scopes", + "type": "array" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "vendor_name": { + "title": "Service Provider", + "type": "string" + } + }, + "title": "SSO", + "type": "object" + }, + "token": { + "additionalProperties": false, + "properties": { + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "expiration_time": { + "title": "Expiration Time", + "type": "integer" + }, + "is_renewable": { + "title": "Renewable", + "type": "boolean" + }, + "modified_time": { + "title": "Modified Time", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + }, + "tenant_uid": { + "title": "Tenant UID", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 99 + ], + "title": "Type ID", + "type": "integer" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "zone": { + "title": "Network Zone", + "type": "string" + } + }, + "title": "Token", + "type": "object" + }, + "api": { + "additionalProperties": false, + "properties": { + "group": { + "$ref": "#/$defs/group", + "title": "Group" + }, + "operation": { + "title": "Operation", + "type": "string" + }, + "request": { + "$ref": "#/$defs/request", + "title": "API Request Details" + }, + "response": { + "$ref": "#/$defs/response", + "title": "API Response Details" + }, + "service": { + "$ref": "#/$defs/service", + "title": "Service" + }, + "token": { + "$ref": "#/$defs/token", + "title": "Token" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "operation" + ], + "title": "API", + "type": "object" + }, + "d3fend": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "d3f_tactic" + ] + }, + { + "required": [ + "d3f_technique" + ] + } + ], + "properties": { + "d3f_tactic": { + "$ref": "#/$defs/d3f_tactic", + "title": "MITRE D3FEND\u2122 Tactic" + }, + "d3f_technique": { + "$ref": "#/$defs/d3f_technique", + "title": "MITRE D3FEND\u2122 Technique" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "title": "MITRE D3FEND\u2122", + "type": "object" + }, + "reputation": { + "additionalProperties": false, + "properties": { + "base_score": { + "title": "Reputation Score", + "type": "number" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "score": { + "title": "Reputation Score", + "type": "string" + }, + "score_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 99 + ], + "title": "Reputation Score ID", + "type": "integer" + } + }, + "required": [ + "base_score", + "score_id" + ], + "title": "Reputation", + "type": "object" + }, + "mitigation": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "countermeasures": { + "items": { + "$ref": "#/$defs/d3fend" + }, + "title": "Countermeasures", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "src_url": { + "title": "Source URL", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "title": "MITRE Mitigation", + "type": "object" + }, + "location": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "city" + ] + }, + { + "required": [ + "country" + ] + }, + { + "required": [ + "postal_code" + ] + }, + { + "required": [ + "region" + ] + } + ], + "properties": { + "aerial_height": { + "title": "Aerial Height", + "type": "string" + }, + "city": { + "title": "City", + "type": "string" + }, + "continent": { + "title": "Continent", + "type": "string" + }, + "coordinates": { + "items": { + "type": "number" + }, + "title": "Coordinates", + "type": "array" + }, + "country": { + "title": "Country", + "type": "string" + }, + "desc": { + "title": "Description", + "type": "string" + }, + "geodetic_altitude": { + "title": "Geodetic Altitude", + "type": "string" + }, + "geodetic_vertical_accuracy": { + "title": "Geodetic Vertical Accuracy", + "type": "string" + }, + "geohash": { + "title": "Geohash", + "type": "string" + }, + "horizontal_accuracy": { + "title": "Horizontal Accuracy", + "type": "string" + }, + "is_on_premises": { + "title": "On-Premises", + "type": "boolean" + }, + "isp": { + "title": "ISP Name", + "type": "string" + }, + "lat": { + "title": "Latitude", + "type": "number" + }, + "long": { + "title": "Longitude", + "type": "number" + }, + "postal_code": { + "title": "Postal Code", + "type": "string" + }, + "pressure_altitude": { + "title": "Pressure Altitude", + "type": "string" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "region": { + "title": "Region", + "type": "string" + } + }, + "title": "Geo Location", + "type": "object" + }, + "idp": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "auth_factors": { + "items": { + "$ref": "#/$defs/auth_factor" + }, + "title": "Authentication Factors", + "type": "array" + }, + "domain": { + "title": "Domain", + "type": "string" + }, + "fingerprint": { + "$ref": "#/$defs/fingerprint", + "title": "Certificate Fingerprint" + }, + "has_mfa": { + "title": "MFA Enforced", + "type": "boolean" + }, + "issuer": { + "title": "Issuer Details", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "protocol_name": { + "title": "Supported Protocol", + "type": "string" + }, + "scim": { + "$ref": "#/$defs/scim", + "title": "SCIM" + }, + "sso": { + "$ref": "#/$defs/sso", + "title": "SSO" + }, + "state": { + "title": "State", + "type": "string" + }, + "state_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 99 + ], + "title": "State ID", + "type": "integer" + }, + "tenant_uid": { + "title": "Tenant UID", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "url_string": { + "title": "Configuration URL", + "type": "string" + } + }, + "title": "Identity Provider", + "type": "object" + }, + "process": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "cpid" + ] + }, + { + "required": [ + "pid" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "ancestry": { + "items": { + "$ref": "#/$defs/process_entity" + }, + "title": "Ancestry", + "type": "array" + }, + "cmd_line": { + "title": "Command Line", + "type": "string" + }, + "container": { + "$ref": "#/$defs/container", + "title": "Container" + }, + "cpid": { + "title": "Common Process Identifier", + "type": "string" + }, + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "environment_variables": { + "items": { + "$ref": "#/$defs/environment_variable" + }, + "title": "Environment Variables", + "type": "array" + }, + "file": { + "$ref": "#/$defs/file", + "title": "File" + }, + "hosted_services": { + "items": { + "$ref": "#/$defs/win_win_service" + }, + "title": "Hosted Services", + "type": "array" + }, + "integrity": { + "title": "Integrity", + "type": "string" + }, + "integrity_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 99 + ], + "title": "Integrity Level", + "type": "integer" + }, + "lineage": { + "items": { + "type": "string" + }, + "title": "Lineage", + "type": "array" + }, + "loaded_modules": { + "items": { + "type": "string" + }, + "title": "Loaded Modules", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "namespace_pid": { + "title": "Namespace PID", + "type": "integer" + }, + "parent_process": { + "$ref": "#/$defs/process", + "title": "Parent Process" + }, + "path": { + "title": "Path", + "type": "string" + }, + "pid": { + "title": "Process ID", + "type": "integer" + }, + "ptid": { + "title": "Process Thread ID", + "type": "integer" + }, + "sandbox": { + "title": "Sandbox", + "type": "string" + }, + "session": { + "$ref": "#/$defs/session", + "title": "Session" + }, + "terminated_time": { + "title": "Terminated Time", + "type": "integer" + }, + "tid": { + "title": "Thread ID", + "type": "integer" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "user": { + "$ref": "#/$defs/user", + "title": "User" + }, + "working_directory": { + "title": "Working Directory", + "type": "string" + }, + "xattributes": { + "$ref": "#/$defs/object", + "title": "Extended Attributes" + } + }, + "title": "Process", + "type": "object" + }, + "vulnerability": { + "additionalProperties": false, + "oneOf": [ + { + "not": { + "required": [ + "cve", + "cwe" + ] + }, + "required": [ + "advisory" + ] + }, + { + "not": { + "required": [ + "advisory", + "cwe" + ] + }, + "required": [ + "cve" + ] + }, + { + "not": { + "required": [ + "advisory", + "cve" + ] + }, + "required": [ + "cwe" + ] + } + ], + "properties": { + "advisory": { + "$ref": "#/$defs/advisory", + "title": "Security Advisory" + }, + "affected_code": { + "items": { + "$ref": "#/$defs/affected_code" + }, + "title": "Affected Code", + "type": "array" + }, + "affected_packages": { + "items": { + "$ref": "#/$defs/affected_package" + }, + "title": "Affected Software Packages", + "type": "array" + }, + "category": { + "title": "Category", + "type": "string" + }, + "cve": { + "$ref": "#/$defs/cve", + "title": "CVE" + }, + "cwe": { + "$ref": "#/$defs/cwe", + "title": "CWE" + }, + "dependency_chain": { + "title": "Dependency Chain", + "type": "string" + }, + "desc": { + "title": "Description", + "type": "string" + }, + "exploit_last_seen_time": { + "title": "Exploit Last Seen Time", + "type": "integer" + }, + "exploit_ref_url": { + "title": "Exploit URL", + "type": "string" + }, + "exploit_requirement": { + "title": "Exploit Requirement", + "type": "string" + }, + "exploit_type": { + "title": "Exploit Type", + "type": "string" + }, + "first_seen_time": { + "title": "First Seen", + "type": "integer" + }, + "fix_available": { + "title": "Fix Availability", + "type": "boolean" + }, + "fix_coverage": { + "title": "Fix Coverage", + "type": "string" + }, + "fix_coverage_id": { + "enum": [ + 0, + 1, + 2, + 3, + 99 + ], + "title": "Fix Coverage ID", + "type": "integer" + }, + "is_exploit_available": { + "title": "Exploit Availability", + "type": "boolean" + }, + "is_fix_available": { + "title": "Fix Availability", + "type": "boolean" + }, + "kb_article_list": { + "items": { + "$ref": "#/$defs/kb_article" + }, + "title": "Knowledgebase Articles", + "type": "array" + }, + "kb_articles": { + "items": { + "type": "string" + }, + "title": "Knowledgebase Articles", + "type": "array" + }, + "last_seen_time": { + "title": "Last Seen", + "type": "integer" + }, + "packages": { + "items": { + "$ref": "#/$defs/package" + }, + "title": "Software Packages", + "type": "array" + }, + "references": { + "items": { + "type": "string" + }, + "title": "References", + "type": "array" + }, + "related_vulnerabilities": { + "items": { + "type": "string" + }, + "title": "Related Vulnerability IDs", + "type": "array" + }, + "remediation": { + "$ref": "#/$defs/remediation", + "title": "Remediation Guidance" + }, + "severity": { + "title": "Severity", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + }, + "vendor_name": { + "title": "Vendor Name", + "type": "string" + } + }, + "title": "Vulnerability Details", + "type": "object" + }, + "affected_code": { + "additionalProperties": false, + "properties": { + "end_column": { + "title": "End Column", + "type": "integer" + }, + "end_line": { + "title": "End Line", + "type": "integer" + }, + "file": { + "$ref": "#/$defs/file", + "title": "File" + }, + "owner": { + "$ref": "#/$defs/user", + "title": "Owner" + }, + "remediation": { + "$ref": "#/$defs/remediation", + "title": "Remediation Guidance" + }, + "rule": { + "$ref": "#/$defs/rule", + "title": "Related Rule" + }, + "start_column": { + "title": "Start Column", + "type": "integer" + }, + "start_line": { + "title": "Start Line", + "type": "integer" + } + }, + "required": [ + "file" + ], + "title": "Affected Code", + "type": "object" + }, + "keyboard_info": { + "additionalProperties": false, + "properties": { + "function_keys": { + "title": "Function Keys", + "type": "integer" + }, + "ime": { + "title": "IME", + "type": "string" + }, + "keyboard_layout": { + "title": "Keyboard Layout", + "type": "string" + }, + "keyboard_subtype": { + "title": "Keyboard Subtype", + "type": "integer" + }, + "keyboard_type": { + "title": "Keyboard Type", + "type": "string" + } + }, + "title": "Keyboard Information", + "type": "object" + }, + "certificate": { + "additionalProperties": false, + "properties": { + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "expiration_time": { + "title": "Expiration Time", + "type": "integer" + }, + "fingerprints": { + "items": { + "$ref": "#/$defs/fingerprint" + }, + "title": "Fingerprints", + "type": "array" + }, + "is_self_signed": { + "title": "Certificate Self-Signed", + "type": "boolean" + }, + "issuer": { + "title": "Issuer Distinguished Name", + "type": "string" + }, + "sans": { + "items": { + "$ref": "#/$defs/san" + }, + "title": "Subject Alternative Names", + "type": "array" + }, + "serial_number": { + "title": "Certificate Serial Number", + "type": "string" + }, + "subject": { + "title": "Subject Distinguished Name", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "issuer", + "serial_number" + ], + "title": "Digital Certificate", + "type": "object" + }, + "account": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "is_disabled": { + "title": "Disabled", + "type": "boolean" + }, + "is_locked": { + "title": "Locked", + "type": "boolean" + }, + "is_on_premises_sync_enabled": { + "title": "On-Premises Sync Enabled", + "type": "boolean" + }, + "labels": { + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "tags": { + "items": { + "$ref": "#/$defs/key_value_object" + }, + "title": "Tags", + "type": "array" + }, + "type": { + "title": "Type", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 99 + ], + "title": "Type ID", + "type": "integer" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "title": "Account", + "type": "object" + }, + "authorization": { + "additionalProperties": false, + "properties": { + "decision": { + "title": "Authorization Decision/Outcome", + "type": "string" + }, + "policy": { + "$ref": "#/$defs/policy", + "title": "Policy" + } + }, + "title": "Authorization Result", + "type": "object" + }, + "autonomous_system": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "number" + ] + } + ], + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "number": { + "title": "Number", + "type": "integer" + } + }, + "title": "Autonomous System", + "type": "object" + }, + "classifier_details": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "Classifier Details", + "type": "object" + }, + "session": { + "additionalProperties": false, + "properties": { + "count": { + "title": "Count", + "type": "integer" + }, + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "credential_uid": { + "title": "User Credential ID", + "type": "string" + }, + "expiration_reason": { + "title": "Expiration Reason", + "type": "string" + }, + "expiration_time": { + "title": "Expiration Time", + "type": "integer" + }, + "is_mfa": { + "title": "Multi Factor Authentication", + "type": "boolean" + }, + "is_remote": { + "title": "Remote", + "type": "boolean" + }, + "is_vpn": { + "title": "VPN Session", + "type": "boolean" + }, + "issuer": { + "title": "Issuer Details", + "type": "string" + }, + "terminal": { + "title": "Terminal", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "uid_alt": { + "title": "Alternate ID", + "type": "string" + }, + "uuid": { + "title": "UUID", + "type": "string" + } + }, + "title": "Session", + "type": "object" + }, + "d3f_technique": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "src_url": { + "title": "Source URL", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "title": "MITRE D3FEND\u2122 Technique", + "type": "object" + }, + "image": { + "additionalProperties": false, + "properties": { + "labels": { + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "tag": { + "title": "Image Tag", + "type": "string" + }, + "tags": { + "items": { + "$ref": "#/$defs/key_value_object" + }, + "title": "Tags", + "type": "array" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "required": [ + "uid" + ], + "title": "Image", + "type": "object" + }, + "script": { + "additionalProperties": false, + "properties": { + "file": { + "$ref": "#/$defs/file", + "title": "File" + }, + "hashes": { + "items": { + "$ref": "#/$defs/fingerprint" + }, + "title": "Hashes", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "parent_uid": { + "title": "Parent Unique ID", + "type": "string" + }, + "script_content": { + "$ref": "#/$defs/long_string", + "title": "Script Content" + }, + "type": { + "title": "Type", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 99 + ], + "title": "Type ID", + "type": "integer" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "required": [ + "script_content", + "type_id" + ], + "title": "Script", + "type": "object" + }, + "affected_package": { + "additionalProperties": false, + "properties": { + "architecture": { + "title": "Architecture", + "type": "string" + }, + "cpe_name": { + "title": "The product CPE identifier", + "type": "string" + }, + "epoch": { + "title": "Epoch", + "type": "integer" + }, + "fixed_in_version": { + "title": "Fixed In Version", + "type": "string" + }, + "hash": { + "$ref": "#/$defs/fingerprint", + "title": "Hash" + }, + "license": { + "title": "Software License", + "type": "string" + }, + "license_url": { + "title": "Software License URL", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "package_manager": { + "title": "Package Manager", + "type": "string" + }, + "package_manager_url": { + "title": "Package Manager URL", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "purl": { + "title": "Package URL", + "type": "string" + }, + "release": { + "title": "Software Release Details", + "type": "string" + }, + "remediation": { + "$ref": "#/$defs/remediation", + "title": "Remediation Guidance" + }, + "src_url": { + "title": "Source URL", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 99 + ], + "title": "Type ID", + "type": "integer" + }, + "uid": { + "title": "Package UID", + "type": "string" + }, + "vendor_name": { + "title": "Vendor Name", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "title": "Affected Software Package", + "type": "object" + }, + "cis_control": { + "additionalProperties": false, + "properties": { + "desc": { + "title": "Description", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "CIS Control", + "type": "object" + }, + "analytic": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "algorithm": { + "title": "Algorithm", + "type": "string" + }, + "category": { + "title": "Category", + "type": "string" + }, + "desc": { + "title": "Description", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "related_analytics": { + "items": { + "$ref": "#/$defs/analytic" + }, + "title": "Related Analytics", + "type": "array" + }, + "state": { + "title": "State", + "type": "string" + }, + "state_id": { + "enum": [ + 0, + 1, + 2, + 3, + 99 + ], + "title": "State ID", + "type": "integer" + }, + "type": { + "title": "Type", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 99 + ], + "title": "Type ID", + "type": "integer" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "type_id" + ], + "title": "Analytic", + "type": "object" + }, + "actor": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "app_name" + ] + }, + { + "required": [ + "app_uid" + ] + }, + { + "required": [ + "invoked_by" + ] + }, + { + "required": [ + "process" + ] + }, + { + "required": [ + "session" + ] + }, + { + "required": [ + "user" + ] + } + ], + "properties": { + "app_name": { + "title": "Application Name", + "type": "string" + }, + "app_uid": { + "title": "Application ID", + "type": "string" + }, + "authorizations": { + "items": { + "$ref": "#/$defs/authorization" + }, + "title": "Authorization Information", + "type": "array" + }, + "idp": { + "$ref": "#/$defs/idp", + "title": "Identity Provider" + }, + "invoked_by": { + "title": "Invoked by", + "type": "string" + }, + "process": { + "$ref": "#/$defs/process", + "title": "Process" + }, + "session": { + "$ref": "#/$defs/session", + "title": "Session" + }, + "user": { + "$ref": "#/$defs/user", + "title": "User" + } + }, + "title": "Actor", + "type": "object" + }, + "cloud": { + "additionalProperties": false, + "properties": { + "account": { + "$ref": "#/$defs/account", + "title": "Account" + }, + "cloud_partition": { + "title": "Cloud Partition", + "type": "string" + }, + "org": { + "$ref": "#/$defs/organization", + "title": "Organization" + }, + "project_uid": { + "title": "Project ID", + "type": "string" + }, + "provider": { + "title": "Cloud Provider", + "type": "string" + }, + "region": { + "title": "Region", + "type": "string" + }, + "zone": { + "title": "Cloud Availability Zone", + "type": "string" + } + }, + "required": [ + "provider" + ], + "title": "Cloud", + "type": "object" + }, + "file": { + "additionalProperties": false, + "properties": { + "parent_folder": { + "title": "Parent Folder", + "type": "string" + }, + "is_system": { + "title": "System", + "type": "boolean" + }, + "xattributes": { + "$ref": "#/$defs/object", + "title": "Extended Attributes" + }, + "confidentiality_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 99 + ], + "title": "Confidentiality ID", + "type": "integer" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "accessor": { + "$ref": "#/$defs/user", + "title": "Accessor" + }, + "is_readonly": { + "title": "Read-Only", + "type": "boolean" + }, + "modified_time": { + "title": "Modified Time", + "type": "integer" + }, + "creator": { + "$ref": "#/$defs/user", + "title": "Creator" + }, + "ext": { + "title": "File Extension", + "type": "string" + }, + "company_name": { + "title": "Company Name", + "type": "string" + }, + "attributes": { + "title": "Attributes", + "type": "integer" + }, + "desc": { + "title": "Description", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "uri": { + "title": "File URI", + "type": "string" + }, + "is_deleted": { + "title": "Deleted", + "type": "boolean" + }, + "confidentiality": { + "title": "Confidentiality", + "type": "string" + }, + "drive_type": { + "title": "Drive Type", + "type": "string" + }, + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "imported_symbols": { + "items": { + "type": "string" + }, + "title": "Imported Symbols", + "type": "array" + }, + "signature": { + "$ref": "#/$defs/digital_signature", + "title": "Digital Signature" + }, + "is_encrypted": { + "title": "Encrypted", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "accessed_time": { + "title": "Accessed Time", + "type": "integer" + }, + "tags": { + "items": { + "$ref": "#/$defs/key_value_object" + }, + "title": "Tags", + "type": "array" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 99 + ], + "title": "Type ID", + "type": "integer" + }, + "url": { + "$ref": "#/$defs/url", + "title": "URL" + }, + "size": { + "title": "Size", + "type": "integer" + }, + "owner": { + "$ref": "#/$defs/user", + "title": "Owner" + }, + "internal_name": { + "title": "Internal Name", + "type": "string" + }, + "hashes": { + "items": { + "$ref": "#/$defs/fingerprint" + }, + "title": "Hashes", + "type": "array" + }, + "drive_type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 99 + ], + "title": "Drive Type ID", + "type": "integer" + }, + "security_descriptor": { + "title": "Security Descriptor", + "type": "string" + }, + "volume": { + "title": "Volume", + "type": "string" + }, + "modifier": { + "$ref": "#/$defs/user", + "title": "Modifier" + }, + "mime_type": { + "title": "MIME type", + "type": "string" + }, + "encryption_details": { + "$ref": "#/$defs/encryption_details", + "title": "Encryption Details" + }, + "signatures": { + "items": { + "$ref": "#/$defs/digital_signature" + }, + "title": "Digital Signatures", + "type": "array" + }, + "product": { + "$ref": "#/$defs/product", + "title": "Product" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "name", + "type_id" + ], + "title": "File", + "type": "object" + }, + "remediation": { + "additionalProperties": false, + "properties": { + "cis_controls": { + "items": { + "$ref": "#/$defs/cis_control" + }, + "title": "CIS Controls", + "type": "array" + }, + "desc": { + "title": "Description", + "type": "string" + }, + "kb_article_list": { + "items": { + "$ref": "#/$defs/kb_article" + }, + "title": "Knowledgebase Articles", + "type": "array" + }, + "kb_articles": { + "items": { + "type": "string" + }, + "title": "Knowledgebase Articles", + "type": "array" + }, + "references": { + "items": { + "type": "string" + }, + "title": "References", + "type": "array" + } + }, + "required": [ + "desc" + ], + "title": "Remediation", + "type": "object" + }, + "email": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "from" + ] + }, + { + "required": [ + "to" + ] + } + ], + "properties": { + "cc": { + "items": { + "type": "string" + }, + "title": "Cc", + "type": "array" + }, + "cc_mailboxes": { + "items": { + "type": "string" + }, + "title": "Cc Mailboxes", + "type": "array" + }, + "delivered_to": { + "title": "Delivered To", + "type": "string" + }, + "delivered_to_list": { + "items": { + "type": "string" + }, + "title": "Delivered To List", + "type": "array" + }, + "files": { + "items": { + "$ref": "#/$defs/file" + }, + "title": "Files", + "type": "array" + }, + "from": { + "title": "From", + "type": "string" + }, + "from_list": { + "items": { + "type": "string" + }, + "title": "From List", + "type": "array" + }, + "from_mailbox": { + "title": "From Mailbox", + "type": "string" + }, + "from_mailboxes": { + "items": { + "type": "string" + }, + "title": "From Mailboxes", + "type": "array" + }, + "http_headers": { + "items": { + "$ref": "#/$defs/http_header" + }, + "title": "HTTP Headers", + "type": "array" + }, + "is_read": { + "title": "Read", + "type": "boolean" + }, + "message_uid": { + "title": "Message UID", + "type": "string" + }, + "raw_header": { + "title": "Raw Header", + "type": "string" + }, + "reply_to": { + "title": "Reply To", + "type": "string" + }, + "reply_to_list": { + "items": { + "type": "string" + }, + "title": "Reply To List", + "type": "array" + }, + "reply_to_mailboxes": { + "items": { + "type": "string" + }, + "title": "Reply To Mailboxes", + "type": "array" + }, + "return_path": { + "title": "Return Path", + "type": "string" + }, + "sender": { + "title": "Sender", + "type": "string" + }, + "sender_mailbox": { + "title": "Sender Mailbox", + "type": "string" + }, + "size": { + "title": "Size", + "type": "integer" + }, + "smtp_from": { + "title": "SMTP From", + "type": "string" + }, + "smtp_to": { + "items": { + "type": "string" + }, + "title": "SMTP To", + "type": "array" + }, + "subject": { + "title": "Subject", + "type": "string" + }, + "to": { + "items": { + "type": "string" + }, + "title": "To", + "type": "array" + }, + "to_mailboxes": { + "items": { + "type": "string" + }, + "title": "To Mailboxes", + "type": "array" + }, + "uid": { + "title": "Email Thread UID", + "type": "string" + }, + "urls": { + "items": { + "$ref": "#/$defs/url" + }, + "title": "URLs", + "type": "array" + }, + "x_originating_ip": { + "items": { + "type": "string" + }, + "title": "X-Originating-IP", + "type": "array" + } + }, + "title": "Email", + "type": "object" + }, + "environment_variable": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "title": "Environment Variable", + "type": "object" + }, + "technique": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "src_url": { + "title": "Source URL", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "title": "MITRE Technique", + "type": "object" + }, + "cvss": { + "additionalProperties": false, + "properties": { + "base_score": { + "title": "Base Score", + "type": "number" + }, + "depth": { + "enum": [ + "Base", + "Environmental", + "Temporal" + ], + "title": "CVSS Depth", + "type": "string" + }, + "metrics": { + "items": { + "$ref": "#/$defs/metric" + }, + "title": "Metrics", + "type": "array" + }, + "overall_score": { + "title": "Overall Score", + "type": "number" + }, + "severity": { + "title": "Severity", + "type": "string" + }, + "src_url": { + "title": "Source URL", + "type": "string" + }, + "vector_string": { + "title": "Vector String", + "type": "string" + }, + "vendor_name": { + "title": "Vendor Name", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "base_score", + "version" + ], + "title": "CVSS Score", + "type": "object" + }, + "fingerprint": { + "additionalProperties": false, + "properties": { + "algorithm": { + "title": "Algorithm", + "type": "string" + }, + "algorithm_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 99 + ], + "title": "Algorithm ID", + "type": "integer" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "algorithm_id", + "value" + ], + "title": "Fingerprint", + "type": "object" + }, + "process_entity": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "cmd_line" + ] + }, + { + "required": [ + "cpid" + ] + }, + { + "required": [ + "name" + ] + }, + { + "required": [ + "path" + ] + }, + { + "required": [ + "pid" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "cmd_line": { + "title": "Command Line", + "type": "string" + }, + "cpid": { + "title": "Common Process Identifier", + "type": "string" + }, + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "pid": { + "title": "Process ID", + "type": "integer" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "title": "Process Entity", + "type": "object" + }, + "observable": { + "additionalProperties": false, + "properties": { + "event_uid": { + "title": "Event UID", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "reputation": { + "$ref": "#/$defs/reputation", + "title": "Reputation Scores" + }, + "type": { + "title": "Type", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 99 + ], + "title": "Type ID", + "type": "integer" + }, + "type_uid": { + "title": "Type ID", + "type": "integer" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "type_id" + ], + "title": "Observable", + "type": "object" + }, + "http_header": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "title": "HTTP Header", + "type": "object" + }, + "attack": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "sub_technique" + ] + }, + { + "required": [ + "tactic" + ] + }, + { + "required": [ + "technique" + ] + } + ], + "properties": { + "mitigation": { + "$ref": "#/$defs/mitigation", + "title": "MITRE Mitigation" + }, + "sub_technique": { + "$ref": "#/$defs/sub_technique", + "title": "MITRE Sub-technique" + }, + "tactic": { + "$ref": "#/$defs/tactic", + "title": "MITRE Tactic" + }, + "tactics": { + "items": { + "$ref": "#/$defs/tactic" + }, + "title": "Tactics", + "type": "array" + }, + "technique": { + "$ref": "#/$defs/technique", + "title": "MITRE Technique" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "title": "MITRE ATT&CK\u00ae & ATLAS\u2122", + "type": "object" + }, + "network_interface": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "hostname" + ] + }, + { + "required": [ + "ip" + ] + }, + { + "required": [ + "mac" + ] + }, + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "hostname": { + "title": "Hostname", + "type": "string" + }, + "ip": { + "title": "IP Address", + "type": "string" + }, + "mac": { + "title": "MAC Address", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "namespace": { + "title": "Namespace", + "type": "string" + }, + "open_ports": { + "items": { + "$ref": "#/$defs/port_info" + }, + "title": "Open Ports", + "type": "array" + }, + "subnet_prefix": { + "title": "Subnet Prefix Length", + "type": "integer" + }, + "type": { + "title": "Type", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 99 + ], + "title": "Type ID", + "type": "integer" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "title": "Network Interface", + "type": "object" + }, + "user": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "account" + ] + }, + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "account": { + "$ref": "#/$defs/account", + "title": "Account" + }, + "credential_uid": { + "title": "User Credential ID", + "type": "string" + }, + "display_name": { + "title": "Display Name", + "type": "string" + }, + "domain": { + "title": "Domain", + "type": "string" + }, + "email_addr": { + "title": "Email Address", + "type": "string" + }, + "forward_addr": { + "title": "Forwarding Address", + "type": "string" + }, + "full_name": { + "title": "Full Name", + "type": "string" + }, + "groups": { + "items": { + "$ref": "#/$defs/group" + }, + "title": "Groups", + "type": "array" + }, + "has_mfa": { + "title": "MFA Assigned", + "type": "boolean" + }, + "ldap_person": { + "$ref": "#/$defs/ldap_person", + "title": "LDAP Person" + }, + "name": { + "title": "Name", + "type": "string" + }, + "org": { + "$ref": "#/$defs/organization", + "title": "Organization" + }, + "phone_number": { + "title": "Telephone Number", + "type": "string" + }, + "programmatic_credentials": { + "items": { + "$ref": "#/$defs/programmatic_credential" + }, + "title": "Programmatic Credentials", + "type": "array" + }, + "risk_level": { + "title": "Risk Level", + "type": "string" + }, + "risk_level_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 99 + ], + "title": "Risk Level ID", + "type": "integer" + }, + "risk_score": { + "title": "Risk Score", + "type": "integer" + }, + "type": { + "title": "Type", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 99 + ], + "title": "Type ID", + "type": "integer" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "uid_alt": { + "title": "Alternate ID", + "type": "string" + } + }, + "title": "User", + "type": "object" + }, + "auth_factor": { + "additionalProperties": false, + "properties": { + "device": { + "$ref": "#/$defs/device", + "title": "Device" + }, + "email_addr": { + "title": "Email Address", + "type": "string" + }, + "factor_type": { + "title": "Factor Type", + "type": "string" + }, + "factor_type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 99 + ], + "title": "Factor Type ID", + "type": "integer" + }, + "is_hotp": { + "title": "HMAC-based One-time Password (HOTP)", + "type": "boolean" + }, + "is_totp": { + "title": "Time-based One-time Password (TOTP)", + "type": "boolean" + }, + "phone_number": { + "title": "Phone Number", + "type": "string" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "security_questions": { + "items": { + "type": "string" + }, + "title": "Security Questions", + "type": "array" + } + }, + "required": [ + "factor_type_id" + ], + "title": "Authentication Factor", + "type": "object" + }, + "osint": { + "additionalProperties": false, + "properties": { + "references": { + "items": { + "type": "string" + }, + "title": "References", + "type": "array" + }, + "email_auth": { + "$ref": "#/$defs/email_auth", + "title": "Related Email Authentication" + }, + "external_uid": { + "title": "External ID", + "type": "string" + }, + "tlp": { + "enum": [ + "AMBER", + "AMBER STRICT", + "CLEAR", + "GREEN", + "RED", + "WHITE" + ], + "title": "Traffic Light Protocol", + "type": "string" + }, + "detection_pattern": { + "title": "Detection Pattern", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "category": { + "title": "Category", + "type": "string" + }, + "modified_time": { + "title": "Modified Time", + "type": "integer" + }, + "creator": { + "$ref": "#/$defs/user", + "title": "Creator" + }, + "desc": { + "title": "Description", + "type": "string" + }, + "subdomains": { + "items": { + "type": "string" + }, + "title": "Related Subdomains", + "type": "array" + }, + "confidence": { + "title": "Confidence", + "type": "string" + }, + "reputation": { + "$ref": "#/$defs/reputation", + "title": "Reputation Scores" + }, + "type": { + "title": "Type", + "type": "string" + }, + "uploaded_time": { + "title": "Uploaded Time", + "type": "integer" + }, + "location": { + "$ref": "#/$defs/location", + "title": "Geo Location" + }, + "expiration_time": { + "title": "Expiration Time", + "type": "integer" + }, + "comment": { + "title": "Analyst Comments", + "type": "string" + }, + "autonomous_system": { + "$ref": "#/$defs/autonomous_system", + "title": "Autonomous System" + }, + "value": { + "title": "Indicator", + "type": "string" + }, + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "intrusion_sets": { + "items": { + "type": "string" + }, + "title": "Intrusion Sets", + "type": "array" + }, + "script": { + "$ref": "#/$defs/script", + "title": "Related Script Data" + }, + "vendor_name": { + "title": "Vendor Name", + "type": "string" + }, + "attacks": { + "items": { + "$ref": "#/$defs/attack" + }, + "title": "MITRE ATT&CK\u00ae and ATLAS\u2122 Details", + "type": "array" + }, + "related_analytics": { + "items": { + "$ref": "#/$defs/analytic" + }, + "title": "Related Analytics", + "type": "array" + }, + "file": { + "$ref": "#/$defs/file", + "title": "Related File" + }, + "risk_score": { + "title": "Risk Score", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + }, + "email": { + "$ref": "#/$defs/email", + "title": "Related Email" + }, + "kill_chain": { + "items": { + "$ref": "#/$defs/kill_chain_phase" + }, + "title": "Kill Chain", + "type": "array" + }, + "detection_pattern_type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 99 + ], + "title": "Detection Pattern Type ID", + "type": "integer" + }, + "detection_pattern_type": { + "title": "Detection Pattern", + "type": "string" + }, + "confidence_id": { + "enum": [ + 0, + 1, + 2, + 3, + 99 + ], + "title": "Confidence ID", + "type": "integer" + }, + "threat_actor": { + "$ref": "#/$defs/threat_actor", + "title": "Threat Actor" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 99 + ], + "title": "Indicator Type ID", + "type": "integer" + }, + "labels": { + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + }, + "answers": { + "items": { + "$ref": "#/$defs/dns_answer" + }, + "title": "Related DNS Answers", + "type": "array" + }, + "malware": { + "items": { + "$ref": "#/$defs/malware" + }, + "title": "Malware", + "type": "array" + }, + "severity": { + "title": "Severity", + "type": "string" + }, + "campaign": { + "$ref": "#/$defs/campaign", + "title": "Campaign" + }, + "src_url": { + "title": "Source URL", + "type": "string" + }, + "severity_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 99 + ], + "title": "Severity ID", + "type": "integer" + }, + "subnet": { + "title": "Related Subnet", + "type": "string" + }, + "signatures": { + "items": { + "$ref": "#/$defs/digital_signature" + }, + "title": "Related Digital Signatures", + "type": "array" + }, + "whois": { + "$ref": "#/$defs/whois", + "title": "WHOIS" + }, + "vulnerabilities": { + "items": { + "$ref": "#/$defs/vulnerability" + }, + "title": "Related Vulnerabilities", + "type": "array" + } + }, + "required": [ + "type_id", + "value" + ], + "title": "OSINT", + "type": "object" + }, + "san": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "name", + "type" + ], + "title": "Subject Alternative Name", + "type": "object" + }, + "occurrence_details": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "cell_name" + ] + }, + { + "required": [ + "column_name" + ] + }, + { + "required": [ + "column_number" + ] + }, + { + "required": [ + "end_line" + ] + }, + { + "required": [ + "json_path" + ] + }, + { + "required": [ + "page_number" + ] + }, + { + "required": [ + "record_index_in_array" + ] + }, + { + "required": [ + "row_number" + ] + }, + { + "required": [ + "start_line" + ] + } + ], + "properties": { + "cell_name": { + "title": "Cell Name", + "type": "string" + }, + "column_name": { + "title": "Column Name", + "type": "string" + }, + "column_number": { + "title": "Column Number", + "type": "integer" + }, + "end_line": { + "title": "End Line", + "type": "integer" + }, + "json_path": { + "title": "JSON Path", + "type": "string" + }, + "page_number": { + "title": "Page Number", + "type": "integer" + }, + "record_index_in_array": { + "title": "Record Index in Array", + "type": "integer" + }, + "row_number": { + "title": "Row Number", + "type": "integer" + }, + "start_line": { + "title": "Start Line", + "type": "integer" + } + }, + "title": "Occurrence Details", + "type": "object" + }, + "firewall_rule": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "category": { + "title": "Category", + "type": "string" + }, + "condition": { + "title": "Condition", + "type": "string" + }, + "desc": { + "title": "Description", + "type": "string" + }, + "duration": { + "title": "Duration Milliseconds", + "type": "integer" + }, + "match_details": { + "items": { + "type": "string" + }, + "title": "Match Details", + "type": "array" + }, + "match_location": { + "title": "Match Location", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "rate_limit": { + "title": "Rate Limit", + "type": "integer" + }, + "sensitivity": { + "title": "Sensitivity", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "title": "Firewall Rule", + "type": "object" + }, + "threat_actor": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "type": { + "title": "Threat Actor Type", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 99 + ], + "title": "Threat Actor Type ID", + "type": "integer" + } + }, + "required": [ + "name" + ], + "title": "Threat Actor", + "type": "object" + }, + "package": { + "additionalProperties": false, + "properties": { + "architecture": { + "title": "Architecture", + "type": "string" + }, + "cpe_name": { + "title": "The product CPE identifier", + "type": "string" + }, + "epoch": { + "title": "Epoch", + "type": "integer" + }, + "hash": { + "$ref": "#/$defs/fingerprint", + "title": "Hash" + }, + "license": { + "title": "Software License", + "type": "string" + }, + "license_url": { + "title": "Software License URL", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "package_manager": { + "title": "Package Manager", + "type": "string" + }, + "package_manager_url": { + "title": "Package Manager URL", + "type": "string" + }, + "purl": { + "title": "Package URL", + "type": "string" + }, + "release": { + "title": "Software Release Details", + "type": "string" + }, + "src_url": { + "title": "Source URL", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 99 + ], + "title": "Type ID", + "type": "integer" + }, + "uid": { + "title": "Package UID", + "type": "string" + }, + "vendor_name": { + "title": "Vendor Name", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "title": "Software Package", + "type": "object" + }, + "transformation_info": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "lang": { + "title": "Language", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "product": { + "$ref": "#/$defs/product", + "title": "Product" + }, + "time": { + "title": "Event Time", + "type": "integer" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "url_string": { + "title": "URL String", + "type": "string" + } + }, + "title": "Transformation Info", + "type": "object" + }, + "response": { + "additionalProperties": false, + "properties": { + "code": { + "title": "Response Code", + "type": "integer" + }, + "containers": { + "items": { + "$ref": "#/$defs/container" + }, + "title": "Containers", + "type": "array" + }, + "data": { + "title": "Data" + }, + "error": { + "title": "Error Code", + "type": "string" + }, + "error_message": { + "title": "Error Message", + "type": "string" + }, + "flags": { + "items": { + "type": "string" + }, + "title": "Flags", + "type": "array" + }, + "message": { + "title": "Message", + "type": "string" + } + }, + "title": "Response Elements", + "type": "object" + }, + "reporter": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "hostname" + ] + }, + { + "required": [ + "ip" + ] + }, + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "hostname": { + "title": "Hostname", + "type": "string" + }, + "ip": { + "title": "IP Address", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "org": { + "$ref": "#/$defs/organization", + "title": "Organization" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "title": "Reporter", + "type": "object" + }, + "url": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "path" + ] + }, + { + "required": [ + "url_string" + ] + } + ], + "properties": { + "categories": { + "items": { + "type": "string" + }, + "title": "Website Categorization", + "type": "array" + }, + "category_ids": { + "items": { + "enum": [ + 0, + 1, + 3, + 4, + 5, + 6, + 7, + 9, + 11, + 14, + 15, + 16, + 17, + 18, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 40, + 43, + 44, + 45, + 46, + 47, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 63, + 64, + 65, + 66, + 67, + 68, + 71, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 92, + 93, + 95, + 96, + 97, + 98, + 99, + 101, + 102, + 103, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 118, + 121 + ], + "type": "integer" + }, + "title": "Website Categorization IDs", + "type": "array" + }, + "domain": { + "title": "Domain", + "type": "string" + }, + "hostname": { + "title": "Hostname", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "port": { + "title": "Port", + "type": "integer" + }, + "query_string": { + "title": "HTTP Query String", + "type": "string" + }, + "resource_type": { + "title": "Resource Type", + "type": "string" + }, + "scheme": { + "title": "Scheme", + "type": "string" + }, + "subdomain": { + "title": "Subdomain", + "type": "string" + }, + "url_string": { + "title": "URL String", + "type": "string" + } + }, + "title": "Uniform Resource Locator", + "type": "object" + }, + "object": { + "additionalProperties": true, + "properties": {}, + "title": "Object", + "type": "object" + }, + "extension": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "version" + ], + "title": "Schema Extension", + "type": "object" + }, + "gpu_info": { + "additionalProperties": false, + "properties": { + "bus_type": { + "title": "Bus Type", + "type": "string" + }, + "bus_type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 99 + ], + "title": "Bus Type ID", + "type": "integer" + }, + "cores": { + "title": "Cores", + "type": "integer" + }, + "model": { + "title": "Model", + "type": "string" + }, + "vendor_name": { + "title": "Vendor Name", + "type": "string" + }, + "vram_mode": { + "title": "VRAM Mode", + "type": "string" + }, + "vram_mode_id": { + "enum": [ + 0, + 1, + 2, + 99 + ], + "title": "VRAM Mode ID", + "type": "integer" + }, + "vram_size": { + "title": "VRAM Size", + "type": "integer" + } + }, + "title": "GPU Information", + "type": "object" + }, + "port_info": { + "additionalProperties": false, + "properties": { + "port": { + "title": "Port", + "type": "integer" + }, + "protocol_name": { + "title": "Protocol Name", + "type": "string" + }, + "protocol_num": { + "title": "Protocol Number", + "type": "integer" + } + }, + "required": [ + "port" + ], + "title": "Port Information", + "type": "object" + }, + "service": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "labels": { + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "tags": { + "items": { + "$ref": "#/$defs/key_value_object" + }, + "title": "Tags", + "type": "array" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "title": "Service", + "type": "object" + }, + "metric": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "name", + "value" + ], + "title": "Metric", + "type": "object" + }, + "request": { + "additionalProperties": false, + "properties": { + "containers": { + "items": { + "$ref": "#/$defs/container" + }, + "title": "Containers", + "type": "array" + }, + "data": { + "title": "Data" + }, + "flags": { + "items": { + "type": "string" + }, + "title": "Flags", + "type": "array" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "required": [ + "uid" + ], + "title": "Request Elements", + "type": "object" + }, + "sub_technique": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "src_url": { + "title": "Source URL", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "title": "MITRE Sub-technique", + "type": "object" + }, + "container": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "hash": { + "$ref": "#/$defs/fingerprint", + "title": "Hash" + }, + "image": { + "$ref": "#/$defs/image", + "title": "Image" + }, + "labels": { + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "network_driver": { + "title": "Network Driver", + "type": "string" + }, + "orchestrator": { + "title": "Orchestrator", + "type": "string" + }, + "pod_uuid": { + "title": "Pod UUID", + "type": "string" + }, + "runtime": { + "title": "Runtime", + "type": "string" + }, + "size": { + "title": "Size", + "type": "integer" + }, + "tag": { + "title": "Image Tag", + "type": "string" + }, + "tags": { + "items": { + "$ref": "#/$defs/key_value_object" + }, + "title": "Tags", + "type": "array" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "title": "Container", + "type": "object" + }, + "malware": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "classification_ids": { + "items": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 99 + ], + "type": "integer" + }, + "title": "Classification IDs", + "type": "array" + }, + "classifications": { + "items": { + "type": "string" + }, + "title": "Classifications", + "type": "array" + }, + "cves": { + "items": { + "$ref": "#/$defs/cve" + }, + "title": "CVE List", + "type": "array" + }, + "files": { + "items": { + "$ref": "#/$defs/file" + }, + "title": "Files", + "type": "array" + }, + "name": { + "title": "Name", + "type": "string" + }, + "num_infected": { + "title": "Number of Infected Entities", + "type": "integer" + }, + "path": { + "title": "Path", + "type": "string" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "severity": { + "title": "Severity", + "type": "string" + }, + "severity_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 99 + ], + "title": "Severity ID", + "type": "integer" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "required": [ + "classification_ids" + ], + "title": "Malware", + "type": "object" + }, + "win_win_service": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "cmd_line" + ] + }, + { + "required": [ + "service_category_id" + ] + }, + { + "required": [ + "service_dependencies" + ] + }, + { + "required": [ + "service_error_control_id" + ] + }, + { + "required": [ + "service_start_name" + ] + }, + { + "required": [ + "service_start_type_id" + ] + }, + { + "required": [ + "service_type_id" + ] + } + ], + "properties": { + "cmd_line": { + "title": "Command Line", + "type": "string" + }, + "hosting_process": { + "$ref": "#/$defs/process_entity", + "title": "Hosting Process" + }, + "labels": { + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + }, + "load_order_group": { + "title": "Load Order Group", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "service_category": { + "title": "Service Category", + "type": "string" + }, + "service_category_id": { + "enum": [ + 0, + 1, + 2, + 99 + ], + "title": "Service Category ID", + "type": "integer" + }, + "service_dependencies": { + "items": { + "type": "string" + }, + "title": "Service Dependencies", + "type": "array" + }, + "service_dll_file": { + "$ref": "#/$defs/file", + "title": "Service DLL" + }, + "service_error_control": { + "title": "Service Error Control", + "type": "string" + }, + "service_error_control_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 99 + ], + "title": "Service Error Control ID", + "type": "integer" + }, + "service_file": { + "$ref": "#/$defs/file", + "title": "Service File" + }, + "service_start_name": { + "title": "Service Start Name", + "type": "string" + }, + "service_start_type": { + "title": "Service Start Type", + "type": "string" + }, + "service_start_type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 99 + ], + "title": "Service Start Type ID", + "type": "integer" + }, + "service_type": { + "title": "Service Type", + "type": "string" + }, + "service_type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 99 + ], + "title": "Service Type ID", + "type": "integer" + }, + "tags": { + "items": { + "$ref": "#/$defs/key_value_object" + }, + "title": "Tags", + "type": "array" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "Windows Service", + "type": "object" + }, + "group": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "desc": { + "title": "Description", + "type": "string" + }, + "domain": { + "title": "Domain", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "privileges": { + "items": { + "type": "string" + }, + "title": "Privileges", + "type": "array" + }, + "type": { + "title": "Group Type", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "uid_alt": { + "title": "Alternate ID", + "type": "string" + } + }, + "title": "Group", + "type": "object" + }, + "device": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "hostname" + ] + }, + { + "required": [ + "instance_uid" + ] + }, + { + "required": [ + "interface_name" + ] + }, + { + "required": [ + "interface_uid" + ] + }, + { + "required": [ + "ip" + ] + }, + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "region": { + "title": "Region", + "type": "string" + }, + "interface_name": { + "title": "Network Interface Name", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "interface_uid": { + "title": "Network Interface ID", + "type": "string" + }, + "modified_time": { + "title": "Modified Time", + "type": "integer" + }, + "os": { + "$ref": "#/$defs/os", + "title": "OS" + }, + "desc": { + "title": "Description", + "type": "string" + }, + "os_machine_uuid": { + "title": "OS Machine UUID", + "type": "string" + }, + "hypervisor": { + "title": "Hypervisor", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + }, + "location": { + "$ref": "#/$defs/location", + "title": "Geo Location" + }, + "is_backed_up": { + "title": "Back Ups Configured", + "type": "boolean" + }, + "instance_uid": { + "title": "Instance ID", + "type": "string" + }, + "first_seen_time": { + "title": "First Seen", + "type": "integer" + }, + "mac": { + "title": "MAC Address", + "type": "string" + }, + "imei_list": { + "items": { + "type": "string" + }, + "title": "IMEI List", + "type": "array" + }, + "org": { + "$ref": "#/$defs/organization", + "title": "Organization" + }, + "pool": { + "$ref": "#/$defs/group", + "title": "Pool" + }, + "risk_level": { + "title": "Risk Level", + "type": "string" + }, + "image": { + "$ref": "#/$defs/image", + "title": "Image" + }, + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "is_shared": { + "title": "Shared Device", + "type": "boolean" + }, + "subnet_uid": { + "title": "Subnet UID", + "type": "string" + }, + "meid": { + "title": "MEID", + "type": "string" + }, + "vendor_name": { + "title": "Vendor Name", + "type": "string" + }, + "zone": { + "title": "Network Zone", + "type": "string" + }, + "groups": { + "items": { + "$ref": "#/$defs/group" + }, + "title": "Groups", + "type": "array" + }, + "risk_score": { + "title": "Risk Score", + "type": "integer" + }, + "is_personal": { + "title": "Personal Device", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "hw_info": { + "$ref": "#/$defs/device_hw_info", + "title": "Hardware Info" + }, + "iccid": { + "title": "ICCID", + "type": "string" + }, + "namespace_pid": { + "title": "Namespace PID", + "type": "integer" + }, + "uid_alt": { + "title": "Alternate ID", + "type": "string" + }, + "udid": { + "title": "Unique Device Identifier", + "type": "string" + }, + "model": { + "title": "Model", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 99 + ], + "title": "Type ID", + "type": "integer" + }, + "last_seen_time": { + "title": "Last Seen", + "type": "integer" + }, + "owner": { + "$ref": "#/$defs/user", + "title": "Owner" + }, + "boot_time": { + "title": "Boot Time", + "type": "integer" + }, + "is_supervised": { + "title": "Supervised Device", + "type": "boolean" + }, + "is_managed": { + "title": "Managed Device", + "type": "boolean" + }, + "container": { + "$ref": "#/$defs/container", + "title": "Container" + }, + "risk_level_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 99 + ], + "title": "Risk Level ID", + "type": "integer" + }, + "is_trusted": { + "title": "Trusted Device", + "type": "boolean" + }, + "boot_uid": { + "title": "Boot UID", + "type": "string" + }, + "network_interfaces": { + "items": { + "$ref": "#/$defs/network_interface" + }, + "title": "Network Interfaces", + "type": "array" + }, + "autoscale_uid": { + "title": "Autoscale UID", + "type": "string" + }, + "vpc_uid": { + "title": "VPC UID", + "type": "string" + }, + "subnet": { + "title": "Subnet", + "type": "string" + }, + "eid": { + "title": "EID", + "type": "string" + }, + "domain": { + "title": "Domain", + "type": "string" + }, + "imei": { + "title": "IMEI", + "type": "string" + }, + "agent_list": { + "items": { + "$ref": "#/$defs/agent" + }, + "title": "Agent List", + "type": "array" + }, + "ip": { + "title": "IP Address", + "type": "string" + }, + "hostname": { + "title": "Hostname", + "type": "string" + }, + "vlan_uid": { + "title": "VLAN", + "type": "string" + }, + "is_mobile_account_active": { + "title": "Mobile Account Active", + "type": "boolean" + }, + "is_compliant": { + "title": "Compliant Device", + "type": "boolean" + }, + "mac_vendor": { + "title": "MAC Vendor", + "type": "string" + } + }, + "required": [ + "type_id" + ], + "title": "Device", + "type": "object" + }, + "campaign": { + "additionalProperties": false, + "properties": { + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "Campaign", + "type": "object" + }, + "cwe": { + "additionalProperties": false, + "properties": { + "caption": { + "title": "Caption", + "type": "string" + }, + "src_url": { + "title": "Source URL", + "type": "string" + }, + "uid": { + "title": "CWE ID", + "type": "string" + } + }, + "required": [ + "uid" + ], + "title": "CWE", + "type": "object" + }, + "digital_signature": { + "additionalProperties": false, + "properties": { + "algorithm": { + "title": "Algorithm", + "type": "string" + }, + "algorithm_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 99 + ], + "title": "Algorithm ID", + "type": "integer" + }, + "certificate": { + "$ref": "#/$defs/certificate", + "title": "Certificate" + }, + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "developer_uid": { + "title": "Developer UID", + "type": "string" + }, + "digest": { + "$ref": "#/$defs/fingerprint", + "title": "Message Digest" + }, + "state": { + "title": "State", + "type": "string" + }, + "state_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 99 + ], + "title": "State ID", + "type": "integer" + } + }, + "required": [ + "algorithm_id" + ], + "title": "Digital Signature", + "type": "object" + }, + "malware_scan_info": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "end_time": { + "title": "End Time", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + }, + "num_files": { + "title": "Scanned Files", + "type": "integer" + }, + "num_infected": { + "title": "Number of Infected Entities", + "type": "integer" + }, + "num_volumes": { + "title": "Number of Volumes", + "type": "integer" + }, + "size": { + "title": "Size", + "type": "integer" + }, + "start_time": { + "title": "Start Time", + "type": "integer" + }, + "type": { + "title": "Type", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 99 + ], + "title": "Type ID", + "type": "integer" + }, + "uid": { + "title": "Scan UID", + "type": "string" + }, + "unique_malware_count": { + "title": "Unique Malware Count", + "type": "integer" + } + }, + "required": [ + "type_id" + ], + "title": "Malware Scan Info", + "type": "object" + }, + "kill_chain_phase": { + "additionalProperties": false, + "properties": { + "phase": { + "title": "Kill Chain Phase", + "type": "string" + }, + "phase_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 99 + ], + "title": "Kill Chain Phase ID", + "type": "integer" + } + }, + "required": [ + "phase_id" + ], + "title": "Kill Chain Phase", + "type": "object" + }, + "organization": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "ou_name": { + "title": "Org Unit Name", + "type": "string" + }, + "ou_uid": { + "title": "Org Unit ID", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "title": "Organization", + "type": "object" + }, + "encryption_details": { + "additionalProperties": false, + "properties": { + "algorithm": { + "title": "Encryption Algorithm", + "type": "string" + }, + "algorithm_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 99 + ], + "title": "Encryption Algorithm ID", + "type": "integer" + }, + "key_length": { + "title": "Encryption Key Length", + "type": "integer" + }, + "key_uid": { + "title": "Key UID", + "type": "string" + }, + "type": { + "title": "Encryption Type", + "type": "string" + } + }, + "title": "Encryption Details", + "type": "object" + }, + "logger": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "device": { + "$ref": "#/$defs/device", + "title": "Device" + }, + "event_uid": { + "title": "Event UID", + "type": "string" + }, + "is_truncated": { + "title": "Is Truncated", + "type": "boolean" + }, + "log_format": { + "title": "Log Format", + "type": "string" + }, + "log_level": { + "title": "Log Level", + "type": "string" + }, + "log_name": { + "title": "Log Name", + "type": "string" + }, + "log_provider": { + "title": "Log Provider", + "type": "string" + }, + "log_version": { + "title": "Log Version", + "type": "string" + }, + "logged_time": { + "title": "Logged Time", + "type": "integer" + }, + "name": { + "title": "Name", + "type": "string" + }, + "product": { + "$ref": "#/$defs/product", + "title": "Product" + }, + "transmit_time": { + "title": "Transmission Time", + "type": "integer" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "untruncated_size": { + "title": "Untruncated Size", + "type": "integer" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "title": "Logger", + "type": "object" + }, + "feature": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "title": "Feature", + "type": "object" + }, + "timespan": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "duration" + ] + }, + { + "required": [ + "duration_days" + ] + }, + { + "required": [ + "duration_hours" + ] + }, + { + "required": [ + "duration_mins" + ] + }, + { + "required": [ + "duration_months" + ] + }, + { + "required": [ + "duration_secs" + ] + }, + { + "required": [ + "duration_weeks" + ] + }, + { + "required": [ + "duration_years" + ] + }, + { + "required": [ + "end_time" + ] + }, + { + "required": [ + "start_time" + ] + } + ], + "properties": { + "duration": { + "title": "Duration Milliseconds", + "type": "integer" + }, + "duration_days": { + "title": "Duration Days", + "type": "integer" + }, + "duration_hours": { + "title": "Duration Hours", + "type": "integer" + }, + "duration_mins": { + "title": "Duration Minutes", + "type": "integer" + }, + "duration_months": { + "title": "Duration Months", + "type": "integer" + }, + "duration_secs": { + "title": "Duration Seconds", + "type": "integer" + }, + "duration_weeks": { + "title": "Duration Weeks", + "type": "integer" + }, + "duration_years": { + "title": "Duration Years", + "type": "integer" + }, + "end_time": { + "title": "End Time", + "type": "integer" + }, + "start_time": { + "title": "Start Time", + "type": "integer" + }, + "type": { + "title": "Time Span Type", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 99 + ], + "title": "Time Span Type ID", + "type": "integer" + } + }, + "title": "Time Span", + "type": "object" + }, + "rule": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "category": { + "title": "Category", + "type": "string" + }, + "desc": { + "title": "Description", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "title": "Rule", + "type": "object" + }, + "agent": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "name": { + "title": "Agent Name", + "type": "string" + }, + "policies": { + "items": { + "$ref": "#/$defs/policy" + }, + "title": "Agent Policies", + "type": "array" + }, + "type": { + "title": "Agent Type", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 99 + ], + "title": "Type ID", + "type": "integer" + }, + "uid": { + "title": "Agent ID", + "type": "string" + }, + "uid_alt": { + "title": "Alternate Agent ID", + "type": "string" + }, + "vendor_name": { + "title": "Vendor Name", + "type": "string" + }, + "version": { + "title": "Agent Version", + "type": "string" + } + }, + "title": "Agent", + "type": "object" + }, + "whois": { + "additionalProperties": false, + "properties": { + "autonomous_system": { + "$ref": "#/$defs/autonomous_system", + "title": "Autonomous System" + }, + "created_time": { + "title": "Registered At", + "type": "integer" + }, + "dnssec_status": { + "title": "DNSSEC Status", + "type": "string" + }, + "dnssec_status_id": { + "enum": [ + 0, + 1, + 2, + 99 + ], + "title": "DNSSEC Status ID", + "type": "integer" + }, + "domain": { + "title": "Domain", + "type": "string" + }, + "domain_contacts": { + "items": { + "$ref": "#/$defs/domain_contact" + }, + "title": "Domain Contacts", + "type": "array" + }, + "email_addr": { + "title": "Registrar Abuse Email Address", + "type": "string" + }, + "isp": { + "title": "ISP Name", + "type": "string" + }, + "isp_org": { + "title": "ISP Org", + "type": "string" + }, + "last_seen_time": { + "title": "Last Updated At", + "type": "integer" + }, + "name_servers": { + "items": { + "type": "string" + }, + "title": "Name Servers", + "type": "array" + }, + "phone_number": { + "title": "Registrar Abuse Phone Number", + "type": "string" + }, + "registrar": { + "title": "Domain Registrar", + "type": "string" + }, + "status": { + "title": "Domain Status", + "type": "string" + }, + "subdomains": { + "items": { + "type": "string" + }, + "title": "Subdomains", + "type": "array" + }, + "subnet": { + "title": "Subnet Block", + "type": "string" + } + }, + "title": "WHOIS", + "type": "object" + }, + "key_value_object": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "value" + ] + }, + { + "required": [ + "values" + ] + } + ], + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + }, + "values": { + "items": { + "type": "string" + }, + "title": "Values", + "type": "array" + } + }, + "required": [ + "name" + ], + "title": "Key:Value object", + "type": "object" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "log_format": { + "title": "Log Source Format", + "type": "string" + }, + "transformation_info_list": { + "items": { + "$ref": "#/$defs/transformation_info" + }, + "title": "Transformation Info", + "type": "array" + }, + "uid": { + "title": "Event UID", + "type": "string" + }, + "original_event_uid": { + "title": "Original Event ID", + "type": "string" + }, + "log_name": { + "title": "Log Name", + "type": "string" + }, + "modified_time": { + "title": "Modified Time", + "type": "integer" + }, + "log_level": { + "title": "Log Level", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + }, + "log_version": { + "title": "Log Version", + "type": "string" + }, + "extensions": { + "items": { + "$ref": "#/$defs/extension" + }, + "title": "Schema Extensions", + "type": "array" + }, + "source": { + "title": "Source", + "type": "string" + }, + "untruncated_size": { + "title": "Untruncated Size", + "type": "integer" + }, + "loggers": { + "items": { + "$ref": "#/$defs/logger" + }, + "title": "Loggers", + "type": "array" + }, + "processed_time": { + "title": "Processed Time", + "type": "integer" + }, + "log_source": { + "title": "Log Source", + "type": "string" + }, + "logged_time": { + "title": "Logged Time", + "type": "integer" + }, + "debug": { + "items": { + "type": "string" + }, + "title": "Debug Information", + "type": "array" + }, + "profiles": { + "items": { + "type": "string" + }, + "title": "Profiles", + "type": "array" + }, + "log_provider": { + "title": "Log Provider", + "type": "string" + }, + "event_code": { + "title": "Event Code", + "type": "string" + }, + "tags": { + "items": { + "$ref": "#/$defs/key_value_object" + }, + "title": "Tags", + "type": "array" + }, + "tenant_uid": { + "title": "Tenant UID", + "type": "string" + }, + "reporter": { + "$ref": "#/$defs/reporter", + "title": "Reporter" + }, + "correlation_uid": { + "title": "Correlation UID", + "type": "string" + }, + "extension": { + "$ref": "#/$defs/extension", + "title": "Schema Extension" + }, + "labels": { + "items": { + "type": "string" + }, + "title": "Labels", + "type": "array" + }, + "sequence": { + "title": "Sequence Number", + "type": "integer" + }, + "transmit_time": { + "title": "Transmission Time", + "type": "integer" + }, + "is_truncated": { + "title": "Is Truncated", + "type": "boolean" + }, + "original_time": { + "title": "Original Time", + "type": "string" + }, + "total_queued_duration": { + "$ref": "#/$defs/timespan", + "title": "Total Queued Duration" + }, + "product": { + "$ref": "#/$defs/product", + "title": "Product" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "product", + "version" + ], + "title": "Metadata", + "type": "object" + }, + "product": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "cpe_name": { + "title": "The product CPE identifier", + "type": "string" + }, + "feature": { + "$ref": "#/$defs/feature", + "title": "Feature" + }, + "lang": { + "title": "Language", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "url_string": { + "title": "URL String", + "type": "string" + }, + "vendor_name": { + "title": "Vendor Name", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "title": "Product", + "type": "object" + }, + "advisory": { + "additionalProperties": false, + "properties": { + "avg_timespan": { + "$ref": "#/$defs/timespan", + "title": "Average Timespan" + }, + "bulletin": { + "title": "Patch Bulletin", + "type": "string" + }, + "classification": { + "title": "Classification", + "type": "string" + }, + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "desc": { + "title": "Description", + "type": "string" + }, + "install_state": { + "title": "Install State", + "type": "string" + }, + "install_state_id": { + "enum": [ + 0, + 1, + 2, + 3, + 99 + ], + "title": "Install State ID", + "type": "integer" + }, + "is_superseded": { + "title": "The patch is superseded.", + "type": "boolean" + }, + "modified_time": { + "title": "Modified Time", + "type": "integer" + }, + "os": { + "$ref": "#/$defs/os", + "title": "OS" + }, + "product": { + "$ref": "#/$defs/product", + "title": "Product" + }, + "references": { + "items": { + "type": "string" + }, + "title": "References", + "type": "array" + }, + "related_cves": { + "items": { + "$ref": "#/$defs/cve" + }, + "title": "Related CVEs", + "type": "array" + }, + "related_cwes": { + "items": { + "$ref": "#/$defs/cwe" + }, + "title": "Related CWEs", + "type": "array" + }, + "size": { + "title": "Size", + "type": "integer" + }, + "src_url": { + "title": "Source URL", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + }, + "uid": { + "title": "Advisory ID", + "type": "string" + } + }, + "required": [ + "uid" + ], + "title": "Advisory", + "type": "object" + }, + "d3f_tactic": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "name": { + "title": "Name", + "type": "string" + }, + "src_url": { + "title": "Source URL", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "title": "MITRE D3FEND\u2122 Tactic", + "type": "object" + }, + "cve": { + "additionalProperties": false, + "properties": { + "created_time": { + "title": "Created Time", + "type": "integer" + }, + "cvss": { + "items": { + "$ref": "#/$defs/cvss" + }, + "title": "CVSS Score", + "type": "array" + }, + "cwe": { + "$ref": "#/$defs/cwe", + "title": "CWE" + }, + "cwe_uid": { + "title": "CWE UID", + "type": "string" + }, + "cwe_url": { + "title": "CWE URL", + "type": "string" + }, + "desc": { + "title": "Description", + "type": "string" + }, + "epss": { + "$ref": "#/$defs/epss", + "title": "EPSS" + }, + "modified_time": { + "title": "Modified Time", + "type": "integer" + }, + "product": { + "$ref": "#/$defs/product", + "title": "Product" + }, + "references": { + "items": { + "type": "string" + }, + "title": "References", + "type": "array" + }, + "related_cwes": { + "items": { + "$ref": "#/$defs/cwe" + }, + "title": "Related CWEs", + "type": "array" + }, + "title": { + "title": "Title", + "type": "string" + }, + "type": { + "title": "Vulnerability Type", + "type": "string" + }, + "uid": { + "title": "CVE ID", + "type": "string" + } + }, + "required": [ + "uid" + ], + "title": "CVE", + "type": "object" + }, + "domain_contact": { + "additionalProperties": false, + "properties": { + "email_addr": { + "title": "Contact Email", + "type": "string" + }, + "location": { + "$ref": "#/$defs/location", + "title": "Contact Location Information" + }, + "name": { + "title": "Name", + "type": "string" + }, + "phone_number": { + "title": "Phone Number", + "type": "string" + }, + "type": { + "title": "Domain Contact Type", + "type": "string" + }, + "type_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 99 + ], + "title": "Domain Contact Type ID", + "type": "integer" + }, + "uid": { + "title": "Unique ID", + "type": "string" + } + }, + "required": [ + "type_id" + ], + "title": "Domain Contact", + "type": "object" + }, + "policy": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "name" + ] + }, + { + "required": [ + "type" + ] + }, + { + "required": [ + "uid" + ] + } + ], + "properties": { + "data": { + "title": "Data" + }, + "desc": { + "title": "Description", + "type": "string" + }, + "group": { + "$ref": "#/$defs/group", + "title": "Group" + }, + "is_applied": { + "title": "Applied", + "type": "boolean" + }, + "name": { + "title": "Name", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + }, + "uid": { + "title": "Unique ID", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "title": "Policy", + "type": "object" + } + }, + "$id": "https://schema.ocsf.io/schema/classes/file_activity", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "component": { + "title": "Component", + "type": "string" + }, + "unmapped": { + "$ref": "#/$defs/object", + "title": "Unmapped Data" + }, + "message": { + "title": "Message", + "type": "string" + }, + "disposition": { + "title": "Disposition", + "type": "string" + }, + "category_uid": { + "const": 1, + "title": "Category ID", + "type": "integer" + }, + "action": { + "title": "Action", + "type": "string" + }, + "confidence": { + "title": "Confidence", + "type": "string" + }, + "risk_details": { + "title": "Risk Details", + "type": "string" + }, + "duration": { + "title": "Duration Milliseconds", + "type": "integer" + }, + "status_detail": { + "title": "Status Detail", + "type": "string" + }, + "file_result": { + "$ref": "#/$defs/file", + "title": "File Result" + }, + "confidence_score": { + "title": "Confidence Score", + "type": "integer" + }, + "create_mask": { + "title": "Create Mask", + "type": "string" + }, + "status_code": { + "title": "Status Code", + "type": "string" + }, + "disposition_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 99 + ], + "title": "Disposition ID", + "type": "integer" + }, + "type_name": { + "title": "Type Name", + "type": "string" + }, + "risk_level": { + "title": "Risk Level", + "type": "string" + }, + "raw_data": { + "title": "Raw Data", + "type": "string" + }, + "time": { + "title": "Event Time", + "type": "integer" + }, + "status_id": { + "enum": [ + 0, + 1, + 2, + 99 + ], + "title": "Status ID", + "type": "integer" + }, + "actor": { + "$ref": "#/$defs/actor", + "title": "Actor" + }, + "attacks": { + "items": { + "$ref": "#/$defs/attack" + }, + "title": "MITRE ATT&CK\u00ae and ATLAS\u2122 Details", + "type": "array" + }, + "status": { + "title": "Status", + "type": "string" + }, + "activity_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 99 + ], + "title": "Activity ID", + "type": "integer" + }, + "file": { + "$ref": "#/$defs/file", + "title": "File" + }, + "risk_score": { + "title": "Risk Score", + "type": "integer" + }, + "observables": { + "items": { + "$ref": "#/$defs/observable" + }, + "title": "Observables", + "type": "array" + }, + "enrichments": { + "items": { + "$ref": "#/$defs/enrichment" + }, + "title": "Enrichments", + "type": "array" + }, + "authorizations": { + "items": { + "$ref": "#/$defs/authorization" + }, + "title": "Authorization Information", + "type": "array" + }, + "raw_data_hash": { + "$ref": "#/$defs/fingerprint", + "title": "Raw Data Hash" + }, + "connection_uid": { + "title": "Connection Identifier", + "type": "string" + }, + "action_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 99 + ], + "title": "Action ID", + "type": "integer" + }, + "confidence_id": { + "enum": [ + 0, + 1, + 2, + 3, + 99 + ], + "title": "Confidence ID", + "type": "integer" + }, + "firewall_rule": { + "$ref": "#/$defs/firewall_rule", + "title": "Firewall Rule" + }, + "class_uid": { + "const": 1001, + "title": "Class ID", + "type": "integer" + }, + "activity_name": { + "title": "Activity", + "type": "string" + }, + "start_time": { + "title": "Start Time", + "type": "integer" + }, + "class_name": { + "title": "Class", + "type": "string" + }, + "timezone_offset": { + "title": "Timezone Offset", + "type": "integer" + }, + "risk_level_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 99 + ], + "title": "Risk Level ID", + "type": "integer" + }, + "raw_data_size": { + "title": "Raw Data Size", + "type": "integer" + }, + "malware": { + "items": { + "$ref": "#/$defs/malware" + }, + "title": "Malware", + "type": "array" + }, + "is_alert": { + "title": "Alert", + "type": "boolean" + }, + "device": { + "$ref": "#/$defs/device", + "title": "Device" + }, + "severity": { + "title": "Severity", + "type": "string" + }, + "file_diff": { + "title": "File Diff", + "type": "string" + }, + "category_name": { + "title": "Category", + "type": "string" + }, + "malware_scan_info": { + "$ref": "#/$defs/malware_scan_info", + "title": "Malware Scan Info" + }, + "severity_id": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 99 + ], + "title": "Severity ID", + "type": "integer" + }, + "count": { + "title": "Count", + "type": "integer" + }, + "end_time": { + "title": "End Time", + "type": "integer" + }, + "type_uid": { + "title": "Type ID", + "type": "integer" + }, + "metadata": { + "$ref": "#/$defs/metadata", + "title": "Metadata" + }, + "access_mask": { + "title": "Access Mask", + "type": "integer" + }, + "policy": { + "$ref": "#/$defs/policy", + "title": "Policy" + } + }, + "required": [ + "activity_id", + "actor", + "category_uid", + "class_uid", + "device", + "file", + "metadata", + "severity_id", + "time", + "type_uid" + ], + "title": "File System Activity", + "type": "object" +} diff --git a/crates/openshell-ocsf/src/builders/file.rs b/crates/openshell-ocsf/src/builders/file.rs new file mode 100644 index 0000000000..fac5c191d8 --- /dev/null +++ b/crates/openshell-ocsf/src/builders/file.rs @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Builder for File System Activity [1001] events. + +use crate::builders::ContextRef; +use crate::enums::{ActionId, DispositionId, SeverityId, StatusId}; +use crate::events::base_event::BaseEventData; +use crate::events::{FileActivityEvent, OcsfEvent}; +use crate::objects::{Actor, File, Process}; + +/// Build a File System Activity for a requested open. +pub struct FileActivityBuilder<'a> { + ctx: ContextRef<'a>, + file: File, + actor: Actor, + requested_path: String, + access_type: String, + action: ActionId, + disposition: Option, + severity: SeverityId, + status: Option, +} + +impl<'a> FileActivityBuilder<'a> { + /// Start an event for a path as supplied by the workload to open(2). + #[must_use] + pub fn new( + ctx: impl Into>, + requested_path: &str, + actor_process: Process, + access_type: &str, + ) -> Self { + Self { + ctx: ctx.into(), + file: File::requested(requested_path), + actor: Actor::process(actor_process), + requested_path: requested_path.to_string(), + access_type: access_type.to_string(), + action: ActionId::Observed, + disposition: None, + severity: SeverityId::Informational, + status: None, + } + } + + /// Set the security-control action when the source knows the decision. + #[must_use] + pub fn action(mut self, action: ActionId) -> Self { + self.action = action; + self + } + + /// Set the security-control disposition when the source knows the outcome. + #[must_use] + pub fn disposition(mut self, disposition: DispositionId) -> Self { + self.disposition = Some(disposition); + self + } + + /// Set the event severity. + #[must_use] + pub fn severity(mut self, severity: SeverityId) -> Self { + self.severity = severity; + self + } + + /// Set the outcome status when the source knows it. + #[must_use] + pub fn status(mut self, status: StatusId) -> Self { + self.status = Some(status); + self + } + + /// Finalize the event with an Open activity and explicit path provenance. + #[must_use] + pub fn build(self) -> OcsfEvent { + let mut base = BaseEventData::new( + 1001, + "File System Activity", + 1, + "System Activity", + 14, + "Open", + self.severity, + self.ctx + .metadata(&["container", "host", "security_control"]), + ); + base.unmapped = Some(serde_json::json!({ + "requested_path": self.requested_path, + "path_source": "syscall_argument", + "access_type": self.access_type, + })); + self.ctx.apply_common_fields(&mut base, self.status, None); + OcsfEvent::FileActivity(FileActivityEvent { + base, + file: self.file, + actor: self.actor, + action: Some(self.action), + disposition: self.disposition, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::builders::test_sandbox_context; + + #[test] + fn observed_open_is_schema_valid_and_round_trips() { + let event = FileActivityBuilder::new( + &test_sandbox_context(), + "/sandbox/project/.env", + Process::new("python3", 42).with_path("/usr/bin/python3"), + "read", + ) + .build(); + let json = event.to_json().unwrap(); + openshell_ocsf_validate::validate_event(&json).unwrap(); + assert_eq!(json["class_uid"], 1001); + assert_eq!(json["activity_id"], 14); + assert_eq!(json["action_id"], 3); + assert_eq!(json["file"]["path"], "/sandbox/project/.env"); + assert_eq!(json["actor"]["process"]["path"], "/usr/bin/python3"); + assert_eq!(json["unmapped"]["path_source"], "syscall_argument"); + assert!(event.format_shorthand().contains( + "FILE:OPEN [INFO] OBSERVED python3(42) /sandbox/project/.env [access:read path:requested]" + )); + let restored: OcsfEvent = serde_json::from_value(json).unwrap(); + assert_eq!(restored, event); + } +} diff --git a/crates/openshell-ocsf/src/builders/mod.rs b/crates/openshell-ocsf/src/builders/mod.rs index b86e39a10a..4ffca71310 100644 --- a/crates/openshell-ocsf/src/builders/mod.rs +++ b/crates/openshell-ocsf/src/builders/mod.rs @@ -96,6 +96,7 @@ mod api_activity; mod authentication; mod base; mod config; +mod file; mod finding; mod http; mod lifecycle; @@ -107,6 +108,7 @@ pub use api_activity::ApiActivityBuilder; pub use authentication::AuthenticationBuilder; pub use base::BaseEventBuilder; pub use config::ConfigStateChangeBuilder; +pub use file::FileActivityBuilder; pub use finding::DetectionFindingBuilder; pub use http::HttpActivityBuilder; pub use lifecycle::AppLifecycleBuilder; @@ -292,6 +294,16 @@ pub(crate) fn sample_events(ctx: &EventContext) -> Vec<(&'static str, crate::Ocs let peer = IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 5)); vec![ + ( + "file_activity", + FileActivityBuilder::new( + ctx, + "/sandbox/project/.env", + Process::new("python3", 42).with_path("/usr/bin/python3"), + "read", + ) + .build(), + ), ( "network_activity", NetworkActivityBuilder::new(ctx) diff --git a/crates/openshell-ocsf/src/events/file_activity.rs b/crates/openshell-ocsf/src/events/file_activity.rs new file mode 100644 index 0000000000..e1684d4702 --- /dev/null +++ b/crates/openshell-ocsf/src/events/file_activity.rs @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OCSF File System Activity [1001] event class. + +use serde::{Deserialize, Serialize}; + +use crate::enums::{ActionId, DispositionId}; +use crate::events::base_event::BaseEventData; +use crate::objects::{Actor, File}; + +/// OCSF File System Activity Event [1001]. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct FileActivityEvent { + /// Common base event fields. + #[serde(flatten)] + pub base: BaseEventData, + /// The file named by the activity. + pub file: File, + /// The process that requested the operation. + pub actor: Actor, + /// Security control action, when known. + #[serde(rename = "action_id", default, skip_serializing_if = "Option::is_none")] + pub action: Option, + /// Security control disposition, when known. + #[serde( + rename = "disposition_id", + default, + skip_serializing_if = "Option::is_none" + )] + pub disposition: Option, +} + +impl Serialize for FileActivityEvent { + fn serialize(&self, serializer: S) -> Result { + use crate::events::serde_helpers::{insert_enum_pair, insert_required}; + + let mut value = serde_json::to_value(&self.base).map_err(serde::ser::Error::custom)?; + let object = value + .as_object_mut() + .ok_or_else(|| serde::ser::Error::custom("expected object"))?; + insert_required!(object, "file", self.file); + insert_required!(object, "actor", self.actor); + insert_enum_pair!(object, "action", self.action); + insert_enum_pair!(object, "disposition", self.disposition); + value.serialize(serializer) + } +} diff --git a/crates/openshell-ocsf/src/events/mod.rs b/crates/openshell-ocsf/src/events/mod.rs index 46f8416c0f..6d932f50d0 100644 --- a/crates/openshell-ocsf/src/events/mod.rs +++ b/crates/openshell-ocsf/src/events/mod.rs @@ -9,6 +9,7 @@ mod authentication; pub(crate) mod base_event; mod config_state_change; mod detection_finding; +mod file_activity; mod http_activity; mod network_activity; mod process_activity; @@ -21,6 +22,7 @@ pub use authentication::AuthenticationEvent; pub use base_event::{BaseEvent, BaseEventData}; pub use config_state_change::DeviceConfigStateChangeEvent; pub use detection_finding::DetectionFindingEvent; +pub use file_activity::FileActivityEvent; pub use http_activity::HttpActivityEvent; pub use network_activity::NetworkActivityEvent; pub use process_activity::ProcessActivityEvent; @@ -43,6 +45,8 @@ pub enum OcsfEvent { SshActivity(SshActivityEvent), /// Process Activity [1007] ProcessActivity(ProcessActivityEvent), + /// File System Activity [1001] + FileActivity(FileActivityEvent), /// Authentication [3002] Authentication(AuthenticationEvent), /// Detection Finding [2004] @@ -64,6 +68,7 @@ impl Serialize for OcsfEvent { Self::HttpActivity(e) => e.serialize(serializer), Self::SshActivity(e) => e.serialize(serializer), Self::ProcessActivity(e) => e.serialize(serializer), + Self::FileActivity(e) => e.serialize(serializer), Self::Authentication(e) => e.serialize(serializer), Self::DetectionFinding(e) => e.serialize(serializer), Self::ApplicationLifecycle(e) => e.serialize(serializer), @@ -97,6 +102,9 @@ impl<'de> Deserialize<'de> for OcsfEvent { 1007 => serde_json::from_value::(value) .map(Self::ProcessActivity) .map_err(serde::de::Error::custom), + 1001 => serde_json::from_value::(value) + .map(Self::FileActivity) + .map_err(serde::de::Error::custom), 3002 => serde_json::from_value::(value) .map(Self::Authentication) .map_err(serde::de::Error::custom), @@ -131,6 +139,7 @@ impl OcsfEvent { Self::HttpActivity(_) => 4002, Self::SshActivity(_) => 4007, Self::ProcessActivity(_) => 1007, + Self::FileActivity(_) => 1001, Self::Authentication(_) => 3002, Self::DetectionFinding(_) => 2004, Self::ApplicationLifecycle(_) => 6002, @@ -148,6 +157,7 @@ impl OcsfEvent { Self::HttpActivity(e) => &e.base, Self::SshActivity(e) => &e.base, Self::ProcessActivity(e) => &e.base, + Self::FileActivity(e) => &e.base, Self::Authentication(e) => &e.base, Self::DetectionFinding(e) => &e.base, Self::ApplicationLifecycle(e) => &e.base, diff --git a/crates/openshell-ocsf/src/format/shorthand.rs b/crates/openshell-ocsf/src/format/shorthand.rs index 5b80f29205..74cf223ba6 100644 --- a/crates/openshell-ocsf/src/format/shorthand.rs +++ b/crates/openshell-ocsf/src/format/shorthand.rs @@ -233,6 +233,37 @@ impl OcsfEvent { let sev = severity_tag(base.severity.as_u8()); match self { + Self::FileActivity(event) => { + let action = event.action.map_or("", |value| value.label()); + let actor = event + .actor + .process + .as_ref() + .map(|process| format!("{}({})", process.name, process.pid)) + .unwrap_or_default(); + let path = event + .base + .unmapped + .as_ref() + .and_then(|value| value.get("requested_path")) + .and_then(serde_json::Value::as_str) + .or(event.file.path.as_deref()) + .unwrap_or(&event.file.name); + let access = event + .base + .unmapped + .as_ref() + .and_then(|value| value.get("access_type")) + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"); + format!( + "FILE:OPEN {sev} {} {} {} [access:{} path:requested]", + action.to_uppercase(), + escape_context_field(&actor), + escape_context_field(path), + escape_context_field(access), + ) + } Self::NetworkActivity(e) => { let activity = e.base.activity_name.to_uppercase(); let action = e.action.map_or(String::new(), |a| a.label().to_uppercase()); @@ -517,7 +548,45 @@ impl OcsfEvent { Self::ProcessActivity(e) => { let activity = e.base.activity_name.to_uppercase(); - let proc_str = format!("{}({})", e.process.name, e.process.pid); + let proc_str = format!( + "{}({})", + escape_context_field(&truncate_with_ellipsis(&e.process.name, MAX_REASON_LEN)), + e.process.pid + ); + let parent_ctx = e + .process + .parent_process + .as_ref() + .map(|parent| format!(" [ppid:{}]", parent.pid)) + .unwrap_or_default(); + let start_ctx = e + .process + .created_time + .map(|time| format!(" [start:{time}]")) + .unwrap_or_default(); + let uid_ctx = e + .process + .uid + .as_ref() + .map(|uid| { + format!( + " [process_uid:{}]", + escape_context_field(&truncate_with_ellipsis(uid, MAX_REASON_LEN)) + ) + }) + .unwrap_or_default(); + let parent_uid_ctx = e + .process + .parent_process + .as_ref() + .and_then(|parent| parent.uid.as_ref()) + .map(|uid| { + format!( + " [parent_uid:{}]", + escape_context_field(&truncate_with_ellipsis(uid, MAX_REASON_LEN)) + ) + }) + .unwrap_or_default(); let exit_ctx = e .exit_code .map(|c| format!(" [exit:{c}]")) @@ -529,7 +598,17 @@ impl OcsfEvent { .map(|c| format!(" [cmd:{c}]")) .unwrap_or_default(); - format!("PROC:{activity} {sev} {proc_str}{exit_ctx}{cmd_ctx}") + let signal_ctx = e + .base + .unmapped + .as_ref() + .and_then(|fields| fields.get("signal")) + .and_then(serde_json::Value::as_i64) + .map(|signal| format!(" [signal:{signal}]")) + .unwrap_or_default(); + format!( + "PROC:{activity} {sev} {proc_str}{parent_ctx}{start_ctx}{uid_ctx}{parent_uid_ctx}{exit_ctx}{signal_ctx}{cmd_ctx}" + ) } Self::DetectionFinding(e) => { @@ -1236,6 +1315,36 @@ mod tests { assert_eq!(shorthand, "PROC:TERMINATE [INFO] python3(42) [exit:0]"); } + #[test] + fn process_activity_shorthand_shows_parent_start_and_signal() { + let mut base = base( + 1007, + "Process Activity", + 1, + "System Activity", + 2, + "Terminate", + ); + base.unmapped = Some(serde_json::json!({"signal": 9, "other": "hidden"})); + let event = OcsfEvent::ProcessActivity(ProcessActivityEvent { + base, + process: Process::new("sleep\nspoof", 42) + .with_uid("42:1234") + .with_created_time(1_234_000) + .with_parent(Process::new("openshell-sandbox", 1)), + actor: None, + launch_type: None, + exit_code: None, + action: None, + disposition: None, + }); + + assert_eq!( + event.format_shorthand(), + "PROC:TERMINATE [INFO] sleep\\u{a}spoof(42) [ppid:1] [start:1234000] [process_uid:42:1234] [signal:9]" + ); + } + #[test] fn test_detection_finding_shorthand() { let event = OcsfEvent::DetectionFinding(DetectionFindingEvent { diff --git a/crates/openshell-ocsf/src/lib.rs b/crates/openshell-ocsf/src/lib.rs index 661b4c6f5a..f261b9e5fb 100644 --- a/crates/openshell-ocsf/src/lib.rs +++ b/crates/openshell-ocsf/src/lib.rs @@ -7,9 +7,8 @@ //! sandbox logging. //! //! This crate provides: -//! - **8 OCSF event classes**: Network Activity, HTTP Activity, SSH Activity, -//! Process Activity, Detection Finding, Application Lifecycle, Device Config -//! State Change, and Base Event +//! - OCSF event classes for filesystem, network, HTTP, SSH, process, findings, +//! lifecycle, configuration, API, authentication, and base events //! - **Typed enums and objects**: All OCSF enum and object types used by the //! event classes //! - **Builders**: Ergonomic per-class builders with `EventContext` for shared @@ -39,8 +38,8 @@ pub mod test_support; // --- Core event types --- pub use events::{ ApiActivityEvent, ApplicationLifecycleEvent, AuthenticationEvent, BaseEvent, BaseEventData, - DetectionFindingEvent, DeviceConfigStateChangeEvent, HttpActivityEvent, NetworkActivityEvent, - OcsfEvent, ProcessActivityEvent, SshActivityEvent, + DetectionFindingEvent, DeviceConfigStateChangeEvent, FileActivityEvent, HttpActivityEvent, + NetworkActivityEvent, OcsfEvent, ProcessActivityEvent, SshActivityEvent, }; // --- Enum types --- @@ -52,7 +51,7 @@ pub use enums::{ // --- Object types --- pub use objects::{ - Actor, AiModel, Api, Attack, ConnectionInfo, Container, Device, Endpoint, Evidence, + Actor, AiModel, Api, Attack, ConnectionInfo, Container, Device, Endpoint, Evidence, File, FindingInfo, FirewallRule, HttpRequest, HttpResponse, Image, Metadata, OsInfo, Process, Product, Remediation, ResourceDetails, Service, Tactic, Technique, Url, User, }; @@ -64,7 +63,8 @@ pub use sanitize::sanitize_reason_for_audit; pub use builders::{ ApiActivityBuilder, AppLifecycleBuilder, AuthenticationBuilder, BaseEventBuilder, ConfigStateChangeBuilder, ContextRef, DetectionFindingBuilder, EventContext, EventOrigin, - HttpActivityBuilder, NetworkActivityBuilder, ProcessActivityBuilder, SshActivityBuilder, + FileActivityBuilder, HttpActivityBuilder, NetworkActivityBuilder, ProcessActivityBuilder, + SshActivityBuilder, }; // --- Tracing layers --- diff --git a/crates/openshell-ocsf/src/objects/file.rs b/crates/openshell-ocsf/src/objects/file.rs new file mode 100644 index 0000000000..1819c1feef --- /dev/null +++ b/crates/openshell-ocsf/src/objects/file.rs @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OCSF File object. + +use serde::{Deserialize, Serialize}; + +/// A file named in a filesystem event. Metadata is unknown for an attempted open. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct File { + /// Last component of the requested path. + pub name: String, + /// OCSF File type ID. Zero means unknown. + pub type_id: u8, + /// Absolute path text, when the caller supplied an absolute path. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +impl File { + /// Describe a workload-supplied path without resolving or statting it. + #[must_use] + pub fn requested(path: &str) -> Self { + let name = path + .rsplit('/') + .find(|component| !component.is_empty()) + .unwrap_or(path); + Self { + name: name.to_string(), + type_id: 0, + path: path.starts_with('/').then(|| path.to_string()), + } + } +} diff --git a/crates/openshell-ocsf/src/objects/mod.rs b/crates/openshell-ocsf/src/objects/mod.rs index 9025834bfe..0c76bb93a6 100644 --- a/crates/openshell-ocsf/src/objects/mod.rs +++ b/crates/openshell-ocsf/src/objects/mod.rs @@ -10,6 +10,7 @@ mod connection; mod container; mod device; mod endpoint; +mod file; mod finding; mod firewall_rule; mod http; @@ -26,6 +27,7 @@ pub use connection::ConnectionInfo; pub use container::{Container, Image}; pub use device::{Device, OsInfo}; pub use endpoint::Endpoint; +pub use file::File; pub use finding::{Evidence, FindingInfo, Remediation}; pub use firewall_rule::FirewallRule; pub use http::{HttpRequest, HttpResponse, Url}; diff --git a/crates/openshell-ocsf/src/objects/process.rs b/crates/openshell-ocsf/src/objects/process.rs index bb51781217..8c73ead8f5 100644 --- a/crates/openshell-ocsf/src/objects/process.rs +++ b/crates/openshell-ocsf/src/objects/process.rs @@ -13,9 +13,21 @@ pub struct Process { /// Process name (e.g., "python3"). pub name: String, + /// Executable path, when observed. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Process ID. pub pid: i64, + /// Stable process instance ID, when the source can identify one. + #[serde(skip_serializing_if = "Option::is_none")] + pub uid: Option, + + /// Process start time in Unix epoch milliseconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub created_time: Option, + /// Full command line. #[serde(skip_serializing_if = "Option::is_none")] pub cmd_line: Option, @@ -31,7 +43,10 @@ impl Process { pub fn new(name: &str, pid: i64) -> Self { Self { name: name.to_string(), + path: None, pid, + uid: None, + created_time: None, cmd_line: None, parent_process: None, } @@ -44,6 +59,27 @@ impl Process { self } + /// Set the executable path without recording its command line. + #[must_use] + pub fn with_path(mut self, path: &str) -> Self { + self.path = Some(path.to_string()); + self + } + + /// Set a stable identifier for this process instance. + #[must_use] + pub fn with_uid(mut self, uid: impl Into) -> Self { + self.uid = Some(uid.into()); + self + } + + /// Set the process start time in Unix epoch milliseconds. + #[must_use] + pub fn with_created_time(mut self, time_ms: i64) -> Self { + self.created_time = Some(time_ms); + self + } + /// Set the parent process. #[must_use] pub fn with_parent(mut self, parent: Self) -> Self { @@ -130,6 +166,19 @@ mod tests { assert_eq!(parent.pid, 1); } + #[test] + fn process_uid_distinguishes_reused_pids() { + let first = Process::new("sleep", 42) + .with_uid("42:100") + .with_created_time(1_000); + let second = Process::new("sleep", 42) + .with_uid("42:200") + .with_created_time(2_000); + assert_ne!(first.uid, second.uid); + assert_eq!(serde_json::to_value(first).unwrap()["uid"], "42:100"); + assert_eq!(serde_json::to_value(second).unwrap()["uid"], "42:200"); + } + #[test] fn test_process_from_bypass() { let proc = Process::from_bypass("node", "1234", "bash -> node"); diff --git a/crates/openshell-ocsf/tests/roundtrip.rs b/crates/openshell-ocsf/tests/roundtrip.rs index 6368d55386..8706fd5eff 100644 --- a/crates/openshell-ocsf/tests/roundtrip.rs +++ b/crates/openshell-ocsf/tests/roundtrip.rs @@ -124,8 +124,14 @@ fn process_activity_round_trips() { .activity(ActivityId::Open) .process( Process::new("/usr/bin/python3", 4713) + .with_uid("4713:123456") + .with_created_time(1_793_620_000_120) .with_cmd_line("python3 -m pytest") - .with_parent(Process::new("/bin/sh", 4701)), + .with_parent( + Process::new("/bin/sh", 4701) + .with_uid("4701:123400") + .with_created_time(1_793_619_999_560), + ), ) .actor_process(Process::new("openshell-sandbox", 4700)) .launch_type(LaunchTypeId::Other) @@ -136,6 +142,7 @@ fn process_activity_round_trips() { .build(); assert_round_trips("process_activity", &event); + openshell_ocsf_validate::validate_event(&event.to_json().unwrap()).unwrap(); } #[test] diff --git a/crates/openshell-policy-schema/src/lib.rs b/crates/openshell-policy-schema/src/lib.rs index 656433b794..4fe6314436 100644 --- a/crates/openshell-policy-schema/src/lib.rs +++ b/crates/openshell-policy-schema/src/lib.rs @@ -210,6 +210,9 @@ pub struct FilesystemPolicy { pub read_only: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub read_write: Vec, + /// Path globs whose attempted opens are recorded by the sandbox boundary. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub audit_paths: Vec, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -751,7 +754,7 @@ fn inspect_document(root: &serde_yml::Value) -> InspectionResult { inspect_named( root.get("filesystem_policy"), "filesystem_policy", - &["include_workdir", "read_only", "read_write"], + &["include_workdir", "read_only", "read_write", "audit_paths"], )?; inspect_named(root.get("landlock"), "landlock", &["compatibility"])?; inspect_named( @@ -1085,6 +1088,7 @@ impl PolicyDocument { include_workdir: true, read_only: Vec::new(), read_write: Vec::new(), + audit_paths: Vec::new(), }) } } @@ -1209,6 +1213,17 @@ mod tests { assert!(!empty.effective_filesystem_policy().include_workdir); } + #[test] + fn accepts_opt_in_filesystem_audit_paths() { + let policy = parse_policy( + "version: 1\nfilesystem_policy:\n audit_paths:\n - /sandbox/**/.env\n", + ) + .unwrap(); + let output = serde_yml::to_string(&policy).unwrap(); + assert!(output.contains("audit_paths:")); + assert!(output.contains("/sandbox/**/.env")); + } + #[test] fn rejects_oversized_port() { assert!(parse_policy( diff --git a/crates/openshell-policy/Cargo.toml b/crates/openshell-policy/Cargo.toml index 0515808144..d71585757e 100644 --- a/crates/openshell-policy/Cargo.toml +++ b/crates/openshell-policy/Cargo.toml @@ -11,6 +11,7 @@ license.workspace = true repository.workspace = true [dependencies] +globset = { workspace = true } hickory-proto = { workspace = true } openshell-core = { path = "../openshell-core", default-features = false } openshell-policy-schema = { path = "../openshell-policy-schema" } diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index bad8532cdf..4e4e022070 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -598,6 +598,7 @@ fn to_proto(raw: PolicyFile) -> Result { include_workdir: fs.include_workdir, read_only: fs.read_only, read_write: fs.read_write, + audit_paths: fs.audit_paths, }), landlock: raw.landlock.map(|ll| LandlockPolicy { compatibility: match ll.compatibility { @@ -623,6 +624,7 @@ fn from_proto(policy: &SandboxPolicy) -> Result { include_workdir: fs.include_workdir, read_only: fs.read_only.clone(), read_write: fs.read_write.clone(), + audit_paths: fs.audit_paths.clone(), }); let landlock = match policy.landlock.as_ref() { @@ -984,6 +986,7 @@ pub fn restrictive_default_policy() -> SandboxPolicy { "/var/log".into(), ], read_write: vec!["/tmp".into(), "/dev/null".into()], + audit_paths: Vec::new(), }), landlock: Some(LandlockPolicy { compatibility: "best_effort".into(), @@ -1016,6 +1019,9 @@ pub fn ensure_sandbox_process_identity(policy: &mut SandboxPolicy) { /// Maximum number of filesystem paths (`read_only` + `read_write` combined). const MAX_FILESYSTEM_PATHS: usize = 256; +/// Maximum number of watched filesystem path globs. +const MAX_AUDIT_PATHS: usize = 64; + /// Maximum length of any single filesystem path string. const MAX_PATH_LENGTH: usize = 4096; @@ -1034,6 +1040,10 @@ pub enum PolicyViolation { FieldTooLong { path: String, length: usize }, /// Too many filesystem paths in the policy. TooManyPaths { count: usize }, + /// Too many watched path globs in the filesystem policy. + TooManyAuditPaths { count: usize }, + /// A watched path glob has invalid syntax. + InvalidAuditPattern { path: String }, /// A network endpoint uses a TLD wildcard (e.g. `*.com`). TldWildcard { policy_name: String, host: String }, /// A network endpoint has no hostname. @@ -1146,6 +1156,12 @@ impl fmt::Display for PolicyViolation { "too many filesystem paths ({count} > {MAX_FILESYSTEM_PATHS})" ) } + Self::TooManyAuditPaths { count } => { + write!(f, "too many audit paths ({count} > {MAX_AUDIT_PATHS})") + } + Self::InvalidAuditPattern { path } => { + write!(f, "invalid filesystem audit path glob: {path}") + } Self::TldWildcard { policy_name, host } => { write!( f, @@ -1404,6 +1420,41 @@ pub fn validate_filesystem_paths( } } +/// Validate opt-in path globs without resolving them against the host filesystem. +/// Matching uses the raw path text supplied to open(2), not a canonical path. +pub fn validate_audit_paths(paths: &[String]) -> std::result::Result<(), Vec> { + let mut violations = Vec::new(); + if paths.len() > MAX_AUDIT_PATHS { + violations.push(PolicyViolation::TooManyAuditPaths { count: paths.len() }); + } + for path in paths { + if path.len() > MAX_PATH_LENGTH { + violations.push(PolicyViolation::FieldTooLong { + path: truncate_for_display(path), + length: path.len(), + }); + } else if !path.starts_with('/') { + violations.push(PolicyViolation::RelativePath { path: path.clone() }); + } else if Path::new(path) + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + violations.push(PolicyViolation::PathTraversal { path: path.clone() }); + } else if globset::GlobBuilder::new(path) + .literal_separator(true) + .build() + .is_err() + { + violations.push(PolicyViolation::InvalidAuditPattern { path: path.clone() }); + } + } + if violations.is_empty() { + Ok(()) + } else { + Err(violations) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum McpVersionPresence { RequireMaterialized, @@ -1450,6 +1501,11 @@ fn validate_sandbox_policy_with_mcp_presence( { violations.extend(errors); } + if let Some(ref fs) = policy.filesystem + && let Err(errors) = validate_audit_paths(&fs.audit_paths) + { + violations.extend(errors); + } // Protobuf maps do not preserve iteration order. Sort rule keys so callers // receive stable validation errors for equivalent policy inputs. @@ -1980,6 +2036,29 @@ mod tests { ); } + #[test] + fn audit_paths_round_trip_through_authored_and_proto_policies() { + let source = "version: 1\nfilesystem_policy:\n audit_paths: [/sandbox/**/.env]\n"; + let policy = parse_sandbox_policy(source).unwrap(); + let paths = &policy.filesystem.as_ref().unwrap().audit_paths; + assert_eq!(paths, &["/sandbox/**/.env"]); + let rendered = serialize_sandbox_policy(&policy).unwrap(); + let reparsed = parse_sandbox_policy(&rendered).unwrap(); + assert_eq!(reparsed.filesystem.unwrap().audit_paths, *paths); + } + + #[test] + fn audit_paths_reject_relative_traversal_and_invalid_globs() { + for (path, expected) in [ + ("project/.env", "path must be absolute"), + ("/sandbox/../secret", "traversal"), + ("/sandbox/[", "invalid filesystem audit path glob"), + ] { + let error = validate_audit_paths(&[path.to_string()]).unwrap_err(); + assert!(error[0].to_string().contains(expected), "{error:?}"); + } + } + /// Verify that JSON serialization uses the same canonical schema keys as YAML. #[test] fn serialized_json_uses_policy_schema_keys() { @@ -3446,6 +3525,7 @@ network_policies: include_workdir: true, read_only: vec!["/usr/../etc/shadow".into()], read_write: vec!["/tmp".into()], + audit_paths: Vec::new(), }); let violations = validate_sandbox_policy(&policy).unwrap_err(); assert!( @@ -3462,6 +3542,7 @@ network_policies: include_workdir: true, read_only: vec!["usr/lib".into()], read_write: vec!["/tmp".into()], + audit_paths: Vec::new(), }); let violations = validate_sandbox_policy(&policy).unwrap_err(); assert!( @@ -3478,6 +3559,7 @@ network_policies: include_workdir: true, read_only: vec!["/usr".into()], read_write: vec!["/".into()], + audit_paths: Vec::new(), }); let violations = validate_sandbox_policy(&policy).unwrap_err(); assert!( @@ -3739,6 +3821,7 @@ network_policies: include_workdir: true, read_only: many_paths, read_write: vec!["/tmp".into()], + audit_paths: Vec::new(), }); let violations = validate_sandbox_policy(&policy).unwrap_err(); assert!( @@ -3756,6 +3839,7 @@ network_policies: include_workdir: true, read_only: vec![long_path], read_write: vec!["/tmp".into()], + audit_paths: Vec::new(), }); let violations = validate_sandbox_policy(&policy).unwrap_err(); assert!( diff --git a/crates/openshell-sandbox-backend/Cargo.toml b/crates/openshell-sandbox-backend/Cargo.toml index 8e8ff7cb05..2d104a0ba4 100644 --- a/crates/openshell-sandbox-backend/Cargo.toml +++ b/crates/openshell-sandbox-backend/Cargo.toml @@ -17,6 +17,7 @@ test-support = [] openshell-crypto = { workspace = true } openshell-core = { path = "../openshell-core", default-features = false, features = ["jwt"] } openshell-isolation-interface = { path = "../openshell-isolation-interface" } +openshell-ocsf = { path = "../openshell-ocsf" } async-trait = "0.1" bytes = { workspace = true } hyper-util = { workspace = true } diff --git a/crates/openshell-sandbox-backend/README.md b/crates/openshell-sandbox-backend/README.md index 8deb207f5d..6b4e49d387 100644 --- a/crates/openshell-sandbox-backend/README.md +++ b/crates/openshell-sandbox-backend/README.md @@ -1,5 +1,10 @@ # OpenShell sandbox backend +The boundary attach policy carries optional `audit_paths` globs for the +built-in runtime's watched file opens. The field is omitted from the JSON wire +form when empty and uses a new protobuf field number, so existing attach +policies retain their previous encoding and behavior. + This crate implements the concrete supervisor-side RPC client and the versioned control protocol shared with `openshell-sandbox`. @@ -10,6 +15,16 @@ and produces the ready handle; starting consumes that ready handle. Both the in-tree Linux runtime and external backends implement the generated services. Linux process helpers remain internal to the runtime server. +## Process activity + +When `AgentSpec.process_activity_enabled` is set, the Linux runtime includes +the directly started process's PID, parent PID, start instance ID, and start +time in `Started`, `ExecStarted`, and takeover snapshots. These optional fields +keep older boundary implementations compatible. The supervisor-side client +uses them to emit OCSF launch and exit events through the existing supervisor +log path. `BoundaryProcess::wait` emits an exit only once even if callers +repeat the wait. The protocol carries no arguments or environment. + ## Boundary startup and routing The supervisor uses the generated `IsolationBackend` control service to diff --git a/crates/openshell-sandbox-backend/src/boundary_protocol.rs b/crates/openshell-sandbox-backend/src/boundary_protocol.rs index 724c9e1faa..10a3c50d65 100644 --- a/crates/openshell-sandbox-backend/src/boundary_protocol.rs +++ b/crates/openshell-sandbox-backend/src/boundary_protocol.rs @@ -1003,6 +1003,8 @@ pub enum Response { process_id: String, provider_env_revision: u64, provider_env_generation: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + process_identity: Option, }, ProviderEnvironmentUpdated { revision: u64, @@ -1022,6 +1024,8 @@ pub enum Response { ExecStarted { process_id: String, pty: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + process_identity: Option, }, Resized, LoopbackConnected, @@ -1067,6 +1071,21 @@ pub struct ProcessSnapshotWire { pub terminal: bool, pub status: Option, pub retained_output: OutputWindowWire, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub process_identity: Option, +} + +/// Identity captured by the runtime before a process can be reaped. Arguments +/// are intentionally excluded from this protocol and from audit events. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcessIdentityWire { + pub name: String, + pub pid: u32, + pub uid: Option, + pub created_time_ms: Option, + pub parent_pid: Option, + pub parent_uid: Option, + pub parent_created_time_ms: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -1263,6 +1282,9 @@ pub struct AgentSpecWire { /// relay is unchanged. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub otlp_relay_active: bool, + /// Omitted while false to preserve existing request digests. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub process_activity_enabled: bool, } impl From for AgentSpecWire { @@ -1274,6 +1296,7 @@ impl From for AgentSpecWire { timeout_secs: spec.timeout_secs, interactive: spec.interactive, otlp_relay_active: spec.otlp_relay_active, + process_activity_enabled: spec.process_activity_enabled, } } } @@ -1283,6 +1306,8 @@ pub struct SandboxPolicyWire { pub version: u32, pub read_only: Vec, pub read_write: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub audit_paths: Vec, pub include_workdir: bool, pub network: NetworkModeWire, pub proxy_addr: Option, @@ -1306,6 +1331,7 @@ impl From for SandboxPolicyWire { let FilesystemPolicy { read_only, read_write, + audit_paths, include_workdir, } = filesystem; let NetworkPolicy { mode, proxy } = network; @@ -1318,6 +1344,7 @@ impl From for SandboxPolicyWire { version, read_only, read_write, + audit_paths, include_workdir, network: NetworkModeWire::from(mode), proxy_addr: proxy.and_then(|proxy| proxy.http_addr), @@ -1338,6 +1365,7 @@ impl From for SandboxPolicy { filesystem: FilesystemPolicy { read_only: policy.read_only, read_write: policy.read_write, + audit_paths: policy.audit_paths, include_workdir: policy.include_workdir, }, network: NetworkPolicy { @@ -1697,6 +1725,7 @@ mod tests { timeout_secs: 0, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }; let encoded = serde_json::to_value(&spec).unwrap(); assert!( @@ -1710,6 +1739,17 @@ mod tests { let decoded: AgentSpecWire = serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap(); assert!(decoded.otlp_relay_active); + + assert!( + serde_json::to_value(&spec) + .unwrap() + .get("process_activity_enabled") + .is_none() + ); + spec.process_activity_enabled = true; + let decoded: AgentSpecWire = + serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap(); + assert!(decoded.process_activity_enabled); } #[test] @@ -1728,6 +1768,7 @@ mod tests { timeout_secs: 5, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }, ca_cert: Some("test certificate".to_string()), ca_bundle: Some("test bundle".to_string()), @@ -1886,6 +1927,7 @@ mod tests { timeout_secs: 5, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }, ca_cert: Some("A".repeat(16 * 1024)), ca_bundle: Some("B".repeat(400 * 1024)), diff --git a/crates/openshell-sandbox-backend/src/protobuf.rs b/crates/openshell-sandbox-backend/src/protobuf.rs index 2f0ebc80c4..3341525ce1 100644 --- a/crates/openshell-sandbox-backend/src/protobuf.rs +++ b/crates/openshell-sandbox-backend/src/protobuf.rs @@ -87,6 +87,37 @@ impl WireMessage for w::ExitStatusWire { }) } } + +impl WireMessage for w::ProcessIdentityWire { + type Proto = p::ProcessIdentity; + + fn to_proto(&self) -> Result { + Ok(p::ProcessIdentity { + name: self.name.clone(), + pid: self.pid, + uid: self.uid.clone(), + created_time: self.created_time_ms.map(timestamp_i64_ms).transpose()?, + parent_pid: self.parent_pid, + parent_uid: self.parent_uid.clone(), + parent_created_time: self + .parent_created_time_ms + .map(timestamp_i64_ms) + .transpose()?, + }) + } + + fn from_proto(value: Self::Proto) -> Result { + Ok(Self { + name: value.name, + pid: value.pid, + uid: value.uid, + created_time_ms: value.created_time.map(millis_i64).transpose()?, + parent_pid: value.parent_pid, + parent_uid: value.parent_uid, + parent_created_time_ms: value.parent_created_time.map(millis_i64).transpose()?, + }) + } +} impl WireMessage for w::SandboxPolicyWire { type Proto = p::SandboxPolicy; fn to_proto(&self) -> Result { @@ -102,6 +133,7 @@ impl WireMessage for w::SandboxPolicyWire { .iter() .map(|v| path(v)) .collect::>()?, + audit_paths: self.audit_paths.clone(), include_workdir: self.include_workdir, network: self.network.to_proto()? as i32, proxy_addr: self.proxy_addr.map(|v| v.to_string()), @@ -115,6 +147,7 @@ impl WireMessage for w::SandboxPolicyWire { version: v.version, read_only: v.read_only.into_iter().map(PathBuf::from).collect(), read_write: v.read_write.into_iter().map(PathBuf::from).collect(), + audit_paths: v.audit_paths, include_workdir: v.include_workdir, network: w::NetworkModeWire::from_proto(enum_value(v.network)?)?, proxy_addr: v.proxy_addr.map(|v| parse(&v)).transpose()?, @@ -173,6 +206,7 @@ impl WireMessage for w::AgentSpecWire { }), interactive: self.interactive, otlp_relay_active: self.otlp_relay_active, + process_activity_enabled: self.process_activity_enabled, }) } fn from_proto(v: Self::Proto) -> Result { @@ -187,6 +221,7 @@ impl WireMessage for w::AgentSpecWire { timeout_secs: u64::try_from(timeout.seconds).map_err(invalid)?, interactive: v.interactive, otlp_relay_active: v.otlp_relay_active, + process_activity_enabled: v.process_activity_enabled, }) } } @@ -392,6 +427,11 @@ impl WireMessage for w::SessionSnapshotWire { next_sequence: v.retained_output.next_sequence, truncated: v.retained_output.truncated, }), + process_identity: v + .process_identity + .as_ref() + .map(WireMessage::to_proto) + .transpose()?, }) }) .collect::>()?, @@ -424,6 +464,10 @@ impl WireMessage for w::SessionSnapshotWire { next_sequence: output.next_sequence, truncated: output.truncated, }, + process_identity: v + .process_identity + .map(w::ProcessIdentityWire::from_proto) + .transpose()?, }) }) .collect::>()?, @@ -450,6 +494,14 @@ fn millis(v: prost_types::Timestamp) -> Result { Ok(u64::try_from(v.seconds).map_err(invalid)? * 1000 + u64::try_from(v.nanos).map_err(invalid)? / 1_000_000) } +fn timestamp_i64_ms(value: i64) -> Result { + timestamp(u64::try_from(value).map_err(invalid)?) +} + +fn millis_i64(value: prost_types::Timestamp) -> Result { + i64::try_from(millis(value)?).map_err(invalid) +} + impl WireMessage for w::RequestEnvelope { type Proto = p::RequestEnvelope; fn to_proto(&self) -> Result { @@ -640,10 +692,15 @@ impl WireMessage for w::ResponseEnvelope { process_id, provider_env_revision, provider_env_generation, + process_identity, } => P::Started(p::Started { process_id: process_id.clone(), provider_env_revision: *provider_env_revision, provider_env_generation: *provider_env_generation, + process_identity: process_identity + .as_ref() + .map(WireMessage::to_proto) + .transpose()?, }), W::ProviderEnvironmentUpdated { revision, @@ -663,9 +720,17 @@ impl WireMessage for w::ResponseEnvelope { W::Signaled => P::Signaled(p::Signaled {}), W::Terminated => P::Terminated(p::Terminated {}), W::BoundaryTerminated => P::BoundaryTerminated(p::BoundaryTerminated {}), - W::ExecStarted { process_id, pty } => P::ExecStarted(p::ExecStarted { + W::ExecStarted { + process_id, + pty, + process_identity, + } => P::ExecStarted(p::ExecStarted { process_id: process_id.clone(), pty: *pty, + process_identity: process_identity + .as_ref() + .map(WireMessage::to_proto) + .transpose()?, }), W::Resized => P::Resized(p::Resized {}), W::LoopbackConnected => P::LoopbackConnected(p::LoopbackConnected {}), @@ -717,6 +782,10 @@ impl WireMessage for w::ResponseEnvelope { process_id: v.process_id, provider_env_revision: v.provider_env_revision, provider_env_generation: v.provider_env_generation, + process_identity: v + .process_identity + .map(w::ProcessIdentityWire::from_proto) + .transpose()?, }, P::ProviderEnvironmentUpdated(v) => W::ProviderEnvironmentUpdated { revision: v.revision, @@ -735,6 +804,10 @@ impl WireMessage for w::ResponseEnvelope { P::ExecStarted(v) => W::ExecStarted { process_id: v.process_id, pty: v.pty, + process_identity: v + .process_identity + .map(w::ProcessIdentityWire::from_proto) + .transpose()?, }, P::Resized(_) => W::Resized, P::LoopbackConnected(_) => W::LoopbackConnected, @@ -819,6 +892,138 @@ where mod tests { use super::*; + #[test] + fn audit_paths_survive_boundary_wire_and_older_json_omits_them() { + let policy = openshell_core::policy::SandboxPolicy { + version: 1, + filesystem: openshell_core::policy::FilesystemPolicy { + audit_paths: vec!["/sandbox/**/.env".into()], + ..Default::default() + }, + network: openshell_core::policy::NetworkPolicy::default(), + landlock: openshell_core::policy::LandlockPolicy::default(), + process: openshell_core::policy::ProcessPolicy::default(), + }; + let wire = w::SandboxPolicyWire::from(policy.clone()); + let decoded = w::SandboxPolicyWire::from_proto(wire.to_proto().unwrap()).unwrap(); + assert_eq!(decoded, wire); + assert_eq!(decoded.audit_paths, ["/sandbox/**/.env"]); + let restored: openshell_core::policy::SandboxPolicy = decoded.into(); + assert_eq!( + restored.filesystem.audit_paths, + policy.filesystem.audit_paths + ); + + let mut older_json = serde_json::to_value(wire).unwrap(); + older_json.as_object_mut().unwrap().remove("audit_paths"); + let decoded: w::SandboxPolicyWire = serde_json::from_value(older_json).unwrap(); + assert!(decoded.audit_paths.is_empty()); + } + + #[test] + fn process_identity_uses_well_known_timestamps() { + let identity = w::ProcessIdentityWire { + name: "/bin/true".to_string(), + pid: 42, + uid: None, + created_time_ms: Some(1_793_620_000_123), + parent_pid: Some(1), + parent_uid: None, + parent_created_time_ms: Some(1_793_619_999_456), + }; + let encoded = identity.to_proto().unwrap(); + assert_eq!( + encoded.created_time, + Some(prost_types::Timestamp { + seconds: 1_793_620_000, + nanos: 123_000_000, + }) + ); + assert_eq!( + encoded.parent_created_time, + Some(prost_types::Timestamp { + seconds: 1_793_619_999, + nanos: 456_000_000, + }) + ); + assert_eq!( + w::ProcessIdentityWire::from_proto(encoded.clone()).unwrap(), + identity + ); + + let mut invalid_time = encoded; + invalid_time.created_time.as_mut().unwrap().nanos = 123_000_001; + assert!(w::ProcessIdentityWire::from_proto(invalid_time).is_err()); + } + + #[test] + fn process_identity_survives_start_exec_and_legacy_responses() { + let identity = w::ProcessIdentityWire { + name: "/bin/true".to_string(), + pid: 42, + uid: Some("42:1234".to_string()), + created_time_ms: Some(1_793_620_000_000), + parent_pid: Some(1), + parent_uid: Some("1:2".to_string()), + parent_created_time_ms: Some(1_793_619_999_000), + }; + let start = w::ResponseEnvelope { + request_id: "start".to_string(), + response: w::Response::Started { + process_id: "generation:main:0".to_string(), + provider_env_revision: 0, + provider_env_generation: 0, + process_identity: Some(identity.clone()), + }, + }; + let decoded = w::ResponseEnvelope::from_proto(start.to_proto().unwrap()).unwrap(); + assert_eq!(decoded, start); + + let exec = w::ResponseEnvelope { + request_id: "exec".to_string(), + response: w::Response::ExecStarted { + process_id: "generation:exec:1".to_string(), + pty: false, + process_identity: Some(identity.clone()), + }, + }; + let decoded = w::ResponseEnvelope::from_proto(exec.to_proto().unwrap()).unwrap(); + assert_eq!(decoded, exec); + + let mut old = exec.to_proto().unwrap(); + if let Some(p::response_envelope::Response::ExecStarted(value)) = &mut old.response { + value.process_identity = None; + } + assert!(matches!( + w::ResponseEnvelope::from_proto(old).unwrap().response, + w::Response::ExecStarted { + process_identity: None, + .. + } + )); + + let snapshot = w::SessionSnapshotWire { + generation: "generation".to_string(), + processes: vec![w::ProcessSnapshotWire { + process_id: "generation:exec:1".to_string(), + kind: w::ProcessKindWire::Exec, + terminal: false, + status: Some(w::ExitStatusWire::Exited(0)), + retained_output: w::OutputWindowWire { + first_sequence: 0, + next_sequence: 0, + truncated: false, + }, + process_identity: Some(identity), + }], + bound_policy: None, + }; + assert_eq!( + w::SessionSnapshotWire::from_proto(snapshot.to_proto().unwrap()).unwrap(), + snapshot + ); + } + #[test] fn request_validation_rejects_missing_context_and_unknown_signal() { let id = uuid::Uuid::new_v4().to_string(); @@ -934,6 +1139,7 @@ mod tests { timeout_secs: 0, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }; let inactive = spec.to_proto().unwrap(); assert_eq!(w::AgentSpecWire::from_proto(inactive).unwrap(), spec); @@ -941,6 +1147,10 @@ mod tests { spec.otlp_relay_active = true; let decoded = w::AgentSpecWire::from_proto(spec.to_proto().unwrap()).unwrap(); assert!(decoded.otlp_relay_active); + + spec.process_activity_enabled = true; + let decoded = w::AgentSpecWire::from_proto(spec.to_proto().unwrap()).unwrap(); + assert!(decoded.process_activity_enabled); } #[test] diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 9ea72d396c..b792b22a84 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -28,6 +28,10 @@ use openshell_isolation_interface::contract::{ MediationTiming, PendingDnsQuery, PendingTcpOpen, ProviderEnvironmentInstallation, ResolvedWorkloadIdentity, SandboxContext, TcpOpenDecision, TcpOpenDenial, }; +use openshell_ocsf::{ + ActionId, ActivityId, DispositionId, LaunchTypeId, OcsfEvent, Process, ProcessActivityBuilder, + SeverityId, StatusId, ocsf_emit, +}; use rustls::pki_types::{CertificateDer, pem::PemObject}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[cfg(unix)] @@ -70,6 +74,79 @@ const NETWORK_STREAM_WAIT_WARNING: Duration = Duration::from_secs(5); /// after a takeover retires its connection. const ADOPTED_ATTACHMENT_RELEASE_TIMEOUT: Duration = Duration::from_secs(10); +fn audited_process(identity: &crate::boundary_protocol::ProcessIdentityWire) -> Process { + let mut process = Process::new(&identity.name, i64::from(identity.pid)); + if let Some(uid) = &identity.uid { + process = process.with_uid(uid.clone()); + } + if let Some(time_ms) = identity.created_time_ms { + process = process.with_created_time(time_ms); + } + if let Some(parent_pid) = identity.parent_pid { + let mut parent = Process::new("unknown", i64::from(parent_pid)); + if let Some(uid) = &identity.parent_uid { + parent = parent.with_uid(uid.clone()); + } + if let Some(time_ms) = identity.parent_created_time_ms { + parent = parent.with_created_time(time_ms); + } + process = process.with_parent(parent); + } + process +} + +fn process_start_event(identity: &crate::boundary_protocol::ProcessIdentityWire) -> OcsfEvent { + ProcessActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .launch_type(LaunchTypeId::Spawn) + .process(audited_process(identity)) + .actor_process(Process::new( + "openshell-supervisor", + i64::from(std::process::id()), + )) + .message(format!("Process started: pid={}", identity.pid)) + .build() +} + +fn process_exit_event( + identity: &crate::boundary_protocol::ProcessIdentityWire, + status: ExitStatusWire, +) -> OcsfEvent { + let mut builder = ProcessActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Close) + .severity(SeverityId::Informational) + .process(audited_process(identity)) + .actor_process(Process::new( + "openshell-supervisor", + i64::from(std::process::id()), + )); + if let ExitStatusWire::Exited(code) = status { + builder = builder.exit_code(code); + } + let mut event = builder.build(); + if let ExitStatusWire::Signaled(signal) = status + && let OcsfEvent::ProcessActivity(process_event) = &mut event + { + process_event.base.unmapped = Some(serde_json::json!({"signal": signal})); + } + event +} + +fn emit_process_start(identity: &crate::boundary_protocol::ProcessIdentityWire) { + ocsf_emit!(process_start_event(identity)); +} + +fn emit_process_exit( + identity: &crate::boundary_protocol::ProcessIdentityWire, + status: ExitStatusWire, +) { + ocsf_emit!(process_exit_event(identity, status)); +} + fn begin_recovery_window( deadline: &mut Option, failure_time: tokio::time::Instant, @@ -562,9 +639,15 @@ impl OpenShellRuntimeBackend { #[derive(Clone, Debug, Default)] struct RecoveredProcesses { /// The canonical process, when the previous owner started it. - main: Option, + main: Option<( + String, + Option, + )>, /// Exec processes that have not exited. - execs: Vec, + execs: Vec<( + String, + Option, + )>, } impl RecoveredProcesses { @@ -573,9 +656,14 @@ impl RecoveredProcesses { let mut recovered = Self::default(); for process in &snapshot.processes { match process.kind { - ProcessKindWire::Main => recovered.main = Some(process.process_id.clone()), + ProcessKindWire::Main => { + recovered.main = + Some((process.process_id.clone(), process.process_identity.clone())); + } ProcessKindWire::Exec if process.status.is_none() => { - recovered.execs.push(process.process_id.clone()); + recovered + .execs + .push((process.process_id.clone(), process.process_identity.clone())); } ProcessKindWire::Exec => {} } @@ -809,16 +897,21 @@ impl ReadyBoundary { /// without launching anything. pub async fn start_agent(self: Box) -> Result, BackendError> { if let Some(RecoveredProcesses { - main: Some(process_id), + main: Some((process_id, process_identity)), execs, }) = &self.recovered { - let adopted = |process_id: &String| { + let adopted = |(process_id, process_identity): &( + String, + Option, + )| { Arc::new(BoundaryProcess { exec: true, adopted: true, client: self.client.clone(), process_id: process_id.clone(), + process_identity: process_identity.clone(), + exit_emitted: AtomicBool::new(false), }) }; return Ok(Box::new(RunningBoundary { @@ -827,6 +920,8 @@ impl ReadyBoundary { adopted: true, client: self.client.clone(), process_id: process_id.clone(), + process_identity: process_identity.clone(), + exit_emitted: AtomicBool::new(false), }), adopted_execs: execs.iter().map(adopted).collect(), exec: Arc::new(BoundaryExec { @@ -883,16 +978,22 @@ impl ReadyBoundary { let Response::Started { process_id, provider_env_generation, + process_identity, .. } = response else { return Err(unexpected_response("started", &response)); }; + if let Some(identity) = &process_identity { + emit_process_start(identity); + } let process = Arc::new(BoundaryProcess { exec: false, adopted: false, client: self.client.clone(), process_id, + process_identity, + exit_emitted: AtomicBool::new(false), }); Ok(Box::new(RunningBoundary { process, @@ -980,6 +1081,8 @@ pub struct BoundaryProcess { adopted: bool, client: Arc, process_id: String, + process_identity: Option, + exit_emitted: AtomicBool, } impl BoundaryProcess { @@ -1028,6 +1131,11 @@ impl BoundaryProcess { let Response::Exited { status } = response else { return Err(unexpected_response("exited", &response)); }; + if let Some(identity) = &self.process_identity + && !self.exit_emitted.swap(true, Ordering::AcqRel) + { + emit_process_exit(identity, status); + } Ok(status.into()) } @@ -1266,9 +1374,17 @@ async fn open_exec_session( spec: ExecSpecWire::from(spec), }) .await?; - let Response::ExecStarted { process_id, pty } = response else { + let Response::ExecStarted { + process_id, + pty, + process_identity, + } = response + else { return Err(unexpected_response("exec_started", &response)); }; + if let Some(identity) = &process_identity { + emit_process_start(identity); + } let (network_reader, network_writer) = tokio::io::split(stream); let (stdin, stdin_pump) = tokio::io::duplex(64 * 1024); let (stdout, stdout_pump) = tokio::io::duplex(64 * 1024); @@ -1287,7 +1403,18 @@ async fn open_exec_session( adopted: false, client: client.clone(), process_id: process_id.clone(), + process_identity, + exit_emitted: AtomicBool::new(false), }); + if process.process_identity.is_some() { + // SSH and CLI exec can consume the output stream's terminal status + // without calling wait on this handle. Observe the canonical status + // separately so the exit audit record is still emitted. + let observed = process.clone(); + tokio::spawn(async move { + let _ = observed.wait().await; + }); + } let terminal: Option> = if pty { Some(Arc::new(BoundaryTerminal { client, process_id })) } else { @@ -3066,6 +3193,35 @@ mod tests { const TEST_BOUNDARY_HANDLE: &str = "opaque-test-boundary"; + #[test] + fn process_activity_events_preserve_instance_and_redact_arguments() { + let identity = crate::boundary_protocol::ProcessIdentityWire { + name: "/bin/sh".to_string(), + pid: 57, + uid: Some("57:123456".to_string()), + created_time_ms: Some(1_793_620_000_120), + parent_pid: Some(1), + parent_uid: Some("1:34".to_string()), + parent_created_time_ms: Some(1_793_619_999_000), + }; + let started = process_start_event(&identity).to_json().unwrap(); + let exited = process_exit_event(&identity, ExitStatusWire::Exited(7)) + .to_json() + .unwrap(); + assert_eq!(started["process"], exited["process"]); + assert_eq!(started["process"]["uid"], "57:123456"); + assert_eq!(started["process"]["parent_process"]["uid"], "1:34"); + assert!(started["process"].get("cmd_line").is_none()); + assert_eq!(exited["exit_code"], 7); + + let signaled = process_exit_event(&identity, ExitStatusWire::Signaled(9)) + .to_json() + .unwrap(); + assert_eq!(signaled["process"], started["process"]); + assert_eq!(signaled["unmapped"]["signal"], 9); + assert!(signaled.get("exit_code").is_none()); + } + #[derive(Clone)] struct TestIsolationBackend { metadata: Option, @@ -3700,6 +3856,7 @@ mod tests { Request::Exec { .. } => Response::ExecStarted { process_id: "test-generation:exec:1".to_string(), pty: false, + process_identity: None, }, Request::AttachProcess { .. } => { Response::ProcessAttached { terminal: false } @@ -3725,6 +3882,7 @@ mod tests { process_id: "test-generation:main:0".to_string(), provider_env_revision, provider_env_generation: provider_environment_generation, + process_identity: None, }, Request::AcceptNetwork => Response::Error { kind: crate::boundary_protocol::BoundaryErrorKind::Unavailable, @@ -4301,6 +4459,7 @@ mod tests { timeout_secs: 5, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }, identity: ResolvedWorkloadIdentity::new( 10_001, @@ -4964,6 +5123,7 @@ mod tests { kind: crate::boundary_protocol::ProcessKindWire::Main, terminal: false, status: None, + process_identity: None, retained_output: crate::boundary_protocol::OutputWindowWire { first_sequence: 0, next_sequence: 9, @@ -4975,6 +5135,7 @@ mod tests { kind: crate::boundary_protocol::ProcessKindWire::Exec, terminal: false, status: None, + process_identity: None, retained_output: crate::boundary_protocol::OutputWindowWire { first_sequence: 0, next_sequence: 0, @@ -4986,6 +5147,7 @@ mod tests { kind: crate::boundary_protocol::ProcessKindWire::Exec, terminal: false, status: Some(ExitStatusWire::Exited(0)), + process_identity: None, retained_output: crate::boundary_protocol::OutputWindowWire { first_sequence: 0, next_sequence: 4, @@ -5045,6 +5207,7 @@ mod tests { kind, terminal: false, status: None, + process_identity: None, retained_output: crate::boundary_protocol::OutputWindowWire { first_sequence: 0, next_sequence: 0, diff --git a/crates/openshell-sandbox-backend/src/runtime/test_support.rs b/crates/openshell-sandbox-backend/src/runtime/test_support.rs index 79c36e2959..388efa2ef1 100644 --- a/crates/openshell-sandbox-backend/src/runtime/test_support.rs +++ b/crates/openshell-sandbox-backend/src/runtime/test_support.rs @@ -287,7 +287,11 @@ impl Peer { self.terminals.lock().unwrap().insert(process_id.clone(), t); } io = Some((a.stdin, a.stdout, a.stderr, a.process, a.output_status)); - Response::ExecStarted { process_id, pty } + Response::ExecStarted { + process_id, + pty, + process_identity: None, + } } Request::LoopbackConnect { host, port } => { relay = Some( @@ -777,6 +781,8 @@ pub fn process(process: Arc) -> Arc process_id: "main".into(), exec: false, adopted: false, + process_identity: None, + exit_emitted: std::sync::atomic::AtomicBool::new(false), }) } pub fn terminal(terminal: Arc) -> Arc { diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 0ccc79af88..2fb9c33dd1 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -36,6 +36,7 @@ async-trait = "0.1" bytes = { workspace = true } hex = "0.4" ipnet = "2" +globset = { workspace = true } rand = "0.10" # Async runtime diff --git a/crates/openshell-sandbox/README.md b/crates/openshell-sandbox/README.md index 72d01b73c7..6a34ebad9e 100644 --- a/crates/openshell-sandbox/README.md +++ b/crates/openshell-sandbox/README.md @@ -1,5 +1,21 @@ # Sandbox runtime +## Watched file opens + +The boundary's existing seccomp open listener observes paths in +`filesystem_policy.audit_paths`. The watch list is bound at attach, before the +workload starts. An empty list keeps the ordinary provider-file prefix fast +path. With a watch list, the broker reads each open path from task memory and +matches the workload-supplied text against the configured globs. It emits an +OCSF File System Activity with action Observed; the kernel's later Landlock +decision is not visible to this listener. Relative paths and symlink aliases +may not match an absolute watch pattern. + +The broker emits at most 100 watched opens per second per sandbox. A timer +emits one counted Base Event for suppressed matches in each elapsed window. +These events use the sandbox runtime's OCSF shorthand layer and appear in +workload container logs, not the supervisor's JSONL file. + `openshell-sandbox` owns the Linux workload isolation mechanisms and their kernel qualification probes. The `linux` module contains seccomp notifications and child filters, task-memory access, process-signal mediation, procfs descriptor lookup, @@ -10,6 +26,18 @@ the lower-level mechanisms used by that orchestration and the boundary server. Unsafe kernel ABI, process-launch, and descriptor-ownership operations stay in this runtime crate behind safe APIs where their contracts can be enforced. +When the supervisor passes `process_activity_enabled` in the admitted agent +spec, the boundary emits OCSF Process Activity launch and termination events +for its canonical child and for each exec or SSH child. It captures the child +PID, parent PID, and `/proc//stat` start tick before waiting, then retains +that identity for the exit record and sends it in the existing boundary +responses. The supervisor emits matching events to its gateway log path. +`process.uid` is `pid:start_ticks` and +`process.created_time` is Unix milliseconds derived from the kernel boot time. +The events omit argv. The setting defaults off, while the existing canonical +launch event remains enabled. This path does not observe forks or in-place execs +inside a workload child. + The network broker retains source socket descriptors only while preparing a workload endpoint. A successful connection or `listen` releases that reference; the kernel then tracks the lifetime through workload-owned descriptors. Native diff --git a/crates/openshell-sandbox/src/boundary_exec.rs b/crates/openshell-sandbox/src/boundary_exec.rs index d823fef977..39b1f63b2e 100644 --- a/crates/openshell-sandbox/src/boundary_exec.rs +++ b/crates/openshell-sandbox/src/boundary_exec.rs @@ -25,6 +25,7 @@ use openshell_core::provider_credentials::ProviderCredentialState; use openshell_isolation_interface::contract::{ BackendError, BoundaryExitStatus, BoundaryInput, BoundaryOutput, BoundarySignal, ExecSpec, }; +use openshell_ocsf::ocsf_emit; /// The sandbox executor. Every spawn reuses the same admitted policy and /// execution-environment controls while taking a fresh provider credential @@ -38,6 +39,7 @@ pub struct LocalBoundaryExec { user_environment: HashMap, runtime: Arc, otlp_relay_active: bool, + process_activity_enabled: bool, #[cfg(target_os = "linux")] launcher: crate::linux::workload_launcher::WorkloadLauncher, } @@ -54,6 +56,7 @@ impl LocalBoundaryExec { user_environment: HashMap, runtime: Arc, otlp_relay_active: bool, + process_activity_enabled: bool, #[cfg(target_os = "linux")] launcher: crate::linux::workload_launcher::WorkloadLauncher, ) -> Self { Self { @@ -64,6 +67,7 @@ impl LocalBoundaryExec { user_environment, runtime, otlp_relay_active, + process_activity_enabled, #[cfg(target_os = "linux")] launcher, } @@ -226,6 +230,9 @@ impl LocalBoundaryExec { fn spawn_piped(&self, spec: &ExecSpec) -> Result { self.runtime.ensure_active()?; let mut command = self.command(spec)?; + let audit_program = self + .process_activity_enabled + .then(|| command.get_program().to_string_lossy().into_owned()); command .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -275,6 +282,11 @@ impl LocalBoundaryExec { let managed_child = child_registry.register(pid); #[cfg(target_os = "linux")] drop(child_registry); + let audit = + audit_program.map(|program| crate::process_audit::ProcessAudit::capture(&program, pid)); + if let Some(audit) = &audit { + ocsf_emit!(audit.launch_event()); + } let stdin = child.stdin.take().map(|file| -> BoundaryInput { let fd: OwnedFd = file.into(); Box::new(tokio::fs::File::from_std(std::fs::File::from(fd))) @@ -297,6 +309,7 @@ impl LocalBoundaryExec { self.runtime.clone(), process_terminal, signal_lock, + audit, #[cfg(target_os = "linux")] managed_child, )); @@ -339,6 +352,9 @@ impl LocalBoundaryExec { .try_clone() .map_err(|error| BackendError::Process(error.to_string()))?; let mut command = self.command(spec)?; + let audit_program = self + .process_activity_enabled + .then(|| command.get_program().to_string_lossy().into_owned()); command.stdin(stdin).stdout(stdout).stderr(slave); let effective_workdir = spec.workdir.as_deref().or(self.base_workdir.as_deref()); #[cfg(target_os = "linux")] @@ -385,6 +401,11 @@ impl LocalBoundaryExec { let managed_child = child_registry.register(pid); #[cfg(target_os = "linux")] drop(child_registry); + let audit = + audit_program.map(|program| crate::process_audit::ProcessAudit::capture(&program, pid)); + if let Some(audit) = &audit { + ocsf_emit!(audit.launch_event()); + } let terminal: Arc = Arc::new(LocalTerminal { master }); let process = Arc::new(LocalExecProcess::new( child, @@ -392,6 +413,7 @@ impl LocalBoundaryExec { self.runtime.clone(), process_terminal, signal_lock, + audit, #[cfg(target_os = "linux")] managed_child, )); @@ -561,6 +583,7 @@ impl BoundaryTerminal for LocalTerminal { struct LocalExecProcess { pid: u32, + process_identity: Option, result: Arc>>>, exited: Arc, runtime: Arc, @@ -575,8 +598,12 @@ impl LocalExecProcess { runtime: Arc, terminal: Arc, signal_lock: Arc>, + audit: Option, #[cfg(target_os = "linux")] managed_child: Option, ) -> Self { + let process_identity = audit + .as_ref() + .map(crate::process_audit::ProcessAudit::identity); let result = Arc::new(std::sync::Mutex::new(None)); let exited = Arc::new(tokio::sync::Notify::new()); let result_for_wait = result.clone(); @@ -630,6 +657,11 @@ impl LocalExecProcess { } BoundaryExitStatus::Exited(status.code().unwrap_or(1)) }); + if let Some(audit) = &audit + && let Ok(status) = waited.as_ref() + { + ocsf_emit!(audit.terminate_event(*status)); + } runtime_for_wait.unregister_process_group(pid, ®istration_terminal); if let Ok(mut slot) = result_for_wait.lock() { *slot = Some(waited); @@ -638,6 +670,7 @@ impl LocalExecProcess { }); Self { pid, + process_identity, result, exited, runtime, @@ -662,6 +695,11 @@ impl LocalExecProcess { #[async_trait] impl BoundaryProcess for LocalExecProcess { + fn process_identity( + &self, + ) -> Option { + self.process_identity.clone() + } async fn wait(&self) -> Result { loop { let notified = self.exited.notified(); @@ -737,6 +775,7 @@ mod tests { HashMap::new(), crate::boundary_io::BoundaryRuntimeState::new(), otlp_relay_active, + false, launcher, ) } diff --git a/crates/openshell-sandbox/src/boundary_io.rs b/crates/openshell-sandbox/src/boundary_io.rs index 7d0e65f7b0..12b1f89c2f 100644 --- a/crates/openshell-sandbox/src/boundary_io.rs +++ b/crates/openshell-sandbox/src/boundary_io.rs @@ -333,6 +333,14 @@ fn read_proc_stat(pid: u32) -> Option { }) } +/// Return a process's namespace parent PID and kernel start tick while it is +/// still owned by its caller. Used to identify audited process instances. +#[cfg(target_os = "linux")] +pub(crate) fn process_parent_and_start(pid: u32) -> Option<(u32, u64)> { + let stat = read_proc_stat(pid)?; + Some((stat.parent, stat.start_time)) +} + /// Whether orphaned descendants are reparented to this sandbox process. #[cfg(target_os = "linux")] fn sandbox_owns_process_tree() -> bool { diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 4eb3f1ec02..7c7c5e3cd5 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -2106,6 +2106,7 @@ mod linux { response: Response::ExecStarted { process_id: started.process_id.clone(), pty: started.terminal, + process_identity: started.process_identity, }, }, ) { @@ -2606,6 +2607,7 @@ mod linux { struct StartedExec { process_id: String, terminal: bool, + process_identity: Option, attachment: MainAttachment, } @@ -3495,6 +3497,7 @@ mod linux { return Ok(StartedExec { process_id: process_id.clone(), terminal: handle.terminal.is_some(), + process_identity: handle.process.process_identity(), attachment: acquire_exec_attachment(handle)?, }); } @@ -3575,11 +3578,13 @@ mod linux { status, }; let terminal = handle.terminal.is_some(); + let process_identity = handle.process.process_identity(); let attachment = acquire_exec_attachment(&handle)?; handles.insert(process_id.clone(), handle); Ok(StartedExec { process_id, terminal, + process_identity, attachment, }) } @@ -3692,6 +3697,15 @@ mod linux { } let accepted = match &*state { RuntimeState::AwaitingAttach => { + if let Err(error) = self + .network_broker + .configure_file_audit(&policy.audit_paths) + { + return guest_error( + BoundaryErrorKind::Invalid, + format!("invalid filesystem audit configuration: {error}"), + ); + } let prepared = match PreparedBoundary::establish(self.network_broker.clone()) { Ok(prepared) => prepared, Err(error) => return guest_error(BoundaryErrorKind::Process, error), @@ -3739,6 +3753,7 @@ mod linux { kind: ProcessKindWire::Main, terminal: process.main_session().terminal(), status: process.exit_status(), + process_identity: process.process_identity(), retained_output: OutputWindowWire { first_sequence, next_sequence, @@ -3754,6 +3769,7 @@ mod linux { kind: ProcessKindWire::Exec, terminal: handle.terminal.is_some(), status: *lock(&handle.status), + process_identity: handle.process.process_identity(), retained_output: OutputWindowWire { first_sequence, next_sequence, @@ -3900,6 +3916,7 @@ mod linux { process_id: process.process_id(), provider_env_revision: process.provider_credentials.snapshot().revision, provider_env_generation: *lock(&process.provider_environment_generation), + process_identity: process.process_identity(), } } else { guest_error( @@ -3970,6 +3987,7 @@ mod linux { Err(error) => return guest_error(BoundaryErrorKind::Process, error), }; let process_id = process.process_id(); + let process_identity = process.process_identity(); *lock(&self.started_agent) = Some(requested); *state = RuntimeState::Running(process); openshell_ocsf::ocsf_emit!( @@ -3985,6 +4003,7 @@ mod linux { process_id, provider_env_revision, provider_env_generation: 0, + process_identity, } } @@ -4321,6 +4340,7 @@ mod linux { struct ManagedProcess { process_id: String, + process_identity: Option, signaler: AgentSignaler, exit: SharedProcessExit, boundary_exec: Arc, @@ -4396,9 +4416,11 @@ mod linux { ca_file_paths, Some(boundary_runtime.clone()), spec.otlp_relay_active, + spec.process_activity_enabled, )) .map_err(|error| format!("start process supervisor leaf: {error:?}"))?; let signaler = spawned.signaler(); + let process_identity = spawned.process_identity(); let boundary_exec = spawned.boundary_exec(); let loopback_connector = spawned.loopback_connector(); let main_session = spawned.main_session(); @@ -4416,6 +4438,7 @@ mod linux { }); Ok(Self { process_id, + process_identity, signaler, exit, boundary_exec, @@ -4432,6 +4455,12 @@ mod linux { self.process_id.clone() } + fn process_identity( + &self, + ) -> Option { + self.process_identity.clone() + } + fn wait(&self) -> ProcessExit { let (state, changed) = &*self.exit; let mut exit = lock(state); @@ -5409,6 +5438,7 @@ mod linux { timeout_secs: 0, interactive: true, otlp_relay_active: false, + process_activity_enabled: false, }) .expect("resolve scratch command"); @@ -8022,6 +8052,7 @@ mod linux { timeout_secs: 60, interactive: false, otlp_relay_active: false, + process_activity_enabled: true, }; assert!(matches!( @@ -8044,10 +8075,18 @@ mod linux { process_id, provider_env_revision: 0, provider_env_generation: 0, + process_identity: Some(process_identity), } = start() else { panic!("initial start did not succeed"); }; + assert_eq!(process_identity.name, "/bin/sleep"); + assert!( + process_identity + .uid + .as_deref() + .is_some_and(|uid| uid.starts_with(&format!("{}:", process_identity.pid))) + ); let update = RequestEnvelope::new(Request::UpdateProviderEnvironment { provider_files: std::collections::HashMap::new(), @@ -8131,6 +8170,7 @@ mod linux { process_id: process_id.clone(), provider_env_revision: 7, provider_env_generation: 1, + process_identity: Some(process_identity.clone()), } ); @@ -8250,6 +8290,12 @@ mod linux { ) .expect("exec after reconnect"); let exec_id = exec.process_id.clone(); + let exec_identity = exec + .process_identity + .clone() + .expect("exec identity is captured"); + assert_eq!(exec_identity.name, "/bin/sh"); + assert!(exec_identity.uid.is_some()); let mut output = String::new(); let mut cursor = exec.attachment.session.subscribe(); process_runtime.block_on(async { @@ -8282,12 +8328,15 @@ mod linux { }; assert_eq!(snapshot.generation, "generation-reconnect"); assert!(snapshot.processes.iter().any(|process| { - process.process_id == process_id && process.kind == ProcessKindWire::Main + process.process_id == process_id + && process.kind == ProcessKindWire::Main + && process.process_identity.as_ref() == Some(&process_identity) })); assert!(snapshot.processes.iter().any(|process| { process.process_id == exec_id && process.kind == ProcessKindWire::Exec && process.status == Some(ExitStatusWire::Exited(0)) + && process.process_identity.as_ref() == Some(&exec_identity) && process.retained_output.next_sequence > 0 })); assert_eq!(boundary.terminate(&process_id), Response::Terminated); @@ -8464,6 +8513,7 @@ mod linux { timeout_secs: 120, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }, None, None, @@ -8950,6 +9000,7 @@ mod linux { timeout_secs: 120, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }, None, None, @@ -9557,6 +9608,7 @@ mod linux { timeout_secs: 120, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }, None, None, @@ -9694,6 +9746,7 @@ mod linux { timeout_secs: 5, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, }; let wire_policy = SandboxPolicyWire::from(policy.clone()); let process = Arc::new( @@ -9773,6 +9826,7 @@ mod linux { process_id: process.process_id(), provider_env_revision: 0, provider_env_generation: 0, + process_identity: None, } ); @@ -9876,6 +9930,7 @@ mod linux { process_id: process.process_id(), provider_env_revision: 1, provider_env_generation: 2, + process_identity: None, }, "a replacement control must resume from the boundary's current revision" ); diff --git a/crates/openshell-sandbox/src/delegated.rs b/crates/openshell-sandbox/src/delegated.rs index 7b3ec12b16..61333436d7 100644 --- a/crates/openshell-sandbox/src/delegated.rs +++ b/crates/openshell-sandbox/src/delegated.rs @@ -42,6 +42,7 @@ pub async fn spawn_workload( ca_file_paths: Option<(std::path::PathBuf, std::path::PathBuf)>, boundary_runtime: Option>, otlp_relay_active: bool, + process_activity_enabled: bool, ) -> Result { // Driver-selected workspaces are the sandbox identity's home. This keeps // canonical and later exec processes consistent for image WorkingDir and @@ -85,6 +86,7 @@ pub async fn spawn_workload( user_environment, boundary_runtime.clone(), otlp_relay_active, + process_activity_enabled, launcher.clone(), )); @@ -129,19 +131,25 @@ pub async fn spawn_workload( .register_process_group(handle.pid(), terminal.clone(), signal_lock.clone()) .map_err(|error| miette::miette!(error.to_string()))?; - ocsf_emit!( - ProcessActivityBuilder::new(ocsf_ctx()) - .activity(ActivityId::Open) - .action(ActionId::Allowed) - .disposition(DispositionId::Allowed) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .launch_type(LaunchTypeId::Spawn) - .process(OcsfProcess::new(program, i64::from(handle.pid()))) - .actor_process(crate::process::ocsf_launcher_process()) - .message(format!("Process started: pid={}", handle.pid())) - .build() - ); + let audit = process_activity_enabled + .then(|| crate::process_audit::ProcessAudit::capture(program, handle.pid())); + if let Some(audit) = &audit { + ocsf_emit!(audit.launch_event()); + } else { + ocsf_emit!( + ProcessActivityBuilder::new(ocsf_ctx()) + .activity(ActivityId::Open) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .launch_type(LaunchTypeId::Spawn) + .process(OcsfProcess::new(program, i64::from(handle.pid()))) + .actor_process(crate::process::ocsf_launcher_process()) + .message(format!("Process started: pid={}", handle.pid())) + .build() + ); + } Ok(SpawnedAgent { handle, @@ -152,6 +160,7 @@ pub async fn spawn_workload( boundary_exec, loopback_connector, boundary_runtime, + audit, }) } @@ -165,9 +174,17 @@ pub struct SpawnedAgent { boundary_exec: Arc, loopback_connector: Arc, boundary_runtime: Arc, + audit: Option, } impl SpawnedAgent { + pub(crate) fn process_identity( + &self, + ) -> Option { + self.audit + .as_ref() + .map(crate::process_audit::ProcessAudit::identity) + } #[must_use] pub fn signaler(&self) -> AgentSignaler { AgentSignaler { @@ -213,6 +230,17 @@ impl SpawnedAgent { }; self.boundary_runtime .unregister_process_group(self.handle.pid(), &self.terminal); + if let Some(audit) = &self.audit { + let exit = status.signal().map_or_else( + || { + openshell_isolation_interface::contract::BoundaryExitStatus::Exited( + status.code(), + ) + }, + openshell_isolation_interface::contract::BoundaryExitStatus::Signaled, + ); + ocsf_emit!(audit.terminate_event(exit)); + } let _ = self.main_session.finish(status.code(), false).await; self.main_session.mark_terminal_reported(); Ok(status) diff --git a/crates/openshell-sandbox/src/file_audit.rs b/crates/openshell-sandbox/src/file_audit.rs new file mode 100644 index 0000000000..d4bde69b38 --- /dev/null +++ b/crates/openshell-sandbox/src/file_audit.rs @@ -0,0 +1,303 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Opt-in observation of workload-supplied paths at the existing open listener. + +use std::io; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; +use openshell_ocsf::{BaseEventBuilder, FileActivityBuilder, Process, SeverityId, ocsf_emit}; + +use crate::linux::seccomp_notify::{Notification, NotificationListener}; +use crate::linux::task_memory; +use crate::provider_files::{read_path, thread_group_of}; + +const WINDOW: Duration = Duration::from_secs(1); +const EVENTS_PER_WINDOW: u32 = 100; + +struct RateWindow { + started: Instant, + emitted: u32, + dropped: u64, +} + +impl Default for RateWindow { + fn default() -> Self { + Self { + started: Instant::now(), + emitted: 0, + dropped: 0, + } + } +} + +impl RateWindow { + fn flush(&mut self, now: Instant) -> Option { + if now.duration_since(self.started) < WINDOW { + return None; + } + self.started = now; + self.emitted = 0; + (self.dropped != 0).then(|| std::mem::take(&mut self.dropped)) + } + + fn record(&mut self, now: Instant) -> (bool, Option) { + let dropped = self.flush(now); + if self.emitted < EVENTS_PER_WINDOW { + self.emitted += 1; + (true, dropped) + } else { + self.dropped = self.dropped.saturating_add(1); + (false, dropped) + } + } +} + +#[derive(Default)] +struct AuditState { + patterns: Option, + rate: RateWindow, + worker_started: bool, +} + +/// Shared by the boundary runtime and the notification broker for one sandbox. +#[derive(Clone, Default)] +pub struct FileAudit { + state: Arc>, + enabled: Arc, +} + +impl FileAudit { + /// Bind a validated policy's watch list before the workload starts. + pub fn configure(&self, paths: &[String]) -> io::Result<()> { + openshell_policy::validate_audit_paths(paths) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid audit paths"))?; + let patterns = if paths.is_empty() { + None + } else { + let mut builder = GlobSetBuilder::new(); + for path in paths { + builder.add( + GlobBuilder::new(path) + .literal_separator(true) + .build() + .map_err(io::Error::other)?, + ); + } + Some(builder.build().map_err(io::Error::other)?) + }; + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.patterns = patterns; + state.rate = RateWindow::default(); + self.enabled.store(!paths.is_empty(), Ordering::Release); + if !paths.is_empty() && !state.worker_started { + let weak = Arc::downgrade(&self.state); + let dispatch = tracing::dispatcher::get_default(Clone::clone); + std::thread::Builder::new() + .name("openshell-file-audit-drops".to_string()) + .spawn(move || { + let _subscriber = tracing::dispatcher::set_default(&dispatch); + loop { + std::thread::sleep(WINDOW); + let Some(state) = weak.upgrade() else { + break; + }; + let dropped = state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .rate + .flush(Instant::now()); + if let Some(count) = dropped { + emit_drop_record(count); + } + } + })?; + state.worker_started = true; + } + Ok(()) + } + + /// Observe a notification without changing the syscall's result. + pub fn observe_open(&self, listener: &NotificationListener, notification: Notification) { + if !self.enabled.load(Ordering::Acquire) || listener.validate_id(notification.id).is_err() { + return; + } + let syscall = i64::from(notification.syscall); + let path_address = if syscall == libc::SYS_openat || syscall == libc::SYS_openat2 { + notification.args[1] + } else { + notification.args[0] + }; + // An opted-in sandbox pays for a full path read on each open. The + // default path remains the provider-file prefix fast path. + let Ok(path) = read_path(notification.tid, path_address) else { + return; + }; + if path.is_empty() || listener.validate_id(notification.id).is_err() { + return; + } + let (emit, dropped) = { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !state + .patterns + .as_ref() + .is_some_and(|patterns| patterns.is_match(&path)) + { + return; + } + state.rate.record(Instant::now()) + }; + if let Some(count) = dropped { + emit_drop_record(count); + } + if !emit || listener.validate_id(notification.id).is_err() { + return; + } + let actor = process_for_thread(notification.tid); + let access = requested_access(¬ification); + ocsf_emit!( + FileActivityBuilder::new(openshell_ocsf::ctx::ctx(), &path, actor, access).build() + ); + } +} + +fn emit_drop_record(count: u64) { + ocsf_emit!( + BaseEventBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::Low) + .activity_name("File audit events dropped") + .message(format!( + "Suppressed {count} file audit events in one second" + )) + .unmapped("dropped_count", count) + .unmapped("reason", "rate_limit") + .build() + ); +} + +fn process_for_thread(tid: u32) -> Process { + let pid = thread_group_of(tid).unwrap_or(tid); + let path = std::fs::read_link(format!("/proc/{tid}/exe")) + .ok() + .map(|path| path.to_string_lossy().into_owned()); + let name = path + .as_deref() + .and_then(|path| std::path::Path::new(path).file_name()) + .and_then(std::ffi::OsStr::to_str) + .unwrap_or("unknown"); + let process = Process::new(name, i64::from(pid)); + if let Some(path) = path { + process.with_path(&path) + } else { + process + } +} + +fn requested_access(notification: &Notification) -> &'static str { + let syscall = i64::from(notification.syscall); + let flags = if syscall == libc::SYS_openat2 { + let mut raw = [0_u8; 8]; + if notification.args[3] < 8 + || task_memory::read_exact(notification.tid, notification.args[2], &mut raw).is_err() + { + return "unknown"; + } + u64::from_ne_bytes(raw) + } else if syscall == libc::SYS_openat { + notification.args[2] + } else { + notification.args[1] + }; + let Ok(flags) = i32::try_from(flags) else { + return "unknown"; + }; + if flags & libc::O_PATH != 0 { + "metadata" + } else if flags & libc::O_CREAT != 0 || flags & libc::O_TMPFILE == libc::O_TMPFILE { + "create" + } else if flags & libc::O_ACCMODE == libc::O_RDWR { + "read_write" + } else if flags & libc::O_ACCMODE == libc::O_WRONLY || flags & libc::O_TRUNC != 0 { + "write" + } else { + "read" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn watched_globs_match_only_requested_path_text() { + let mut builder = GlobSetBuilder::new(); + builder.add( + GlobBuilder::new("/sandbox/**/.env") + .literal_separator(true) + .build() + .unwrap(), + ); + builder.add( + GlobBuilder::new("/sandbox/.ssh/**") + .literal_separator(true) + .build() + .unwrap(), + ); + let patterns = builder.build().unwrap(); + assert!(patterns.is_match("/sandbox/project/.env")); + assert!(patterns.is_match("/sandbox/project/src/deep/.env")); + assert!(patterns.is_match("/sandbox/.ssh/id_test")); + assert!(!patterns.is_match("/tmp/.env")); + assert!(!patterns.is_match("project/.env")); + + let direct_only = GlobBuilder::new("/sandbox/*.env") + .literal_separator(true) + .build() + .unwrap(); + let matcher = direct_only.compile_matcher(); + assert!(matcher.is_match("/sandbox/local.env")); + assert!(!matcher.is_match("/sandbox/deep/local.env")); + } + + #[test] + fn rate_limit_counts_suppressed_events_and_resets() { + let mut rate = RateWindow::default(); + let now = rate.started; + for _ in 0..EVENTS_PER_WINDOW { + assert_eq!(rate.record(now), (true, None)); + } + assert_eq!(rate.record(now), (false, None)); + assert_eq!(rate.record(now), (false, None)); + assert_eq!(rate.flush(now + WINDOW), Some(2)); + assert_eq!(rate.record(now + WINDOW), (true, None)); + } + + #[test] + fn open_flags_report_requested_access_without_claiming_outcome() { + let mut notification = Notification { + id: 1, + tid: std::process::id(), + syscall: i32::try_from(libc::SYS_openat).unwrap(), + args: [0; 6], + }; + notification.args[2] = libc::O_RDONLY as u64; + assert_eq!(requested_access(¬ification), "read"); + notification.args[2] = libc::O_WRONLY as u64; + assert_eq!(requested_access(¬ification), "write"); + notification.args[2] = (libc::O_RDWR | libc::O_CREAT) as u64; + assert_eq!(requested_access(¬ification), "create"); + notification.args[2] = (libc::O_RDWR | libc::O_TMPFILE) as u64; + assert_eq!(requested_access(¬ification), "create"); + notification.args[2] = libc::O_TRUNC as u64; + assert_eq!(requested_access(¬ification), "write"); + } +} diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 3a7fd8beb4..2bacf802df 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -15,6 +15,8 @@ pub mod container_log; #[cfg(target_os = "linux")] pub(crate) mod delegated; #[cfg(target_os = "linux")] +mod file_audit; +#[cfg(target_os = "linux")] pub mod linux; #[cfg(target_os = "linux")] pub mod main_session; @@ -27,6 +29,8 @@ pub mod perf; #[cfg(target_os = "linux")] pub mod process; #[cfg(target_os = "linux")] +mod process_audit; +#[cfg(target_os = "linux")] mod provider_files; #[cfg(target_os = "linux")] mod pty; diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 86ef2bb4f5..1273da0685 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -307,6 +307,7 @@ fn run_capability_landlock_child(args: &[String]) -> Result<()> { filesystem: FilesystemPolicy { read_only: vec![allowed.to_path_buf()], read_write: Vec::new(), + audit_paths: Vec::new(), include_workdir: false, }, network: NetworkPolicy::default(), diff --git a/crates/openshell-sandbox/src/native_api.rs b/crates/openshell-sandbox/src/native_api.rs index 3d9adff950..28318450a8 100644 --- a/crates/openshell-sandbox/src/native_api.rs +++ b/crates/openshell-sandbox/src/native_api.rs @@ -12,6 +12,12 @@ use std::sync::Arc; #[async_trait] pub trait BoundaryProcess: Send + Sync { + /// Identity captured at spawn time, when this process is opted in to audit. + fn process_identity( + &self, + ) -> Option { + None + } /// Attach to the admitted process's retained standard I/O. The boundary /// remains the process owner and may permit only one control attachment. async fn attach(&self) -> Result { diff --git a/crates/openshell-sandbox/src/network_broker.rs b/crates/openshell-sandbox/src/network_broker.rs index 70e9b444a1..4870f51b2c 100644 --- a/crates/openshell-sandbox/src/network_broker.rs +++ b/crates/openshell-sandbox/src/network_broker.rs @@ -15,6 +15,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +use crate::file_audit::FileAudit; use crate::linux::seccomp_notify::{Notification, NotificationListener}; use crate::linux::socket_registry::{ InetFamily, InetKind, SocketIdentity, SocketMetadata, SocketRegistry, SocketState, @@ -205,6 +206,7 @@ fn register_dns_socket( #[derive(Clone)] struct NotificationQueues { provider_files: crate::provider_files::ProviderFiles, + file_audit: FileAudit, workload_frozen: Arc, protected_control_port: Option, identity_resolver: ProcfsIdentityResolver, @@ -219,6 +221,7 @@ struct NotificationQueues { #[derive(Clone)] pub struct NetworkBroker { provider_files: crate::provider_files::ProviderFiles, + file_audit: FileAudit, workload_frozen: Arc, pending: Arc>>, pending_dns: Arc>>, @@ -273,9 +276,11 @@ impl NetworkBroker { let descriptor_soft_limit = descriptor_soft_limit()?; let registry = Arc::new(Mutex::new(SocketRegistry::new(1, SOCKET_CAPACITY)?)); let provider_files = crate::provider_files::ProviderFiles::default(); + let file_audit = FileAudit::default(); let workload_frozen = Arc::new(AtomicBool::new(false)); let queues = NotificationQueues { provider_files: provider_files.clone(), + file_audit: file_audit.clone(), workload_frozen: workload_frozen.clone(), protected_control_port, identity_resolver: ProcfsIdentityResolver::for_pid_namespace(), @@ -288,9 +293,11 @@ impl NetworkBroker { let healthy = Arc::new(AtomicBool::new(true)); let broker_healthy = healthy.clone(); let broker_registry = Arc::clone(®istry); + let dispatch = tracing::dispatcher::get_default(Clone::clone); std::thread::Builder::new() .name("openshell-network-broker".to_string()) .spawn(move || { + let _subscriber = tracing::dispatcher::set_default(&dispatch); while broker_healthy.load(Ordering::Acquire) { let notification = match listener.receive() { Ok(notification) => notification, @@ -351,6 +358,7 @@ impl NetworkBroker { .map_err(|error| io::Error::other(format!("start network broker: {error}")))?; Ok(Self { provider_files, + file_audit, workload_frozen, pending: Arc::new(tokio::sync::Mutex::new(pending_rx)), pending_dns: Arc::new(tokio::sync::Mutex::new(pending_dns_rx)), @@ -414,6 +422,10 @@ impl NetworkBroker { pub(crate) fn provider_files(&self) -> &crate::provider_files::ProviderFiles { &self.provider_files } + + pub(crate) fn configure_file_audit(&self, paths: &[String]) -> io::Result<()> { + self.file_audit.configure(paths) + } } fn start_dns_relay( @@ -591,10 +603,12 @@ fn dispatch_notification( ) -> io::Result<()> { let syscall = i64::from(notification.syscall); if syscall == libc::SYS_openat || syscall == libc::SYS_openat2 { + queues.file_audit.observe_open(&listener, notification); return queues.provider_files.handle_open(&listener, notification); } #[cfg(target_arch = "x86_64")] if syscall == libc::SYS_open { + queues.file_audit.observe_open(&listener, notification); return queues.provider_files.handle_open(&listener, notification); } if matches!(syscall, libc::SYS_kill | libc::SYS_rt_sigqueueinfo) { @@ -1939,6 +1953,49 @@ mod tests { } } + #[test] + fn watched_open_emits_observed_file_activity_and_continues() { + let directory = tempfile::tempdir().unwrap(); + let watched = directory.path().join("watched.env"); + let unwatched = directory.path().join("unwatched.txt"); + std::fs::write(&watched, "watched content").unwrap(); + std::fs::write(&unwatched, "other content").unwrap(); + + let capture = openshell_ocsf::test_support::OcsfCapture::default(); + let _capture = capture.install(); + let (launcher, listener) = crate::linux::workload_launcher::start().unwrap(); + let broker = NetworkBroker::start_for_test(listener).unwrap(); + broker + .configure_file_audit(&[watched.to_str().unwrap().to_string()]) + .unwrap(); + + let content = launcher + .execute({ + let watched = watched.clone(); + move || std::fs::read_to_string(watched) + }) + .unwrap() + .unwrap(); + assert_eq!(content, "watched content"); + let content = launcher + .execute(move || std::fs::read_to_string(unwatched)) + .unwrap() + .unwrap(); + assert_eq!(content, "other content"); + + let events: Vec<_> = capture + .json_events() + .into_iter() + .filter(|event| event["class_uid"] == 1001) + .collect(); + assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!(events[0]["action_id"], 3); + assert_eq!(events[0]["file"]["path"], watched.to_str().unwrap()); + assert_eq!(events[0]["unmapped"]["path_source"], "syscall_argument"); + assert_eq!(events[0]["unmapped"]["access_type"], "read"); + assert!(events[0]["actor"]["process"]["pid"].as_i64().is_some()); + } + #[test] fn provider_files_are_opened_on_demand_and_replaced() { let (launcher, listener) = diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index a8995bc7da..5c43158580 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -1902,6 +1902,7 @@ mod tests { policy.filesystem = FilesystemPolicy { read_only: vec![root.clone()], read_write: Vec::new(), + audit_paths: Vec::new(), include_workdir: false, }; policy.landlock = LandlockPolicy { diff --git a/crates/openshell-sandbox/src/process_audit.rs b/crates/openshell-sandbox/src/process_audit.rs new file mode 100644 index 0000000000..fd2e8856a1 --- /dev/null +++ b/crates/openshell-sandbox/src/process_audit.rs @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OCSF lifecycle records for processes directly started by this boundary. + +use std::sync::OnceLock; + +use openshell_isolation_interface::contract::BoundaryExitStatus; +use openshell_ocsf::{ + ActionId, ActivityId, DispositionId, LaunchTypeId, OcsfEvent, Process, ProcessActivityBuilder, + SeverityId, StatusId, +}; + +use crate::boundary_io::process_parent_and_start; +use openshell_sandbox_backend::boundary_protocol::ProcessIdentityWire; + +/// A captured process instance. Keep this across the wait: `/proc/` may +/// be gone or the PID may have been reused by the time the exit is emitted. +#[derive(Clone)] +pub struct ProcessAudit { + process: Process, +} + +impl ProcessAudit { + pub fn capture(program: &str, pid: u32) -> Self { + let mut process = Process::new(program, i64::from(pid)); + if let Some((parent_pid, start_ticks)) = process_parent_and_start(pid) { + process = with_identity(process, pid, start_ticks); + let parent_name = if parent_pid == std::process::id() { + "openshell-sandbox" + } else { + "unknown" + }; + let mut parent = Process::new(parent_name, i64::from(parent_pid)); + if let Some((_, parent_start_ticks)) = process_parent_and_start(parent_pid) { + parent = with_identity(parent, parent_pid, parent_start_ticks); + } + process = process.with_parent(parent); + } + Self { process } + } + + pub fn identity(&self) -> ProcessIdentityWire { + let parent = self.process.parent_process.as_deref(); + ProcessIdentityWire { + name: self.process.name.clone(), + pid: u32::try_from(self.process.pid).expect("captured PID fits u32"), + uid: self.process.uid.clone(), + created_time_ms: self.process.created_time, + parent_pid: parent.and_then(|process| u32::try_from(process.pid).ok()), + parent_uid: parent.and_then(|process| process.uid.clone()), + parent_created_time_ms: parent.and_then(|process| process.created_time), + } + } + + pub fn launch_event(&self) -> OcsfEvent { + ProcessActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Allowed) + .disposition(DispositionId::Allowed) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .launch_type(LaunchTypeId::Spawn) + .process(self.process.clone()) + .actor_process(crate::process::ocsf_launcher_process()) + .message(format!("Process started: pid={}", self.process.pid)) + .build() + } + + pub fn terminate_event(&self, status: BoundaryExitStatus) -> OcsfEvent { + let mut builder = ProcessActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Close) + .severity(SeverityId::Informational) + .process(self.process.clone()) + .actor_process(crate::process::ocsf_launcher_process()); + if let BoundaryExitStatus::Exited(code) = status { + builder = builder.exit_code(code); + } + let mut event = builder.build(); + if let BoundaryExitStatus::Signaled(signal) = status + && let OcsfEvent::ProcessActivity(process_event) = &mut event + { + process_event.base.unmapped = Some(serde_json::json!({"signal": signal})); + } + event + } +} + +fn with_identity(mut process: Process, pid: u32, start_ticks: u64) -> Process { + // The PID is namespace local; the start tick distinguishes reuse in one + // sandbox lifetime even when two starts round to the same millisecond. + process = process.with_uid(format!("{pid}:{start_ticks}")); + if let Some(time_ms) = process_created_time_ms(start_ticks) { + process = process.with_created_time(time_ms); + } + process +} + +#[allow(unsafe_code)] +fn process_created_time_ms(start_ticks: u64) -> Option { + static CLOCK: OnceLock> = OnceLock::new(); + let (boot_seconds, ticks_per_second) = (*CLOCK.get_or_init(|| { + let stat = std::fs::read_to_string("/proc/stat").ok()?; + let boot_seconds = stat + .lines() + .find_map(|line| line.strip_prefix("btime "))? + .parse::() + .ok()?; + // SAFETY: sysconf reads a process-wide kernel parameter and does not + // access caller-owned memory. + let ticks_per_second = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + let ticks_per_second = u64::try_from(ticks_per_second).ok().filter(|hz| *hz > 0)?; + Some((boot_seconds, ticks_per_second)) + }))?; + let milliseconds = boot_seconds + .checked_mul(1_000)? + .checked_add(start_ticks.checked_mul(1_000)? / ticks_per_second)?; + i64::try_from(milliseconds).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn captures_a_stable_process_instance_without_arguments() { + let pid = std::process::id(); + let audit = ProcessAudit::capture("openshell-sandbox", pid); + let event = audit.launch_event().to_json().unwrap(); + let (_, ticks) = process_parent_and_start(pid).unwrap(); + assert_eq!(event["process"]["pid"], pid); + assert_eq!(event["process"]["uid"], format!("{pid}:{ticks}")); + assert!(event["process"]["created_time"].is_i64()); + assert!(event["process"]["cmd_line"].is_null()); + } + + #[test] + fn normal_and_signal_exits_keep_the_launch_identity() { + let audit = ProcessAudit::capture("test", std::process::id()); + let launch = audit.launch_event().to_json().unwrap(); + let normal = audit + .terminate_event(BoundaryExitStatus::Exited(7)) + .to_json() + .unwrap(); + assert_eq!(normal["process"], launch["process"]); + assert_eq!(normal["exit_code"], 7); + + let signal = audit + .terminate_event(BoundaryExitStatus::Signaled(9)) + .to_json() + .unwrap(); + assert_eq!(signal["process"], launch["process"]); + assert_eq!(signal["unmapped"]["signal"], 9); + assert!(signal.get("exit_code").is_none()); + } + + #[test] + fn a_short_lived_child_keeps_its_identity_after_reaping() { + let mut child = std::process::Command::new("/bin/true") + .spawn() + .expect("spawn child"); + let pid = child.id(); + let audit = ProcessAudit::capture("/bin/true", pid); + let launch = audit.launch_event().to_json().unwrap(); + let status = child.wait().expect("reap child"); + assert!(process_parent_and_start(pid).is_none()); + + let exit = audit + .terminate_event(BoundaryExitStatus::Exited(status.code().unwrap())) + .to_json() + .unwrap(); + assert_eq!(launch["process"], exit["process"]); + assert_eq!( + launch["process"]["parent_process"]["pid"], + std::process::id() + ); + assert_eq!(launch["process"]["uid"], exit["process"]["uid"]); + assert_eq!(exit["exit_code"], 0); + } +} diff --git a/crates/openshell-sandbox/src/provider_files.rs b/crates/openshell-sandbox/src/provider_files.rs index e7687ff379..4db3e93505 100644 --- a/crates/openshell-sandbox/src/provider_files.rs +++ b/crates/openshell-sandbox/src/provider_files.rs @@ -242,7 +242,7 @@ fn comm_target(path: &str, caller_tid: u32, caller_group: u32) -> Option { } /// Thread group (process) ID of a thread, from its procfs status. -fn thread_group_of(tid: u32) -> Option { +pub fn thread_group_of(tid: u32) -> Option { std::fs::read_to_string(format!("/proc/{tid}/status")) .ok()? .lines() @@ -252,7 +252,7 @@ fn thread_group_of(tid: u32) -> Option { .ok() } -fn read_path(tid: u32, mut address: u64) -> io::Result { +pub fn read_path(tid: u32, mut address: u64) -> io::Result { if address == 0 { return Err(io::Error::from_raw_os_error(libc::EFAULT)); } diff --git a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs index 27bca169b2..7cdf91d285 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs @@ -598,6 +598,7 @@ mod tests { filesystem: FilesystemPolicy { read_only, read_write, + audit_paths: Vec::new(), include_workdir: false, }, network: NetworkPolicy::default(), diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 279329c666..f96a37f051 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -16067,6 +16067,7 @@ mod tests { include_workdir: true, read_only: vec!["/usr".into()], read_write: vec!["/tmp".into()], + audit_paths: vec![], }), landlock: Some(LandlockPolicy { compatibility: "best_effort".into(), diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index d84488bcca..3df557775b 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -1102,6 +1102,13 @@ fn validate_filesystem_additive( baseline: Option<&openshell_core::proto::FilesystemPolicy>, new: Option<&openshell_core::proto::FilesystemPolicy>, ) -> Result<(), Status> { + let baseline_audit_paths = baseline.map_or(&[][..], |fs| fs.audit_paths.as_slice()); + let new_audit_paths = new.map_or(&[][..], |fs| fs.audit_paths.as_slice()); + if baseline_audit_paths != new_audit_paths { + return Err(Status::invalid_argument( + "filesystem audit_paths cannot be changed on a live sandbox (bound at startup)", + )); + } match (baseline, new) { (Some(base), Some(upd)) => { if base.include_workdir != upd.include_workdir { @@ -2143,6 +2150,7 @@ mod tests { include_workdir: true, read_only: vec!["/usr".into()], read_write: vec!["/tmp".into()], + audit_paths: vec![], }), process: Some(ProcessPolicy { run_as_user: "root".into(), @@ -2165,6 +2173,7 @@ mod tests { include_workdir: true, read_only: vec!["/usr/../etc/shadow".into()], read_write: vec!["/tmp".into()], + audit_paths: vec![], }), ..Default::default() }; @@ -2183,6 +2192,7 @@ mod tests { include_workdir: true, read_only: vec!["/usr".into()], read_write: vec!["/".into()], + audit_paths: vec![], }), ..Default::default() }; @@ -2320,6 +2330,7 @@ mod tests { include_workdir: true, read_only: vec!["/usr".into()], read_write: vec!["/tmp".into()], + audit_paths: vec![], }), landlock: Some(LandlockPolicy { compatibility: "best_effort".into(), @@ -2409,6 +2420,7 @@ mod tests { filesystem: Some(FilesystemPolicy { read_only: vec!["/usr".into(), "/lib".into(), "/etc".into()], read_write: vec!["/sandbox".into(), "/tmp".into()], + audit_paths: vec![], include_workdir: true, }), ..Default::default() @@ -2439,6 +2451,28 @@ mod tests { assert!(result.unwrap_err().message().contains("include_workdir")); } + #[test] + fn validate_static_fields_rejects_audit_path_changes() { + use openshell_core::proto::FilesystemPolicy; + + let baseline = ProtoSandboxPolicy { + filesystem: Some(FilesystemPolicy { + audit_paths: vec!["/sandbox/**/.env".into()], + ..Default::default() + }), + ..Default::default() + }; + let mut changed = baseline.clone(); + changed.filesystem.as_mut().unwrap().audit_paths = vec!["/sandbox/.ssh/**".into()]; + let error = validate_static_fields_unchanged(&baseline, &changed).unwrap_err(); + assert!(error.message().contains("audit_paths cannot be changed")); + + let mut removed = baseline.clone(); + removed.filesystem.as_mut().unwrap().audit_paths.clear(); + let error = validate_static_fields_unchanged(&baseline, &removed).unwrap_err(); + assert!(error.message().contains("audit_paths cannot be changed")); + } + // ---- Exec validation ---- #[test] diff --git a/crates/openshell-server/src/storage_proto.rs b/crates/openshell-server/src/storage_proto.rs index 08a2141884..63c2eb8989 100644 --- a/crates/openshell-server/src/storage_proto.rs +++ b/crates/openshell-server/src/storage_proto.rs @@ -177,10 +177,13 @@ mod tests { // selections absent; storage.v1 remains frozen. Public field 3 retains its // image wire identity, now with explicit presence. Compute configuration // updates use a separate capability/service and add one classified RPC. + // FilesystemPolicy.audit_paths is an additive repeated field in the shared + // public and durable policy schema. Older stored policies decode with an + // empty watch list and retain their previous filesystem behavior. const PUBLIC_RPC_SCHEMA_SHA256: &str = - "be99bb3fcc7894eb85d774f54b642173e70dd37bc8e5fcaf3fdff2454ae689e1"; + "5b19a0d1a4daad0b12d6090b22058cfcbf562b0047de05d8f0e4d108e98d1594"; const DURABLE_SCHEMA_SHA256: &str = - "cb15f8f79a855064a43fe8c6cd908bf07eca6604e2bf8ab7d2de0ef33e97da4e"; + "7758e21d4d40f76e8b1bb46bbb2ed624956abbe1eb2dbf096fc306611e97306c"; const PUBLIC_DURABLE_OVERLAP_SHA256: &str = "c0c3ea17f105aaa6d843c466fd4240451c2105dce7675384935d4aaecdac8abf"; // Encoded with the schema that omitted stable_placeholder. Keep these @@ -502,6 +505,18 @@ mod tests { } } + #[test] + fn legacy_filesystem_policy_decodes_without_audit_paths() { + let legacy = [ + 8, 1, 18, 4, b'/', b'u', b's', b'r', 26, 4, b'/', b't', b'm', b'p', + ]; + let decoded = openshell_core::proto::FilesystemPolicy::decode(&legacy[..]).unwrap(); + assert!(decoded.include_workdir); + assert_eq!(decoded.read_only, ["/usr"]); + assert_eq!(decoded.read_write, ["/tmp"]); + assert!(decoded.audit_paths.is_empty()); + } + #[test] fn deletion_responses_reserve_legacy_booleans() { let public = FileDescriptorSet::decode(openshell_core::FILE_DESCRIPTOR_SET).unwrap(); diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 2d1a73a949..b038fc57af 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -1403,6 +1403,7 @@ fn parse_filesystem_policy(val: ®orus::Value) -> FilesystemPolicy { .into_iter() .map(PathBuf::from) .collect(), + audit_paths: get_str_array(val, "audit_paths"), include_workdir: get_bool(val, "include_workdir").unwrap_or(true), } } @@ -1525,6 +1526,12 @@ fn validate_opa_static_settings(data: &mut serde_json::Value) -> Result<()> { violations.iter().map(redacted_policy_violation_category), )) })?; + openshell_policy::validate_audit_paths(&settings.audit_paths).map_err(|violations| { + miette::miette!(render_bounded_validation_diagnostics( + "invalid filesystem policy settings", + violations.iter().map(redacted_policy_violation_category), + )) + })?; // A present stanza defaults to false; an absent stanza must stay absent // so Rego's undefined result retains the runtime workdir default. filesystem["include_workdir"] = settings.include_workdir.into(); @@ -1565,6 +1572,8 @@ fn redacted_policy_violation_category(violation: &PolicyViolation) -> &'static s | PolicyViolation::OverlyBroadPath { .. } | PolicyViolation::FieldTooLong { .. } => "invalid filesystem path", PolicyViolation::TooManyPaths { .. } => "filesystem path limit exceeded", + PolicyViolation::TooManyAuditPaths { .. } => "filesystem audit path limit exceeded", + PolicyViolation::InvalidAuditPattern { .. } => "invalid filesystem audit path glob", PolicyViolation::TldWildcard { .. } | PolicyViolation::TcpEndpointIpLiteral { .. } | PolicyViolation::InvalidTcpEndpointHost { .. } @@ -2260,6 +2269,7 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St "include_workdir": true, "read_only": [], "read_write": [], + "audit_paths": [], }) }, |fs| { @@ -2267,6 +2277,7 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St "include_workdir": fs.include_workdir, "read_only": fs.read_only, "read_write": fs.read_write, + "audit_paths": fs.audit_paths, }) }, ); @@ -3362,6 +3373,7 @@ mod tests { include_workdir: true, read_only: vec!["/usr".to_string(), "/lib".to_string()], read_write: vec!["/sandbox".to_string(), "/tmp".to_string()], + audit_paths: vec![], }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -4376,6 +4388,24 @@ process: ); } + #[test] + fn yaml_and_proto_filesystem_audit_paths_reach_the_boundary_config() { + let data = "version: 1\nfilesystem_policy:\n audit_paths: [/sandbox/**/.env]\n"; + let proto = openshell_policy::parse_sandbox_policy(data).unwrap(); + for config in [ + OpaEngine::from_proto(&proto) + .unwrap() + .query_sandbox_config() + .unwrap(), + OpaEngine::from_strings(TEST_POLICY, data) + .unwrap() + .query_sandbox_config() + .unwrap(), + ] { + assert_eq!(config.filesystem.audit_paths, ["/sandbox/**/.env"]); + } + } + #[test] fn yaml_and_proto_landlock_policy_have_compatibility_parity() { for (stanza, hard_requirement) in [ @@ -5724,6 +5754,7 @@ process: include_workdir: true, read_only: vec![], read_write: vec![], + audit_paths: vec![], }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -6347,6 +6378,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + audit_paths: vec![], }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -6418,6 +6450,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + audit_paths: vec![], }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -6494,6 +6527,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + audit_paths: vec![], }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -8237,6 +8271,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + audit_paths: vec![], }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -8294,6 +8329,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + audit_paths: vec![], }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -8352,6 +8388,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + audit_paths: vec![], }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -8412,6 +8449,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + audit_paths: vec![], }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -8471,6 +8509,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + audit_paths: vec![], }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -9982,6 +10021,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + audit_paths: vec![], }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -10052,6 +10092,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + audit_paths: vec![], }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -10282,6 +10323,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + audit_paths: vec![], }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -11930,6 +11972,7 @@ network_policies: include_workdir: true, read_only: vec![], read_write: vec![], + audit_paths: vec![], }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), @@ -12093,6 +12136,7 @@ process: include_workdir: true, read_only: vec![], read_write: vec![], + audit_paths: vec![], }), landlock: Some(openshell_core::proto::LandlockPolicy { compatibility: "best_effort".to_string(), diff --git a/crates/openshell-supervisor/src/backend_setup/tests.rs b/crates/openshell-supervisor/src/backend_setup/tests.rs index bdf4035c61..9d31982d01 100644 --- a/crates/openshell-supervisor/src/backend_setup/tests.rs +++ b/crates/openshell-supervisor/src/backend_setup/tests.rs @@ -223,6 +223,7 @@ fn agent() -> AgentSpec { timeout_secs: 15, interactive: false, otlp_relay_active: false, + process_activity_enabled: false, } } @@ -314,6 +315,7 @@ impl PreparedBackend for TestLaunch { kind: ProcessKindWire::Main, terminal: false, status: None, + process_identity: None, retained_output: OutputWindowWire { first_sequence: 0, next_sequence: 0, @@ -380,6 +382,7 @@ impl PreparedBackend for TestLaunch { process_id: "main".into(), provider_env_revision, provider_env_generation: 1, + process_identity: None, } } Request::Wait { .. } => Response::Exited { diff --git a/crates/openshell-supervisor/src/config_runtime.rs b/crates/openshell-supervisor/src/config_runtime.rs index 8ced34ffec..e2512c2cd8 100644 --- a/crates/openshell-supervisor/src/config_runtime.rs +++ b/crates/openshell-supervisor/src/config_runtime.rs @@ -1526,6 +1526,16 @@ pub fn agent_proposals_enabled_from_settings( .unwrap_or(false) } +pub fn process_activity_enabled_from_settings( + settings: &std::collections::HashMap, +) -> bool { + extract_bool_setting( + settings, + openshell_core::settings::PROCESS_ACTIVITY_ENABLED_KEY, + ) + .unwrap_or(false) +} + fn apply_agent_proposals_enabled( agent_proposals: &AgentProposals, enabled: bool, @@ -1754,6 +1764,17 @@ mod tests { assert!(!agent_proposals_enabled_from_settings(&settings)); } + #[test] + fn process_activity_setting_is_opt_in() { + let mut settings = std::collections::HashMap::new(); + assert!(!process_activity_enabled_from_settings(&settings)); + settings.insert( + openshell_core::settings::PROCESS_ACTIVITY_ENABLED_KEY.to_string(), + effective_bool(true), + ); + assert!(process_activity_enabled_from_settings(&settings)); + } + // ---- Policy disk discovery tests ---- #[derive(Clone)] diff --git a/crates/openshell-supervisor/src/lib.rs b/crates/openshell-supervisor/src/lib.rs index 8b1035beca..8aa2c4a00e 100644 --- a/crates/openshell-supervisor/src/lib.rs +++ b/crates/openshell-supervisor/src/lib.rs @@ -28,7 +28,10 @@ mod provider_readiness; pub mod sandbox_instance_host; pub mod shared_host; -use config_runtime::{agent_proposals_enabled_from_settings, run_policy_poll_loop}; +use config_runtime::{ + agent_proposals_enabled_from_settings, process_activity_enabled_from_settings, + run_policy_poll_loop, +}; use miette::{IntoDiagnostic, Result, WrapErr}; use std::future::Future; use std::io::Write as _; @@ -692,7 +695,7 @@ pub async fn run_network_proxy( } let extension_credentials = openshell_extension_core::ExtensionCredentialStore::new(); - let (mut policy, opa_engine, _, _, _, initial_agent_proposals_enabled, _, _) = load_policy( + let (mut policy, opa_engine, _, _, _, initial_agent_proposals_enabled, _, _, _) = load_policy( None, None, None, @@ -1183,6 +1186,7 @@ pub async fn run_sandbox_instance( middleware_registry_status, loaded_policy_origin, initial_agent_proposals_enabled, + initial_process_activity_enabled, initial_extension_authentication_enabled, captured_provider_environment, ) = load_policy_with_gateway( @@ -1320,6 +1324,7 @@ pub async fn run_sandbox_instance( timeout_secs, interactive, otlp_relay_active: otlp_relay.is_some(), + process_activity_enabled: initial_process_activity_enabled, }, ) .instrument(tracing::info_span!(parent: &startup, "supervisor.boundary.attach")) @@ -2321,6 +2326,7 @@ mod baseline_tests { policy.filesystem = Some(openshell_core::proto::FilesystemPolicy { read_only: vec!["/tmp".to_string(), "/proc".to_string()], read_write: vec![], + audit_paths: vec![], include_workdir: false, }); policy.network_policies.insert( @@ -2464,6 +2470,7 @@ mod baseline_tests { filesystem: FilesystemPolicy { read_only: vec![PathBuf::from("/tmp")], read_write: vec![], + audit_paths: vec![], include_workdir: false, }, network: NetworkPolicy { @@ -2749,6 +2756,7 @@ async fn load_policy( LoadedPolicyOrigin, bool, bool, + bool, Option, )> { load_policy_with_gateway( @@ -2791,6 +2799,7 @@ async fn load_policy_with_gateway( LoadedPolicyOrigin, bool, bool, + bool, Option, )> { use openshell_core::proto::ConfigurationAdmissionState; @@ -2857,6 +2866,7 @@ async fn load_policy_with_gateway( LoadedPolicyOrigin::LocalOverride, false, false, + false, None, )); } @@ -3227,6 +3237,7 @@ async fn load_policy_with_gateway( has_last_valid_policy, }, agent_proposals_enabled_from_settings(&snapshot.settings), + process_activity_enabled_from_settings(&snapshot.settings), snapshot.extension_authentication_enabled, Some(CapturedProviderEnvironment::new( captured_provider_credentials, @@ -5558,7 +5569,7 @@ network_policies: .unwrap() .expect("repair returns one launch bundle"); assert!( - bundle.7.is_some(), + bundle.8.is_some(), "launch bundle retains matching provider state" ); } @@ -6786,6 +6797,7 @@ network_policies: registry, origin, proposals, + process_activity_enabled, extension_authentication_enabled, provider_credentials, ) = startup().await.expect("load valid local policy"); @@ -6793,7 +6805,7 @@ network_policies: assert!(proto.is_none()); assert!(matches!(origin, LoadedPolicyOrigin::LocalOverride)); assert!(matches!(registry, MiddlewareRegistryStatus::Synchronized)); - assert!(!proposals && !extension_authentication_enabled); + assert!(!proposals && !extension_authentication_enabled && !process_activity_enabled); let engine = engine.expect("local startup installs OPA"); assert!(engine.binary_identity_required()); // Installing the built-in registry creates the first active generation. diff --git a/docs/how-it-works/policies/manage-policies.mdx b/docs/how-it-works/policies/manage-policies.mdx index c20af7b67b..f766fe7a4c 100644 --- a/docs/how-it-works/policies/manage-policies.mdx +++ b/docs/how-it-works/policies/manage-policies.mdx @@ -192,6 +192,7 @@ running sandbox. The startup sections do not: | Middleware in `network_middlewares` | You can add, remove, or reconfigure built-in middleware and external services already registered with the gateway. | Apply the change with `openshell policy set`. | | External middleware registration | Policy changes cannot register a service or change its gateway connection settings. | Update the gateway configuration and restart the gateway. | | Added filesystem paths | The saved policy can change, but the running workload keeps its existing filesystem permissions. | Recreate the sandbox. | +| Changed `audit_paths` | OpenShell rejects changes to the watch list after the workload starts. | Recreate the sandbox. | | Removed filesystem paths, or changed `include_workdir`, Landlock, or process settings | OpenShell rejects the change after the workload starts. | Recreate the sandbox. | When new network rules take effect, OpenShell closes connections that were diff --git a/docs/how-it-works/policies/schema.mdx b/docs/how-it-works/policies/schema.mdx index 693efb209c..802de9a580 100644 --- a/docs/how-it-works/policies/schema.mdx +++ b/docs/how-it-works/policies/schema.mdx @@ -43,6 +43,7 @@ Effect](/how-it-works/policies/manage-policies#how-changes-take-effect). | `include_workdir` | bool | See below | Adds the sandbox's working directory to `read_write`. | | `read_only` | list of strings | `[]` | Paths the workload can read. | | `read_write` | list of strings | `[]` | Paths the workload can read and write. | +| `audit_paths` | list of path globs | `[]` | Report matching workload open attempts. | When `filesystem_policy` is omitted, `include_workdir` is `true`. When `filesystem_policy` is present, `include_workdir` defaults to `false`. Paths @@ -55,11 +56,34 @@ These defaults and the filesystem path restrictions below also apply when the su Each path must be absolute, must not contain `..`, and must not exceed 4096 bytes. `read_write` cannot contain `/`. A policy can list at most 256 paths. +Set `audit_paths` to observe attempted opens of selected paths. Patterns must +be absolute, contain no `..`, and be valid globs of at most 4096 bytes. A +policy can list at most 64 audit patterns. `*` matches within one directory; +`**` matches across directories. +OpenShell compares each pattern with the path text supplied to `open`, `openat`, +or `openat2`; it does not resolve relative paths, symlinks, or directory file +descriptors for this match. The field takes effect at sandbox startup and +does not grant filesystem access. The MXC driver rejects nonempty +`audit_paths` because it cannot observe these Linux boundary events. + +Matching opens produce OCSF File System Activity (`class_uid: 1001`) with +`action: Observed`, the requested path, access type, and actor process. Observed +means the workload attempted the open; it does not claim that the kernel later +allowed or denied it. Another workload thread can change the path argument +before the kernel uses it, so these events are not a tamper resistant record of +the file actually opened. OpenShell reports at most 100 matching opens per second +per sandbox. For a second in which it suppresses matches, it emits a Base Event +with `unmapped.dropped_count` and `unmapped.reason: rate_limit`. Empty +`audit_paths` produces no file activity events. + ```yaml showLineNumbers={false} filesystem_policy: include_workdir: true read_only: [/usr, /lib, /etc] read_write: [/tmp] + audit_paths: + - /sandbox/.ssh/** + - /sandbox/**/.env ``` ## Landlock diff --git a/docs/how-it-works/sandboxes/overview.mdx b/docs/how-it-works/sandboxes/overview.mdx index 2ac6a4f69d..94154d613e 100644 --- a/docs/how-it-works/sandboxes/overview.mdx +++ b/docs/how-it-works/sandboxes/overview.mdx @@ -206,6 +206,11 @@ openshell sandbox create --from localhost/my-image:latest For a remote gateway, push the image to a registry that the gateway can pull from and use that registry image reference. +If the image starts code outside its workspace, include the code directory in +`filesystem_policy.read_only` before creating the sandbox. See [Sandbox User +Identity](/how-it-works/sandboxes/runtimes#sandbox-user-identity) for an +`/app/agent.py` example and how Docker and Podman use an image's `WORKDIR`. + **Pre-0.1.0 breaking change:** `openshell sandbox create --from ./Dockerfile` and directory sources no longer build images. Bare catalog names are no longer diff --git a/docs/how-it-works/sandboxes/runtimes.mdx b/docs/how-it-works/sandboxes/runtimes.mdx index 70f6891ce1..56a99a2713 100644 --- a/docs/how-it-works/sandboxes/runtimes.mdx +++ b/docs/how-it-works/sandboxes/runtimes.mdx @@ -370,4 +370,11 @@ MicroVM persists the resolved UID:GID with the writable overlay and retains it a On Docker and Podman, the image's `WORKDIR` becomes the workspace. Images with no `WORKDIR`, `/`, or `/sandbox` use `/sandbox`. Any other `WORKDIR` must exist in the image and be writable by the sandbox user. Kubernetes and MicroVM always use `/sandbox`. +If an image keeps its program outside the workspace, add that directory to +`filesystem_policy.read_only` in the sandbox policy. For example, an image with +`/app/agent.py` and workspace `/sandbox` needs `/app` in `read_only`; otherwise +Landlock can return `Permission denied` when the sandbox starts the program. +For a Docker or Podman image whose `WORKDIR` is `/app`, `include_workdir: true` +also covers `/app`, but makes it writable by the workload. + Custom Docker and Podman workspaces use the workload container's writable layer, not a managed named volume. Files survive stop/start of the same container, but removing or replacing that container removes its workspace changes. Export files you need to retain before deleting the sandbox. diff --git a/docs/observability/accessing-logs.mdx b/docs/observability/accessing-logs.mdx index 9e19c637bf..12eae20ee5 100644 --- a/docs/observability/accessing-logs.mdx +++ b/docs/observability/accessing-logs.mdx @@ -43,7 +43,25 @@ docker logs podman logs ``` -The sandbox runtime also emits OCSF shorthand events, including process-launch and Landlock filesystem events, into these container logs when its diagnostic log filter permits them. These runtime-originated events are not pushed to the gateway log stream or included in the supervisor's JSONL file. Collect workload container logs separately from supervisor logs to retain both sources. +The sandbox runtime also emits OCSF shorthand events, including process-launch +and Landlock filesystem events, into these container logs when its diagnostic +log filter permits them. Runtime-originated events are not pushed to the gateway +log stream or included in the supervisor's JSONL file. When +`process_activity_enabled` is on, the runtime sends directly started process +identities to the supervisor, which emits separate launch and exit records into +those supervisor paths. Collect workload container logs separately from +supervisor logs to retain both sources. + +With `filesystem_policy.audit_paths` configured, matching open attempts appear +here as `FILE:OPEN` events. `OBSERVED` means the runtime saw the syscall request; +the event does not report whether Landlock or the filesystem allowed it. +The path is workload-supplied text, not a resolved path. A rate-limit summary +appears as a Base Event with `unmapped.dropped_count` when matching events are +suppressed. + +```text +FILE:OPEN [INFO] OBSERVED python3(42) /sandbox/project/.env [access:read path:requested] +``` A main process started with a TTY writes only to its attachment; use `openshell sandbox connect` to view that output. Output from `sandbox exec` and SSH sessions does not reach the container log, and `openshell logs` does not include main process output. diff --git a/docs/observability/logging.mdx b/docs/observability/logging.mdx index d7f068dca9..3c8c04ba85 100644 --- a/docs/observability/logging.mdx +++ b/docs/observability/logging.mdx @@ -198,6 +198,41 @@ A process launched inside the sandbox: OCSF PROC:LAUNCH [INFO] sleep(49) ``` +### Boundary process events + +To record launch and exit events for the entrypoint and processes started by +`sandbox exec` or SSH, enable the setting before starting a sandbox: + +```shell +openshell settings set --global --key process_activity_enabled --value true +``` + +The setting defaults to `false` and is captured at sandbox startup. When it is +off, the existing entrypoint launch event remains. When it is on, the sandbox +runtime captures each directly started process and sends its identity to the +supervisor. The supervisor emits one launch and one termination event to the +gateway stream and, when enabled, its OCSF JSONL output. The runtime also +emits these events to its own container log. This covers piped and PTY sessions. +The events never include argv or command lines. Each source emits at most two +additional records per process the runtime directly starts. + +```text +OCSF PROC:LAUNCH [INFO] /bin/sh(57) [ppid:1] [start:1793620000120] [process_uid:57:123456] [parent_uid:1:34] +OCSF PROC:TERMINATE [INFO] /bin/sh(57) [ppid:1] [start:1793620000120] [process_uid:57:123456] [parent_uid:1:34] [exit:0] +``` + +`ppid` is the PID namespace parent. `start` is the process start time in Unix +milliseconds. `process_uid` and `parent_uid` identify process instances as +`pid:start_ticks`, where `start_ticks` is field 22 of `/proc//stat`. +Signal exits carry `[signal:N]` in place of `[exit:N]`. Forked descendants and +an executable that replaces itself are outside this setting's current event +coverage. The log stream can drop records under pressure, so these events do +not prove that a process tree is complete. + +Use `openshell logs --source sandbox` to read the supervisor records +through the gateway. See [Accessing Logs](/observability/accessing-logs) for +stream retention and loss behavior. + A policy reload after a settings change: ```text diff --git a/docs/observability/ocsf-json-export.mdx b/docs/observability/ocsf-json-export.mdx index f6c5d306c8..38e04ff0f4 100644 --- a/docs/observability/ocsf-json-export.mdx +++ b/docs/observability/ocsf-json-export.mdx @@ -287,6 +287,7 @@ The `class_uid` field identifies the event type: | `class_uid` | Class | Shorthand prefix | |---|---|---| | 0 | Base Event | `EVENT` | +| 1001 | File System Activity | `FILE:` | | 4001 | Network Activity | `NET:` | | 4002 | HTTP Activity | `HTTP:` | | 4007 | SSH Activity | `SSH:` | @@ -297,6 +298,11 @@ The `class_uid` field identifies the event type: | 6002 | Application Lifecycle | `LIFECYCLE:` | | 6003 | API Activity | `API:` | +The built-in sandbox runtime emits watched-path File System Activity as +shorthand in workload container logs. These runtime events do not enter the +supervisor JSONL file or gateway log stream. See [Main Process +Output](/observability/accessing-logs#main-process-output) for collection. + HTTP Activity records include request or response details. Rejections that have parsed a request, including early credential-binding failures, include safe method-only request context and the generated response. diff --git a/examples/governance-interceptor/Cargo.lock b/examples/governance-interceptor/Cargo.lock index fa08aa3aba..7fb332116d 100644 --- a/examples/governance-interceptor/Cargo.lock +++ b/examples/governance-interceptor/Cargo.lock @@ -306,6 +306,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -907,6 +917,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98b0d85522dc0a824cfebbc9594ff4a9b289c568bea0370319dfbdf20ccdd2d" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -1938,6 +1961,7 @@ dependencies = [ name = "openshell-policy" version = "0.0.0" dependencies = [ + "globset", "hickory-proto", "miette", "openshell-core", diff --git a/examples/supervisor-middleware-content-guard/Cargo.lock b/examples/supervisor-middleware-content-guard/Cargo.lock index f453c06919..77ebea0a36 100644 --- a/examples/supervisor-middleware-content-guard/Cargo.lock +++ b/examples/supervisor-middleware-content-guard/Cargo.lock @@ -356,6 +356,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -1003,6 +1013,19 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "globset" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98b0d85522dc0a824cfebbc9594ff4a9b289c568bea0370319dfbdf20ccdd2d" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -2073,6 +2096,7 @@ dependencies = [ name = "openshell-policy" version = "0.0.0" dependencies = [ + "globset", "hickory-proto", "miette", "openshell-core", diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 80344bd4a1..70a5137013 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -43,6 +43,8 @@ message FilesystemPolicy { repeated string read_only = 2; // Read-write directory allow list. repeated string read_write = 3; + // Path globs whose attempted opens are reported by the sandbox boundary. + repeated string audit_paths = 4; } // Landlock policy configuration. diff --git a/proto/sandbox_protocol.proto b/proto/sandbox_protocol.proto index 91fb882f3c..9540f718ba 100644 --- a/proto/sandbox_protocol.proto +++ b/proto/sandbox_protocol.proto @@ -205,6 +205,7 @@ message Started { string process_id = 1; uint64 provider_env_revision = 2; uint64 provider_env_generation = 3; + ProcessIdentity process_identity = 4; } message ProviderEnvironmentUpdated { uint64 revision = 1; @@ -223,6 +224,7 @@ message BoundaryTerminated {} message ExecStarted { string process_id = 1; bool pty = 2; + ProcessIdentity process_identity = 3; } message Resized {} message LoopbackConnected {} @@ -275,6 +277,8 @@ message AgentSpec { // point workload SDKs at the relay by default. False keeps the workload // environment unchanged. bool otlp_relay_active = 6; + // Opt in to launch and exit events for boundary-started workload processes. + bool process_activity_enabled = 7; } message ExecSpec { string program = 1; @@ -310,6 +314,8 @@ message SandboxPolicy { LandlockCompatibility landlock = 7; optional string run_as_user = 8; optional string run_as_group = 9; + // Path globs whose attempted opens are reported by the sandbox boundary. + repeated string audit_paths = 10; } enum NetworkMode { NETWORK_MODE_UNSPECIFIED = 0; @@ -360,6 +366,17 @@ message ProcessSnapshot { bool terminal = 3; ExitStatus status = 4; OutputWindow retained_output = 5; + ProcessIdentity process_identity = 6; +} +// Runtime-captured identity; no argv, environment, or full command line. +message ProcessIdentity { + string name = 1; + uint32 pid = 2; + optional string uid = 3; + google.protobuf.Timestamp created_time = 4; + optional uint32 parent_pid = 5; + optional string parent_uid = 6; + google.protobuf.Timestamp parent_created_time = 7; } enum ProcessKind { PROCESS_KIND_UNSPECIFIED = 0; diff --git a/sdk/go/openshell/v1/doc.go b/sdk/go/openshell/v1/doc.go index a086bbbbe5..cd8492125d 100644 --- a/sdk/go/openshell/v1/doc.go +++ b/sdk/go/openshell/v1/doc.go @@ -306,6 +306,7 @@ // Filesystem: &v1.FilesystemPolicy{ // IncludeWorkdir: true, // ReadOnly: []string{"/usr", "/lib"}, +// AuditPaths: []string{"/sandbox/**/.env"}, // }, // Process: &v1.ProcessPolicy{ // RunAsUser: "sandbox", diff --git a/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go index 221e1f7524..fc40cb80e8 100644 --- a/sdk/go/openshell/v1/fake/sandbox.go +++ b/sdk/go/openshell/v1/fake/sandbox.go @@ -69,6 +69,7 @@ func copySandboxPolicy(p *types.SandboxPolicy) *types.SandboxPolicy { fs := *p.Filesystem fs.ReadOnly = copyStringSlice(p.Filesystem.ReadOnly) fs.ReadWrite = copyStringSlice(p.Filesystem.ReadWrite) + fs.AuditPaths = copyStringSlice(p.Filesystem.AuditPaths) cp.Filesystem = &fs } if p.Landlock != nil { diff --git a/sdk/go/openshell/v1/fake/sandbox_test.go b/sdk/go/openshell/v1/fake/sandbox_test.go index 3f4419ba2f..f37131cf4f 100644 --- a/sdk/go/openshell/v1/fake/sandbox_test.go +++ b/sdk/go/openshell/v1/fake/sandbox_test.go @@ -869,6 +869,7 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { IncludeWorkdir: true, ReadOnly: []string{"/etc", "/usr/share"}, ReadWrite: []string{"/tmp"}, + AuditPaths: []string{"/sandbox/**/.env"}, }, Landlock: &types.LandlockPolicy{ Compatibility: "best_effort", @@ -907,6 +908,7 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { assert.True(t, p.Filesystem.IncludeWorkdir) assert.Equal(t, []string{"/etc", "/usr/share"}, p.Filesystem.ReadOnly) assert.Equal(t, []string{"/tmp"}, p.Filesystem.ReadWrite) + assert.Equal(t, []string{"/sandbox/**/.env"}, p.Filesystem.AuditPaths) require.NotNil(t, p.Landlock) assert.Equal(t, "best_effort", p.Landlock.Compatibility) @@ -926,19 +928,23 @@ func TestFakeSandboxCreateWithPolicy(t *testing.T) { // Deep-copy isolation: mutate input spec, verify stored copy unchanged spec.Policy.Version = 99 spec.Policy.Filesystem.ReadOnly[0] = "mutated" + spec.Policy.Filesystem.AuditPaths[0] = "mutated" spec.Policy.NetworkPolicies["web"] = types.NetworkPolicyRule{Name: "mutated"} got2, err := sc.Get(ctx, "default", "policy-sb") require.NoError(t, err) assert.Equal(t, uint32(3), got2.Spec.Policy.Version) assert.Equal(t, "/etc", got2.Spec.Policy.Filesystem.ReadOnly[0]) + assert.Equal(t, "/sandbox/**/.env", got2.Spec.Policy.Filesystem.AuditPaths[0]) assert.Equal(t, "web", got2.Spec.Policy.NetworkPolicies["web"].Name) // Deep-copy isolation: mutate returned object, verify store unchanged got.Spec.Policy.Filesystem.ReadWrite[0] = "mutated" + got.Spec.Policy.Filesystem.AuditPaths[0] = "mutated" got3, err := sc.Get(ctx, "default", "policy-sb") require.NoError(t, err) assert.Equal(t, "/tmp", got3.Spec.Policy.Filesystem.ReadWrite[0]) + assert.Equal(t, "/sandbox/**/.env", got3.Spec.Policy.Filesystem.AuditPaths[0]) } func TestFakeSandboxCreateWithNilPolicy(t *testing.T) { diff --git a/sdk/go/openshell/v1/internal/converter/policy.go b/sdk/go/openshell/v1/internal/converter/policy.go index 1a473654e9..0d3ec045c2 100644 --- a/sdk/go/openshell/v1/internal/converter/policy.go +++ b/sdk/go/openshell/v1/internal/converter/policy.go @@ -235,6 +235,7 @@ func filesystemPolicyFromProto(f *sbv1.FilesystemPolicy) *types.FilesystemPolicy IncludeWorkdir: f.GetIncludeWorkdir(), ReadOnly: CopyStringSlice(f.GetReadOnly()), ReadWrite: CopyStringSlice(f.GetReadWrite()), + AuditPaths: CopyStringSlice(f.GetAuditPaths()), } } @@ -246,6 +247,7 @@ func filesystemPolicyToProto(f *types.FilesystemPolicy) *sbv1.FilesystemPolicy { IncludeWorkdir: f.IncludeWorkdir, ReadOnly: CopyStringSlice(f.ReadOnly), ReadWrite: CopyStringSlice(f.ReadWrite), + AuditPaths: CopyStringSlice(f.AuditPaths), } } diff --git a/sdk/go/openshell/v1/internal/converter/policy_test.go b/sdk/go/openshell/v1/internal/converter/policy_test.go index dde2205773..6d0ba38384 100644 --- a/sdk/go/openshell/v1/internal/converter/policy_test.go +++ b/sdk/go/openshell/v1/internal/converter/policy_test.go @@ -192,6 +192,7 @@ func TestSandboxPolicyRoundTrip(t *testing.T) { IncludeWorkdir: true, ReadOnly: []string{"/etc", "/usr/share"}, ReadWrite: []string{"/tmp", "/workspace"}, + AuditPaths: []string{"/sandbox/**/.env"}, }, Landlock: &v1.LandlockPolicy{ Compatibility: "best_effort", @@ -229,6 +230,7 @@ func TestSandboxPolicyRoundTrip(t *testing.T) { assert.Equal(t, original.Filesystem.IncludeWorkdir, roundTrip.Filesystem.IncludeWorkdir) assert.Equal(t, original.Filesystem.ReadOnly, roundTrip.Filesystem.ReadOnly) assert.Equal(t, original.Filesystem.ReadWrite, roundTrip.Filesystem.ReadWrite) + assert.Equal(t, original.Filesystem.AuditPaths, roundTrip.Filesystem.AuditPaths) // Landlock require.NotNil(t, roundTrip.Landlock) @@ -260,6 +262,7 @@ func TestSandboxPolicyDeepCopy(t *testing.T) { IncludeWorkdir: true, ReadOnly: []string{"/original"}, ReadWrite: []string{"/tmp"}, + AuditPaths: []string{"/sandbox/**/.env"}, }, NetworkPolicies: map[string]*sbv1.NetworkPolicyRule{ "rule1": { @@ -278,6 +281,7 @@ func TestSandboxPolicyDeepCopy(t *testing.T) { proto.Version = 99 proto.Filesystem.ReadOnly[0] = "mutated" proto.Filesystem.ReadWrite[0] = "mutated" + proto.Filesystem.AuditPaths[0] = "mutated" proto.NetworkPolicies["rule1"].Name = "mutated" proto.NetworkPolicies["rule1"].Endpoints[0].Host = "mutated.host" @@ -285,6 +289,7 @@ func TestSandboxPolicyDeepCopy(t *testing.T) { assert.Equal(t, uint32(1), sdk.Version) assert.Equal(t, "/original", sdk.Filesystem.ReadOnly[0]) assert.Equal(t, "/tmp", sdk.Filesystem.ReadWrite[0]) + assert.Equal(t, "/sandbox/**/.env", sdk.Filesystem.AuditPaths[0]) assert.Equal(t, "rule1", sdk.NetworkPolicies["rule1"].Name) assert.Equal(t, "original.host", sdk.NetworkPolicies["rule1"].Endpoints[0].Host) @@ -294,9 +299,11 @@ func TestSandboxPolicyDeepCopy(t *testing.T) { // Mutate SDK after ToProto conversion. sdk.Filesystem.ReadOnly[0] = "sdk-mutated" + sdk.Filesystem.AuditPaths[0] = "sdk-mutated" // Proto output must be unaffected. assert.Equal(t, "/original", protoOut.Filesystem.ReadOnly[0]) + assert.Equal(t, "/sandbox/**/.env", protoOut.Filesystem.AuditPaths[0]) } func TestSandboxPolicyPartialSubPolicies(t *testing.T) { @@ -376,6 +383,7 @@ func TestFilesystemPolicyRoundTrip(t *testing.T) { IncludeWorkdir: true, ReadOnly: []string{"/etc", "/usr/lib"}, ReadWrite: []string{"/tmp", "/var/run"}, + AuditPaths: []string{"/sandbox/**/.env"}, } proto := filesystemPolicyToProto(original) @@ -387,6 +395,7 @@ func TestFilesystemPolicyRoundTrip(t *testing.T) { assert.Equal(t, original.IncludeWorkdir, roundTrip.IncludeWorkdir) assert.Equal(t, original.ReadOnly, roundTrip.ReadOnly) assert.Equal(t, original.ReadWrite, roundTrip.ReadWrite) + assert.Equal(t, original.AuditPaths, roundTrip.AuditPaths) } func TestFilesystemPolicyNil(t *testing.T) { diff --git a/sdk/go/openshell/v1/types/policy.go b/sdk/go/openshell/v1/types/policy.go index 8fb0712b60..9c90d34955 100644 --- a/sdk/go/openshell/v1/types/policy.go +++ b/sdk/go/openshell/v1/types/policy.go @@ -151,8 +151,7 @@ type MiddlewareEndpointSelector struct { Exclude []string } -// FilesystemPolicy controls which directories the sandbox can access -// in read-only or read-write mode. +// FilesystemPolicy controls directory access and optional watched file opens. type FilesystemPolicy struct { // IncludeWorkdir auto-includes the working directory as read-write. IncludeWorkdir bool @@ -162,6 +161,9 @@ type FilesystemPolicy struct { // ReadWrite is the list of read-write directory paths. // Nil means no read-write directories; an empty slice is distinct from nil. ReadWrite []string + // AuditPaths contains absolute globs for observed file open attempts. + // Nil disables file activity events; an empty slice is distinct from nil. + AuditPaths []string } // LandlockPolicy configures the Linux Landlock LSM for filesystem restriction enforcement. diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 4b09b2016c..5aab5c316d 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -510,7 +510,9 @@ type FilesystemPolicy struct { // Read-only directory allow list. ReadOnly []string `protobuf:"bytes,2,rep,name=read_only,json=readOnly,proto3" json:"read_only,omitempty"` // Read-write directory allow list. - ReadWrite []string `protobuf:"bytes,3,rep,name=read_write,json=readWrite,proto3" json:"read_write,omitempty"` + ReadWrite []string `protobuf:"bytes,3,rep,name=read_write,json=readWrite,proto3" json:"read_write,omitempty"` + // Path globs whose attempted opens are reported by the sandbox boundary. + AuditPaths []string `protobuf:"bytes,4,rep,name=audit_paths,json=auditPaths,proto3" json:"audit_paths,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -566,6 +568,13 @@ func (x *FilesystemPolicy) GetReadWrite() []string { return nil } +func (x *FilesystemPolicy) GetAuditPaths() []string { + if x != nil { + return x.AuditPaths + } + return nil +} + // Landlock policy configuration. type LandlockPolicy struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2620,12 +2629,14 @@ const file_sandbox_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\v2'.openshell.sandbox.v1.NetworkPolicyRuleR\x05value:\x028\x01\x1at\n" + "\x17NetworkMiddlewaresEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12C\n" + - "\x05value\x18\x02 \x01(\v2-.openshell.sandbox.v1.NetworkMiddlewareConfigR\x05value:\x028\x01\"w\n" + + "\x05value\x18\x02 \x01(\v2-.openshell.sandbox.v1.NetworkMiddlewareConfigR\x05value:\x028\x01\"\x98\x01\n" + "\x10FilesystemPolicy\x12'\n" + "\x0finclude_workdir\x18\x01 \x01(\bR\x0eincludeWorkdir\x12\x1b\n" + "\tread_only\x18\x02 \x03(\tR\breadOnly\x12\x1d\n" + "\n" + - "read_write\x18\x03 \x03(\tR\treadWrite\"6\n" + + "read_write\x18\x03 \x03(\tR\treadWrite\x12\x1f\n" + + "\vaudit_paths\x18\x04 \x03(\tR\n" + + "auditPaths\"6\n" + "\x0eLandlockPolicy\x12$\n" + "\rcompatibility\x18\x01 \x01(\tR\rcompatibility\"Q\n" + "\rProcessPolicy\x12\x1e\n" + diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 351d7e3b84..266326d9f5 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -979,6 +979,8 @@ openshell settings set --global --key ocsf_json_enabled --value true Enabling `ocsf_json_enabled` adds compact `OCSF-JSON` records to the supervisor's stderr, alongside shorthand, and to its JSONL file when file storage is available. Collect these records through the supervisor's platform logs (`docker logs`, `podman logs`, or `kubectl logs`); `openshell logs` still serves shorthand. Match the `OCSF-JSON` marker after the timestamp and parse the remainder as JSON. See the published [OCSF JSON export guide](https://docs.nvidia.com/openshell/latest/observability/ocsf-json-export) for delivery and retention limits. +To opt in to entrypoint and exec or SSH process lifecycle events for newly started sandboxes, set `process_activity_enabled` to `true` globally before creating them. The setting is captured at startup. See [Sandbox Logging](https://docs.nvidia.com/openshell/latest/observability/logging) for the event shape and coverage. + Global mutations prompt for confirmation. Use `--yes` only in reviewed automation. `policy_validation_failure_mode` is gateway startup configuration, not a diff --git a/tests/suites/conformance/sandbox/tests/policy_advisor.rs b/tests/suites/conformance/sandbox/tests/policy_advisor.rs index 003917163c..68f5504cfe 100644 --- a/tests/suites/conformance/sandbox/tests/policy_advisor.rs +++ b/tests/suites/conformance/sandbox/tests/policy_advisor.rs @@ -252,6 +252,7 @@ fn no_network_policy() -> proto::SandboxPolicy { "/var/log", ]), read_write: paths(&["/sandbox", "/tmp", "/dev/null"]), + audit_paths: Vec::new(), }), landlock: Some(proto::LandlockPolicy { compatibility: "best_effort".to_string(),