use std::io::{self, Write};
use serde_json::json;
use crate::{
AnomalyFields, KeyFields,
event::{AnomalyKind, EndReason, FlowEvent, FlowStats, Severity},
};
pub struct EveJsonWriter<W>
where
W: Write,
{
sink: W,
options: EveOptions,
flow_id_counter: u64,
ts_buf: String,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct EveOptions {
pub in_iface: String,
pub include_flow: bool,
pub include_anomalies: bool,
pub include_stats: bool,
pub severity_numeric: fn(Severity) -> u8,
pub custom_anomaly_type: &'static str,
}
impl Default for EveOptions {
fn default() -> Self {
Self {
in_iface: String::new(),
include_flow: true,
include_anomalies: true,
include_stats: false,
severity_numeric: default_severity_numeric,
custom_anomaly_type: "applayer",
}
}
}
pub fn default_severity_numeric(s: Severity) -> u8 {
match s {
Severity::Critical => 1,
Severity::Error => 2,
Severity::Warning => 3,
Severity::Info => 4,
}
}
impl<W> EveJsonWriter<W>
where
W: Write,
{
pub fn new(sink: W) -> Self {
Self::with_options(sink, EveOptions::default())
}
pub fn with_options(sink: W, options: EveOptions) -> Self {
Self {
sink,
options,
flow_id_counter: 0,
ts_buf: String::with_capacity(40),
}
}
pub fn write_lifecycle<T, K>(&mut self, ev: &T) -> io::Result<()>
where
T: crate::emit::LifecycleEvent<K>,
K: KeyFields + Clone,
{
match ev.as_flow_event() {
Some(fe) => self.write_event(fe.as_ref()),
None => Ok(()),
}
}
pub fn write_event<K>(&mut self, ev: &FlowEvent<K>) -> io::Result<()>
where
K: KeyFields,
{
match ev {
FlowEvent::Ended {
key,
reason,
stats,
l4: _,
..
} if self.options.include_flow => self.write_flow_ended(key, *reason, stats),
FlowEvent::FlowAnomaly { key, kind, ts } if self.options.include_anomalies => {
self.write_anomaly(Some(key), kind, *ts)
}
FlowEvent::TrackerAnomaly { kind, ts } if self.options.include_anomalies => {
self.write_anomaly::<K>(None, kind, *ts)
}
FlowEvent::Tick { key, stats, ts } if self.options.include_stats => {
self.write_stats(key, stats, *ts)
}
_ => Ok(()),
}
}
pub fn flush(&mut self) -> io::Result<()> {
self.sink.flush()
}
pub fn finish(mut self) -> io::Result<W> {
self.flush()?;
Ok(self.sink)
}
#[cfg(feature = "ipfix")]
pub fn write_flow_record(&mut self, rec: &crate::FlowRecord) -> io::Result<()> {
let start_ts = ms_to_iso8601(rec.flow_start_milliseconds);
let end_ts = ms_to_iso8601(rec.flow_end_milliseconds);
let flow_id = self.next_flow_id();
let mut obj = serde_json::Map::with_capacity(10);
obj.insert("timestamp".into(), json!(end_ts));
obj.insert("flow_id".into(), json!(flow_id));
obj.insert("event_type".into(), json!("flow"));
if !self.options.in_iface.is_empty() {
obj.insert("in_iface".into(), json!(self.options.in_iface));
}
insert_flow_record_5tuple(&mut obj, rec);
let age_secs = rec
.flow_end_milliseconds
.saturating_sub(rec.flow_start_milliseconds)
/ 1000;
let reason = super::csv::flow_record_reason_str(rec);
obj.insert(
"flow".into(),
json!({
"pkts_toserver": rec.packet_delta_count_initiator,
"pkts_toclient": rec.packet_delta_count_responder,
"bytes_toserver": rec.octet_delta_count_initiator,
"bytes_toclient": rec.octet_delta_count_responder,
"start": start_ts,
"end": end_ts,
"age": age_secs,
"reason": reason,
"alerted": false,
}),
);
if let Some(cid) = rec.community_id.as_deref() {
obj.insert("community_id".into(), json!(cid));
}
self.write_line(&obj)
}
pub fn write_owned_anomaly(&mut self, a: &crate::OwnedAnomaly) -> io::Result<()> {
use crate::anomaly_fields::AnomalyFields;
self.ts_buf.clear();
let _ = a.ts.write_iso8601(&mut self.ts_buf);
let flow_id = self.next_flow_id();
let severity = (self.options.severity_numeric)(a.severity);
let anomaly_type: &str = a
.flowscope_kind
.as_ref()
.and_then(|k| k.anomaly_type())
.unwrap_or(self.options.custom_anomaly_type);
let mut obj = serde_json::Map::with_capacity(12);
obj.insert("timestamp".into(), json!(self.ts_buf));
obj.insert("flow_id".into(), json!(flow_id));
obj.insert("event_type".into(), json!("anomaly"));
if !self.options.in_iface.is_empty() {
obj.insert("in_iface".into(), json!(self.options.in_iface));
}
if let Some(ip) = a.src_ip {
obj.insert("src_ip".into(), json!(ip.to_string()));
}
if let Some(p) = a.src_port {
obj.insert("src_port".into(), json!(p));
}
if let Some(ip) = a.dest_ip {
obj.insert("dest_ip".into(), json!(ip.to_string()));
}
if let Some(p) = a.dest_port {
obj.insert("dest_port".into(), json!(p));
}
if let Some(p) = a.proto {
obj.insert("proto".into(), json!(p));
}
let mut anomaly_obj = serde_json::Map::with_capacity(4);
anomaly_obj.insert("type".into(), json!(anomaly_type));
anomaly_obj.insert("event".into(), json!(a.kind.as_ref()));
anomaly_obj.insert("code".into(), json!(0u32));
if !a.observations.is_empty() {
let mut labels = serde_json::Map::with_capacity(a.observations.len());
for (label, value) in &a.observations {
labels.insert((*label).to_string(), json!(value.as_ref()));
}
anomaly_obj.insert("labels".into(), serde_json::Value::Object(labels));
}
if !a.metrics.is_empty() {
let mut metrics = serde_json::Map::with_capacity(a.metrics.len());
for (label, value) in &a.metrics {
metrics.insert((*label).to_string(), json!(value));
}
anomaly_obj.insert("metrics".into(), serde_json::Value::Object(metrics));
}
obj.insert("anomaly".into(), serde_json::Value::Object(anomaly_obj));
obj.insert("severity".into(), json!(severity));
self.write_line(&obj)
}
#[cfg(feature = "analysis")]
pub fn write_analyzed_flow<K>(&mut self, af: &crate::AnalyzedFlow<K>) -> io::Result<()>
where
K: KeyFields,
{
let stats = &af.stats;
self.ts_buf.clear();
let _ = stats.last_seen.write_iso8601(&mut self.ts_buf);
let end_ts = self.ts_buf.clone();
self.ts_buf.clear();
let _ = stats.started.write_iso8601(&mut self.ts_buf);
let start_ts = self.ts_buf.clone();
let flow_id = self.next_flow_id();
let mut obj = serde_json::Map::with_capacity(12);
obj.insert("timestamp".into(), json!(end_ts));
obj.insert("flow_id".into(), json!(flow_id));
obj.insert("event_type".into(), json!("flow"));
if !self.options.in_iface.is_empty() {
obj.insert("in_iface".into(), json!(self.options.in_iface));
}
insert_5tuple(&mut obj, &af.key);
if let Some(ap) = af.l7.app_proto {
obj.insert("app_proto".into(), json!(ap));
}
obj.insert(
"flow".into(),
json!({
"pkts_toserver": stats.packets_initiator,
"pkts_toclient": stats.packets_responder,
"bytes_toserver": stats.bytes_initiator,
"bytes_toclient": stats.bytes_responder,
"start": start_ts,
"end": end_ts,
"age": stats.duration().as_secs(),
"alerted": !af.is_clean(),
}),
);
let l7 = &af.l7;
if l7.ja3.is_some()
|| l7.ja4.is_some()
|| l7.tls_version.is_some()
|| l7.tls_cipher.is_some()
{
let mut tls = serde_json::Map::with_capacity(5);
if let Some(sni) = &l7.server_name {
tls.insert("sni".into(), json!(sni));
}
if let Some(j) = &l7.ja3 {
tls.insert("ja3".into(), json!({ "hash": j }));
}
if let Some(j) = &l7.ja4 {
tls.insert("ja4".into(), json!(j));
}
if let Some(v) = l7.tls_version {
tls.insert("version_raw".into(), json!(format!("0x{v:04x}")));
}
if let Some(c) = l7.tls_cipher {
tls.insert("cipher_raw".into(), json!(format!("0x{c:04x}")));
}
obj.insert("tls".into(), serde_json::Value::Object(tls));
}
if l7.http_method.is_some() || l7.http_uri.is_some() || l7.user_agent.is_some() {
let mut http = serde_json::Map::with_capacity(4);
if let Some(h) = &l7.server_name {
http.insert("hostname".into(), json!(h));
}
if let Some(m) = &l7.http_method {
http.insert("http_method".into(), json!(m));
}
if let Some(u) = &l7.http_uri {
http.insert("url".into(), json!(u));
}
if let Some(ua) = &l7.user_agent {
http.insert("http_user_agent".into(), json!(ua));
}
obj.insert("http".into(), serde_json::Value::Object(http));
}
if !l7.dns_queries.is_empty() {
obj.insert("dns".into(), json!({ "queries": l7.dns_queries }));
}
let mut fs = serde_json::Map::with_capacity(4);
if !af.risk.is_empty() {
let slugs: Vec<&str> = af.risk.as_slugs().collect();
fs.insert("risk".into(), json!(slugs));
fs.insert("risk_score".into(), json!(af.score()));
if let Some(sev) = af.severity() {
fs.insert("risk_severity".into(), json!(sev.as_str()));
}
}
if !af.ioc_hits.is_empty() {
let hits: Vec<serde_json::Value> = af
.ioc_hits
.iter()
.map(|m| {
json!({
"kind": m.kind.as_str(),
"value": m.value,
"reputation": m.reputation,
"source": m.source,
})
})
.collect();
fs.insert("ioc".into(), json!(hits));
}
if !fs.is_empty() {
obj.insert("flowscope".into(), serde_json::Value::Object(fs));
}
self.write_line(&obj)
}
fn write_anomaly<K>(
&mut self,
key: Option<&K>,
kind: &AnomalyKind,
ts: crate::Timestamp,
) -> io::Result<()>
where
K: KeyFields,
{
self.ts_buf.clear();
let _ = ts.write_iso8601(&mut self.ts_buf);
let flow_id = self.next_flow_id();
let severity = (self.options.severity_numeric)(kind.severity());
let mut obj = serde_json::Map::with_capacity(12);
obj.insert("timestamp".into(), json!(self.ts_buf));
obj.insert("flow_id".into(), json!(flow_id));
obj.insert("event_type".into(), json!("anomaly"));
if !self.options.in_iface.is_empty() {
obj.insert("in_iface".into(), json!(self.options.in_iface));
}
if let Some(k) = key {
insert_5tuple(&mut obj, k);
}
obj.insert(
"anomaly".into(),
json!({
"type": kind.anomaly_type(),
"event": kind.anomaly_event(),
"code": 0u32,
}),
);
obj.insert("severity".into(), json!(severity));
self.write_line(&obj)
}
fn write_flow_ended<K>(
&mut self,
key: &K,
reason: EndReason,
stats: &FlowStats,
) -> io::Result<()>
where
K: KeyFields,
{
#[cfg(feature = "ipfix")]
{
let rec = crate::FlowRecord::from_key_fields(stats, key, Some(reason));
self.write_flow_record(&rec)
}
#[cfg(not(feature = "ipfix"))]
{
self.ts_buf.clear();
let _ = stats.last_seen.write_iso8601(&mut self.ts_buf);
let end_ts = self.ts_buf.clone();
self.ts_buf.clear();
let _ = stats.started.write_iso8601(&mut self.ts_buf);
let start_ts = self.ts_buf.clone();
let flow_id = self.next_flow_id();
let mut obj = serde_json::Map::with_capacity(10);
obj.insert("timestamp".into(), json!(end_ts));
obj.insert("flow_id".into(), json!(flow_id));
obj.insert("event_type".into(), json!("flow"));
if !self.options.in_iface.is_empty() {
obj.insert("in_iface".into(), json!(self.options.in_iface));
}
insert_5tuple(&mut obj, key);
obj.insert(
"flow".into(),
json!({
"pkts_toserver": stats.packets_initiator,
"pkts_toclient": stats.packets_responder,
"bytes_toserver": stats.bytes_initiator,
"bytes_toclient": stats.bytes_responder,
"start": start_ts,
"end": end_ts,
"age": stats.duration().as_secs(),
"reason": reason.as_str(),
"alerted": false,
}),
);
self.write_line(&obj)?;
Ok(())
}
}
fn write_stats<K>(&mut self, key: &K, stats: &FlowStats, ts: crate::Timestamp) -> io::Result<()>
where
K: KeyFields,
{
self.ts_buf.clear();
let _ = ts.write_iso8601(&mut self.ts_buf);
let flow_id = self.next_flow_id();
let mut obj = serde_json::Map::with_capacity(8);
obj.insert("timestamp".into(), json!(self.ts_buf));
obj.insert("flow_id".into(), json!(flow_id));
obj.insert("event_type".into(), json!("stats"));
if !self.options.in_iface.is_empty() {
obj.insert("in_iface".into(), json!(self.options.in_iface));
}
insert_5tuple(&mut obj, key);
obj.insert(
"stats".into(),
json!({
"pkts_toserver": stats.packets_initiator,
"pkts_toclient": stats.packets_responder,
"bytes_toserver": stats.bytes_initiator,
"bytes_toclient": stats.bytes_responder,
}),
);
self.write_line(&obj)
}
fn write_line(&mut self, obj: &serde_json::Map<String, serde_json::Value>) -> io::Result<()> {
serde_json::to_writer(&mut self.sink, obj).map_err(io::Error::other)?;
self.sink.write_all(b"\n")
}
fn next_flow_id(&mut self) -> u64 {
self.flow_id_counter += 1;
self.flow_id_counter
}
}
#[cfg(feature = "ipfix")]
fn insert_flow_record_5tuple(
obj: &mut serde_json::Map<String, serde_json::Value>,
rec: &crate::FlowRecord,
) {
let src_ip = super::csv::flow_record_src_ip(rec);
if !src_ip.is_empty() {
obj.insert("src_ip".into(), json!(src_ip));
}
obj.insert("src_port".into(), json!(rec.source_transport_port));
let dst_ip = super::csv::flow_record_dst_ip(rec);
if !dst_ip.is_empty() {
obj.insert("dest_ip".into(), json!(dst_ip));
}
obj.insert("dest_port".into(), json!(rec.destination_transport_port));
let proto = super::csv::flow_record_proto_str(rec);
if !proto.is_empty() {
obj.insert("proto".into(), json!(proto.to_uppercase()));
}
if let Some(app) = rec.application_name.as_deref() {
obj.insert("app_proto".into(), json!(app));
}
}
#[cfg(feature = "ipfix")]
fn ms_to_iso8601(ms: u64) -> String {
use crate::Timestamp;
let secs = (ms / 1000) as u32;
let nsec = ((ms % 1000) as u32) * 1_000_000;
let ts = Timestamp::new(secs, nsec);
let mut buf = String::new();
let _ = ts.write_iso8601(&mut buf);
buf
}
fn insert_5tuple<K: KeyFields>(obj: &mut serde_json::Map<String, serde_json::Value>, key: &K) {
if let Some(ip) = key.src_ip() {
obj.insert("src_ip".into(), json!(ip.to_string()));
}
if let Some(p) = key.src_port() {
obj.insert("src_port".into(), json!(p));
}
if let Some(ip) = key.dest_ip() {
obj.insert("dest_ip".into(), json!(ip.to_string()));
}
if let Some(p) = key.dest_port() {
obj.insert("dest_port".into(), json!(p));
}
if let Some(p) = key.proto_str() {
obj.insert("proto".into(), json!(p));
}
if let Some(p) = key.app_proto_str() {
obj.insert("app_proto".into(), json!(p));
}
if let Some(cid) = key.community_id() {
obj.insert("community_id".into(), json!(cid));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_severity_mapping_matches_suricata_convention() {
assert_eq!(default_severity_numeric(Severity::Critical), 1);
assert_eq!(default_severity_numeric(Severity::Error), 2);
assert_eq!(default_severity_numeric(Severity::Warning), 3);
assert_eq!(default_severity_numeric(Severity::Info), 4);
}
#[cfg(feature = "community-id")]
#[test]
fn community_id_direction_invariant() {
use crate::{L4Proto, extract::FiveTupleKey};
let a = FiveTupleKey {
proto: L4Proto::Tcp,
a: "10.0.0.1:33000".parse().unwrap(),
b: "10.0.0.2:80".parse().unwrap(),
};
let b = FiveTupleKey {
proto: L4Proto::Tcp,
a: "10.0.0.2:80".parse().unwrap(),
b: "10.0.0.1:33000".parse().unwrap(),
};
assert_eq!(a.community_id(), b.community_id());
assert!(a.community_id().starts_with("1:"));
}
#[cfg(all(feature = "analysis", feature = "extractors"))]
#[test]
fn analyzed_flow_emits_enriched_flow_event() {
use crate::analysis::L7Summary;
use crate::detect::{FlowRisk, IocKind, IocMatch};
use crate::{AnalyzedFlow, FlowStats, L4Proto, extract::FiveTupleKey};
let key = FiveTupleKey {
proto: L4Proto::Tcp,
a: "10.0.0.1:50000".parse().unwrap(),
b: "93.184.216.34:443".parse().unwrap(),
};
let l7 = L7Summary {
app_proto: Some("tls"),
server_name: Some("evil.example".into()),
ja4: Some("t13d1516h2_x_y".into()),
tls_version: Some(0x0301),
..Default::default()
};
let af = AnalyzedFlow {
key,
stats: FlowStats::default(),
l7,
risk: FlowRisk::TLS_OBSOLETE_VERSION | FlowRisk::SUSPICIOUS_JA4,
ioc_hits: vec![IocMatch {
kind: IocKind::Ja4,
value: "t13d1516h2_x_y".into(),
reputation: Some(95),
source: Some("intel".into()),
}],
};
let mut buf = Vec::new();
EveJsonWriter::new(&mut buf)
.write_analyzed_flow(&af)
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&buf).unwrap();
assert_eq!(v["event_type"], "flow");
assert_eq!(v["app_proto"], "tls");
assert_eq!(v["flow"]["alerted"], true);
assert_eq!(v["tls"]["sni"], "evil.example");
assert_eq!(v["tls"]["ja4"], "t13d1516h2_x_y");
assert_eq!(v["tls"]["version_raw"], "0x0301");
let risks = v["flowscope"]["risk"].as_array().unwrap();
assert!(risks.iter().any(|s| s == "tls_obsolete_version"));
assert!(risks.iter().any(|s| s == "suspicious_ja4"));
assert_eq!(v["flowscope"]["risk_severity"], "high");
assert_eq!(v["flowscope"]["ioc"][0]["kind"], "ja4");
assert_eq!(v["flowscope"]["ioc"][0]["source"], "intel");
assert!(buf.ends_with(b"\n"));
}
}