use std::borrow::Cow;
use std::io::Write;
use flowscope::Timestamp;
use serde_json::{Map, Value, json};
use crate::anomaly::Severity;
use crate::anomaly::key::Key;
use crate::anomaly::sink::AnomalySink;
const OCSF_VERSION: &str = "1.3.0";
pub struct OcsfSink<W: Write + Send> {
out: W,
product_name: &'static str,
vendor_name: &'static str,
}
impl<W: Write + Send> OcsfSink<W> {
pub fn new(out: W) -> Self {
Self {
out,
product_name: "netring",
vendor_name: "netring",
}
}
pub fn product(mut self, name: &'static str, vendor: &'static str) -> Self {
self.product_name = name;
self.vendor_name = vendor;
self
}
pub fn into_inner(self) -> W {
self.out
}
}
impl OcsfSink<std::io::Stdout> {
pub fn stdout() -> Self {
Self::new(std::io::stdout())
}
}
fn ocsf_severity(s: Severity) -> (u8, &'static str) {
match s {
Severity::Info => (1, "Informational"),
Severity::Warning => (3, "Medium"),
Severity::Error => (4, "High"),
Severity::Critical => (5, "Critical"),
}
}
fn proto_num(p: flowscope::L4Proto) -> u8 {
use flowscope::L4Proto::*;
match p {
Tcp => 6,
Udp => 17,
Icmp => 1,
IcmpV6 => 58,
Sctp => 132,
Other(n) => n,
_ => 0,
}
}
fn ts_millis(ts: Timestamp) -> i64 {
ts.sec as i64 * 1000 + ts.nsec as i64 / 1_000_000
}
impl<W: Write + Send> AnomalySink for OcsfSink<W> {
fn write(
&mut self,
kind: &'static str,
severity: Severity,
ts: Timestamp,
key: Option<&dyn Key>,
observations: &[(&'static str, Cow<'_, str>)],
metrics: &[(&'static str, f64)],
) {
let (severity_id, severity_name) = ocsf_severity(severity);
let mut ev = json!({
"activity_id": 1,
"activity_name": "Create",
"category_uid": 2,
"category_name": "Findings",
"class_uid": 2004,
"class_name": "Detection Finding",
"type_uid": 200_401,
"time": ts_millis(ts),
"severity_id": severity_id,
"severity": severity_name,
"status_id": 1,
"status": "New",
"message": kind,
"metadata": {
"product": { "name": self.product_name, "vendor_name": self.vendor_name },
"version": OCSF_VERSION,
"uid": kind,
},
"finding_info": { "title": kind, "uid": kind },
});
if let Some(k) = key
&& let Some(fk) = k
.as_any()
.downcast_ref::<flowscope::extract::FiveTupleKey>()
{
ev["src_endpoint"] = json!({ "ip": fk.a.ip().to_string(), "port": fk.a.port() });
ev["dst_endpoint"] = json!({ "ip": fk.b.ip().to_string(), "port": fk.b.port() });
ev["connection_info"] = json!({ "protocol_num": proto_num(fk.proto) });
}
if !observations.is_empty() || !metrics.is_empty() {
let mut unmapped = Map::new();
for (label, value) in observations {
unmapped.insert((*label).to_string(), Value::String(value.to_string()));
}
for (label, value) in metrics {
unmapped.insert((*label).to_string(), json!(value));
}
ev["unmapped"] = Value::Object(unmapped);
}
if serde_json::to_writer(&mut self.out, &ev).is_ok() {
let _ = self.out.write_all(b"\n");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
fn key() -> flowscope::extract::FiveTupleKey {
flowscope::extract::FiveTupleKey::new(
flowscope::L4Proto::Tcp,
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), 44321),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 443),
)
}
#[test]
fn maps_anomaly_to_ocsf_detection_finding() {
let mut sink = OcsfSink::new(Vec::<u8>::new());
let k = key();
sink.write(
"ioc_match",
Severity::Critical,
Timestamp {
sec: 1700,
nsec: 500_000_000,
},
Some(&k),
&[("indicator", Cow::Borrowed("evil.example"))],
&[("score", 9.5)],
);
let bytes = sink.into_inner();
let line = std::str::from_utf8(&bytes).unwrap().trim();
let v: Value = serde_json::from_str(line).unwrap();
assert_eq!(v["class_uid"], 2004);
assert_eq!(v["category_uid"], 2);
assert_eq!(v["type_uid"], 200_401);
assert_eq!(v["severity_id"], 5);
assert_eq!(v["severity"], "Critical");
assert_eq!(v["time"], 1_700_500i64);
assert_eq!(v["finding_info"]["title"], "ioc_match");
assert_eq!(v["metadata"]["product"]["name"], "netring");
assert_eq!(v["metadata"]["version"], OCSF_VERSION);
assert_eq!(v["src_endpoint"]["ip"], "10.0.0.1");
assert_eq!(v["src_endpoint"]["port"], 44321);
assert_eq!(v["dst_endpoint"]["ip"], "203.0.113.7");
assert_eq!(v["dst_endpoint"]["port"], 443);
assert_eq!(v["connection_info"]["protocol_num"], 6);
assert_eq!(v["unmapped"]["indicator"], "evil.example");
assert_eq!(v["unmapped"]["score"], 9.5);
}
#[test]
fn keyless_anomaly_omits_endpoints() {
let mut sink = OcsfSink::new(Vec::<u8>::new());
sink.write(
"PortScan",
Severity::Warning,
Timestamp { sec: 1, nsec: 0 },
None,
&[],
&[],
);
let bytes = sink.into_inner();
let v: Value = serde_json::from_str(std::str::from_utf8(&bytes).unwrap().trim()).unwrap();
assert_eq!(v["severity_id"], 3);
assert!(v.get("src_endpoint").is_none());
assert!(v.get("unmapped").is_none());
}
}