ntopng/scripts/lua/modules/historical_flow_llm_formatter.lua

307 lines
No EOL
11 KiB
Lua

--
-- (C) 2013-26 - ntop.org
--
-- Formats a historical flow record into a JSON structure
-- Returns plaintext labels for protocols, applications etc along with numeric id
local dirs = ntop.getDirs()
package.path = dirs.installdir .. "/scripts/lua/modules/?.lua;" .. package.path
package.path = dirs.installdir .. "/scripts/lua/modules/alert_store/?.lua;" .. package.path
local json = require "dkjson"
local dscp_consts = require "dscp_consts"
local format_utils = require "format_utils"
local alert_consts = require "alert_consts"
local alert_entities = require "alert_entities"
local historical_flow_llm_formatter = {}
local function safe_tonumber(v)
return tonumber(v) or 0
end
local function non_zero_string(s)
if isEmptyString(s) or s == "0" or s == "0.0.0.0" or s == "::" then
return nil
end
return s
end
local function bool_flag(v)
return (tostring(v) == "1")
end
-- Resolve L4 protocol number to label
local l4_labels = {
[1] = "ICMP",
[6] = "TCP",
[17] = "UDP",
[47] = "GRE",
[50] = "ESP",
[58] = "ICMPv6",
[132]= "SCTP",
}
local function l4_label(proto_id)
return l4_labels[safe_tonumber(proto_id)] or ("L4_" .. tostring(proto_id))
end
-- Resolve alert type label (may return nil if unknown)
local function alert_label(alert_id)
if not alert_id or safe_tonumber(alert_id) == 0 then return nil end
local ok, label = pcall(function()
return alert_consts.alertTypeLabel(alert_id, true)
end)
return ok and label or nil
end
local function build_context(flow)
return {
ifid = safe_tonumber(flow["INTERFACE_ID"]),
flow_id = flow["FLOW_ID"],
ntopng_instance = non_zero_string(flow["NTOPNG_INSTANCE_NAME"]),
alert_id = safe_tonumber(flow["STATUS"]),
alert_label = alert_label(flow["STATUS"]),
alert_status = safe_tonumber(flow["ALERT_STATUS"]),
score = safe_tonumber(flow["SCORE"]),
severity = safe_tonumber(flow["SEVERITY"]),
observation_point_id = safe_tonumber(flow["OBSERVATION_POINT_ID"]),
}
end
-- Build protocol
local function build_protocol(flow, proto_info_json)
local l4_id = safe_tonumber(flow["PROTOCOL"])
return {
l4_proto_id = l4_id,
l4_proto_label = l4_label(l4_id),
l7_proto_id = safe_tonumber(flow["L7_PROTO"]),
l7_proto_master_id = safe_tonumber(flow["L7_PROTO_MASTER"]),
l7_category_id = safe_tonumber(flow["L7_CATEGORY"]),
ip_version = safe_tonumber(flow["IP_PROTOCOL_VERSION"]),
vlan_id = safe_tonumber(flow["VLAN_ID"]),
-- ICMPdetail if present
icmp = (proto_info_json.proto and proto_info_json.proto.icmp) and {
type = proto_info_json.proto.icmp.type,
code = proto_info_json.proto.icmp.code,
} or nil,
}
end
-- Build peers block (client / server)
local function build_peer(flow, side)
-- side is "cli" or "srv", fields use SRC/DST prefix
local is_cli = (side == "cli")
local ip4 = is_cli and flow["IPV4_SRC_ADDR"] or flow["IPV4_DST_ADDR"]
local ip6 = is_cli and flow["IPV6_SRC_ADDR"] or flow["IPV6_DST_ADDR"]
local port = is_cli and flow["IP_SRC_PORT"] or flow["IP_DST_PORT"]
local mac = is_cli and flow["SRC_MAC"] or flow["DST_MAC"]
local asn = is_cli and flow["SRC_ASN"] or flow["DST_ASN"]
local peer_asn = is_cli and flow["SRC_PEER_ASN"] or flow["DST_PEER_ASN"]
local pool = is_cli and flow["SRC_HOST_POOL_ID"] or flow["DST_HOST_POOL_ID"]
local cc = is_cli and flow["SRC_COUNTRY_CODE"] or flow["DST_COUNTRY_CODE"]
local lbl = is_cli and flow["SRC_LABEL"] or flow["DST_LABEL"]
local loc = is_cli and flow["CLIENT_LOCATION"] or flow["SERVER_LOCATION"]
local bl = is_cli and flow["IS_CLI_BLACKLISTED"] or flow["IS_SRV_BLACKLISTED"]
local attk = is_cli and flow["IS_CLI_ATTACKER"] or flow["IS_SRV_ATTACKER"]
local vctm = is_cli and flow["IS_CLI_VICTIM"] or flow["IS_SRV_VICTIM"]
return {
ip = non_zero_string(ip4) or non_zero_string(ip6),
ip4 = non_zero_string(ip4),
ip6 = non_zero_string(ip6),
port = (safe_tonumber(port) ~= 0) and safe_tonumber(port) or nil,
mac = non_zero_string(mac),
label = non_zero_string(lbl),
asn = safe_tonumber(asn),
peer_asn = safe_tonumber(peer_asn),
host_pool_id = safe_tonumber(pool),
country_code = non_zero_string(cc),
location = (safe_tonumber(loc) == 1) and "local" or "remote",
is_blacklisted = bool_flag(bl),
is_attacker = bool_flag(attk),
is_victim = bool_flag(vctm),
}
end
-- Build traffic block
local function build_traffic(flow)
local total = safe_tonumber(flow["TOTAL_BYTES"])
local cli2srv = safe_tonumber(flow["SRC2DST_BYTES"])
local srv2cli = safe_tonumber(flow["DST2SRC_BYTES"])
local pkt_tot = safe_tonumber(flow["PACKETS"])
local pkt_c2s = safe_tonumber(flow["SRC2DST_PACKETS"])
local pkt_s2c = safe_tonumber(flow["DST2SRC_PACKETS"])
local cli_pct = (total > 0) and math.floor(cli2srv * 100 / total) or 0
return {
total_bytes = total,
total_packets = pkt_tot,
cli2srv_bytes = cli2srv,
cli2srv_packets = pkt_c2s,
srv2cli_bytes = srv2cli,
srv2cli_packets = pkt_s2c,
cli2srv_pct = cli_pct,
srv2cli_pct = 100 - cli_pct,
}
end
-- Build timing block
local function build_timing(flow)
local first = safe_tonumber(flow["FIRST_SEEN"])
local last = safe_tonumber(flow["LAST_SEEN"])
return {
first_seen_epoch = first,
last_seen_epoch = last,
first_seen_human = os.date("!%Y-%m-%dT%H:%M:%SZ", first),
last_seen_human = os.date("!%Y-%m-%dT%H:%M:%SZ", last),
duration_sec = last - first,
}
end
-- Build latency block
local function build_latency(flow)
local cli_us = safe_tonumber(flow["CLIENT_NW_LATENCY_US"])
local srv_us = safe_tonumber(flow["SERVER_NW_LATENCY_US"])
if cli_us == 0 and srv_us == 0 then return nil end
return {
client_nw_latency_ms = cli_us / 1000,
server_nw_latency_ms = srv_us / 1000,
rtt_ms = (cli_us + srv_us) / 1000,
}
end
-- Build TCP block (only for TCP flows)
local function build_tcp(flow, proto_info_json)
if safe_tonumber(flow["PROTOCOL"]) ~= 6 then return nil end
local tcp_flags = proto_info_json and proto_info_json.tcp_flags_analysis
return {
src2dst_flags = safe_tonumber(flow["SRC2DST_TCP_FLAGS"]),
dst2src_flags = safe_tonumber(flow["DST2SRC_TCP_FLAGS"]),
fingerprint = non_zero_string(flow["TCP_FINGERPRINT"]),
major_conn_state = safe_tonumber(flow["MAJOR_CONNECTION_STATE"]),
minor_conn_state = safe_tonumber(flow["MINOR_CONNECTION_STATE"]),
flags_analysis = tcp_flags or nil,
}
end
-- Build QoE block
local function build_qoe(flow, proto_info_json)
local qoe_score = safe_tonumber(flow["QOE_SCORE"])
if qoe_score == 0 then return nil end
local qoe = proto_info_json and proto_info_json.qoe
return {
qoe_score = qoe_score,
c2s_score = qoe and qoe.c2s and qoe.c2s.score or nil,
s2c_score = qoe and qoe.s2c and qoe.s2c.qoe_score or nil,
}
end
-- Build issues / alerts block
local function build_issues(flow, alert_json)
local score = safe_tonumber(flow["SCORE"])
local alert_id = safe_tonumber(flow["STATUS"])
local alerts_map = flow["ALERTS_MAP"] or ""
local issues = {
total_score = score,
main_alert = {
id = alert_id,
label = alert_label(tostring(alert_id)),
},
flow_risks = {},
per_alert_scores = {},
}
-- Flow risk info (human-readable reasons)
local risk_info = (alert_json and alert_json.flow_risk_info) or {}
for risk_id, description in pairs(risk_info) do
issues.flow_risks[#issues.flow_risks + 1] = {
risk_id = tonumber(risk_id),
description = description,
}
end
-- Per-alert scores from ALERT_JSON.alerts
if alert_json and alert_json.alerts then
for aid, data in pairs(alert_json.alerts) do
issues.per_alert_scores[#issues.per_alert_scores + 1] = {
alert_id = tonumber(aid),
alert_label = alert_label(aid),
score = data.score,
}
end
end
-- Per-alert scores from alert_score flat map (older format)
if alert_json and alert_json.alert_score then
for aid, sc in pairs(alert_json.alert_score) do
-- avoid duplicates already captured above
if not (alert_json.alerts and alert_json.alerts[aid]) then
issues.per_alert_scores[#issues.per_alert_scores + 1] = {
alert_id = tonumber(aid),
alert_label = alert_label(aid),
score = sc,
}
end
end
end
return issues
end
-- Build probe / SNMP block
local function build_probe(flow)
local probe_ip = non_zero_string(flow["PROBE_IP"])
local input_snmp = safe_tonumber(flow["INPUT_SNMP"])
local output_snmp= safe_tonumber(flow["OUTPUT_SNMP"])
if not probe_ip and input_snmp == 0 and output_snmp == 0 then return nil end
return {
probe_ip = probe_ip,
input_snmp = (input_snmp ~= 0) and input_snmp or nil,
output_snmp = (output_snmp ~= 0) and output_snmp or nil,
}
end
-- Main public function
function historical_flow_llm_formatter.formatFlowForLLM(flow)
if not flow then return nil end
-- Decode embedded JSON blobs
local proto_info_json = json.decode(flow["PROTOCOL_INFO_JSON"] or "") or {}
local alert_json = json.decode(flow["ALERT_JSON"] or "") or {}
local result = {
context = build_context(flow),
protocol = build_protocol(flow, proto_info_json),
client = build_peer(flow, "cli"),
server = build_peer(flow, "srv"),
timing = build_timing(flow),
-- Bytes / packets
traffic = build_traffic(flow),
latency = build_latency(flow),
tcp = build_tcp(flow, proto_info_json),
qoe = build_qoe(flow, proto_info_json),
-- Security issues and scored alerts
issues = build_issues(flow, alert_json),
probe = build_probe(flow),
info = non_zero_string(flow["INFO"]),
}
return result
end
-- Convenience: return JSON string directly
function historical_flow_llm_formatter.flowToJSONString(flow, pretty)
local result = historical_flow_llm_formatter.formatFlowForLLM(flow)
if not result then return "{}" end
return result
end
return historical_flow_llm_formatter