Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions openssh/changelog/unreleased/openssh-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
title: OpenSSH package for Syslog and OCSF
type: feature
authors:
- mavam
prs:
- 175
created: 2026-08-17T00:00:00Z
---

The library now includes an `openssh` package for parsing OpenSSH `sshd`
messages and mapping them to OCSF Authentication (class 3002).

The package exposes three operators. `parse` and `map` each take the field they
read and the field they write, in that order:

| Operator | Purpose |
|---|---|
| `openssh::ocsf::normalize` | Take a Syslog event carrying an `sshd` message all the way to OCSF |
| `openssh::parse` | Parse one raw `sshd` message into a structured OpenSSH event |
| `openssh::ocsf::map` | Map a structured OpenSSH event to OCSF Authentication |

An `sshd` message carries neither a timestamp nor a host, so the normalizer
works on the Syslog event around it and takes both from there. It needs no
arguments:

```tql
accept_relp "0.0.0.0:2514"
this = data.parse_syslog()
where app_name == "sshd"
openssh::ocsf::normalize
```

Pipelines that inspect, enrich, or route the structured event stage the steps
themselves:

```tql
openssh::parse message, event
event.time = move timestamp
openssh::ocsf::map event, ocsf
this = {...ocsf, raw_data: message}
```

`openssh::ocsf::map` consumes the source event: what it maps becomes OCSF, and
what it cannot map becomes `unmapped` inside the OCSF event.
29 changes: 29 additions & 0 deletions openssh/examples/message-to-ocsf-staged.tql
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
name: Map an OpenSSH message to OCSF in stages
description: |
Runs the same transformation as `openssh::ocsf::normalize`, but one step at a
time: parse the message body into an OpenSSH event, add the Syslog envelope
fields, map the event to OCSF Authentication, then store the body as
provenance. Use this form when a pipeline has to inspect, enrich, or route the
parsed event before mapping, or when it picks which representation becomes
`raw_data`.
---

from {
timestamp: 2026-01-01T00:00:00Z,
hostname: "bastion-01",
message: "Failed password for invalid user admin from 198.51.100.42 port 49152 ssh2",
}
// 1. Parse: the opaque message becomes a structured OpenSSH event.
openssh::parse message, event
// 2. Stage: the envelope contributes what the message body cannot carry.
event.time = move timestamp
event.hostname = move hostname
// 3. Map: the OpenSSH event becomes minimal OCSF Authentication.
openssh::ocsf::map event, ocsf
// 4. Embed: the caller decides that the message is the event's provenance.
this = {
...ocsf,
raw_data: message,
raw_data_size: message.length_bytes(),
}
16 changes: 16 additions & 0 deletions openssh/examples/message-to-ocsf.tql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
name: Map an OpenSSH message to OCSF
description: |
Normalizes one Syslog event carrying an `sshd` message to OCSF
Authentication. The normalizer parses the message, takes the timestamp and
host from the envelope around it, maps the structured event, and preserves
the message as OCSF provenance.
---

from {
timestamp: 2026-01-01T00:00:00Z,
hostname: "bastion-01",
message: "Failed password for invalid user admin from 198.51.100.42 port 49152 ssh2",
}
openssh::ocsf::normalize
ocsf::derive
13 changes: 13 additions & 0 deletions openssh/examples/syslog-to-ocsf.tql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
name: Turn OpenSSH Syslog messages into OCSF
description: |
Receives Syslog from an existing collector, keeps the `sshd` messages, and
normalizes them to OCSF Authentication events.
---

accept_tcp "0.0.0.0:514" {
read_syslog
}
where app_name == "sshd"
openssh::ocsf::normalize
publish "ocsf"
47 changes: 47 additions & 0 deletions openssh/operators/ocsf/events/authentication.tql
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
description: Structured OpenSSH event β†’ OCSF Authentication (class 3002).
args:
positional:
- name: openssh
description: The OpenSSH record to read.
type: field
- name: ocsf
description: The OCSF event to build.
type: field
---

let $activity_ids = {
logon: 1,
logoff: 2,
}
let $status_details = {
failed: "INVALID_CREDENTIALS",
invalid_user: "USER_DOES_NOT_EXIST",
max_auth_attempts: "MAX_ATTEMPTS_EXCEEDED",
}

@name = "ocsf.authentication"

$ocsf.status_detail = $status_details[$openssh.template]?
// A login against an account that does not exist fails for that reason, unless
// OpenSSH already gave a more specific one.
if $openssh.user.is_invalid and $openssh.template != "max_auth_attempts" {
$ocsf.status_detail = "USER_DOES_NOT_EXIST"
}

$ocsf.category_uid = 3
$ocsf.class_uid = 3002
$ocsf.activity_id = $activity_ids[move $openssh.activity]
$ocsf.user.name = move $openssh.user.name
$ocsf.src_endpoint.ip = move $openssh.source.ip
$ocsf.src_endpoint.port = move $openssh.source.port
$ocsf.dst_endpoint.hostname = move $openssh.hostname?
$ocsf.service.name = "sshd"
$ocsf.auth_protocol_id = 99
$ocsf.auth_protocol = "SSH"
$ocsf.logon_type_id = 10
$ocsf.is_remote = true
// SSH encrypts the transport regardless of the credential method.
$ocsf.is_cleartext = false

drop $openssh.template, $openssh.user, $openssh.source
72 changes: 72 additions & 0 deletions openssh/operators/ocsf/map.tql
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
description: |
OpenSSH `sshd` events β†’ OCSF.

Reads a structured OpenSSH event, as `openssh::parse` produces it, and builds
OCSF Authentication (class 3002) in the second field:

```tql
openssh::ocsf::map openssh, ocsf
```

A `null` event, which is what the parser yields for a message template it
does not model, becomes an OCSF Base Event.

The operator consumes the source event: what it maps becomes OCSF, and what it
cannot map becomes `unmapped` inside the OCSF event. Callers stage the Syslog
envelope's `time` and `hostname` into the source event beforehand, and own
provenance afterwards.
args:
positional:
- name: openssh
description: The structured OpenSSH event to read.
type: field
- name: ocsf
description: The OCSF event to build.
type: field
---

let $status_ids = {
success: 1,
failure: 2,
}

// --- OCSF: shared attributes ------------------

$ocsf = {
time: move $openssh.time?,
severity_id: 1,
metadata: {
version: "1.9.0",
log_name: "sshd",
log_provider: "OpenSSH",
product: {
name: "OpenSSH",
vendor_name: "OpenBSD",
},
},
}
if $openssh.outcome? != null {
$ocsf.status_id = $status_ids[move $openssh.outcome]?
}

match $openssh.category? {
"authentication" => {
openssh::ocsf::events::authentication $openssh, $ocsf
}
_ => {
@name = "ocsf.base_event"
$ocsf.category_uid = 0
$ocsf.class_uid = 0
$ocsf.activity_id = 0
}
}

// --- Finalize ---------------------------------
// The event type derives from what the arm set, and whatever the mapping did
// not consume becomes the residue. The envelope fields the caller staged are
// not residue, so they go first.

$ocsf.type_uid = $ocsf.class_uid * 100 + $ocsf.activity_id
drop $openssh.category?, $openssh.hostname?
$ocsf.unmapped = move $openssh
46 changes: 46 additions & 0 deletions openssh/operators/ocsf/normalize.tql
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
description: |
Normalizes a Syslog event carrying an OpenSSH `sshd` message to OCSF
Authentication (class 3002).

One call takes the event all the way. It parses the `sshd` message, takes the
timestamp and host from the envelope around it, maps the structured event, and
stores the message as provenance:

```tql
where app_name == "sshd"
openssh::ocsf::normalize
```

The input is a Syslog event as `read_syslog` or `parse_syslog` yields it, which
is the shape `sshd` messages arrive in, and it defaults to the whole event. A
message template the package does not model becomes an OCSF Base Event that
carries the payload.

Callers that hold an `sshd` message without its envelope use `openssh::parse`
and `openssh::ocsf::map` directly.
args:
positional:
- name: syslog
description: The Syslog event carrying the `sshd` message.
type: field
default: this
named:
- name: into
description: The field that receives the OCSF event.
type: field
default: this
---

openssh::parse $syslog.message, $into.openssh
// An `sshd` message carries neither a timestamp nor a host, so the envelope
// supplies both.
$into.openssh.time = $syslog.timestamp
$into.openssh.hostname = $syslog.hostname
openssh::ocsf::map $into.openssh, $into.ocsf

$into = {
...$into.ocsf,
raw_data: $syslog.message,
raw_data_size: $syslog.message.length_bytes(),
}
117 changes: 117 additions & 0 deletions openssh/operators/parse.tql
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
---
description: |
Parses an OpenSSH `sshd` log message.

The first field holds the raw `sshd` message body, not the full Syslog
envelope, and the second receives the structured OpenSSH event:

```tql
openssh::parse message, openssh
```

The output is `null` when no supported message template matches, which is
what the mapper turns into an OCSF Base Event.

Recognized messages describe the authentication lifecycle: accepted logins,
failed logins, unknown accounts, exhausted authentication attempts, and
session termination.

The parsed event carries no timestamp and no provenance, because an `sshd`
message body holds neither. Callers add the Syslog envelope's `time` and
`hostname` before mapping to OCSF.
args:
positional:
- name: log
type: field
description: The field holding the raw OpenSSH message body.
- name: openssh
type: field
description: The field that receives the structured OpenSSH event.
---

let $attempt = r"(?<verb>Accepted|Failed) %{NOTSPACE:method} for (?:(?<invalid>invalid user) )?%{NOTSPACE:user} from %{IP:source_ip} port %{POSINT:source_port:int}(?: %{NOTSPACE:protocol})?(?:: %{GREEDYDATA:credential})?"
let $invalid_user = r"(?<verb>Invalid user) %{NOTSPACE:user} from %{IP:source_ip}(?: port %{POSINT:source_port:int})?(?: \[(?<phase>preauth)\])?"
let $max_attempts = r"(?:error: )?(?<verb>maximum authentication attempts exceeded) for (?:(?<invalid>invalid user) )?%{NOTSPACE:user} from %{IP:source_ip} port %{POSINT:source_port:int}(?: %{NOTSPACE:protocol})?(?: \[(?<phase>preauth)\])?"
let $session_end = r"(?<verb>Disconnected from|Connection closed by)(?: (?<user_state>(?:authenticating |invalid )?user) %{NOTSPACE:user})? %{IP:source_ip} port %{POSINT:source_port:int}(?: \[(?<phase>preauth)\])?"
let $pattern = "^(?:%{OPENSSH_ATTEMPT}|%{OPENSSH_INVALID_USER}|%{OPENSSH_MAX_ATTEMPTS}|%{OPENSSH_SESSION_END}|%{GREEDYDATA:other})$"
let $pattern_definitions = {
OPENSSH_ATTEMPT: $attempt,
OPENSSH_INVALID_USER: $invalid_user,
OPENSSH_MAX_ATTEMPTS: $max_attempts,
OPENSSH_SESSION_END: $session_end,
}
let $verbs = {
Accepted: {
template: "accepted",
activity: "logon",
outcome: "success",
},
Failed: {
template: "failed",
activity: "logon",
outcome: "failure",
},
"Invalid user": {
template: "invalid_user",
activity: "logon",
outcome: "failure",
},
"maximum authentication attempts exceeded": {
template: "max_auth_attempts",
activity: "logon",
outcome: "failure",
},
"Disconnected from": {
template: "disconnected",
activity: "logon",
outcome: null,
},
"Connection closed by": {
template: "connection_closed",
activity: "logon",
outcome: null,
},
}
// Session state overrides the anonymous session-end defaults above.
let $session_states = {
user: {
activity: "logoff",
outcome: "success",
},
"authenticating user": {
outcome: "failure",
},
"invalid user": {
outcome: "failure",
},
}

$openssh = $log.parse_grok(
$pattern,
pattern_definitions=$pattern_definitions,
)
// Only a template that matched captures a verb. The catch-all keeps `sshd`
// chatter from warning.
if $openssh.verb == null {
$openssh = null
} else {
@name = "openssh.authentication"
$openssh = {
...$openssh,
...$verbs[$openssh.verb]?,
...$session_states[$openssh.user_state]?,
category: "authentication",
}
$openssh.user = {
name: move $openssh.user,
is_invalid: $openssh.invalid != null
or $openssh.user_state == "invalid user"
or $openssh.template == "invalid_user",
}
$openssh.source = {
ip: move $openssh.source_ip,
port: move $openssh.source_port,
}
$openssh.preauth = $openssh.phase == "preauth"
drop $openssh.verb, $openssh.invalid, $openssh.user_state, $openssh.phase, $openssh.other
}
Loading
Loading