bokamba / logforge / parse / FortiGate firewall

$ logforge parse fortigate

Parse FortiGate firewall logs → regex, Grok, Wazuh & rsyslog

FortiGate firewalls (Fortinet's FortiOS appliances) emit logs as a single line of space-separated key=value pairs, one event per line. Depending on how the appliance is configured they arrive over syslog to a collector, get pulled by FortiAnalyzer/FortiManager, or are shipped to a SIEM; the on-the-wire text is the same key=value blob regardless of transport. Every line leads with date= and time= as two separate fields (date=2026-07-03 time=14:22:15) rather than one combined timestamp, and carries device identity in devname= and devid=, a numeric logid=, and a type/subtype pair — the most common being type="traffic" subtype="forward" for through-traffic sessions, alongside type="utm", "event", and others.

The structure looks trivial until you try to tokenize it. Values are sometimes quoted and sometimes bare: devname="FGT60F" and action="accept" are quoted, but srcip=192.0.2.10 and dstport=443 are not, and a naive split on spaces breaks the moment a quoted value contains a space (a msg="..." or a URL). The key set is not fixed — it varies by log type, by FortiOS major version, and by which UTM features are licensed and enabled — so two lines from the same box can carry different keys, and a parser that assumes a fixed column order will fail. This is the canonical case for order-independent, key-anchored parsing rather than a rigid left-to-right template: when your real traffic reorders or omits keys between events, you want a pattern that matches each key=value pair wherever it appears (LogForge switches to lookahead-based captures once its sample lines disagree on key order).

For firewall analytics and detection the load-bearing fields are the five-tuple and the verdict: srcip, srcport, dstip, dstport, the service ("HTTPS", "SSH", …), and action, which is the field everyone alerts on — action="accept" versus action="deny" is the difference between allowed and blocked traffic. sentbyte and rcvdbyte drive volume and exfiltration analysis (a deny with both at 0 is a blocked connection attempt; a huge sentbyte to an external dstip is worth a look). level= carries the Fortinet severity (notice, warning, alert). Because FortiGate is one of the most widely deployed firewalls, mature decoders exist — Wazuh ships a FortiGate decoder using exactly the parent/child prematch pattern LogForge generates — but hand-writing one that survives version drift and the quoting rules is where the time goes.

Open this in LogForge →

What a FortiGate firewall line looks like

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

date=2026-07-03 time=14:22:15 devname="FGT60F" devid="FGT60FTK20012345" logid="0000000013" type="traffic" subtype="forward" level="notice" srcip=192.0.2.10 srcport=51234 dstip=198.51.100.20 dstport=443 action="accept" service="HTTPS" sentbyte=15320 rcvdbyte=88210
date=2026-07-03 time=14:22:44 devname="FGT60F" devid="FGT60FTK20012345" logid="0000000013" type="traffic" subtype="forward" level="notice" srcip=192.0.2.31 srcport=61002 dstip=203.0.113.80 dstport=22 action="deny" service="SSH" sentbyte=0 rcvdbyte=0

Detected fields

The engine classified this sample as kv and consolidated 16 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.

  • date : timestamp · literal
  • time : timestamp
  • devname : hostname · literal
  • devid : quoted_string · literal
  • logid : number · literal
  • type : quoted_string · literal
  • subtype : quoted_string · literal
  • level : severity · literal
  • srcip : ipv4
  • srcport : port
  • dstip : ipv4
  • dstport : port
  • action : quoted_string
  • service : quoted_string
  • sentbyte : number
  • rcvdbyte : number

Regex (named capture groups)

# sample: date=2026-07-03 time=14:22:15 devname="FGT60F" devid="FGT60FTK20012345" logid="0000000013" type="traffic" subtype="forward" level="notice" srcip=192.0.2.10 srcport=51234 dstip=198.51.100.20 dstport=443 action="accept" service="HTTPS" sentbyte=15320 rcvdbyte=88210
# groups: time=14:22:15, srcip=192.0.2.10, srcport=51234, dstip=198.51.100.20, dstport=443, action=accept, service=HTTPS, sentbyte=15320, rcvdbyte=88210
^date=2026-07-03 time=(?<time>\d+:\d+:\d+) devname="FGT60F" devid="FGT60FTK20012345" logid="0000000013" type="traffic" subtype="forward" level="notice" srcip=(?<srcip>\d{1,3}(?:\.\d{1,3}){3}) srcport=(?<srcport>\d{1,5}) dstip=(?<dstip>\d{1,3}(?:\.\d{1,3}){3}) dstport=(?<dstport>\d{1,5}) action="(?<action>[^"]*)" service="(?<service>[^"]*)" sentbyte=(?<sentbyte>-?\d+(?:\.\d+)?) rcvdbyte=(?<rcvdbyte>-?\d+(?:\.\d+)?)$

Grok pattern (Logstash / Elastic)

# custom patterns
FORTIGATE_NOTDQUOTE [^"]*

date=2026-07-03 time=%{TIME:time} devname="FGT60F" devid="FGT60FTK20012345" logid="0000000013" type="traffic" subtype="forward" level="notice" srcip=%{IPV4:srcip} srcport=%{INT:srcport} dstip=%{IPV4:dstip} dstport=%{INT:dstport} action="%{FORTIGATE_NOTDQUOTE:action}" service="%{FORTIGATE_NOTDQUOTE:service}" sentbyte=%{NUMBER:sentbyte} rcvdbyte=%{NUMBER:rcvdbyte}
  • note kv-structured input — consider the Logstash kv filter instead of (or after) grok
  • note constant field "date" embedded as literal anchor "2026-07-03" (varying=false)
  • note constant field "devname" embedded as literal anchor "FGT60F" (varying=false)
  • note constant field "devid" embedded as literal anchor "FGT60FTK20012345" (varying=false)
  • note constant field "logid" embedded as literal anchor "0000000013" (varying=false)
  • note constant field "type" embedded as literal anchor "traffic" (varying=false)
  • note constant field "subtype" embedded as literal anchor "forward" (varying=false)
  • note constant field "level" embedded as literal anchor "notice" (varying=false)
  • note custom patterns emitted — save the '# custom patterns' block to a file in your patterns_dir

Wazuh decoder (OS_Regex XML)

<!--
  Generated by LogForge - Wazuh decoder (OS_Regex dialect, not PCRE)
  sample: date=2026-07-03 time=14:22:15 devname="FGT60F" devid="FGT60FTK20012345" logid="0000000013" type="traffic" subtype="forward" level="notice" srcip=192.0.2.10 srcp
  test with: /var/ossec/bin/wazuh-logtest
-->

<decoder name="fortigate-kv">
  <prematch>^date=\d+-\d+-\d+ time=</prematch>
</decoder>

<decoder name="fortigate-kv">
  <parent>fortigate-kv</parent>
  <regex offset="after_parent">^(\d+:\d+:\d+) devname="\w+" devid="\w+" logid="\d+" type="\w+" subtype="\w+" level="\w+" srcip=(\d+.\d+.\d+.\d+) srcport=(\d+) dstip=(\d+.\d+.\d+.\d+) dstport=(\d+) action="(\w+)" service="(\w+)" sentbyte=(\d+)</regex>
  <order>time, srcip, srcport, dstip, dstport, action, service, sentbyte</order>
</decoder>

<decoder name="fortigate-kv">
  <parent>fortigate-kv</parent>
  <regex offset="after_parent"> rcvdbyte=(\d+)</regex>
  <order>rcvdbyte</order>
</decoder>
  • note constant field "date" skipped (identical in every line)
  • 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 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

rsyslog template / liblognorm rulebase

version=2
# fortigate — liblognorm v2 rulebase (generated by LogForge)
# Usage with rsyslog (mmnormalize runs liblognorm):
#   module(load="mmnormalize")
#   action(type="mmnormalize" rulebase="/etc/rsyslog.d/fortigate.rb" useRawMsg="on")
# Literal "%" is escaped as "%%"; raw tabs are written as \x09.
rule=fortigate:date=2026-07-03 time=%time:time-24hr% devname="FGT60F" devid="FGT60FTK20012345" logid="0000000013" type="traffic" subtype="forward" level="notice" srcip=%srcip:ipv4% srcport=%srcport:number% dstip=%dstip:ipv4% dstport=%dstport:number% action="%action:char-to{"extradata":"\""}%" service="%service:char-to{"extradata":"\""}%" sentbyte=%sentbyte:number% rcvdbyte=%rcvdbyte:number%
  • 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 chosen parser types: time=time-24hr, srcip=ipv4, srcport=number, dstip=ipv4, dstport=number, action=char-to("), service=char-to("), sentbyte=number, rcvdbyte=number

FAQ

What fields does a FortiGate traffic log contain?
A traffic (type="traffic", subtype="forward") log carries date and time as separate fields, device identity (devname, devid), a numeric logid, the connection five-tuple (srcip, srcport, dstip, dstport), the service name, the action (accept/deny), byte counters (sentbyte, rcvdbyte), and a severity level. UTM and event logs add their own keys — the exact set depends on FortiOS version and enabled features.
Why are some FortiGate values quoted and others not?
FortiOS quotes values that may contain spaces or are string-typed (devname="FGT60F", action="accept", msg="…") and leaves numeric or token values bare (srcip=192.0.2.10, dstport=443). A parser must handle both forms and must not split on spaces inside a quoted value, which is the number-one cause of broken FortiGate parsing.
Why do two FortiGate lines have different keys?
Because the key set depends on the log type, subtype, FortiOS version, and which UTM inspection profiles are active. A traffic log and a UTM/threat log share the header keys but diverge in the body. Use an order-independent, key-anchored parser (match key=value pairs wherever they appear) rather than a fixed-column pattern.
How do I tell allowed traffic from blocked traffic in a FortiGate log?
The action field is the verdict: action="accept" is allowed, action="deny" is blocked (other values like "close", "timeout", and "server-rst" describe session teardown). A deny with sentbyte=0 and rcvdbyte=0 is a connection that was refused outright — a common signal when hunting for scanning or blocked lateral movement.

Try it on your own FortiGate firewall 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 →