bokamba / logforge / parse / iptables / netfilter

$ logforge parse iptables

Parse iptables / netfilter logs → regex, Grok, Wazuh & rsyslog

iptables logging is not really an iptables format at all — it is the Linux netfilter framework writing to the kernel ring buffer via the LOG target, so the lines surface through the kernel facility in dmesg, /var/log/kern.log, or the journal. What makes a netfilter line identifiable is the --log-prefix you set on the rule: a short literal string (commonly '[UFW BLOCK] ', 'iptables-drop: ', or your own tag) that the kernel prepends to every packet the rule matches, which is how you tell which rule fired. After the syslog/kernel header and that prefix comes the packet dump: a space-separated list of KEY=VALUE tokens that netfilter fills from the packet headers. The worked example below is deliberately reduced to that netfilter KEY=VALUE payload — the syslog/kernel header and --log-prefix stripped off — so the generated parser locks onto the packet fields (which is exactly what you want a decoder that runs after the syslog layer has already split off the header to do); prepend your header/prefix pattern when you parse the raw kernel line end to end.

The token set is well-defined but conditional. IN= and OUT= name the ingress and egress interfaces, and one of them is legitimately empty — for an inbound packet OUT= has no value (it is a bare 'OUT=' with nothing after it), and for an outbound packet IN= is empty. That empty-value case is normal and must not break your tokenizer. MAC= is the packet's link-layer header as a long colon-separated hex string (destination MAC + source MAC + ethertype concatenated). SRC= and DST= are the IPv4/IPv6 addresses, LEN= the total length, TTL= the time-to-live, and PROTO= the protocol (TCP, UDP, ICMP). For TCP and UDP you then get SPT= and DPT= (source and destination ports), and for TCP the flags appear as bare words — SYN, ACK, PSH, RST, FIN, URG — sitting between the KEY=VALUE tokens with no key of their own, which is the second thing that trips naive KEY=VALUE parsers. WINDOW=, RES=, and ID= round out the header detail.

Parsing is therefore mostly about being liberal: accept KEY=VALUE pairs in any order (netfilter's order is stable but assume it is not), tolerate empty values (OUT=), and treat the bare TCP-flag words as a separate flag set rather than expecting every token to contain an equals sign. The syslog header carries no year and the kernel timestamp may also appear as a bracketed seconds-since-boot value on some setups. For detection the fields that matter are the log-prefix (which rule/policy blocked it), SRC and SPT (attacker origin), DST and DPT (what they were reaching for), PROTO, and the TCP flags — a burst of SYN-only packets to many DPTs from one SRC is a port scan, and grouping drops by DPT shows what is being probed.

Open this in LogForge →

What an iptables / netfilter line looks like

The key=value sample below is fed verbatim into the engine to produce every parser on this page.

IN=eth0 OUT= MAC=00:1a:2b:3c:4d:5e SRC=203.0.113.45 DST=192.0.2.10 LEN=60 TTL=54 PROTO=TCP SPT=51234 DPT=22 WINDOW=1024 SYN
IN=eth0 OUT= MAC=00:1a:2b:3c:4d:5e SRC=198.51.100.77 DST=192.0.2.20 LEN=40 TTL=118 PROTO=TCP SPT=40112 DPT=3389 WINDOW=512 SYN

Detected fields

The engine classified this sample as kv and consolidated 11 fields across 2 lines. Fields marked literal were identical on every sample line, so they are baked into the pattern as anchors rather than captured.

  • in : literal · literal
  • out : literal · literal
  • mac : mac · literal
  • src : ipv4
  • dst : ipv4
  • len : number
  • ttl : number
  • proto : literal · literal
  • spt : port
  • dpt : port
  • window : number

Regex (named capture groups)

# sample: IN=eth0 OUT= MAC=00:1a:2b:3c:4d:5e SRC=203.0.113.45 DST=192.0.2.10 LEN=60 TTL=54 PROTO=TCP SPT=51234 DPT=22 WINDOW=1024 SYN
# groups: src=203.0.113.45, dst=192.0.2.10, len=60, ttl=54, spt=51234, dpt=22, window=1024
^(?=.*?(?<![\w.\-])IN=eth0)(?=.*?(?<![\w.\-])OUT=|)(?=.*?(?<![\w.\-])MAC=00:1a:2b:3c:4d:5e)(?=.*?(?<![\w.\-])SRC=(?<src>\d{1,3}(?:\.\d{1,3}){3}))(?=.*?(?<![\w.\-])DST=(?<dst>\d{1,3}(?:\.\d{1,3}){3}))(?=.*?(?<![\w.\-])LEN=(?<len>-?\d+(?:\.\d+)?))(?=.*?(?<![\w.\-])TTL=(?<ttl>-?\d+(?:\.\d+)?))(?=.*?(?<![\w.\-])PROTO=TCP)(?=.*?(?<![\w.\-])SPT=(?<spt>\d{1,5}))(?=.*?(?<![\w.\-])DPT=(?<dpt>\d{1,5}))(?=.*?(?<![\w.\-])WINDOW=(?<window>-?\d+(?:\.\d+)?)).*$
  • note a single linear template could not reproduce every input line — fields are captured with order-independent lookaheads instead

Grok pattern (Logstash / Elastic)

IN=eth0(?: OUT=)? MAC=00:1a:2b:3c:4d:5e SRC=%{IPV4:src} DST=%{IPV4:dst} LEN=%{NUMBER:len} TTL=%{NUMBER:ttl} PROTO=TCP SPT=%{INT:spt} DPT=%{INT:dpt} WINDOW=%{NUMBER:window}
  • note kv-structured input — consider the Logstash kv filter instead of (or after) grok
  • note constant field "mac" embedded as literal anchor "00:1a:2b:3c:4d:5e" (varying=false)
  • note 1 optional field(s) wrapped in (?:…)? inline regex — grok has no native optional syntax

Wazuh decoder (OS_Regex XML)

<!--
  Generated by LogForge - Wazuh decoder (OS_Regex dialect, not PCRE)
  sample: IN=eth0 OUT= MAC=00:1a:2b:3c:4d:5e SRC=203.0.113.45 DST=192.0.2.10 LEN=60 TTL=54 PROTO=TCP SPT=51234 DPT=22 WINDOW=1024 SYN
  test with: /var/ossec/bin/wazuh-logtest
-->

<decoder name="iptables-kv">
  <prematch>^IN=\w+ OUT=</prematch>
</decoder>

<decoder name="iptables-kv">
  <parent>iptables-kv</parent>
  <regex offset="after_parent"> SRC=(\d+.\d+.\d+.\d+)</regex>
  <order>srcip</order>
</decoder>

<decoder name="iptables-kv">
  <parent>iptables-kv</parent>
  <regex offset="after_parent"> DST=(\d+.\d+.\d+.\d+)</regex>
  <order>dstip</order>
</decoder>

<decoder name="iptables-kv">
  <parent>iptables-kv</parent>
  <regex offset="after_parent"> LEN=(\d+)</regex>
  <order>len</order>
</decoder>

<decoder name="iptables-kv">
  <parent>iptables-kv</parent>
  <regex offset="after_parent"> TTL=(\d+)</regex>
  <order>ttl</order>
</decoder>

<decoder name="iptables-kv">
  <parent>iptables-kv</parent>
  <regex offset="after_parent"> SPT=(\d+)</regex>
  <order>srcport</order>
</decoder>

<decoder name="iptables-kv">
  <parent>iptables-kv</parent>
  <regex offset="after_parent"> DPT=(\d+)</regex>
  <order>dstport</order>
</decoder>

<decoder name="iptables-kv">
  <parent>iptables-kv</parent>
  <regex offset="after_parent"> WINDOW=(\d+)</regex>
  <order>window</order>
</decoder>

<!-- ============================================================
     ALERT RULE (starter) — put this in a RULES file, e.g.
     /var/ossec/etc/rules/local_rules.xml. Decoders and rules live
     in SEPARATE files. The rule matches the decoder above through
     <decoded_as>; set <level> and add <field>/<match> conditions so
     it alerts only on the events you care about. Rule ids 100000+
     are the user range — change them if they collide with yours.
     ============================================================ -->
<group name="iptables,">
  <rule id="100000" level="3">
    <decoded_as>iptables-kv</decoded_as>
    <description>iptables: srcip=$(srcip) dstip=$(dstip)</description>
  </rule>

  <!-- Example — a higher-level alert gated on one field (uncomment and edit):
  <rule id="100001" level="10">
    <if_sid>100000</if_sid>
    <field name="srcip">^CHANGE_ME$</field>
    <description>iptables: a value you care about from $(srcip)</description>
  </rule>
  -->
</group>
  • note constant field "in" skipped (identical in every line)
  • note constant field "out" skipped (identical in every line)
  • note constant field "mac" skipped (identical in every line)
  • note field "src" mapped to Wazuh conventional field "srcip"
  • note field "dst" mapped to Wazuh conventional field "dstip"
  • note constant field "proto" skipped (identical in every line)
  • note field "spt" mapped to Wazuh conventional field "srcport"
  • note field "dpt" mapped to Wazuh conventional field "dstport"
  • note kv fields are extracted by same-named sibling decoders (offset="after_parent"), so per-line field order/absence is tolerated — the shared name is what makes Wazuh evaluate every sibling
  • note added a starter alert <rule> (level 3, matched to the decoder via <decoded_as>) — put it in a RULES file (not the decoders file), set the level, and add <field>/<match> conditions; the commented example child rule shows the pattern
  • note decoder order and prematch specificity may need site-specific tuning (other decoders in your ruleset can shadow these) — validate with /var/ossec/bin/wazuh-logtest

Wazuh's OS_Regex is not PCRE — a bare . is a literal dot and \. matches any character. Test Wazuh OS_Regex patterns →

rsyslog template / liblognorm rulebase

version=2
# iptables — liblognorm v2 rulebase (generated by LogForge)
# Usage with rsyslog (mmnormalize runs liblognorm):
#   module(load="mmnormalize")
#   action(type="mmnormalize" rulebase="/etc/rsyslog.d/iptables.rb" useRawMsg="on")
# Literal "%" is escaped as "%%"; raw tabs are written as \x09.
rule=iptables:IN=eth0 OUT= MAC=00:1a:2b:3c:4d:5e SRC=%src:ipv4% DST=%dst:ipv4% LEN=%len:number% TTL=%ttl:number% PROTO=TCP SPT=%spt:number% DPT=%dpt:number% WINDOW=%window:number% SYN
  • note kv structure: rsyslog offers mmfields (fast, fixed single-char separator, untyped) and mmnormalize (this rulebase, typed fields + literal anchors); mmnormalize was chosen for typed extraction
  • note trailing literal " SYN" reconstructed from line 1
  • note chosen parser types: src=ipv4, dst=ipv4, len=number, ttl=number, spt=number, dpt=number, window=number

Splunk

# props.conf  (search-time extraction)
[<REPLACE_WITH_SOURCETYPE>]
EXTRACT-logforge = (?=.*?(?<![\w.\-])IN=eth0)(?=.*?(?<![\w.\-])OUT=|)(?=.*?(?<![\w.\-])MAC=00:1a:2b:3c:4d:5e)(?=.*?(?<![\w.\-])SRC=(?<src>\d{1,3}(?:\.\d{1,3}){3}))(?=.*?(?<![\w.\-])DST=(?<dst>\d{1,3}(?:\.\d{1,3}){3}))(?=.*?(?<![\w.\-])LEN=(?<len>-?\d+(?:\.\d+)?))(?=.*?(?<![\w.\-])TTL=(?<ttl>-?\d+(?:\.\d+)?))(?=.*?(?<![\w.\-])PROTO=TCP)(?=.*?(?<![\w.\-])SPT=(?<spt>\d{1,5}))(?=.*?(?<![\w.\-])DPT=(?<dpt>\d{1,5}))(?=.*?(?<![\w.\-])WINDOW=(?<window>-?\d+(?:\.\d+)?)).*

# Quick search-time test in SPL:
# | rex field=_raw "(?=.*?(?<![\\w.\\-])IN=eth0)(?=.*?(?<![\\w.\\-])OUT=|)(?=.*?(?<![\\w.\\-])MAC=00:1a:2b:3c:4d:5e)(?=.*?(?<![\\w.\\-])SRC=(?<src>\\d{1,3}(?:\\.\\d{1,3}){3}))(?=.*?(?<![\\w.\\-])DST=(?<dst>\\d{1,3}(?:\\.\\d{1,3}){3}))(?=.*?(?<![\\w.\\-])LEN=(?<len>-?\\d+(?:\\.\\d+)?))(?=.*?(?<![\\w.\\-])TTL=(?<ttl>-?\\d+(?:\\.\\d+)?))(?=.*?(?<![\\w.\\-])PROTO=TCP)(?=.*?(?<![\\w.\\-])SPT=(?<spt>\\d{1,5}))(?=.*?(?<![\\w.\\-])DPT=(?<dpt>\\d{1,5}))(?=.*?(?<![\\w.\\-])WINDOW=(?<window>-?\\d+(?:\\.\\d+)?)).*"
  • note regex: a single linear template could not reproduce every input line — fields are captured with order-independent lookaheads instead
  • note EXTRACT-<class> names must be unique within a sourcetype stanza — rename EXTRACT-logforge if you already use that class for this sourcetype

ES ingest

PUT _ingest/pipeline/iptables
{
  "description": "LogForge-generated ingest pipeline for iptables",
  "processors": [
    {
      "kv": {
        "field": "message",
        "field_split": " ",
        "value_split": "="
      }
    }
  ]
}
  • note grok: kv-structured input — consider the Logstash kv filter instead of (or after) grok
  • note grok: constant field "mac" embedded as literal anchor "00:1a:2b:3c:4d:5e" (varying=false)
  • note grok: 1 optional field(s) wrapped in (?:…)? inline regex — grok has no native optional syntax
  • note kv structure: emitted a { kv: { field: "message", field_split: " ", value_split: "=" } } processor — it extracts key=value pairs natively; adjust field_split/value_split if your delimiter differs
  • note a grok alternative is available too (the reused grok pattern is shown in the notes above); prefer the kv processor unless you need typed/anchored extraction
  • note test in Kibana Dev Tools with: POST _ingest/pipeline/iptables/_simulate (supply a docs[] array whose _source.message holds a sample line)

Graylog

# --- Graylog processing pipeline rule (primary) ---
# Paste under System > Pipelines > Manage rules, then attach the rule to a pipeline stage.
rule "iptables-parse"
when
  has_field("message")
then
  let gp = grok(pattern: "IN=eth0(?: OUT=)? MAC=00:1a:2b:3c:4d:5e SRC=%{IPV4:src} DST=%{IPV4:dst} LEN=%{NUMBER:len} TTL=%{NUMBER:ttl} PROTO=TCP SPT=%{INT:spt} DPT=%{INT:dpt} WINDOW=%{NUMBER:window}", value: to_string($message.message), only_named_captures: true);
  set_fields(gp);
end

# --- Graylog import-ready extractor JSON (secondary) ---
# Save as a .json file and import under System > Inputs > (input) > Manage extractors > Actions > Import extractors.
{
  "extractors": [
    {
      "title": "iptables",
      "extractor_type": "grok",
      "converters": [],
      "order": 0,
      "cursor_strategy": "copy",
      "source_field": "message",
      "target_field": "",
      "extractor_config": {
        "grok_pattern": "IN=eth0(?: OUT=)? MAC=00:1a:2b:3c:4d:5e SRC=%{IPV4:src} DST=%{IPV4:dst} LEN=%{NUMBER:len} TTL=%{NUMBER:ttl} PROTO=TCP SPT=%{INT:spt} DPT=%{INT:dpt} WINDOW=%{NUMBER:window}",
        "named_captures_only": true
      },
      "condition_type": "none",
      "condition_value": ""
    }
  ],
  "version": "5.0.0"
}
  • note grok: kv-structured input — consider the Logstash kv filter instead of (or after) grok
  • note grok: constant field "mac" embedded as literal anchor "00:1a:2b:3c:4d:5e" (varying=false)
  • note grok: 1 optional field(s) wrapped in (?:…)? inline regex — grok has no native optional syntax
  • note primary artifact is the processing-pipeline rule; the extractor JSON is an equivalent import-ready alternative for the classic extractor UI

Datadog

logforge_rule IN=eth0 OUT= MAC=00:1a:2b:3c:4d:5e SRC=%{ipv4:src} DST=%{ipv4:dst} LEN=%{number:len} TTL=%{number:ttl} PROTO=TCP SPT=%{integer:spt} DPT=%{integer:dpt} WINDOW=%{number:window}
  • note emitted rule name is "logforge_rule"; rename it to match your "iptables" convention if desired
  • note kv input — consider Datadog's key-value/`keyvalue()` filter in the Grok Parser instead of anchoring each key by hand
  • note constant field "mac" embedded as literal anchor "00:1a:2b:3c:4d:5e" (varying=false)
  • note paste this line into a Grok Parser processor in a Datadog Log Pipeline; matchers are anchored left-to-right and rule whitespace matches log whitespace. Complex or multi-shape logs may need Helper Rules.

Fluent Bit

[PARSER]
    Name        iptables
    Format      regex
    Regex       ^(?=.*?(?<![\w.\-])IN=eth0)(?=.*?(?<![\w.\-])OUT=|)(?=.*?(?<![\w.\-])MAC=00:1a:2b:3c:4d:5e)(?=.*?(?<![\w.\-])SRC=(?<src>\d{1,3}(?:\.\d{1,3}){3}))(?=.*?(?<![\w.\-])DST=(?<dst>\d{1,3}(?:\.\d{1,3}){3}))(?=.*?(?<![\w.\-])LEN=(?<len>-?\d+(?:\.\d+)?))(?=.*?(?<![\w.\-])TTL=(?<ttl>-?\d+(?:\.\d+)?))(?=.*?(?<![\w.\-])PROTO=TCP)(?=.*?(?<![\w.\-])SPT=(?<spt>\d{1,5}))(?=.*?(?<![\w.\-])DPT=(?<dpt>\d{1,5}))(?=.*?(?<![\w.\-])WINDOW=(?<window>-?\d+(?:\.\d+)?)).*$
    # Time_Key    <name of a timestamp capture group, if any>
    # Time_Format <strptime format, e.g. %Y-%m-%dT%H:%M:%S>
# Fluentd <parse> block:
#   <parse>
#     @type regexp
#     expression /^(?=.*?(?<![\w.\-])IN=eth0)(?=.*?(?<![\w.\-])OUT=|)(?=.*?(?<![\w.\-])MAC=00:1a:2b:3c:4d:5e)(?=.*?(?<![\w.\-])SRC=(?<src>\d{1,3}(?:\.\d{1,3}){3}))(?=.*?(?<![\w.\-])DST=(?<dst>\d{1,3}(?:\.\d{1,3}){3}))(?=.*?(?<![\w.\-])LEN=(?<len>-?\d+(?:\.\d+)?))(?=.*?(?<![\w.\-])TTL=(?<ttl>-?\d+(?:\.\d+)?))(?=.*?(?<![\w.\-])PROTO=TCP)(?=.*?(?<![\w.\-])SPT=(?<spt>\d{1,5}))(?=.*?(?<![\w.\-])DPT=(?<dpt>\d{1,5}))(?=.*?(?<![\w.\-])WINDOW=(?<window>-?\d+(?:\.\d+)?)).*$/
#   </parse>
  • note regex: a single linear template could not reproduce every input line — fields are captured with order-independent lookaheads instead

Vector

[transforms.iptables_parse]
type = "remap"
inputs = ["REPLACE_WITH_SOURCE"]
source = '''
. = parse_key_value!(.message)
'''
  • note the reused regex needs lookahead, which the Rust regex crate behind parse_regex rejects; used parse_key_value! on .message (also the idiomatic Vector parser for key=value logs)

Loki

# promtail pipeline for "iptables" (generated by LogForge)
# Add these stages under a scrape_config in your promtail config:
#   scrape_configs:
#     - job_name: iptables
#       pipeline_stages:
# (the stages below are indented to sit under pipeline_stages)
pipeline_stages:
  # NOTE: the extraction regex below uses lookahead, which Go RE2
  #       (promtail's regex engine) CANNOT compile. It is commented out.
  # - regex:
  #     expression: '^(?=.*?(?<![\w.\-])IN=eth0)(?=.*?(?<![\w.\-])OUT=|)(?=.*?(?<![\w.\-])MAC=00:1a:2b:3c:4d:5e)(?=.*?(?<![\w.\-])SRC=(?P<src>\d{1,3}(?:\.\d{1,3}){3}))(?=.*?(?<![\w.\-])DST=(?P<dst>\d{1,3}(?:\.\d{1,3}){3}))(?=.*?(?<![\w.\-])LEN=(?P<len>-?\d+(?:\.\d+)?))(?=.*?(?<![\w.\-])TTL=(?P<ttl>-?\d+(?:\.\d+)?))(?=.*?(?<![\w.\-])PROTO=TCP)(?=.*?(?<![\w.\-])SPT=(?P<spt>\d{1,5}))(?=.*?(?<![\w.\-])DPT=(?P<dpt>\d{1,5}))(?=.*?(?<![\w.\-])WINDOW=(?P<window>-?\d+(?:\.\d+)?)).*$'
  # Use a `- logfmt:` stage instead for key=value logs, e.g.:
  # - logfmt:
  #     mapping:
  #       src: 'SRC'
  #       dst: 'DST'
  #       len: 'LEN'
  #       ttl: 'TTL'
  #       spt: 'SPT'
  #       dpt: 'DPT'
  #       window: 'WINDOW'
  • note regex: a single linear template could not reproduce every input line — fields are captured with order-independent lookaheads instead
  • note kv input: the reused regex needs lookahead (Go RE2 cannot do it) because key order varies per line — use a `- logfmt:` stage (shown commented) which parses key=value pairs order-independently, then a `- labels:` stage for the low-cardinality keys

syslog-ng

parser p_iptables {
    regexp-parser(
        prefix(".iptables.")
        patterns("(?=.*?(?<![\\w.\\-])IN=eth0)(?=.*?(?<![\\w.\\-])OUT=|)(?=.*?(?<![\\w.\\-])MAC=00:1a:2b:3c:4d:5e)(?=.*?(?<![\\w.\\-])SRC=(?<src>\\d{1,3}(?:\\.\\d{1,3}){3}))(?=.*?(?<![\\w.\\-])DST=(?<dst>\\d{1,3}(?:\\.\\d{1,3}){3}))(?=.*?(?<![\\w.\\-])LEN=(?<len>-?\\d+(?:\\.\\d+)?))(?=.*?(?<![\\w.\\-])TTL=(?<ttl>-?\\d+(?:\\.\\d+)?))(?=.*?(?<![\\w.\\-])PROTO=TCP)(?=.*?(?<![\\w.\\-])SPT=(?<spt>\\d{1,5}))(?=.*?(?<![\\w.\\-])DPT=(?<dpt>\\d{1,5}))(?=.*?(?<![\\w.\\-])WINDOW=(?<window>-?\\d+(?:\\.\\d+)?)).*")
    );
};
  • note regex: a single linear template could not reproduce every input line — fields are captured with order-independent lookaheads instead
  • note captured fields are stored as name-value pairs under the prefix ".iptables." (e.g. a group (?<srcip>…) becomes ".iptables.srcip")
  • note kv structure: syslog-ng has a dedicated kv-parser() that is simpler and more robust than regexp-parser for key=value logs — consider kv-parser(prefix(".logforge.")) instead of the emitted regexp-parser

FAQ

Why is OUT= (or IN=) empty in my iptables log line?
That is expected. For an inbound packet netfilter fills IN= with the ingress interface and leaves OUT= empty; for an outbound packet the reverse. So a bare 'OUT=' with no value is normal, not corruption — your parser must accept empty-valued keys rather than assuming every KEY= has a value.
What is the --log-prefix and why does it matter?
It is a literal string you attach to a LOG rule (e.g. '[UFW BLOCK] ') that the kernel prepends to every matching packet's log line. Since netfilter itself does not name the rule, the prefix is how you attribute a logged packet to a specific rule or policy — parse it as your rule/action label.
How are TCP flags represented in an iptables log?
As bare uppercase words (SYN, ACK, PSH, RST, FIN, URG) sitting among the KEY=VALUE tokens with no key of their own. A parser must collect these standalone flag words separately rather than expecting every token to be KEY=VALUE. A packet with only SYN set, repeated across many destination ports, is the signature of a port scan.
Where do iptables logs actually appear on disk?
They come from the kernel (netfilter LOG target), so they land wherever kernel messages go: dmesg, /var/log/kern.log (Debian/Ubuntu), /var/log/messages (RHEL), or the systemd journal. There is no dedicated iptables file; the log-prefix is what lets you filter your firewall lines out of the general kernel stream.

Try it on your own iptables / netfilter lines

Paste a few real lines, review the detected fields, and copy whichever format your stack needs. Free, no account, nothing uploaded.

Open this sample in LogForge →