feat(harfanglab): map the fields the alert and threat payloads actually carry (v1.4.0)

Replayed a real get_alerts and a real get_threats response through the
bundled mappers to see what came out.

get_alerts: a sigma alert matching a Windows event log carries the whole
record under `eventlog`, and none of it was mapped. On a Defender 1116
detection that left the malware, the file, the acting user and the
detecting product invisible — the incident indexed a hostname and
nothing else. The event data uses the provider's own field names, spaces
included, so the new entries quote them with JSONata backticks, and
Defender's "file:_" path prefix is stripped. Also added the agent IP
(placed before details_connection.SourceIp so a network alert's own
source still wins), the DNS domain, device type, agent version and
groups, the tenant, the alert subtype and threat type, the string
severity and the event time.

get_threats: `level` had no entry at all, so a threat's severity never
reached the OCSF document even though field_mappings derived the
incident's 1-5 from it. Added it, the event time, and the top agent's
reachability — whether the endpoint is online decides what response is
possible, and it was excluded without a reason.

Both trailers now record why each remaining field is left out, and the
event log's own SYSTEM writer and FQDN spelling are called out: indexing
them would create a junk user entity and a second spelling of one host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 17:30:01 +02:00
parent 6343a0c952
commit f7c5e99cc5
3 changed files with 75 additions and 8 deletions
@@ -11,6 +11,7 @@ ocsf:
- { source_path: "id", ocsf_field: "finding_info.uid" }
- { source_path: "slug", ocsf_field: "finding_info.title" }
- { source_path: "creation_date", ocsf_field: "finding_info.created_time" }
- { source_path: "creation_date", ocsf_field: "time" }
- { source_path: "first_seen", ocsf_field: "finding_info.first_seen_time" }
- { source_path: "last_seen", ocsf_field: "finding_info.last_seen_time" }
# x ? x guards: these are null on open/mono-tenant threats — null is falsy in
@@ -18,6 +19,10 @@ ocsf:
# of writing a literal null into the OCSF document.
- { source_path: "closed_date ? closed_date", ocsf_field: "end_time" }
- { source_path: "status", ocsf_field: "status" }
# field_mappings.severity turns this into the incident's 1-5 number; the string
# itself has no other home, so without this entry the threat's own level never
# reaches the OCSF document at all.
- { source_path: "level", ocsf_field: "severity" } # low | medium | high | critical
- { source_path: "linked_threat ? linked_threat", ocsf_field: "finding_info.uid_alt" }
- { source_path: "tenant ? tenant", ocsf_field: "metadata.tenant_uid" }
# ── Aggregate counts ──────────────────────────────────────────────
@@ -35,6 +40,10 @@ ocsf:
- { source_path: "top_agents[0].agent_osproducttype", ocsf_field: "src_endpoint.os.name" }
- { source_path: "top_agents[0].agent_ostype", ocsf_field: "src_endpoint.os.type" }
- { source_path: "top_agents[0].agent_osversion", ocsf_field: "device.os.version" }
# Whether the agent is still reachable decides what response is even possible
# (isolate, collect, kill) — the first thing asked of a threat, so it travels
# with the endpoint rather than being left to the console.
- { source_path: "top_agents[0].agent_status", ocsf_field: "unmapped.agent_status" }
# ── Top impacted user ─────────────────────────────────────────────
- { source_path: "top_impacted_users[0].user_name", ocsf_field: "user.name" }
- { source_path: "top_impacted_users[0].user_sid", ocsf_field: "user.uid" }
@@ -43,8 +52,13 @@ ocsf:
- { source_path: "top_rules[0].rule_id", ocsf_field: "rule.uid" }
- { source_path: "top_rules[0].rule_type", ocsf_field: "finding_info.analytic.type" }
- { source_path: "top_rules[0].rule_msg", ocsf_field: "finding_info.desc" }
# Deliberately unmapped: old_id/origin_stack (legacy/console internals), agent_status,
# Deliberately unmapped: old_id/origin_stack (legacy/console internals),
# top_rules[0].id (numeric console id — rule_id is the stable uid, mapped above),
# per-item security_event_count, rule_os/rule_level/rule creation dates (no OCSF home),
# top_rules[0].description (duplicate of rule_msg), and entries [1..] of the top_* arrays
# (OCSF detection_finding models a single primary endpoint/user/rule; counts convey scale).
# per-item security_event_count (equals the total while agent_count is 1, and the total
# is already mapped), top_rules[0].rule_level (the same value as the threat's own level,
# mapped to severity above), rule_os (the same value as the agent's ostype), the rule's
# creation_date/last_update (rule bookkeeping, not incident evidence),
# mitre_tactics[*].security_event_percentage (the tactic names are what pivot; the
# per-tactic weights have no OCSF home), top_rules[0].description (duplicate of rule_msg),
# and entries [1..] of the top_* arrays (OCSF detection_finding models a single primary
# endpoint/user/rule; counts convey scale).