diff --git a/docs/spec/trace/events.md b/docs/spec/trace/events.md index 25d271c..d0b909c 100644 --- a/docs/spec/trace/events.md +++ b/docs/spec/trace/events.md @@ -79,10 +79,13 @@ The full mapping lives in [`trace/ocsf-mapping.json`](https://genai-security-pro | `steps/subagentStart`, `steps/subagentStop` | Authentication | 3002 (logon/logoff for the subagent's session) | | `steps/turnStart`, `steps/turnEnd` | Application Lifecycle | 6002 | | Decision (deny/modify/ask/defer) | Detection Finding | 2004 | +| Decision (allow) | Application Lifecycle | 6002 | | `agbom/snapshot`, `agbom/changed` | Device Inventory Info | 5001 (`activity_id` 2, Collect) | | `steps/skillRegister` | Device Inventory Info | 5001 (`activity_id` 2, Collect: skill enters the component set) | | `steps/sessionEnd` | Authentication | 3002 (logoff) | +An allow goes to Application Lifecycle (6002) with `severity_id` 1. A deployment MAY record allows as Detection Findings instead, for audit symmetry, and then every decision event rule below applies to them. A 6002 allow carries the decision's `unmapped.acs` fields as a decision event does, so `unmapped.acs.decision` holds the disposition and `unmapped.acs.metadata` holds the evaluator. It sets `metadata.correlation_uid` to the gated step's pair. `allow_class` in the mapping lists its required fields. + OCSF `severity_id` for decision events is set from the disposition: | Disposition | `severity_id` | diff --git a/docs/spec/trace/extend_ocsf.md b/docs/spec/trace/extend_ocsf.md index b8efdfe..ab57078 100644 --- a/docs/spec/trace/extend_ocsf.md +++ b/docs/spec/trace/extend_ocsf.md @@ -14,6 +14,7 @@ ACS events map to OCSF 1.9.0 event classes. The normative class assignments and | `steps/skillLoad`, `steps/skillUnload` | Module Activity | 1005 (`activity_id` 1 load, 2 unload) | | `steps/knowledgeRetrieval`, `steps/memoryStore`, `steps/memoryContextRetrieval`, `steps/preCompact`, `steps/postCompact` | Datastore Activity | 6005 | | Decision (deny/modify/ask/defer) | Detection Finding | 2004 | +| Decision (allow) | Application Lifecycle | 6002 | | `agbom/snapshot`, `agbom/changed` | Device Inventory Info | 5001 (`activity_id` 2, Collect) | | `steps/skillRegister` | Device Inventory Info | 5001 (`activity_id` 2, Collect) | diff --git a/specification/v0.1.0/trace/ocsf-mapping.json b/specification/v0.1.0/trace/ocsf-mapping.json index 879d4b2..583a85a 100644 --- a/specification/v0.1.0/trace/ocsf-mapping.json +++ b/specification/v0.1.0/trace/ocsf-mapping.json @@ -44,10 +44,24 @@ }, "decision_class": { "type": "object", - "description": "Decisions (deny/modify/ask/defer) are emitted as Detection Finding events. 'allow' is normally emitted as Application Activity with informational severity rather than as a finding, unless the deployment elects to record allows as findings for full audit symmetry. action_id and disposition_id record the Guardian's verdict. Only the Observed Agent sees the enforced outcome (Specification §6.4), so an action_id of 2 (Denied) or a disposition_id of 2 (Blocked) never proves the action was blocked. When any acs.decision.failure event in either party's stream carries the same session_id and request_id, a reader MUST NOT count the decision event as an enforced outcome. A missing failure event does not prove the decision was enforced. A Guardian SHOULD NOT set verdict_id or the finding's status_id, because both belong to whoever triages the finding.", + "description": "Decisions (deny/modify/ask/defer) are emitted as Detection Finding events. allow_class says where an allow goes. action_id and disposition_id record the Guardian's verdict. Only the Observed Agent sees the enforced outcome (Specification §6.4), so an action_id of 2 (Denied) or a disposition_id of 2 (Blocked) never proves the action was blocked. When any acs.decision.failure event in either party's stream carries the same session_id and request_id, a reader MUST NOT count the decision event as an enforced outcome. A missing failure event does not prove the decision was enforced. A Guardian SHOULD NOT set verdict_id or the finding's status_id, because both belong to whoever triages the finding.", "properties": { "class_uid": { "type": "integer", "const": 2004 }, "class_name": { "type": "string", "const": "Detection Finding" }, + "allow_class": { + "type": "object", + "description": "Where an allow decision goes. An allow is emitted as Application Lifecycle (6002) with severity_id 1, unless the deployment elects to record allows as Detection Findings for audit symmetry, in which case every rule for a decision event applies to it. A 6002 allow carries the decision's join_fields and acs_fields under unmapped.acs, as a decision event does, so unmapped.acs.decision holds the disposition and unmapped.acs.metadata the evaluator, and it sets metadata.correlation_uid to the gated step's pair. Its activity_id is open with the other 6002 rows.", + "properties": { + "class_uid": { "type": "integer", "const": 6002 }, + "class_name": { "type": "string", "const": "Application Lifecycle" }, + "required_fields": { + "type": "array", + "items": { "type": "string" }, + "description": "Fields every allow event emitted as Application Lifecycle carries. The rest of acs_fields ride under unmapped.acs whenever the response carries them.", + "default": ["severity_id", "metadata.correlation_uid", "unmapped.acs.request_id", "unmapped.acs.session_id", "unmapped.acs.decision"] + } + } + }, "required_fields": { "type": "array", "items": { "type": "string" }, diff --git a/tests/test_trace_mapping_defaults.py b/tests/test_trace_mapping_defaults.py index b99314d..8d6c1a7 100644 --- a/tests/test_trace_mapping_defaults.py +++ b/tests/test_trace_mapping_defaults.py @@ -141,6 +141,25 @@ def test_ocsf_decision_codes_match_the_answered_proposal(): assert set(decision["late_decision"]["properties"]["failure_fields"]["default"]) <= failure_fields +def test_ocsf_allow_class_is_structured_and_its_fields_exist(): + # Issue #198: the allow rule lived only in decision_class.description, so + # nothing held its class or said where the disposition goes. Its class + # name must match the step_to_class caption for 6002, and every + # unmapped.acs field it requires must be one a decision event carries. + ocsf = json.loads((TRACE / "ocsf-mapping.json").read_text(encoding="utf-8"))["properties"] + decision = ocsf["decision_class"]["properties"] + allow = decision["allow_class"]["properties"] + assert allow["class_uid"]["const"] == 6002 + captions = {entry["class_name"] for entry in ocsf["step_to_class"]["default"].values() if entry["class_uid"] == 6002} + assert captions == {allow["class_name"]["const"]} + carried = set(decision["join_fields"]["default"]) | set(decision["acs_fields"]["default"]) + required = allow["required_fields"]["default"] + assert "unmapped.acs.decision" in required + unknown = [field for field in required if field.startswith("unmapped.acs.") and field.removeprefix("unmapped.acs.") not in carried] + assert not unknown, f"allow_class requires unmapped.acs fields no decision event carries: {unknown}" + assert ocsf["severity_mapping"]["default"]["allow"] == 1 + + def test_padded_source_fields_reject_the_empty_string(): # Trace pads the policy and provenance arrays with "" where a value is # absent, so "" must never be a legal wire value for these fields (#263).