diff --git a/crates/openshell-ocsf/schemas/ocsf/README.md b/crates/openshell-ocsf/schemas/ocsf/README.md index b15b6511c6..401ebc3392 100644 --- a/crates/openshell-ocsf/schemas/ocsf/README.md +++ b/crates/openshell-ocsf/schemas/ocsf/README.md @@ -9,7 +9,7 @@ for offline test validation. ## Contents -### Classes (8) +### Classes (10) - `network_activity` [4001] - `http_activity` [4002] @@ -18,14 +18,16 @@ for offline test validation. - `detection_finding` [2004] - `application_lifecycle` [6002] - `device_config_state_change` [5019] +- `api_activity` [6003] +- `authentication` [3002] - `base_event` [0] -### Objects (17) +### Objects (21) - `metadata`, `network_endpoint`, `network_proxy`, `process`, `actor` - `device`, `container`, `product`, `firewall_rule`, `finding_info` - `evidences`, `http_request`, `http_response`, `url`, `attack` -- `remediation`, `connection_info`, `ai_model` +- `remediation`, `connection_info`, `ai_model`, `api`, `user`, `service` ### Profiles (1) @@ -39,14 +41,16 @@ To update to a new OCSF version: VERSION=1.8.0 for class in network_activity http_activity ssh_activity process_activity \ - detection_finding application_lifecycle device_config_state_change base_event; do + detection_finding application_lifecycle device_config_state_change \ + api_activity authentication base_event; do curl -s "https://schema.ocsf.io/api/${VERSION}/classes/${class}" \ | python3 -m json.tool > "classes/${class}.json" done for object in metadata network_endpoint network_proxy process actor device \ container product firewall_rule finding_info evidences \ - http_request http_response url attack remediation connection_info; do + http_request http_response url attack remediation connection_info \ + ai_model api user service; do curl -s "https://schema.ocsf.io/api/${VERSION}/objects/${object}" \ | python3 -m json.tool > "objects/${object}.json" done diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/authentication.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/authentication.json new file mode 100644 index 0000000000..afd15156f7 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/classes/authentication.json @@ -0,0 +1,1271 @@ +{ + "attributes": { + "class_name": { + "type": "string_t", + "description": "The event class name, as defined by class_uid value: Authentication.", + "group": "classification", + "requirement": "optional", + "caption": "Class", + "type_name": "String" + }, + "raw_data": { + "type": "string_t", + "description": "The raw event/finding data as received from the source.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data", + "type_name": "String" + }, + "status_code": { + "type": "string_t", + "description": "The event status code, as reported by the event source.

For example, in a Windows Failed Authentication event, this would be the value of 'Failure Code', e.g. 0x18.", + "group": "primary", + "requirement": "recommended", + "caption": "Status Code", + "type_name": "String" + }, + "is_cleartext": { + "type": "boolean_t", + "description": "Indicates whether the credentials were passed in clear text.

Note: True if the credentials were passed in a clear text protocol such as FTP or TELNET, or if Windows detected that a user's logon password was passed to the authentication package in clear text.

", + "group": "context", + "requirement": "optional", + "caption": "Cleartext Credentials", + "type_name": "Boolean" + }, + "risk_score": { + "type": "integer_t", + "description": "The risk score as reported by the event source.", + "group": "context", + "requirement": "optional", + "profiles": [ + "security_control" + ], + "caption": "Risk Score", + "type_name": "Integer" + }, + "is_remote": { + "type": "boolean_t", + "description": "The attempted authentication is over a remote connection.", + "group": "primary", + "requirement": "recommended", + "caption": "Remote", + "type_name": "Boolean" + }, + "time": { + "type": "timestamp_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "required", + "caption": "Event Time", + "type_name": "Timestamp" + }, + "severity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "Action is required but the situation is not serious at this time.", + "caption": "Medium" + }, + "6": { + "description": "An error occurred but it is too late to take remedial action.", + "caption": "Fatal" + }, + "0": { + "description": "The event/finding severity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Informational message. No action required.", + "caption": "Informational" + }, + "2": { + "description": "The user decides if action is needed.", + "caption": "Low" + }, + "99": { + "description": "The event/finding severity is not mapped. See the severity attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Action is required immediately.", + "caption": "High" + }, + "5": { + "description": "Action is required immediately and the scope is broad.", + "caption": "Critical" + } + }, + "description": "

The normalized identifier of the event/finding severity.

The normalized severity is a measurement the effort and expense required to manage and resolve an event or incident. Smaller numerical values represent lower impact events, and larger numerical values represent higher impact events.", + "group": "classification", + "requirement": "required", + "sibling": "severity", + "caption": "Severity ID", + "type_name": "Integer" + }, + "observables": { + "type": "object_t", + "description": "The observables associated with the event or a finding.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "OCSF Observables FAQ", + "url": "https://github.com/ocsf/ocsf-docs/blob/main/articles/defining-and-using-observables.md" + } + ], + "requirement": "recommended", + "object_type": "observable", + "object_name": "Observable", + "caption": "Observables" + }, + "http_response": { + "type": "object_t", + "description": "Details about the underlying HTTP response.", + "group": "context", + "requirement": "optional", + "object_type": "http_response", + "object_name": "HTTP Response", + "caption": "HTTP Response" + }, + "user": { + "type": "object_t", + "description": "The subject (user/role or account) to authenticate.", + "group": "primary", + "requirement": "required", + "object_type": "user", + "object_name": "User", + "caption": "User" + }, + "enrichments": { + "type": "object_t", + "description": "The additional information from an external data source, which is associated with the event or a finding. For example add location information for the IP address in the DNS answers:

[{\"name\": \"answers.ip\", \"value\": \"92.24.47.250\", \"type\": \"location\", \"data\": {\"city\": \"Socotra\", \"continent\": \"Asia\", \"coordinates\": [-25.4153, 17.0743], \"country\": \"YE\", \"desc\": \"Yemen\"}}]", + "group": "context", + "is_array": true, + "requirement": "optional", + "object_type": "enrichment", + "object_name": "Enrichment", + "caption": "Enrichments" + }, + "severity": { + "type": "string_t", + "description": "The event/finding severity, normalized to the caption of the severity_id value. In the case of 'Other', it is defined by the source.", + "group": "classification", + "requirement": "optional", + "caption": "Severity", + "type_name": "String" + }, + "category_name": { + "type": "string_t", + "description": "The event category name, as defined by category_uid value: Identity & Access Management.", + "group": "classification", + "requirement": "optional", + "caption": "Category", + "type_name": "String" + }, + "authorizations": { + "type": "object_t", + "description": "Provides details about an authorization, such as authorization outcome, and any associated policies related to the activity/event.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "object_type": "authorization", + "object_name": "Authorization Result", + "profiles": [ + "security_control" + ], + "caption": "Authorization Information" + }, + "attacks": { + "type": "object_t", + "description": "An array of MITRE ATT&CK\u00ae objects describing identified tactics, techniques & sub-techniques. The objects are compatible with MITRE ATLAS\u2122 tactics, techniques & sub-techniques.", + "group": "primary", + "is_array": true, + "references": [ + { + "description": "MITRE ATT&CK\u00ae", + "url": "https://attack.mitre.org" + }, + { + "description": "MITRE ATLAS", + "url": "https://atlas.mitre.org/matrices/ATLAS" + } + ], + "requirement": "optional", + "object_type": "attack", + "object_name": "MITRE ATT&CK\u00ae & ATLAS\u2122", + "profiles": [ + "security_control" + ], + "caption": "MITRE ATT&CK\u00ae and ATLAS\u2122 Details" + }, + "confidence": { + "type": "string_t", + "description": "The confidence, normalized to the caption of the confidence_id value. In the case of 'Other', it is defined by the event source.", + "group": "context", + "requirement": "optional", + "profiles": [ + "security_control" + ], + "caption": "Confidence", + "type_name": "String" + }, + "account_switch_type_id": { + "type": "integer_t", + "enum": { + "0": { + "description": "The account switch type is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "A utility like sudo, su, or equivalent was used to perform actions in the context of another user.", + "references": [ + { + "description": "sudo documentation", + "url": "https://www.sudo.ws/" + }, + { + "description": "su Linux manual page", + "url": "https://man7.org/linux/man-pages/man1/su.1.html" + }, + { + "description": "su macOS manual page", + "url": "https://www.unix.com/man_page/osx/1/su/" + } + ], + "caption": "Substitute User" + }, + "2": { + "description": "An API like ImpersonateLoggedOnUser() or equivalent was used to perform actions in the context of another user.", + "references": [ + { + "description": "Microsoft ImpersonateLoggedOnUser() documentation", + "url": "https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-impersonateloggedonuser" + } + ], + "caption": "Impersonate" + }, + "99": { + "description": "The account switch type is not mapped. See the account_switch_type attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the account switch method.", + "group": "primary", + "requirement": "recommended", + "sibling": "account_switch_type", + "caption": "Account Switch Type ID", + "type_name": "Integer" + }, + "certificate": { + "type": "object_t", + "description": "The certificate associated with the authentication or pre-authentication (Kerberos).", + "group": "primary", + "requirement": "recommended", + "object_type": "certificate", + "object_name": "Digital Certificate", + "caption": "Certificate" + }, + "osint": { + "type": "object_t", + "description": "The OSINT (Open Source Intelligence) object contains details related to an indicator such as the indicator itself, related indicators, geolocation, registrar information, subdomains, analyst commentary, and other contextual information. This information can be used to further enrich a detection or finding by providing decisioning support to other analysts and engineers.", + "group": "primary", + "is_array": true, + "requirement": "required", + "object_type": "osint", + "object_name": "OSINT", + "profiles": [ + "osint" + ], + "caption": "OSINT" + }, + "firewall_rule": { + "type": "object_t", + "description": "The firewall rule that pertains to the control that triggered the event, if applicable.", + "group": "primary", + "requirement": "optional", + "object_type": "firewall_rule", + "object_name": "Firewall Rule", + "profiles": [ + "security_control" + ], + "caption": "Firewall Rule" + }, + "malware_scan_info": { + "type": "object_t", + "description": "Describes details about the scan job that identified malware on the target system.", + "group": "primary", + "requirement": "optional", + "object_type": "malware_scan_info", + "object_name": "Malware Scan Info", + "profiles": [ + "security_control" + ], + "caption": "Malware Scan Info" + }, + "unmapped": { + "type": "object_t", + "description": "The attributes that are not mapped to the event schema. The names and values of those attributes are specific to the event source.", + "group": "context", + "requirement": "optional", + "object_type": "object", + "object_name": "Object", + "caption": "Unmapped Data" + }, + "activity_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A Kerberos authentication ticket (TGT) was requested.", + "caption": "Authentication Ticket" + }, + "6": { + "description": "A preauthentication stage was engaged.", + "caption": "Preauth" + }, + "0": { + "description": "The event activity is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "A new logon session was requested.", + "caption": "Logon" + }, + "2": { + "description": "A logon session was terminated and no longer exists.", + "caption": "Logoff" + }, + "99": { + "description": "The event activity is not mapped. See the activity_name attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A Kerberos service ticket was requested.", + "caption": "Service Ticket Request" + }, + "5": { + "description": "A Kerberos service ticket was renewed.", + "caption": "Service Ticket Renew" + }, + "7": { + "description": "A utility or service switched the user account. See the account_switch_type_id attribute for more details.", + "caption": "Account Switch" + } + }, + "description": "The normalized identifier of the activity that triggered the event.", + "group": "classification", + "requirement": "required", + "sibling": "activity_name", + "caption": "Activity ID", + "type_name": "Integer", + "suppress_checks": [ + "sibling_convention" + ] + }, + "type_name": { + "type": "string_t", + "description": "The event/finding type name, as defined by the type_uid.", + "group": "classification", + "requirement": "optional", + "caption": "Type Name", + "type_name": "String" + }, + "time_dt": { + "type": "datetime_t", + "description": "The normalized event occurrence time or the finding creation time.", + "group": "occurrence", + "requirement": "optional", + "profiles": [ + "datetime" + ], + "caption": "Event Time", + "type_name": "Datetime" + }, + "auth_protocol": { + "type": "string_t", + "description": "The authentication protocol as defined by the caption of auth_protocol_id. In the case of Other, it is defined by the event source.", + "group": "primary", + "requirement": "recommended", + "caption": "Auth Protocol", + "type_name": "String" + }, + "metadata": { + "type": "object_t", + "description": "The metadata associated with the event or a finding.", + "group": "context", + "requirement": "required", + "object_type": "metadata", + "object_name": "Metadata", + "caption": "Metadata" + }, + "raw_data_hash": { + "type": "object_t", + "description": "The hash, which describes the content of the raw_data field.", + "group": "context", + "requirement": "optional", + "object_type": "fingerprint", + "object_name": "Fingerprint", + "caption": "Raw Data Hash" + }, + "start_time": { + "type": "timestamp_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "Start Time", + "type_name": "Timestamp" + }, + "end_time": { + "type": "timestamp_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "caption": "End Time", + "type_name": "Timestamp" + }, + "service": { + "type": "object_t", + "description": "The service or gateway to which the user or process is being authenticated", + "group": "primary", + "requirement": "recommended", + "object_type": "service", + "object_name": "Service", + "caption": "Service" + }, + "is_mfa": { + "type": "boolean_t", + "description": "Indicates whether Multi Factor Authentication was used during authentication.", + "group": "primary", + "requirement": "recommended", + "caption": "Multi Factor Authentication", + "type_name": "Boolean" + }, + "device": { + "type": "object_t", + "description": "An addressable device, computer system or host.", + "group": "primary", + "requirement": "recommended", + "object_type": "device", + "object_name": "Device", + "profiles": [ + "host" + ], + "caption": "Device" + }, + "confidence_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "description": "The normalized confidence is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The confidence is not mapped to the defined enum values. See the confidence attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized confidence refers to the accuracy of the rule that created the finding. A rule with a low confidence means that the finding scope is wide and may create finding reports that may not be malicious in nature.", + "group": "context", + "requirement": "recommended", + "profiles": [ + "security_control" + ], + "sibling": "confidence", + "caption": "Confidence ID", + "type_name": "Integer" + }, + "confidence_score": { + "type": "integer_t", + "description": "The confidence score as reported by the event source.", + "group": "context", + "requirement": "optional", + "profiles": [ + "security_control" + ], + "caption": "Confidence Score", + "type_name": "Integer" + }, + "logon_type_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A user or device logged onto this device from the network.", + "caption": "Network" + }, + "0": { + "description": "The logon type is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Used only by the System account, for example at system startup.", + "caption": "System" + }, + "2": { + "description": "A local logon to device console.", + "caption": "Interactive" + }, + "99": { + "description": "The logon type is not mapped. See the logon_type attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A batch server logon, where processes may be executing on behalf of a user without their direct intervention.", + "caption": "Batch" + }, + "5": { + "description": "A logon by a service or daemon that was started by the OS.", + "caption": "OS Service" + }, + "7": { + "description": "A user unlocked the device.", + "caption": "Unlock" + }, + "8": { + "description": "A user logged on to this device from the network. The user's password in the authentication package was not hashed.", + "caption": "Network Cleartext" + }, + "9": { + "description": "A caller cloned its current token and specified new credentials for outbound connections. The new logon session has the same local identity, but uses different credentials for other network connections.", + "caption": "New Credentials" + }, + "10": { + "description": "A remote logon using Terminal Services or remote desktop application.", + "caption": "Remote Interactive" + }, + "11": { + "description": "A user logged on to this device with network credentials that were stored locally on the device and the domain controller was not contacted to verify the credentials.", + "caption": "Cached Interactive" + }, + "12": { + "description": "Same as Remote Interactive. This is used for internal auditing.", + "caption": "Cached Remote Interactive" + }, + "13": { + "description": "Workstation logon.", + "caption": "Cached Unlock" + } + }, + "description": "The normalized logon type identifier.", + "group": "primary", + "requirement": "recommended", + "sibling": "logon_type", + "caption": "Logon Type ID", + "type_name": "Integer" + }, + "cloud": { + "type": "object_t", + "description": "Describes details about the Cloud environment where the event or finding was created.", + "group": "primary", + "requirement": "required", + "object_type": "cloud", + "object_name": "Cloud", + "profiles": [ + "cloud" + ], + "caption": "Cloud" + }, + "duration": { + "type": "long_t", + "description": "The event duration or aggregate time, the amount of time the event covers from start_time to end_time in milliseconds.", + "group": "occurrence", + "requirement": "optional", + "caption": "Duration Milliseconds", + "type_name": "Long" + }, + "auth_protocol_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "Digest" + }, + "6": { + "caption": "OAUTH 2.0" + }, + "0": { + "description": "The authentication protocol is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "NTLM" + }, + "2": { + "caption": "Kerberos" + }, + "99": { + "description": "The authentication protocol is not mapped. See the auth_protocol attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "caption": "OpenID" + }, + "5": { + "caption": "SAML" + }, + "7": { + "caption": "PAP" + }, + "8": { + "caption": "CHAP" + }, + "9": { + "caption": "EAP" + }, + "10": { + "caption": "RADIUS" + }, + "11": { + "caption": "Basic Authentication" + }, + "12": { + "caption": "LDAP" + } + }, + "description": "The normalized identifier of the authentication protocol used to create the user session.", + "group": "primary", + "requirement": "recommended", + "sibling": "auth_protocol", + "caption": "Auth Protocol ID", + "type_name": "Integer" + }, + "status_detail": { + "type": "string_t", + "description": "The details about the authentication request. For example, possible details for Windows logon or logoff events are:", + "group": "primary", + "requirement": "recommended", + "caption": "Status Detail", + "type_name": "String" + }, + "category_uid": { + "type": "integer_t", + "enum": { + "3": { + "description": "Identity & Access Management (IAM) events relate to the supervision of the system's authentication and access control model. Examples of such events are the success or failure of authentication, granting of authority, password change, entity change, privileged use etc.", + "uid": 3, + "caption": "Identity & Access Management" + } + }, + "description": "The category unique identifier of the event.", + "group": "classification", + "requirement": "required", + "sibling": "category_name", + "caption": "Category ID", + "type_name": "Integer" + }, + "type_uid": { + "type": "long_t", + "enum": { + "300200": { + "caption": "Authentication: Unknown" + }, + "300299": { + "caption": "Authentication: Other" + }, + "300201": { + "description": "A new logon session was requested.", + "caption": "Authentication: Logon" + }, + "300202": { + "description": "A logon session was terminated and no longer exists.", + "caption": "Authentication: Logoff" + }, + "300203": { + "description": "A Kerberos authentication ticket (TGT) was requested.", + "caption": "Authentication: Authentication Ticket" + }, + "300204": { + "description": "A Kerberos service ticket was requested.", + "caption": "Authentication: Service Ticket Request" + }, + "300205": { + "description": "A Kerberos service ticket was renewed.", + "caption": "Authentication: Service Ticket Renew" + }, + "300206": { + "description": "A preauthentication stage was engaged.", + "caption": "Authentication: Preauth" + }, + "300207": { + "description": "A utility or service switched the user account. See the account_switch_type_id attribute for more details.", + "caption": "Authentication: Account Switch" + } + }, + "description": "The event/finding type ID. It identifies the event's semantics and structure. The value is calculated by the logging system as: class_uid * 100 + activity_id.", + "group": "classification", + "requirement": "required", + "sibling": "type_name", + "caption": "Type ID", + "type_name": "Long" + }, + "action": { + "type": "string_t", + "description": "The normalized caption of action_id.", + "group": "primary", + "requirement": "optional", + "profiles": [ + "security_control" + ], + "caption": "Action", + "type_name": "String" + }, + "is_new_logon": { + "type": "boolean_t", + "description": "Indicates logon is from a device not seen before or a first time account logon.", + "group": "context", + "requirement": "optional", + "caption": "New Logon", + "type_name": "Boolean" + }, + "message": { + "type": "string_t", + "description": "The description of the event/finding, as defined by the source.", + "group": "primary", + "requirement": "recommended", + "caption": "Message", + "type_name": "String" + }, + "raw_data_size": { + "type": "long_t", + "description": "The size of the raw data which was transformed into an OCSF event, in bytes.", + "group": "context", + "requirement": "optional", + "caption": "Raw Data Size", + "type_name": "Long" + }, + "api": { + "type": "object_t", + "description": "Describes details about a typical API (Application Programming Interface) call.", + "group": "context", + "requirement": "optional", + "object_type": "api", + "object_name": "API", + "profiles": [ + "cloud" + ], + "caption": "API Details" + }, + "status_id": { + "type": "integer_t", + "enum": { + "0": { + "description": "The status is unknown.", + "caption": "Unknown" + }, + "1": { + "caption": "Success" + }, + "2": { + "caption": "Failure" + }, + "99": { + "description": "The status is not mapped. See the status attribute, which contains a data source specific value.", + "caption": "Other" + } + }, + "description": "The normalized identifier of the event status.", + "group": "primary", + "requirement": "recommended", + "sibling": "status", + "caption": "Status ID", + "type_name": "Integer" + }, + "authentication_token": { + "type": "object_t", + "description": "The authentication token, ticket, or assertion, e.g. Kerberos, OIDC, SAML.", + "group": "context", + "references": [ + { + "description": "RFC 4120: The Kerberos Network Authentication Service (V5)", + "url": "https://www.rfc-editor.org/rfc/rfc4120" + }, + { + "description": "OpenID Connect Core 1.0", + "url": "https://openid.net/specs/openid-connect-core-1_0.html" + }, + { + "description": "The OASIS Security Assertion Markup Language (SAML) V2.0", + "url": "https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf" + } + ], + "requirement": "optional", + "object_type": "authentication_token", + "object_name": "Authentication Token", + "caption": "Authentication Token" + }, + "http_request": { + "type": "object_t", + "description": "Details about the underlying HTTP request.", + "group": "context", + "requirement": "optional", + "object_type": "http_request", + "object_name": "HTTP Request", + "caption": "HTTP Request" + }, + "activity_name": { + "type": "string_t", + "description": "The event activity name, as defined by the activity_id.", + "group": "classification", + "requirement": "optional", + "caption": "Activity", + "type_name": "String" + }, + "count": { + "type": "integer_t", + "description": "The number of times that events in the same logical group occurred during the event Start Time to End Time period.", + "group": "occurrence", + "requirement": "optional", + "caption": "Count", + "type_name": "Integer" + }, + "action_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "The activity was observed, but neither explicitly allowed nor denied. This is common with IDS and EDR controls that report additional information on observed behavior such as TTPs. The disposition_id attribute should be set to a value that conforms to this action, for example 'Logged', 'Alert', 'Detected', 'Count', etc.", + "caption": "Observed" + }, + "0": { + "description": "The action was unknown. The disposition_id attribute may still be set to a non-unknown value, for example 'Custom Action', 'Challenge'.", + "caption": "Unknown" + }, + "1": { + "description": "The activity was allowed. The disposition_id attribute should be set to a value that conforms to this action, for example 'Allowed', 'Approved', 'Delayed', 'No Action', 'Count' etc.", + "caption": "Allowed" + }, + "2": { + "description": "The attempted activity was denied. The disposition_id attribute should be set to a value that conforms to this action, for example 'Blocked', 'Rejected', 'Quarantined', 'Isolated', 'Dropped', 'Access Revoked, etc.", + "caption": "Denied" + }, + "99": { + "description": "The action is not mapped. See the action attribute which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "The activity was modified, adjusted, or corrected. The disposition_id attribute should be set appropriately, for example 'Restored', 'Corrected', 'Delayed', 'Captcha', 'Tagged'.", + "caption": "Modified" + } + }, + "description": "The action taken by a control or other policy-based system leading to an outcome or disposition. An unknown action may still correspond to a known disposition. Refer to disposition_id for the outcome of the action.", + "group": "primary", + "requirement": "recommended", + "profiles": [ + "security_control" + ], + "sibling": "action", + "caption": "Action ID", + "type_name": "Integer" + }, + "end_time_dt": { + "type": "datetime_t", + "description": "The end time of a time period, or the time of the most recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "profiles": [ + "datetime" + ], + "caption": "End Time", + "type_name": "Datetime" + }, + "start_time_dt": { + "type": "datetime_t", + "description": "The start time of a time period, or the time of the least recent event included in the aggregate event.", + "group": "occurrence", + "requirement": "optional", + "profiles": [ + "datetime" + ], + "caption": "Start Time", + "type_name": "Datetime" + }, + "risk_level_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "caption": "Info" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The risk level is not mapped. See the risk_level attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "caption": "Critical" + } + }, + "description": "The normalized risk level id.", + "group": "context", + "requirement": "optional", + "profiles": [ + "security_control" + ], + "sibling": "risk_level", + "caption": "Risk Level ID", + "type_name": "Integer", + "suppress_checks": [ + "enum_convention" + ] + }, + "dst_endpoint": { + "type": "object_t", + "description": "The endpoint to which the authentication was targeted.", + "group": "primary", + "requirement": "recommended", + "object_type": "network_endpoint", + "object_name": "Network Endpoint", + "caption": "Destination Endpoint" + }, + "is_alert": { + "type": "boolean_t", + "description": "Indicates that the event is considered to be an alertable signal. Should be set to true if disposition_id = Alert among other dispositions, and/or risk_level_id or severity_id of the event is elevated. Not all control events will be alertable, for example if disposition_id = Exonerated or disposition_id = Allowed.", + "group": "primary", + "requirement": "recommended", + "profiles": [ + "security_control" + ], + "caption": "Alert", + "type_name": "Boolean" + }, + "timezone_offset": { + "type": "integer_t", + "description": "The number of minutes that the reported event time is ahead or behind UTC, in the range -1,080 to +1,080.", + "group": "occurrence", + "requirement": "recommended", + "caption": "Timezone Offset", + "type_name": "Integer" + }, + "policy": { + "type": "object_t", + "description": "The policy that pertains to the control that triggered the event, if applicable. For example the name of an anti-malware policy or an access control policy.", + "group": "primary", + "requirement": "optional", + "object_type": "policy", + "object_name": "Policy", + "profiles": [ + "security_control" + ], + "caption": "Policy" + }, + "auth_factors": { + "type": "object_t", + "description": "Describes a category of methods used for identity verification in an authentication attempt.", + "group": "context", + "is_array": true, + "requirement": "optional", + "object_type": "auth_factor", + "object_name": "Authentication Factor", + "caption": "Authentication Factors" + }, + "risk_details": { + "type": "string_t", + "description": "Describes the risk associated with the finding.", + "group": "context", + "requirement": "optional", + "profiles": [ + "security_control" + ], + "caption": "Risk Details", + "type_name": "String" + }, + "account_switch_type": { + "type": "string_t", + "description": "The account switch method, normalized to the caption of the account_switch_type_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "recommended", + "caption": "Account Switch Type", + "type_name": "String" + }, + "src_endpoint": { + "type": "object_t", + "description": "Details about the source of the IAM activity.", + "group": "primary", + "requirement": "recommended", + "object_type": "network_endpoint", + "object_name": "Network Endpoint", + "caption": "Source Endpoint" + }, + "malware": { + "type": "object_t", + "description": "A list of Malware objects, describing details about the identified malware.", + "group": "primary", + "is_array": true, + "requirement": "optional", + "object_type": "malware", + "object_name": "Malware", + "profiles": [ + "security_control" + ], + "caption": "Malware" + }, + "actor": { + "type": "object_t", + "description": "The actor object describes details about the user/role/process that was the source of the activity. Note that this is not the threat actor of a campaign but may be part of a campaign.", + "group": "context", + "requirement": "recommended", + "object_type": "actor", + "object_name": "Actor", + "profiles": null, + "caption": "Actor" + }, + "session": { + "type": "object_t", + "description": "The authenticated user or service session.", + "group": "primary", + "requirement": "recommended", + "object_type": "session", + "object_name": "Session", + "caption": "Session" + }, + "risk_level": { + "type": "string_t", + "description": "The risk level, normalized to the caption of the risk_level_id value.", + "group": "context", + "requirement": "optional", + "profiles": [ + "security_control" + ], + "caption": "Risk Level", + "type_name": "String" + }, + "status": { + "type": "string_t", + "description": "The event status, normalized to the caption of the status_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "recommended", + "caption": "Status", + "type_name": "String" + }, + "class_uid": { + "type": "integer_t", + "enum": { + "3002": { + "description": "Authentication events report authentication session activities, including user attempts to log on or log off, regardless of success, as well as other key stages within the authentication process. These events are typically generated by authentication services, such as Kerberos, OIDC, or SAML, and may include information about the user, the authentication method used, and the status of the authentication attempt.", + "caption": "Authentication" + } + }, + "description": "The unique identifier of a class. A class describes the attributes available in an event.", + "group": "classification", + "requirement": "required", + "sibling": "class_name", + "caption": "Class ID", + "type_name": "Integer" + }, + "logon_type": { + "type": "string_t", + "description": "The logon type, normalized to the caption of the logon_type_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "recommended", + "caption": "Logon Type", + "type_name": "String" + }, + "disposition": { + "type": "string_t", + "description": "The disposition name, normalized to the caption of the disposition_id value. In the case of 'Other', it is defined by the event source.", + "group": "primary", + "requirement": "optional", + "profiles": [ + "security_control" + ], + "caption": "Disposition", + "type_name": "String" + }, + "disposition_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "A suspicious file or other content was moved to a benign location.", + "caption": "Quarantined" + }, + "6": { + "description": "The request was detected as a threat and resulted in the connection being dropped.", + "caption": "Dropped" + }, + "0": { + "description": "The disposition is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Granted access or allowed the action to the protected resource.", + "caption": "Allowed" + }, + "2": { + "description": "Denied access or blocked the action to the protected resource.", + "caption": "Blocked" + }, + "99": { + "description": "The disposition is not mapped. See the disposition attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "A session was isolated on the network or within a browser.", + "caption": "Isolated" + }, + "5": { + "description": "A file or other content was deleted.", + "caption": "Deleted" + }, + "7": { + "description": "A custom action was executed such as running of a command script. Use the message attribute of the base class for details.", + "caption": "Custom Action" + }, + "8": { + "description": "A request or submission was approved. For example, when a form was properly filled out and submitted. This is distinct from 1 'Allowed'.", + "caption": "Approved" + }, + "9": { + "description": "A quarantined file or other content was restored to its original location.", + "caption": "Restored" + }, + "10": { + "description": "A suspicious or risky entity was deemed to no longer be suspicious (re-scored).", + "caption": "Exonerated" + }, + "11": { + "description": "A corrupt file or configuration was corrected.", + "caption": "Corrected" + }, + "12": { + "description": "A corrupt file or configuration was partially corrected.", + "caption": "Partially Corrected" + }, + "14": { + "description": "An operation was delayed, for example if a restart was required to finish the operation.", + "caption": "Delayed" + }, + "15": { + "description": "Suspicious activity or a policy violation was detected without further action.", + "caption": "Detected" + }, + "16": { + "description": "The outcome of an operation had no action taken.", + "caption": "No Action" + }, + "17": { + "description": "The operation or action was logged without further action.", + "caption": "Logged" + }, + "18": { + "description": "A file or other entity was marked with extended attributes.", + "caption": "Tagged" + }, + "20": { + "description": "Counted the request or activity but did not determine whether to allow it or block it.", + "caption": "Count" + }, + "21": { + "description": "The request was detected as a threat and resulted in the connection being reset.", + "caption": "Reset" + }, + "22": { + "description": "Required the end user to solve a CAPTCHA puzzle to prove that a human being is sending the request.", + "caption": "Captcha" + }, + "23": { + "description": "Ran a silent challenge that required the client session to verify that it's a browser, and not a bot.", + "caption": "Challenge" + }, + "24": { + "description": "The requestor's access has been revoked due to security policy enforcements. Note: use the Host profile if the User or Actor requestor is not present in the event class.", + "caption": "Access Revoked" + }, + "25": { + "description": "A request or submission was rejected. For example, when a form was improperly filled out and submitted. This is distinct from 2 'Blocked'.", + "caption": "Rejected" + }, + "26": { + "description": "An attempt to access a resource was denied due to an authorization check that failed. This is a more specific disposition than 2 'Blocked' and can be complemented with the authorizations attribute for more detail.", + "caption": "Unauthorized" + }, + "27": { + "description": "An error occurred during the processing of the activity or request. Use the message attribute of the base class for details.", + "caption": "Error" + }, + "13": { + "description": "A corrupt file or configuration was not corrected.", + "caption": "Uncorrected" + }, + "19": { + "description": "The request or activity was detected as a threat and resulted in a notification but request was not blocked.", + "caption": "Alert" + } + }, + "description": "Describes the outcome or action taken by a security control, such as access control checks, malware detections or various types of policy violations.", + "group": "primary", + "requirement": "recommended", + "profiles": [ + "security_control" + ], + "sibling": "disposition", + "caption": "Disposition ID", + "type_name": "Integer" + }, + "logon_process": { + "type": "object_t", + "description": "The trusted process that validated the authentication credentials.", + "group": "context", + "requirement": "optional", + "object_type": "process", + "object_name": "Process", + "caption": "Logon Process" + } + }, + "name": "authentication", + "description": "Authentication events report authentication session activities, including user attempts to log on or log off, regardless of success, as well as other key stages within the authentication process. These events are typically generated by authentication services, such as Kerberos, OIDC, or SAML, and may include information about the user, the authentication method used, and the status of the authentication attempt.", + "uid": 3002, + "extends": "iam", + "category": "iam", + "constraints": { + "at_least_one": [ + "service", + "dst_endpoint" + ] + }, + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:AuthenticationEvent", + "url": "https://d3fend.mitre.org/event/d3f:AuthenticationEvent" + } + ], + "profiles": [ + "cloud", + "container", + "data_classification", + "datetime", + "host", + "linux/linux_users", + "macos/macos_users", + "osint", + "security_control" + ], + "caption": "Authentication", + "associations": { + "user": [ + "dst_endpoint" + ], + "dst_endpoint": [ + "user" + ], + "src_endpoint": [ + "actor.user" + ], + "actor.user": [ + "src_endpoint" + ] + }, + "category_name": "Identity & Access Management", + "category_uid": 3 +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/service.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/service.json new file mode 100644 index 0000000000..aa791d0517 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/service.json @@ -0,0 +1,58 @@ +{ + "attributes": { + "name": { + "type": "string_t", + "description": "The name of the service.", + "requirement": "recommended", + "caption": "Name", + "type_name": "String" + }, + "version": { + "type": "string_t", + "description": "The version of the service.", + "requirement": "recommended", + "caption": "Version", + "type_name": "String" + }, + "uid": { + "type": "string_t", + "description": "The unique identifier of the service.", + "requirement": "recommended", + "caption": "Unique ID", + "type_name": "String" + }, + "labels": { + "type": "string_t", + "description": "The list of labels associated with the service.", + "is_array": true, + "requirement": "optional", + "caption": "Labels", + "type_name": "String" + }, + "tags": { + "type": "object_t", + "description": "The list of tags; {key:value} pairs associated to the service.", + "is_array": true, + "requirement": "optional", + "object_type": "key_value_object", + "object_name": "Key:Value object", + "caption": "Tags" + } + }, + "name": "service", + "description": "The Service object describes characteristics of a service, e.g. AWS EC2. ", + "extends": "_entity", + "constraints": { + "at_least_one": [ + "name", + "uid" + ] + }, + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:ServiceApplication.", + "url": "https://d3fend.mitre.org/dao/artifact/d3f:ServiceApplication/" + } + ], + "caption": "Service" +} diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/user.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/user.json new file mode 100644 index 0000000000..93562914e3 --- /dev/null +++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/user.json @@ -0,0 +1,234 @@ +{ + "attributes": { + "name": { + "type": "username_t", + "description": "The username. For example, janedoe1.", + "requirement": "recommended", + "caption": "Name", + "type_name": "User Name" + }, + "type": { + "type": "string_t", + "description": "The type of the user. For example, System, AWS IAM User, etc.", + "requirement": "optional", + "caption": "Type", + "type_name": "String" + }, + "domain": { + "type": "string_t", + "description": "The domain where the user is defined. For example: the LDAP or Active Directory domain.", + "requirement": "optional", + "caption": "Domain", + "type_name": "String" + }, + "uid": { + "type": "string_t", + "description": "The unique user identifier. For example, the Windows user SID, ActiveDirectory DN or AWS user ARN.", + "requirement": "recommended", + "caption": "Unique ID", + "type_name": "String", + "observable": 31 + }, + "groups": { + "type": "object_t", + "description": "The administrative groups to which the user belongs.", + "is_array": true, + "requirement": "optional", + "object_type": "group", + "object_name": "Group", + "caption": "Groups" + }, + "org": { + "type": "object_t", + "description": "Organization and org unit related to the user.", + "requirement": "optional", + "object_type": "organization", + "object_name": "Organization", + "caption": "Organization" + }, + "full_name": { + "type": "string_t", + "description": "The full name of the user, as reported by the product.", + "requirement": "optional", + "caption": "Full Name", + "type_name": "String" + }, + "type_id": { + "type": "integer_t", + "enum": { + "3": { + "description": "System account. For example, Windows computer accounts with a trailing dollar sign ($).", + "caption": "System" + }, + "0": { + "description": "The type is unknown.", + "caption": "Unknown" + }, + "1": { + "description": "Regular user account.", + "caption": "User" + }, + "2": { + "description": "Admin/root user account.", + "caption": "Admin" + }, + "99": { + "description": "The type is not mapped. See the type attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "description": "Service account. For example, Windows service account.", + "caption": "Service" + } + }, + "description": "The account type identifier.", + "requirement": "recommended", + "sibling": "type", + "caption": "Type ID", + "type_name": "Integer" + }, + "account": { + "type": "object_t", + "description": "The user's account or the account associated with the user.", + "requirement": "optional", + "object_type": "account", + "object_name": "Account", + "caption": "Account" + }, + "credential_uid": { + "type": "string_t", + "description": "The unique identifier of the user's credential. For example, AWS Access Key ID.", + "requirement": "optional", + "@deprecated": { + "message": "Use programmatic_credentials instead.", + "since": "1.6.0" + }, + "caption": "User Credential ID", + "type_name": "String", + "observable": 19 + }, + "display_name": { + "type": "string_t", + "description": "The display name of the user, as reported by the product.", + "requirement": "optional", + "caption": "Display Name", + "type_name": "String" + }, + "email_addr": { + "type": "email_t", + "description": "The user's primary email address.", + "requirement": "optional", + "caption": "Email Address", + "type_name": "Email Address" + }, + "forward_addr": { + "type": "email_t", + "description": "The user's forwarding email address.", + "requirement": "optional", + "caption": "Forwarding Address", + "type_name": "Email Address" + }, + "has_mfa": { + "type": "boolean_t", + "description": "The user has a multi-factor or secondary-factor device assigned.", + "requirement": "recommended", + "caption": "MFA Assigned", + "type_name": "Boolean" + }, + "ldap_person": { + "type": "object_t", + "description": "The additional LDAP attributes that describe a person.", + "requirement": "optional", + "object_type": "ldap_person", + "object_name": "LDAP Person", + "caption": "LDAP Person" + }, + "phone_number": { + "type": "string_t", + "description": "The telephone number of the user.", + "requirement": "optional", + "caption": "Telephone Number", + "type_name": "String" + }, + "programmatic_credentials": { + "type": "object_t", + "description": "Details about the programmatic credential (API keys, access tokens, certificates, etc) associated to the user.", + "is_array": true, + "requirement": "optional", + "object_type": "programmatic_credential", + "object_name": "Programmatic Credential", + "caption": "Programmatic Credentials" + }, + "risk_level": { + "type": "string_t", + "description": "The risk level, normalized to the caption of the risk_level_id value.", + "requirement": "optional", + "caption": "Risk Level", + "type_name": "String" + }, + "risk_level_id": { + "type": "integer_t", + "enum": { + "3": { + "caption": "High" + }, + "0": { + "caption": "Info" + }, + "1": { + "caption": "Low" + }, + "2": { + "caption": "Medium" + }, + "99": { + "description": "The risk level is not mapped. See the risk_level attribute, which contains a data source specific value.", + "caption": "Other" + }, + "4": { + "caption": "Critical" + } + }, + "description": "The normalized risk level id.", + "requirement": "optional", + "sibling": "risk_level", + "caption": "Risk Level ID", + "type_name": "Integer", + "suppress_checks": [ + "enum_convention" + ] + }, + "risk_score": { + "type": "integer_t", + "description": "The risk score as reported by the event source.", + "requirement": "optional", + "caption": "Risk Score", + "type_name": "Integer" + }, + "uid_alt": { + "type": "string_t", + "description": "The alternate user identifier. For example, the Active Directory user GUID or AWS user Principal ID.", + "requirement": "optional", + "caption": "Alternate ID", + "type_name": "String" + } + }, + "name": "user", + "description": "The User object describes the characteristics of a user/person or a security principal.", + "extends": "_entity", + "constraints": { + "at_least_one": [ + "account", + "name", + "uid" + ] + }, + "references": [ + { + "description": "D3FEND\u2122 Ontology d3f:UserAccount", + "url": "https://d3fend.mitre.org/dao/artifact/d3f:UserAccount/" + } + ], + "caption": "User", + "observable": 21 +} diff --git a/crates/openshell-ocsf/src/builders/authentication.rs b/crates/openshell-ocsf/src/builders/authentication.rs new file mode 100644 index 0000000000..fcd14db542 --- /dev/null +++ b/crates/openshell-ocsf/src/builders/authentication.rs @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Builder for Authentication [3002] events. + +use crate::builders::EventContext; +use crate::enums::{ActionId, AuthProtocolId, DispositionId, SeverityId, StatusId}; +use crate::events::base_event::BaseEventData; +use crate::events::{AuthenticationEvent, OcsfEvent}; +use crate::objects::{Endpoint, HttpRequest, Service, User}; + +/// Builder for Authentication [3002] Logon events. +pub struct AuthenticationBuilder<'a> { + ctx: &'a EventContext, + user: User, + service: Service, + auth_protocol: Option, + auth_protocol_label: Option, + action: Option, + disposition: Option, + severity: SeverityId, + status: Option, + status_detail: Option, + src_endpoint: Option, + http_request: Option, + message: Option, +} + +impl<'a> AuthenticationBuilder<'a> { + #[must_use] + pub fn new(ctx: &'a EventContext, user: User, service: Service) -> Self { + Self { + ctx, + user, + service, + auth_protocol: None, + auth_protocol_label: None, + action: None, + disposition: None, + severity: SeverityId::Informational, + status: None, + status_detail: None, + src_endpoint: None, + http_request: None, + message: None, + } + } + + /// Set a schema-defined authentication protocol. + #[must_use] + pub fn auth_protocol(mut self, id: AuthProtocolId) -> Self { + self.auth_protocol = Some(id); + self.auth_protocol_label = None; + self + } + + /// Set a protocol with no schema equivalent, recorded as Other. + #[must_use] + pub fn auth_protocol_other(mut self, label: impl Into) -> Self { + self.auth_protocol = Some(AuthProtocolId::Other); + self.auth_protocol_label = Some(label.into()); + self + } + + #[must_use] + pub fn status_detail(mut self, detail: impl Into) -> Self { + self.status_detail = Some(detail.into()); + self + } + + #[must_use] + pub fn src_endpoint(mut self, endpoint: Endpoint) -> Self { + self.src_endpoint = Some(endpoint); + self + } + + /// Set the request the credential was presented with. + #[must_use] + pub fn http_request(mut self, request: HttpRequest) -> Self { + self.http_request = Some(request); + self + } + + #[must_use] + pub fn build(self) -> OcsfEvent { + let mut base = BaseEventData::new( + 3002, + "Authentication", + 3, + "Identity & Access Management", + 1, + "Logon", + self.severity, + self.ctx.metadata(&["security_control", "host"]), + ); + if let Some(status) = self.status { + base.set_status(status); + } + if let Some(detail) = self.status_detail { + base.set_status_detail(detail); + } + if let Some(message) = self.message { + base.set_message(message); + } + // Not `apply_common_fields`: the container profile defines `container` + // on endpoints and processes, never at the event top level. + base.set_device(self.ctx.device()); + + OcsfEvent::Authentication(AuthenticationEvent { + base, + user: self.user, + auth_protocol: self.auth_protocol, + auth_protocol_label: self.auth_protocol_label, + service: self.service, + src_endpoint: self.src_endpoint, + http_request: self.http_request, + action: self.action, + disposition: self.disposition, + }) + } +} + +impl_action_disposition_setters!(AuthenticationBuilder); +impl_builder_setters!(AuthenticationBuilder); + +#[cfg(test)] +mod tests { + use super::*; + use crate::builders::test_sandbox_context; + use crate::enums::{HttpMethod, UserTypeId}; + use crate::objects::Url; + use crate::validation::schema::{ + load_class_schema, load_object_schema, validate_enum_value, validate_known_fields, + validate_required_fields, + }; + + fn gateway_failure() -> serde_json::Value { + let ctx = test_sandbox_context(); + AuthenticationBuilder::new( + &ctx, + User::unidentified(), + Service::new("openshell-gateway"), + ) + .auth_protocol(AuthProtocolId::OpenId) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .status_detail("expired_credential") + .src_endpoint(Endpoint::from_ip_str("203.0.113.7", 51234)) + .http_request(HttpRequest { + http_method: HttpMethod::Post, + url: Some(Url::from_path("/openshell.v1.OpenShell/ListSandboxes")), + }) + .message("authentication failed") + .build() + .to_json() + .unwrap() + } + + #[test] + fn failure_event_matches_vendored_schema() { + let json = gateway_failure(); + + let class = load_class_schema("authentication"); + validate_required_fields(&json, &class); + validate_known_fields(&json, &class); + for field in [ + "activity_id", + "action_id", + "auth_protocol_id", + "disposition_id", + "severity_id", + "status_id", + ] { + validate_enum_value(&json, field, &class); + } + + let user = load_object_schema("user"); + validate_required_fields(&json["user"], &user); + validate_known_fields(&json["user"], &user); + validate_enum_value(&json["user"], "type_id", &user); + + let service = load_object_schema("service"); + validate_required_fields(&json["service"], &service); + validate_known_fields(&json["service"], &service); + + let endpoint = load_object_schema("network_endpoint"); + validate_required_fields(&json["src_endpoint"], &endpoint); + + let url = load_object_schema("url"); + validate_required_fields(&json["http_request"]["url"], &url); + validate_known_fields(&json["http_request"]["url"], &url); + } + + #[test] + fn failure_event_carries_outcome_fields() { + let json = gateway_failure(); + + assert_eq!(json["class_uid"], 3002); + assert_eq!(json["class_name"], "Authentication"); + assert_eq!(json["category_uid"], 3); + assert_eq!(json["category_name"], "Identity & Access Management"); + assert_eq!(json["activity_id"], 1); + assert_eq!(json["activity_name"], "Logon"); + assert_eq!(json["type_uid"], 300_201); + assert_eq!(json["auth_protocol_id"], 4); + assert_eq!(json["auth_protocol"], "OpenID"); + assert_eq!(json["action_id"], 2); + assert_eq!(json["disposition_id"], 2); + assert_eq!(json["status_id"], 2); + assert_eq!(json["status_detail"], "expired_credential"); + assert_eq!(json["service"]["name"], "openshell-gateway"); + assert_eq!(json["src_endpoint"]["ip"], "203.0.113.7"); + assert_eq!( + json["http_request"], + serde_json::json!({ + "http_method": "POST", + "url": {"path": "/openshell.v1.OpenShell/ListSandboxes"} + }) + ); + assert_eq!( + json["metadata"]["profiles"], + serde_json::json!(["security_control", "host"]) + ); + assert!(json.get("container").is_none()); + } + + #[test] + fn unidentified_user_is_a_named_placeholder() { + let json = gateway_failure(); + + assert_eq!( + json["user"], + serde_json::json!({"name": "unknown", "type_id": 0, "type": "Unknown"}) + ); + } + + #[test] + fn custom_protocol_uses_other_with_label() { + let ctx = test_sandbox_context(); + let json = AuthenticationBuilder::new( + &ctx, + User::other("sb-7f3a", "Sandbox"), + Service::new("openshell-gateway"), + ) + .auth_protocol_other("sandbox_jwt") + .build() + .to_json() + .unwrap(); + + assert_eq!(json["auth_protocol_id"], 99); + assert_eq!(json["auth_protocol"], "sandbox_jwt"); + assert_eq!( + json["user"], + serde_json::json!({"uid": "sb-7f3a", "type_id": 99, "type": "Sandbox"}) + ); + } + + #[test] + fn known_user_type_uses_schema_caption() { + let ctx = test_sandbox_context(); + let json = AuthenticationBuilder::new( + &ctx, + User::new("alice@example.com", UserTypeId::User), + Service::new("openshell-gateway"), + ) + .build() + .to_json() + .unwrap(); + + assert_eq!( + json["user"], + serde_json::json!({"uid": "alice@example.com", "type_id": 1, "type": "User"}) + ); + assert!(json.get("auth_protocol_id").is_none()); + } +} diff --git a/crates/openshell-ocsf/src/builders/mod.rs b/crates/openshell-ocsf/src/builders/mod.rs index 6d5b83c13f..8fb3a82e12 100644 --- a/crates/openshell-ocsf/src/builders/mod.rs +++ b/crates/openshell-ocsf/src/builders/mod.rs @@ -135,6 +135,7 @@ macro_rules! impl_src_endpoint_addr_setter { } mod api_activity; +mod authentication; mod base; mod config; mod finding; @@ -145,6 +146,7 @@ mod process; mod ssh; pub use api_activity::ApiActivityBuilder; +pub use authentication::AuthenticationBuilder; pub use base::BaseEventBuilder; pub use config::ConfigStateChangeBuilder; pub use finding::DetectionFindingBuilder; diff --git a/crates/openshell-ocsf/src/enums/auth_protocol.rs b/crates/openshell-ocsf/src/enums/auth_protocol.rs new file mode 100644 index 0000000000..667583803e --- /dev/null +++ b/crates/openshell-ocsf/src/enums/auth_protocol.rs @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OCSF `auth_protocol_id` enum for Authentication. + +use serde_repr::{Deserialize_repr, Serialize_repr}; + +/// OCSF Authentication Protocol ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize_repr, Deserialize_repr)] +#[repr(u8)] +pub enum AuthProtocolId { + /// 0 — Unknown + Unknown = 0, + /// 1 — NTLM + Ntlm = 1, + /// 2 — Kerberos + Kerberos = 2, + /// 3 — Digest + Digest = 3, + /// 4 — `OpenID` + OpenId = 4, + /// 5 — SAML + Saml = 5, + /// 6 — OAUTH 2.0 + OAuth2 = 6, + /// 7 — PAP + Pap = 7, + /// 8 — CHAP + Chap = 8, + /// 9 — EAP + Eap = 9, + /// 10 — RADIUS + Radius = 10, + /// 11 — Basic Authentication + Basic = 11, + /// 12 — LDAP + Ldap = 12, + /// 99 — Other + Other = 99, +} + +impl AuthProtocolId { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Unknown => "Unknown", + Self::Ntlm => "NTLM", + Self::Kerberos => "Kerberos", + Self::Digest => "Digest", + Self::OpenId => "OpenID", + Self::Saml => "SAML", + Self::OAuth2 => "OAUTH 2.0", + Self::Pap => "PAP", + Self::Chap => "CHAP", + Self::Eap => "EAP", + Self::Radius => "RADIUS", + Self::Basic => "Basic Authentication", + Self::Ldap => "LDAP", + Self::Other => "Other", + } + } + + #[must_use] + pub fn as_u8(self) -> u8 { + self as u8 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_auth_protocol_json_roundtrip() { + let json = serde_json::to_value(AuthProtocolId::OpenId).unwrap(); + assert_eq!(json, serde_json::json!(4)); + let deserialized: AuthProtocolId = serde_json::from_value(json).unwrap(); + assert_eq!(deserialized, AuthProtocolId::OpenId); + } +} diff --git a/crates/openshell-ocsf/src/enums/mod.rs b/crates/openshell-ocsf/src/enums/mod.rs index b9ae43229d..3ad8978130 100644 --- a/crates/openshell-ocsf/src/enums/mod.rs +++ b/crates/openshell-ocsf/src/enums/mod.rs @@ -6,6 +6,7 @@ mod action; mod activity; mod auth; +mod auth_protocol; mod device_type; mod disposition; mod http_method; @@ -13,10 +14,12 @@ mod launch; mod security; mod severity; mod status; +mod user_type; pub use action::ActionId; pub use activity::ActivityId; pub use auth::AuthTypeId; +pub use auth_protocol::AuthProtocolId; pub use device_type::DeviceTypeId; pub use disposition::DispositionId; pub use http_method::HttpMethod; @@ -24,6 +27,7 @@ pub use launch::LaunchTypeId; pub use security::{ConfidenceId, RiskLevelId, SecurityLevelId}; pub use severity::SeverityId; pub use status::{StateId, StatusId}; +pub use user_type::UserTypeId; /// Trait for OCSF enum types that have an integer ID and a string label. /// @@ -51,6 +55,7 @@ macro_rules! impl_ocsf_enum { impl_ocsf_enum!( ActionId, + AuthProtocolId, AuthTypeId, ConfidenceId, DispositionId, @@ -60,4 +65,5 @@ impl_ocsf_enum!( SeverityId, StateId, StatusId, + UserTypeId, ); diff --git a/crates/openshell-ocsf/src/enums/user_type.rs b/crates/openshell-ocsf/src/enums/user_type.rs new file mode 100644 index 0000000000..9c63920f2a --- /dev/null +++ b/crates/openshell-ocsf/src/enums/user_type.rs @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OCSF `user.type_id` enum. + +use serde_repr::{Deserialize_repr, Serialize_repr}; + +/// OCSF User Type ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize_repr, Deserialize_repr)] +#[repr(u8)] +pub enum UserTypeId { + /// 0 — Unknown + Unknown = 0, + /// 1 — User + User = 1, + /// 2 — Admin + Admin = 2, + /// 3 — System + System = 3, + /// 4 — Service + Service = 4, + /// 99 — Other + Other = 99, +} + +impl UserTypeId { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Unknown => "Unknown", + Self::User => "User", + Self::Admin => "Admin", + Self::System => "System", + Self::Service => "Service", + Self::Other => "Other", + } + } + + #[must_use] + pub fn as_u8(self) -> u8 { + self as u8 + } +} diff --git a/crates/openshell-ocsf/src/events/authentication.rs b/crates/openshell-ocsf/src/events/authentication.rs new file mode 100644 index 0000000000..0b4f3bf412 --- /dev/null +++ b/crates/openshell-ocsf/src/events/authentication.rs @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OCSF Authentication [3002] event class. + +use serde::{Deserialize, Serialize}; + +use crate::enums::{ActionId, AuthProtocolId, DispositionId}; +use crate::events::base_event::BaseEventData; +use crate::objects::{Endpoint, HttpRequest, Service, User}; + +/// OCSF Authentication Event [3002]. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct AuthenticationEvent { + /// Common base event fields. + #[serde(flatten)] + pub base: BaseEventData, + + /// The principal that authenticated, or attempted to. + pub user: User, + + /// Authentication protocol. + #[serde( + rename = "auth_protocol_id", + default, + skip_serializing_if = "Option::is_none" + )] + pub auth_protocol: Option, + + /// Authentication protocol label (custom when `auth_protocol` is Other). + #[serde( + rename = "auth_protocol", + default, + skip_serializing_if = "Option::is_none" + )] + pub auth_protocol_label: Option, + + /// The service authenticated to. + pub service: Service, + + /// Source endpoint (connecting peer). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub src_endpoint: Option, + + /// The request the credential was presented with. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http_request: Option, + + /// Action (Security Control profile). + #[serde(rename = "action_id", default, skip_serializing_if = "Option::is_none")] + pub action: Option, + + /// Disposition. + #[serde( + rename = "disposition_id", + default, + skip_serializing_if = "Option::is_none" + )] + pub disposition: Option, +} + +impl Serialize for AuthenticationEvent { + fn serialize(&self, serializer: S) -> Result { + use crate::events::serde_helpers::{ + insert_enum_pair, insert_enum_pair_custom, insert_optional, insert_required, + }; + + let mut base_val = serde_json::to_value(&self.base).map_err(serde::ser::Error::custom)?; + let obj = base_val + .as_object_mut() + .ok_or_else(|| serde::ser::Error::custom("expected object"))?; + + insert_required!(obj, "user", self.user); + insert_enum_pair_custom!( + obj, + "auth_protocol", + self.auth_protocol, + self.auth_protocol_label + ); + insert_required!(obj, "service", self.service); + insert_optional!(obj, "src_endpoint", self.src_endpoint); + insert_optional!(obj, "http_request", self.http_request); + insert_enum_pair!(obj, "action", self.action); + insert_enum_pair!(obj, "disposition", self.disposition); + + base_val.serialize(serializer) + } +} diff --git a/crates/openshell-ocsf/src/events/mod.rs b/crates/openshell-ocsf/src/events/mod.rs index 93101bee2f..7dca70e953 100644 --- a/crates/openshell-ocsf/src/events/mod.rs +++ b/crates/openshell-ocsf/src/events/mod.rs @@ -5,6 +5,7 @@ mod api_activity; mod app_lifecycle; +mod authentication; pub(crate) mod base_event; mod config_state_change; mod detection_finding; @@ -16,6 +17,7 @@ mod ssh_activity; pub use api_activity::ApiActivityEvent; pub use app_lifecycle::ApplicationLifecycleEvent; +pub use authentication::AuthenticationEvent; pub use base_event::{BaseEvent, BaseEventData}; pub use config_state_change::DeviceConfigStateChangeEvent; pub use detection_finding::DetectionFindingEvent; @@ -41,6 +43,8 @@ pub enum OcsfEvent { SshActivity(SshActivityEvent), /// Process Activity [1007] ProcessActivity(ProcessActivityEvent), + /// Authentication [3002] + Authentication(AuthenticationEvent), /// Detection Finding [2004] DetectionFinding(DetectionFindingEvent), /// Application Lifecycle [6002] @@ -60,6 +64,7 @@ impl Serialize for OcsfEvent { Self::HttpActivity(e) => e.serialize(serializer), Self::SshActivity(e) => e.serialize(serializer), Self::ProcessActivity(e) => e.serialize(serializer), + Self::Authentication(e) => e.serialize(serializer), Self::DetectionFinding(e) => e.serialize(serializer), Self::ApplicationLifecycle(e) => e.serialize(serializer), Self::DeviceConfigStateChange(e) => e.serialize(serializer), @@ -92,6 +97,9 @@ impl<'de> Deserialize<'de> for OcsfEvent { 1007 => serde_json::from_value::(value) .map(Self::ProcessActivity) .map_err(serde::de::Error::custom), + 3002 => serde_json::from_value::(value) + .map(Self::Authentication) + .map_err(serde::de::Error::custom), 2004 => serde_json::from_value::(value) .map(Self::DetectionFinding) .map_err(serde::de::Error::custom), @@ -123,6 +131,7 @@ impl OcsfEvent { Self::HttpActivity(_) => 4002, Self::SshActivity(_) => 4007, Self::ProcessActivity(_) => 1007, + Self::Authentication(_) => 3002, Self::DetectionFinding(_) => 2004, Self::ApplicationLifecycle(_) => 6002, Self::DeviceConfigStateChange(_) => 5019, @@ -139,6 +148,7 @@ impl OcsfEvent { Self::HttpActivity(e) => &e.base, Self::SshActivity(e) => &e.base, Self::ProcessActivity(e) => &e.base, + Self::Authentication(e) => &e.base, Self::DetectionFinding(e) => &e.base, Self::ApplicationLifecycle(e) => &e.base, Self::DeviceConfigStateChange(e) => &e.base, @@ -152,9 +162,9 @@ impl OcsfEvent { mod tests { use super::*; use crate::builders::{ - ApiActivityBuilder, AppLifecycleBuilder, BaseEventBuilder, ConfigStateChangeBuilder, - DetectionFindingBuilder, HttpActivityBuilder, NetworkActivityBuilder, - ProcessActivityBuilder, SshActivityBuilder, test_sandbox_context, + ApiActivityBuilder, AppLifecycleBuilder, AuthenticationBuilder, BaseEventBuilder, + ConfigStateChangeBuilder, DetectionFindingBuilder, HttpActivityBuilder, + NetworkActivityBuilder, ProcessActivityBuilder, SshActivityBuilder, test_sandbox_context, }; use crate::enums::*; use crate::objects::*; @@ -318,6 +328,22 @@ mod tests { assert!(deserialized.base().ai_model.is_some()); } + #[test] + fn test_roundtrip_authentication() { + let ctx = test_sandbox_context(); + let event = AuthenticationBuilder::new( + &ctx, + User::unidentified(), + Service::new("openshell-gateway"), + ) + .build(); + + let json = serde_json::to_value(&event).unwrap(); + let deserialized: OcsfEvent = serde_json::from_value(json).unwrap(); + assert!(matches!(deserialized, OcsfEvent::Authentication(_))); + assert_eq!(deserialized.class_uid(), 3002); + } + #[test] fn test_deserialize_unknown_class_uid_errors() { let json = serde_json::json!({"class_uid": 9999}); diff --git a/crates/openshell-ocsf/src/format/shorthand.rs b/crates/openshell-ocsf/src/format/shorthand.rs index c31260bea8..c51caacdc1 100644 --- a/crates/openshell-ocsf/src/format/shorthand.rs +++ b/crates/openshell-ocsf/src/format/shorthand.rs @@ -404,6 +404,66 @@ impl OcsfEvent { format!("SSH:{activity} {sev}{detail}{auth_ctx}") } + Self::Authentication(e) => { + let activity = e.base.activity_name.to_uppercase(); + let action = e + .action + .map_or(String::new(), |a| format!(" {}", a.label().to_uppercase())); + let principal = e + .user + .uid + .as_deref() + .or(e.user.name.as_deref()) + .unwrap_or_default(); + let principal = + escape_context_field(&truncate_with_ellipsis(principal, MAX_REASON_LEN)); + let principal = if e.user.type_id == crate::enums::UserTypeId::Unknown { + principal + } else { + format!( + "{}:{principal}", + escape_context_field(&e.user.type_label.to_lowercase()) + ) + }; + let peer = e + .src_endpoint + .as_ref() + .map(|ep| { + let port = ep.port.map_or(String::new(), |p| format!(":{p}")); + format!(" from {}{port}", ep.domain_or_ip()) + }) + .unwrap_or_default(); + let mut context = Vec::new(); + if let Some(protocol) = e.auth_protocol.map(|p| { + e.auth_protocol_label + .as_deref() + .unwrap_or_else(|| p.label()) + }) { + context.push(format!("protocol:{}", escape_context_field(protocol))); + } + if let Some(path) = e + .http_request + .as_ref() + .and_then(|r| r.url.as_ref()) + .and_then(|u| u.path.as_deref()) + { + context.push(format!( + "op:{}", + escape_context_field(&truncate_with_ellipsis(path, MAX_REASON_LEN)) + )); + } + if let Some(reason) = reason_text(e.base.status_detail.as_deref()) { + context.push(format!("reason:{}", escape_context_field(&reason))); + } + let context = if context.is_empty() { + String::new() + } else { + format!(" [{}]", context.join(" ")) + }; + + format!("AUTH:{activity} {sev}{action} {principal}{peer}{context}") + } + Self::ProcessActivity(e) => { let activity = e.base.activity_name.to_uppercase(); let proc_str = format!("{}({})", e.process.name, e.process.pid); @@ -1040,6 +1100,49 @@ mod tests { ); } + #[test] + fn test_authentication_failure_shorthand() { + let ctx = crate::builders::test_sandbox_context(); + let event = crate::builders::AuthenticationBuilder::new( + &ctx, + User::unidentified(), + Service::new("openshell-gateway"), + ) + .auth_protocol(crate::enums::AuthProtocolId::OpenId) + .action(ActionId::Denied) + .severity(crate::enums::SeverityId::Medium) + .status_detail("expired_credential") + .src_endpoint(Endpoint::from_ip_str("203.0.113.7", 51234)) + .http_request(HttpRequest { + http_method: crate::enums::HttpMethod::Post, + url: Some(Url::from_path("/openshell.v1.OpenShell/ListSandboxes")), + }) + .build(); + + assert_eq!( + event.format_shorthand(), + "AUTH:LOGON [MED] DENIED unknown from 203.0.113.7:51234 [protocol:OpenID op:/openshell.v1.OpenShell/ListSandboxes reason:expired_credential]" + ); + } + + #[test] + fn test_authentication_success_shorthand_labels_principal_type() { + let ctx = crate::builders::test_sandbox_context(); + let event = crate::builders::AuthenticationBuilder::new( + &ctx, + User::other("sb 7f3a", "Sandbox"), + Service::new("openshell-gateway"), + ) + .auth_protocol_other("sandbox_jwt") + .action(ActionId::Allowed) + .build(); + + assert_eq!( + event.format_shorthand(), + "AUTH:LOGON [INFO] ALLOWED sandbox:sb\\u{20}7f3a [protocol:sandbox_jwt]" + ); + } + #[test] fn test_process_activity_shorthand_launch() { let event = OcsfEvent::ProcessActivity(ProcessActivityEvent { diff --git a/crates/openshell-ocsf/src/lib.rs b/crates/openshell-ocsf/src/lib.rs index fc52b4c377..746aea9aac 100644 --- a/crates/openshell-ocsf/src/lib.rs +++ b/crates/openshell-ocsf/src/lib.rs @@ -36,24 +36,28 @@ pub mod tracing_layers; #[cfg(any(test, feature = "test-support"))] pub mod validation; +#[cfg(any(test, feature = "test-support"))] +pub mod test_support; + // --- Core event types --- pub use events::{ - ApiActivityEvent, ApplicationLifecycleEvent, BaseEvent, BaseEventData, DetectionFindingEvent, - DeviceConfigStateChangeEvent, HttpActivityEvent, NetworkActivityEvent, OcsfEvent, - ProcessActivityEvent, SshActivityEvent, + ApiActivityEvent, ApplicationLifecycleEvent, AuthenticationEvent, BaseEvent, BaseEventData, + DetectionFindingEvent, DeviceConfigStateChangeEvent, HttpActivityEvent, NetworkActivityEvent, + OcsfEvent, ProcessActivityEvent, SshActivityEvent, }; // --- Enum types --- pub use enums::{ - ActionId, ActivityId, AuthTypeId, ConfidenceId, DeviceTypeId, DispositionId, HttpMethod, - LaunchTypeId, OcsfEnum, RiskLevelId, SecurityLevelId, SeverityId, StateId, StatusId, + ActionId, ActivityId, AuthProtocolId, AuthTypeId, ConfidenceId, DeviceTypeId, DispositionId, + HttpMethod, LaunchTypeId, OcsfEnum, RiskLevelId, SecurityLevelId, SeverityId, StateId, + StatusId, UserTypeId, }; // --- Object types --- pub use objects::{ Actor, AiModel, Api, Attack, ConnectionInfo, Container, Device, Endpoint, Evidence, FindingInfo, FirewallRule, HttpRequest, HttpResponse, Image, Metadata, OsInfo, Process, - Product, Remediation, Tactic, Technique, Url, + Product, Remediation, Service, Tactic, Technique, Url, User, }; // --- Sanitizers --- @@ -63,9 +67,9 @@ pub use format::downgrade::OcsfSchemaVersion; // --- Builders --- pub use builders::{ - ApiActivityBuilder, AppLifecycleBuilder, BaseEventBuilder, ConfigStateChangeBuilder, - DetectionFindingBuilder, EventContext, EventOrigin, HttpActivityBuilder, - NetworkActivityBuilder, ProcessActivityBuilder, SshActivityBuilder, + ApiActivityBuilder, AppLifecycleBuilder, AuthenticationBuilder, BaseEventBuilder, + ConfigStateChangeBuilder, DetectionFindingBuilder, EventContext, EventOrigin, + HttpActivityBuilder, NetworkActivityBuilder, ProcessActivityBuilder, SshActivityBuilder, }; // --- Tracing layers --- diff --git a/crates/openshell-ocsf/src/objects/http.rs b/crates/openshell-ocsf/src/objects/http.rs index c541c1fa93..897c6b659e 100644 --- a/crates/openshell-ocsf/src/objects/http.rs +++ b/crates/openshell-ocsf/src/objects/http.rs @@ -57,6 +57,17 @@ impl Url { } } + /// A URL with only a path, for requests whose authority is implicit. + #[must_use] + pub fn from_path(path: &str) -> Self { + Self { + scheme: None, + hostname: None, + path: Some(path.to_string()), + port: None, + } + } + /// Format as a display string. /// /// Includes the port when it is present and differs from the scheme default diff --git a/crates/openshell-ocsf/src/objects/mod.rs b/crates/openshell-ocsf/src/objects/mod.rs index 764d27f801..9462fe988c 100644 --- a/crates/openshell-ocsf/src/objects/mod.rs +++ b/crates/openshell-ocsf/src/objects/mod.rs @@ -15,6 +15,8 @@ mod firewall_rule; mod http; mod metadata; mod process; +mod service; +mod user; pub use ai_model::AiModel; pub use api::Api; @@ -28,3 +30,5 @@ pub use firewall_rule::FirewallRule; pub use http::{HttpRequest, HttpResponse, Url}; pub use metadata::{Metadata, Product}; pub use process::{Actor, Process}; +pub use service::Service; +pub use user::User; diff --git a/crates/openshell-ocsf/src/objects/service.rs b/crates/openshell-ocsf/src/objects/service.rs new file mode 100644 index 0000000000..3586cd2a3f --- /dev/null +++ b/crates/openshell-ocsf/src/objects/service.rs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OCSF `service` object. + +use serde::{Deserialize, Serialize}; + +/// OCSF Service object — the service a principal authenticated to. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Service { + /// Service name. + pub name: String, +} + +impl Service { + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } +} diff --git a/crates/openshell-ocsf/src/objects/user.rs b/crates/openshell-ocsf/src/objects/user.rs new file mode 100644 index 0000000000..53995bfb2d --- /dev/null +++ b/crates/openshell-ocsf/src/objects/user.rs @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OCSF `user` object. + +use serde::{Deserialize, Serialize}; + +use crate::enums::UserTypeId; + +/// OCSF User object — the principal an event is about. +/// +/// The schema requires at least one of `account`, `name`, or `uid`; the +/// constructors always set `name` or `uid`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct User { + /// Display name. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Stable identifier (subject, sandbox ID, or replica ID). + #[serde(skip_serializing_if = "Option::is_none")] + pub uid: Option, + + /// User type. + pub type_id: UserTypeId, + + /// User type label; the schema caption unless `type_id` is Other. + #[serde(rename = "type")] + pub type_label: String, +} + +impl User { + /// A principal with a schema-defined user type. + #[must_use] + pub fn new(uid: impl Into, type_id: UserTypeId) -> Self { + Self { + name: None, + uid: Some(uid.into()), + type_id, + type_label: type_id.label().to_string(), + } + } + + /// A principal whose type has no schema equivalent (e.g. "Sandbox"). + #[must_use] + pub fn other(uid: impl Into, type_label: impl Into) -> Self { + Self { + name: None, + uid: Some(uid.into()), + type_id: UserTypeId::Other, + type_label: type_label.into(), + } + } + + /// Placeholder for an authentication attempt that established no + /// verified identity. + #[must_use] + pub fn unidentified() -> Self { + Self { + name: Some("unknown".to_string()), + uid: None, + type_id: UserTypeId::Unknown, + type_label: UserTypeId::Unknown.label().to_string(), + } + } + + #[must_use] + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } +} diff --git a/crates/openshell-ocsf/src/test_support.rs b/crates/openshell-ocsf/src/test_support.rs new file mode 100644 index 0000000000..07d3a91886 --- /dev/null +++ b/crates/openshell-ocsf/src/test_support.rs @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Helpers for asserting on emitted OCSF events in producer tests. + +use std::sync::{Arc, Mutex}; + +use tracing_subscriber::layer::SubscriberExt; + +use crate::events::OcsfEvent; +use crate::tracing_layers::{OCSF_TARGET, clone_current_event}; + +/// Tracing layer that records every OCSF event emitted while installed. +#[derive(Clone, Default)] +pub struct OcsfCapture(Arc>>); + +impl tracing_subscriber::Layer for OcsfCapture { + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + if event.metadata().target() == OCSF_TARGET + && let Some(ocsf) = clone_current_event() + { + self.0.lock().expect("capture lock").push(ocsf); + } + } +} + +impl OcsfCapture { + /// Install as the thread's default subscriber until the guard drops. + #[must_use] + pub fn install(&self) -> tracing::subscriber::DefaultGuard { + tracing::subscriber::set_default(tracing_subscriber::registry().with(self.clone())) + } + + /// Events captured so far. + #[must_use] + pub fn events(&self) -> Vec { + self.0.lock().expect("capture lock").clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::builders::{BaseEventBuilder, test_sandbox_context}; + + fn emit(message: &str) { + crate::ocsf_emit!( + BaseEventBuilder::new(&test_sandbox_context()) + .message(message) + .build() + ); + } + + #[test] + fn captures_events_emitted_while_installed() { + let capture = OcsfCapture::default(); + { + let _guard = capture.install(); + emit("captured"); + tracing::info!("not an ocsf event"); + } + emit("after guard dropped"); + + let events = capture.events(); + assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!(events[0].base().message.as_deref(), Some("captured")); + } +} diff --git a/crates/openshell-ocsf/src/validation/schema.rs b/crates/openshell-ocsf/src/validation/schema.rs index 73adeb9c6a..ad6f7b4fdd 100644 --- a/crates/openshell-ocsf/src/validation/schema.rs +++ b/crates/openshell-ocsf/src/validation/schema.rs @@ -83,6 +83,39 @@ pub fn validate_required_fields(event: &Value, schema: &Value) { } } +/// Validate that every attribute in the event is defined by the schema and, +/// for event classes, that profile attributes belong to a profile the event +/// declares in `metadata.profiles` (none when absent). +pub fn validate_known_fields(event: &Value, schema: &Value) { + let Some(attrs) = schema.get("attributes").and_then(Value::as_object) else { + return; + }; + let declared: Option> = schema.get("uid").map(|_| { + event + .pointer("/metadata/profiles") + .and_then(Value::as_array) + .map(|profiles| profiles.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default() + }); + let schema_name = schema["name"].as_str().unwrap_or_default(); + for name in event.as_object().into_iter().flat_map(|o| o.keys()) { + let Some(def) = attrs.get(name) else { + panic!("Field '{name}' is not defined by the '{schema_name}' schema"); + }; + if let (Some(declared), Some(profiles)) = + (&declared, def.get("profiles").and_then(Value::as_array)) + { + assert!( + profiles + .iter() + .filter_map(Value::as_str) + .any(|profile| declared.contains(&profile)), + "Field '{name}' requires one of the profiles {profiles:?}, declared {declared:?}" + ); + } + } +} + /// Validate that an enum field in the event has a valid value per the schema. /// /// Checks the `enum` map in the schema attribute definition. @@ -132,6 +165,41 @@ mod tests { } } + #[test] + fn known_fields_rejects_attributes_outside_the_schema() { + let schema = load_class_schema("base_event"); + let mut event = serde_json::json!({"class_uid": 0, "severity_id": 1}); + validate_known_fields(&event, &schema); + + event["is_src_dst_assignment_known"] = Value::Bool(true); + assert!(std::panic::catch_unwind(|| validate_known_fields(&event, &schema)).is_err()); + } + + #[test] + fn known_fields_rejects_attributes_of_undeclared_profiles() { + let schema = load_class_schema("authentication"); + let mut event = serde_json::json!({ + "class_uid": 3002, + "metadata": {"profiles": ["host"]}, + "device": {} + }); + validate_known_fields(&event, &schema); + + event["api"] = serde_json::json!({"operation": "Op"}); + assert!(std::panic::catch_unwind(|| validate_known_fields(&event, &schema)).is_err()); + } + + #[test] + fn known_fields_treats_missing_profile_declarations_as_none() { + let schema = load_class_schema("authentication"); + let event = serde_json::json!({ + "class_uid": 3002, + "metadata": {}, + "api": {"operation": "Op"} + }); + assert!(std::panic::catch_unwind(|| validate_known_fields(&event, &schema)).is_err()); + } + #[test] fn test_load_class_schemas() { // These tests only pass when the vendored schemas are present @@ -143,6 +211,8 @@ mod tests { "detection_finding", "application_lifecycle", "device_config_state_change", + "api_activity", + "authentication", "base_event", ]; diff --git a/crates/openshell-ocsf/tests/roundtrip.rs b/crates/openshell-ocsf/tests/roundtrip.rs index bfc651be25..e36200714d 100644 --- a/crates/openshell-ocsf/tests/roundtrip.rs +++ b/crates/openshell-ocsf/tests/roundtrip.rs @@ -11,11 +11,11 @@ use std::net::{IpAddr, Ipv4Addr}; use openshell_ocsf::{ ActionId, ActivityId, AiModel, ApiActivityBuilder, AppLifecycleBuilder, Attack, AuthTypeId, - BaseEventBuilder, ConfidenceId, ConfigStateChangeBuilder, ConnectionInfo, - DetectionFindingBuilder, DispositionId, Endpoint, EventContext, EventOrigin, FindingInfo, - HttpActivityBuilder, HttpMethod, HttpRequest, HttpResponse, LaunchTypeId, + AuthenticationBuilder, BaseEventBuilder, ConfidenceId, ConfigStateChangeBuilder, + ConnectionInfo, DetectionFindingBuilder, DispositionId, Endpoint, EventContext, EventOrigin, + FindingInfo, HttpActivityBuilder, HttpMethod, HttpRequest, HttpResponse, LaunchTypeId, NetworkActivityBuilder, OcsfEvent, Process, ProcessActivityBuilder, RiskLevelId, - SecurityLevelId, SeverityId, SshActivityBuilder, StateId, StatusId, Url, + SecurityLevelId, Service, SeverityId, SshActivityBuilder, StateId, StatusId, Url, User, }; fn ctx() -> EventContext { @@ -208,6 +208,33 @@ fn api_activity_round_trips() { assert_round_trips("api_activity", &event); } +#[test] +fn authentication_round_trips() { + let event = AuthenticationBuilder::new( + &ctx(), + User::other("sb-7f3a9c2e14b8", "Sandbox").with_name("agent-workspace-01"), + Service::new("openshell-gateway"), + ) + .auth_protocol_other("sandbox_jwt") + .action(ActionId::Denied) + .disposition(DispositionId::Unauthorized) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .status_detail("stale_identity") + .src_endpoint(Endpoint::from_ip( + IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), + 51236, + )) + .http_request(HttpRequest { + http_method: HttpMethod::Post, + url: Some(Url::from_path("/openshell.v1.OpenShell/GetSandboxConfig")), + }) + .message("authentication failed") + .build(); + + assert_round_trips("authentication", &event); +} + #[test] fn base_event_round_trips() { let event = BaseEventBuilder::new(&ctx()) diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 32ca8242f3..883e75d479 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -136,6 +136,7 @@ tokio = { workspace = true, features = ["test-util"] } # Tests import the example profiles from providers/ the way an operator # would; the feature is test-only and never reaches a release binary. openshell-providers = { path = "../openshell-providers", features = ["example-profiles"] } +openshell-ocsf = { path = "../openshell-ocsf", features = ["test-support"] } base64 = { workspace = true } hyper-rustls = { version = "0.27", default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "aws-lc-rs"] } rcgen = { workspace = true } diff --git a/crates/openshell-server/src/auth/audit.rs b/crates/openshell-server/src/auth/audit.rs new file mode 100644 index 0000000000..26131d3c83 --- /dev/null +++ b/crates/openshell-server/src/auth/audit.rs @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OCSF security events for gateway authentication outcomes. +//! +//! Events carry a low-cardinality reason category, never presented +//! credentials or raw verification errors. + +use openshell_ocsf::{ + ActionId, AuthenticationBuilder, DispositionId, HttpMethod, HttpRequest, Service, SeverityId, + StatusId, Url, User, ocsf_emit, +}; + +/// Why an authentication attempt failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthFailureReason { + /// No credential was presented. + MissingCredential, + /// Credentials for more than one mechanism were presented together. + ConflictingCredentials, +} + +impl AuthFailureReason { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::MissingCredential => "missing_credential", + Self::ConflictingCredentials => "conflicting_credentials", + } + } + + fn severity(self) -> SeverityId { + match self { + Self::MissingCredential => SeverityId::Low, + Self::ConflictingCredentials => SeverityId::Medium, + } + } +} + +/// The mechanism a caller attempted to authenticate with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthMechanism { + Mtls, +} + +impl AuthMechanism { + fn label(self) -> &'static str { + match self { + Self::Mtls => "mtls", + } + } +} + +/// Emit an Authentication failure event for a gRPC request to `path`. +pub fn emit_authentication_failure( + path: &str, + mechanism: Option, + reason: AuthFailureReason, +) { + let ctx = crate::gateway_ocsf::context("", ""); + let mut builder = AuthenticationBuilder::new( + &ctx, + User::unidentified(), + Service::new("openshell-gateway"), + ) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(reason.severity()) + .status(StatusId::Failure) + .status_detail(reason.as_str()) + .http_request(HttpRequest { + http_method: HttpMethod::Post, + url: Some(Url::from_path(path)), + }) + .message(format!("authentication failed: {}", reason.as_str())); + if let Some(mechanism) = mechanism { + builder = builder.auth_protocol_other(mechanism.label()); + } + ocsf_emit!(builder.build()); +} diff --git a/crates/openshell-server/src/auth/mod.rs b/crates/openshell-server/src/auth/mod.rs index da54287744..5b0209cae4 100644 --- a/crates/openshell-server/src/auth/mod.rs +++ b/crates/openshell-server/src/auth/mod.rs @@ -8,6 +8,7 @@ //! - `identity`: Provider-agnostic identity representation //! - `http`: HTTP endpoints for auth discovery and token exchange +pub mod audit; pub mod authenticator; pub mod authz; pub mod compute_driver; diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index d1b26220b4..881b44fc80 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -43,6 +43,7 @@ use tracing_opentelemetry::OpenTelemetrySpanExt as _; use crate::{ OpenShellService, ServerState, + auth::audit::{self, AuthFailureReason, AuthMechanism}, auth::authenticator::AuthenticatorChain, auth::authz::AuthzPolicy, auth::identity::Identity, @@ -1124,6 +1125,11 @@ where let principal = if let Some(operator) = operator { if req.headers().contains_key(http::header::AUTHORIZATION) { + audit::emit_authentication_failure( + &path, + Some(AuthMechanism::Mtls), + AuthFailureReason::ConflictingCredentials, + ); return Ok(status_response(tonic::Status::unauthenticated( "operator certificates must not be combined with authorization headers", ))); @@ -1136,6 +1142,11 @@ where (true, Some(identity)) => Principal::User(UserPrincipal { identity }), _ if allow_unauthenticated_users => unauthenticated_dev_user_principal(), _ => { + audit::emit_authentication_failure( + &path, + None, + AuthFailureReason::MissingCredential, + ); return Ok(status_response(tonic::Status::unauthenticated( "missing authorization header", ))); @@ -1145,6 +1156,11 @@ where } } else if mtls_auth_enabled { let Some(identity) = peer_identity else { + audit::emit_authentication_failure( + &path, + Some(AuthMechanism::Mtls), + AuthFailureReason::MissingCredential, + ); return Ok(status_response(tonic::Status::unauthenticated( "missing client certificate", ))); @@ -3208,6 +3224,10 @@ mod tests { }; use http_body_util::Full; use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; + use openshell_ocsf::test_support::OcsfCapture; + use openshell_ocsf::validation::schema::{ + load_class_schema, validate_known_fields, validate_required_fields, + }; use serde::Serialize; use std::sync::Arc; use std::sync::Mutex; @@ -3798,6 +3818,123 @@ mod tests { assert_eq!(grpc_status(&res).as_deref(), Some("16")); } + fn captured_json(capture: &OcsfCapture) -> Vec { + capture + .events() + .iter() + .map(|event| event.to_json().unwrap()) + .collect() + } + + fn assert_authentication_failure( + event: &serde_json::Value, + path: &str, + reason: &str, + severity_id: u8, + ) { + let schema = load_class_schema("authentication"); + validate_required_fields(event, &schema); + validate_known_fields(event, &schema); + assert_eq!(event["class_uid"], 3002); + assert_eq!(event["activity_id"], 1); + assert_eq!(event["status_id"], 2); + assert_eq!(event["action_id"], 2); + assert_eq!(event["disposition_id"], 2); + assert_eq!(event["severity_id"], severity_id); + assert_eq!(event["status_detail"], reason); + assert_eq!(event["user"]["type_id"], 0); + assert_eq!(event["service"]["name"], "openshell-gateway"); + assert_eq!(event["http_request"]["url"]["path"], path); + assert_eq!(event["metadata"]["product"]["name"], "OpenShell Gateway"); + } + + #[tokio::test] + async fn missing_credential_emits_authentication_failure() { + let capture = OcsfCapture::default(); + let _guard = capture.install(); + let chain = + AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok(None)))]); + let (recorder, _) = PrincipalRecorder::new(); + let mut router = AuthGrpcRouter::new(recorder, Some(chain), None); + let path = "/openshell.v1.OpenShell/ListSandboxes"; + + let res = router.call(empty_request(path)).await.unwrap(); + + assert_eq!(grpc_status(&res).as_deref(), Some("16")); + let events = captured_json(&capture); + assert_eq!(events.len(), 1, "{events:?}"); + assert_authentication_failure(&events[0], path, "missing_credential", 2); + assert!(events[0].get("auth_protocol_id").is_none()); + } + + #[tokio::test] + async fn missing_client_certificate_emits_mtls_authentication_failure() { + let capture = OcsfCapture::default(); + let _guard = capture.install(); + let (recorder, _) = PrincipalRecorder::new(); + let mut router = + AuthGrpcRouter::with_peer_identity(recorder, None, None, None, true, false); + let path = "/openshell.v1.OpenShell/ListSandboxes"; + + let res = router.call(empty_request(path)).await.unwrap(); + + assert_eq!(grpc_status(&res).as_deref(), Some("16")); + let events = captured_json(&capture); + assert_eq!(events.len(), 1, "{events:?}"); + assert_authentication_failure(&events[0], path, "missing_credential", 2); + assert_eq!(events[0]["auth_protocol_id"], 99); + assert_eq!(events[0]["auth_protocol"], "mtls"); + } + + #[tokio::test] + async fn operator_with_authorization_header_emits_conflicting_credentials() { + let capture = OcsfCapture::default(); + let _guard = capture.install(); + let (recorder, seen) = PrincipalRecorder::new(); + let mut router = + AuthGrpcRouter::with_peer_identity(recorder, None, None, None, true, false) + .with_operator(Some(operator_principal())); + let path = "/openshell.v1.OpenShell/ListSandboxes"; + let canary = "Bearer canary-secret-token-9f2c"; + let request = Request::builder() + .uri(path) + .header("authorization", canary) + .body(Full::new(Bytes::new())) + .unwrap(); + + let res = router.call(request).await.unwrap(); + + assert!(seen.lock().unwrap().is_none()); + assert_eq!(grpc_status(&res).as_deref(), Some("16")); + let events = captured_json(&capture); + assert_eq!(events.len(), 1, "{events:?}"); + assert_authentication_failure(&events[0], path, "conflicting_credentials", 3); + assert_eq!(events[0]["auth_protocol"], "mtls"); + assert!(!events[0].to_string().contains("canary-secret-token")); + } + + #[tokio::test] + async fn authenticated_and_exempt_requests_emit_no_authentication_event() { + let capture = OcsfCapture::default(); + let _guard = capture.install(); + let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( + Some(user_principal("alice")), + )))]); + let (recorder, _) = PrincipalRecorder::new(); + let mut router = AuthGrpcRouter::new(recorder, Some(chain), None); + + for path in [ + "/openshell.v1.OpenShell/ListSandboxes", + "/openshell.v1.OpenShell/Health", + ] { + let res = router.call(empty_request(path)).await.unwrap(); + assert!(grpc_status(&res).is_none(), "{path}"); + } + + let events = captured_json(&capture); + assert!(events.is_empty(), "{events:?}"); + } + #[tokio::test] async fn health_methods_bypass_chain() { // Authenticator is wired to fail-closed; the request still gets diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index fa5c408b21..a84245d2cc 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -1830,47 +1830,21 @@ mod tests { ); } - /// Captures structured OCSF events during tracing dispatch. - #[derive(Clone, Default)] - struct ProbeLayer { - seen: Arc>>>, - } - - impl tracing_subscriber::Layer for ProbeLayer { - fn on_event( - &self, - event: &tracing::Event<'_>, - _ctx: tracing_subscriber::layer::Context<'_, S>, - ) { - if event.metadata().target() == openshell_ocsf::OCSF_TARGET { - self.seen - .lock() - .unwrap() - .push(openshell_ocsf::clone_current_event()); - } - } - } - #[test] fn gateway_ocsf_events_expose_the_structured_event_to_layers() { - use tracing_subscriber::layer::SubscriberExt; - - let probe = ProbeLayer::default(); - let subscriber = tracing_subscriber::registry().with(probe.clone()); + let capture = openshell_ocsf::test_support::OcsfCapture::default(); let endpoint = endpoint(); let expected = build_service_endpoint_config_event(&endpoint, "https://example.test", true); let expected_shorthand = expected.format_shorthand(); - tracing::subscriber::with_default(subscriber, || { + { + let _guard = capture.install(); emit_service_endpoint_config_event(&endpoint, "https://example.test", true); - }); + } - let seen = probe.seen.lock().unwrap(); + let seen = capture.events(); assert_eq!(seen.len(), 1, "expected exactly one OCSF tracing event"); - let event = seen[0] - .as_ref() - .expect("structured OCSF event should be reachable from the layer"); - assert_eq!(event.format_shorthand(), expected_shorthand); + assert_eq!(seen[0].format_shorthand(), expected_shorthand); } } diff --git a/docs/observability/logging.mdx b/docs/observability/logging.mdx index 2f6983b129..3a94bf65bd 100644 --- a/docs/observability/logging.mdx +++ b/docs/observability/logging.mdx @@ -60,6 +60,7 @@ OpenShell maps sandbox events to these OCSF classes: | `SSH:` | SSH Activity | 4007 | SSH handshakes, authentication, channel operations | | `PROC:` | Process Activity | 1007 | Process start, exit, timeout, signal failures | | `FINDING:` | Detection Finding | 2004 | Security findings (nonce replay, proxy bypass, unsafe policy) | +| `AUTH:` | Authentication | 3002 | Gateway authentication failures | | `CONFIG:` | Device Config State Change | 5019 | Policy load/reload, Landlock, TLS setup, provider and settings changes | | `LIFECYCLE:` | Application Lifecycle | 6002 | Sandbox supervisor start, SSH server ready | @@ -97,6 +98,7 @@ endpoint, use `EVENT` rather than a network or lifecycle event. - Network: `process(pid) -> host:port` with the process identity and destination - HTTP: `METHOD url` with the HTTP method and target - SSH: peer address and authentication type +- Authentication: the principal as `type:id`, or `unknown` when none was established, and the peer address when known - Process: `name(pid)` with exit code or command line - Config: description of what changed - Finding: quoted title with the stable finding type, optional confidence, and source-specific context attributes when available @@ -105,6 +107,12 @@ endpoint, use `EVENT` rather than a network or lifecycle event. ### Examples +A gateway request rejected because it presented no credential: + +```text +OCSF AUTH:LOGON [LOW] DENIED unknown [op:/openshell.v1.OpenShell/ListSandboxes reason:missing_credential] +``` + An allowed HTTPS connection: ```text diff --git a/docs/observability/ocsf-json-export.mdx b/docs/observability/ocsf-json-export.mdx index 3fc84114a6..5ae0b9c5ba 100644 --- a/docs/observability/ocsf-json-export.mdx +++ b/docs/observability/ocsf-json-export.mdx @@ -21,6 +21,8 @@ This output is separate from the supervisor stderr and file output described bel Gateway records use the configured gateway name for both `device.uid` and `device.name`. Replicas of one installation share that identity; `device.hostname` identifies the emitting replica. Assign distinct gateway names to installations whose events are collected together. Reusing a name, including the default name, groups those installations under the same identity. Renaming a gateway changes its audit identity. +The gateway emits an Authentication (`class_uid: 3002`) event when it rejects a gRPC request that presents no credential, or an operator certificate together with an `authorization` header. `status_detail` holds the reason category: `missing_credential` or `conflicting_credentials`. These events never include presented credentials or headers. + Without JSONL enabled, gateway OCSF events still appear as shorthand in console output, subject to the diagnostic log filter. Events associated with a sandbox also appear in its gateway log stream; gateway-wide events have no sandbox association. ## Enable Sandbox JSON Export @@ -245,6 +247,7 @@ The `class_uid` field identifies the event type: | 4007 | SSH Activity | `SSH:` | | 1007 | Process Activity | `PROC:` | | 2004 | Detection Finding | `FINDING:` | +| 3002 | Authentication | `AUTH:` | | 5019 | Device Config State Change | `CONFIG:` | | 6002 | Application Lifecycle | `LIFECYCLE:` |