bokamba / logforge / parse / AWS VPC Flow

$ logforge parse aws-vpc-flow

Parse AWS VPC Flow logs → regex, Grok, Wazuh & rsyslog

AWS VPC Flow Logs capture the IP traffic that traverses your VPC's network interfaces, and unlike CloudTrail they are not JSON — the default format is a single line of SPACE-DELIMITED fixed columns. The version-2 default layout is a positional sequence of exactly these fields, in order: version, account-id, interface-id, srcaddr, dstaddr, srcport, dstport, protocol, packets, bytes, start, end, action, log-status. So a line like '2 123456789012 eni-0abc12 203.0.113.45 10.0.1.20 51234 443 6 12 3520 1783085000 1783085060 ACCEPT OK' is read strictly by position. This positional nature is the whole game: get the column order right and every field falls out; get it wrong and you silently mislabel ports as protocols.

Two fields need decoding. protocol is the IANA protocol NUMBER, not a name: 6 is TCP, 17 is UDP, 1 is ICMP — so a value of 6 in that column means TCP, and a detection rule keyed on 'protocol == TCP' has to compare against 6. start and end are Unix epoch SECONDS marking the capture window for the aggregated flow (VPC flow logs aggregate traffic over a window rather than logging per-packet), so packets and bytes are totals for that window, not for one packet. action is ACCEPT or REJECT — whether the traffic was permitted or denied by the security groups and network ACLs — and log-status is OK, NODATA (no traffic in the window), or SKIPDATA (records were dropped by the capture). The srcaddr/dstaddr can be private VPC addresses, public addresses, or, for AWS-service traffic, sometimes the interface's own address depending on flow direction.

The critical parsing caveat is that the format is CUSTOMIZABLE: you can define your own field list and order (adding vpc-id, subnet-id, tcp-flags, pkt-srcaddr, flow-direction, and many more), and when you do, the columns are no longer the version-2 default and appear in whatever order your format string specifies. So a parser must be told, or must infer from the format definition, which columns are present and in what order — the format string is authoritative, not a fixed template. For detection the load-bearing fields are srcaddr/dstaddr and srcport/dstport (the flow), action (REJECT bursts to many dstports from one srcaddr is a scan; REJECTs to a sensitive dstport are probes), protocol, and bytes (a large outbound byte total to an external dstaddr is possible exfiltration).

Open this in LogForge →

What an AWS VPC Flow line looks like

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

2 123456789012 eni-0abc12de34567890 203.0.113.45 192.0.2.10 51234 443 6 12 1520 1783085000 1783085060 ACCEPT OK
2 445566778899 eni-0f9e87dc65432100 198.51.100.77 192.0.2.20 40112 53 17 3 240 1783085100 1783085160 REJECT OK

Detected fields

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

  • number : number · literal
  • number2 : number
  • literal : literal
  • ip1 : ipv4
  • ip2 : ipv4
  • number3 : number
  • number4 : number
  • number5 : number
  • number6 : number
  • number7 : number
  • timestamp : timestamp
  • timestamp2 : timestamp
  • literal2 : literal
  • _lit1 : literal · literal

Regex (named capture groups)

# sample: 2 123456789012 eni-0abc12de34567890 203.0.113.45 192.0.2.10 51234 443 6 12 1520 1783085000 1783085060 ACCEPT OK
# groups: number2=123456789012, literal=eni-0abc12de34567890, ip1=203.0.113.45, ip2=192.0.2.10, number3=51234, number4=443, number5=6, number6=12, number7=1520, timestamp=1783085000, timestamp2=1783085060, literal2=ACCEPT
^2 (?<number2>-?\d+(?:\.\d+)?) (?<literal>(?:[A-Za-z]+-\d+[A-Za-z]+\d+[A-Za-z]+\d+|[A-Za-z]+-\d+[A-Za-z]+\d+[A-Za-z]+\d+[A-Za-z]+\d+)) (?<ip1>\d{1,3}(?:\.\d{1,3}){3}) (?<ip2>\d{1,3}(?:\.\d{1,3}){3}) (?<number3>-?\d+(?:\.\d+)?) (?<number4>-?\d+(?:\.\d+)?) (?<number5>-?\d+(?:\.\d+)?) (?<number6>-?\d+(?:\.\d+)?) (?<number7>-?\d+(?:\.\d+)?) (?<timestamp>\d+) (?<timestamp2>\d+) (?<literal2>[A-Za-z]+) OK$
  • note the message tail diverges across lines and could not be split into stable fields — the trailing per-token literal group(s) are positional word-slices of that unstructured message, not stable fields

Grok pattern (Logstash / Elastic)

# custom patterns
AWS_VPC_FLOW_EPOCH \d{10}(?:\d{3})?

2 %{NUMBER:number2} %{NOTSPACE:literal} %{IPV4:ip1} %{IPV4:ip2} %{NUMBER:number3} %{NUMBER:number4} %{NUMBER:number5} %{NUMBER:number6} %{NUMBER:number7} %{AWS_VPC_FLOW_EPOCH:timestamp} %{AWS_VPC_FLOW_EPOCH:timestamp2} %{NOTSPACE:literal2} OK
  • note constant field "number" embedded as literal anchor "2" (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: 2 123456789012 eni-0abc12de34567890 203.0.113.45 192.0.2.10 51234 443 6 12 1520 1783085000 1783085060 ACCEPT OK
  test with: /var/ossec/bin/wazuh-logtest
-->

<decoder name="aws-vpc-flow-freeform">
  <prematch>^\d+ </prematch>
</decoder>

<decoder name="aws-vpc-flow-freeform">
  <parent>aws-vpc-flow-freeform</parent>
  <regex>^2 (\d+) (\w+) (\d+.\d+.\d+.\d+) (\d+.\d+.\d+.\d+) (\d+) (\d+) (\d+) (\d+)</regex>
  <order>number2, literal, srcip, ip2, number3, number4, number5, number6</order>
</decoder>

<decoder name="aws-vpc-flow-freeform">
  <parent>aws-vpc-flow-freeform</parent>
  <regex offset="after_regex">^ (\d+) (\d+) (\d+) (\w+) OK</regex>
  <order>number7, timestamp, timestamp2, literal2</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="aws-vpc-flow,">
  <rule id="100000" level="3">
    <decoded_as>aws-vpc-flow-freeform</decoded_as>
    <description>aws-vpc-flow: srcip=$(srcip)</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>aws-vpc-flow: a value you care about from $(srcip)</description>
  </rule>
  -->
</group>
  • note field "ip1" mapped to Wazuh conventional field "srcip"
  • note constant field "number" embedded as literal anchor "2"
  • 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
# aws_vpc_flow — liblognorm v2 rulebase (generated by LogForge)
# Usage with rsyslog (mmnormalize runs liblognorm):
#   module(load="mmnormalize")
#   action(type="mmnormalize" rulebase="/etc/rsyslog.d/aws_vpc_flow.rb" useRawMsg="on")
# Literal "%" is escaped as "%%"; raw tabs are written as \x09.
rule=aws_vpc_flow:2 %number2:number% %literal:word% %ip1:ipv4% %ip2:ipv4% %number3:number% %number4:number% %number5:number% %number6:number% %number7:number% %timestamp:number% %timestamp2:number% %literal2:word% OK
  • note chosen parser types: number2=number, literal=word, ip1=ipv4, ip2=ipv4, number3=number, number4=number, number5=number, number6=number, number7=number, timestamp=number, timestamp2=number, literal2=word

Splunk

# props.conf  (search-time extraction)
[<REPLACE_WITH_SOURCETYPE>]
EXTRACT-logforge = 2 (?<number2>-?\d+(?:\.\d+)?) (?<literal>(?:[A-Za-z]+-\d+[A-Za-z]+\d+[A-Za-z]+\d+|[A-Za-z]+-\d+[A-Za-z]+\d+[A-Za-z]+\d+[A-Za-z]+\d+)) (?<ip1>\d{1,3}(?:\.\d{1,3}){3}) (?<ip2>\d{1,3}(?:\.\d{1,3}){3}) (?<number3>-?\d+(?:\.\d+)?) (?<number4>-?\d+(?:\.\d+)?) (?<number5>-?\d+(?:\.\d+)?) (?<number6>-?\d+(?:\.\d+)?) (?<number7>-?\d+(?:\.\d+)?) (?<timestamp>\d+) (?<timestamp2>\d+) (?<literal2>[A-Za-z]+) OK

# Quick search-time test in SPL:
# | rex field=_raw "2 (?<number2>-?\\d+(?:\\.\\d+)?) (?<literal>(?:[A-Za-z]+-\\d+[A-Za-z]+\\d+[A-Za-z]+\\d+|[A-Za-z]+-\\d+[A-Za-z]+\\d+[A-Za-z]+\\d+[A-Za-z]+\\d+)) (?<ip1>\\d{1,3}(?:\\.\\d{1,3}){3}) (?<ip2>\\d{1,3}(?:\\.\\d{1,3}){3}) (?<number3>-?\\d+(?:\\.\\d+)?) (?<number4>-?\\d+(?:\\.\\d+)?) (?<number5>-?\\d+(?:\\.\\d+)?) (?<number6>-?\\d+(?:\\.\\d+)?) (?<number7>-?\\d+(?:\\.\\d+)?) (?<timestamp>\\d+) (?<timestamp2>\\d+) (?<literal2>[A-Za-z]+) OK"
  • note regex: the message tail diverges across lines and could not be split into stable fields — the trailing per-token literal group(s) are positional word-slices of that unstructured message, not stable fields
  • note EXTRACT-<class> names must be unique within a sourcetype stanza — rename EXTRACT-logforge if you already use that class for this sourcetype
  • note a timestamp field was detected: this EXTRACT only makes it a searchable field. To set the event _time at index time, add TIME_PREFIX and TIME_FORMAT to this props.conf stanza (TIME_FORMAT uses Splunk strptime, e.g. %Y-%m-%dT%H:%M:%S) — this generator does not guess the strptime format.

ES ingest

PUT _ingest/pipeline/aws-vpc-flow
{
  "description": "LogForge-generated ingest pipeline for aws-vpc-flow",
  "processors": [
    {
      "grok": {
        "field": "message",
        "patterns": [
          "2 %{NUMBER:number2} %{NOTSPACE:literal} %{IPV4:ip1} %{IPV4:ip2} %{NUMBER:number3} %{NUMBER:number4} %{NUMBER:number5} %{NUMBER:number6} %{NUMBER:number7} %{AWS_VPC_FLOW_EPOCH:timestamp} %{AWS_VPC_FLOW_EPOCH:timestamp2} %{NOTSPACE:literal2} OK"
        ],
        "pattern_definitions": {
          "AWS_VPC_FLOW_EPOCH": "\\d{10}(?:\\d{3})?"
        }
      }
    }
  ]
}
  • note grok: constant field "number" embedded as literal anchor "2" (varying=false)
  • note grok: custom patterns emitted — save the '# custom patterns' block to a file in your patterns_dir
  • note test in Kibana Dev Tools with: POST _ingest/pipeline/aws-vpc-flow/_simulate (supply a docs[] array whose _source.message holds a sample line)

Graylog

# Grok patterns to add under System > Grok Patterns:
# (Graylog needs these custom patterns installed globally BEFORE the rule/extractor below will work.)
#   AWS_VPC_FLOW_EPOCH \d{10}(?:\d{3})?

# --- Graylog processing pipeline rule (primary) ---
# Paste under System > Pipelines > Manage rules, then attach the rule to a pipeline stage.
rule "aws-vpc-flow-parse"
when
  has_field("message")
then
  let gp = grok(pattern: "2 %{NUMBER:number2} %{NOTSPACE:literal} %{IPV4:ip1} %{IPV4:ip2} %{NUMBER:number3} %{NUMBER:number4} %{NUMBER:number5} %{NUMBER:number6} %{NUMBER:number7} %{AWS_VPC_FLOW_EPOCH:timestamp} %{AWS_VPC_FLOW_EPOCH:timestamp2} %{NOTSPACE:literal2} OK", 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": "aws-vpc-flow",
      "extractor_type": "grok",
      "converters": [],
      "order": 0,
      "cursor_strategy": "copy",
      "source_field": "message",
      "target_field": "",
      "extractor_config": {
        "grok_pattern": "2 %{NUMBER:number2} %{NOTSPACE:literal} %{IPV4:ip1} %{IPV4:ip2} %{NUMBER:number3} %{NUMBER:number4} %{NUMBER:number5} %{NUMBER:number6} %{NUMBER:number7} %{AWS_VPC_FLOW_EPOCH:timestamp} %{AWS_VPC_FLOW_EPOCH:timestamp2} %{NOTSPACE:literal2} OK",
        "named_captures_only": true
      },
      "condition_type": "none",
      "condition_value": ""
    }
  ],
  "version": "5.0.0"
}
  • note grok: constant field "number" embedded as literal anchor "2" (varying=false)
  • note grok: custom patterns emitted — save the '# custom patterns' block to a file in your patterns_dir
  • note 1 custom grok pattern(s) (AWS_VPC_FLOW_EPOCH) must be installed globally first under System > Grok Patterns — see the block at the top of the output
  • 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 2 %{number:number2} %{notSpace:literal} %{ipv4:ip1} %{ipv4:ip2} %{number:number3} %{number:number4} %{number:number5} %{number:number6} %{number:number7} %{data:timestamp} %{data:timestamp2} %{notSpace:literal2} OK
  • note emitted rule name is "logforge_rule"; rename it to match your "aws-vpc-flow" convention if desired
  • note constant field "number" embedded as literal anchor "2" (varying=false)
  • note field "timestamp" (timestamp): could not derive a Joda/Java date format from the sample shape; using %{data} — add a date("…") format by hand if you need a parsed timestamp
  • note field "timestamp2" (timestamp): could not derive a Joda/Java date format from the sample shape; using %{data} — add a date("…") format by hand if you need a parsed timestamp
  • 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        aws-vpc-flow
    Format      regex
    Regex       ^2 (?<number2>-?\d+(?:\.\d+)?) (?<literal>(?:[A-Za-z]+-\d+[A-Za-z]+\d+[A-Za-z]+\d+|[A-Za-z]+-\d+[A-Za-z]+\d+[A-Za-z]+\d+[A-Za-z]+\d+)) (?<ip1>\d{1,3}(?:\.\d{1,3}){3}) (?<ip2>\d{1,3}(?:\.\d{1,3}){3}) (?<number3>-?\d+(?:\.\d+)?) (?<number4>-?\d+(?:\.\d+)?) (?<number5>-?\d+(?:\.\d+)?) (?<number6>-?\d+(?:\.\d+)?) (?<number7>-?\d+(?:\.\d+)?) (?<timestamp>\d+) (?<timestamp2>\d+) (?<literal2>[A-Za-z]+) OK$
    Time_Key    timestamp
    # Time_Format <unrecognized timestamp shape — set a strptime format, e.g. %Y-%m-%dT%H:%M:%S>
# Fluentd <parse> block:
#   <parse>
#     @type regexp
#     expression /^2 (?<number2>-?\d+(?:\.\d+)?) (?<literal>(?:[A-Za-z]+-\d+[A-Za-z]+\d+[A-Za-z]+\d+|[A-Za-z]+-\d+[A-Za-z]+\d+[A-Za-z]+\d+[A-Za-z]+\d+)) (?<ip1>\d{1,3}(?:\.\d{1,3}){3}) (?<ip2>\d{1,3}(?:\.\d{1,3}){3}) (?<number3>-?\d+(?:\.\d+)?) (?<number4>-?\d+(?:\.\d+)?) (?<number5>-?\d+(?:\.\d+)?) (?<number6>-?\d+(?:\.\d+)?) (?<number7>-?\d+(?:\.\d+)?) (?<timestamp>\d+) (?<timestamp2>\d+) (?<literal2>[A-Za-z]+) OK$/
#     time_key timestamp
#   </parse>
  • note regex: the message tail diverges across lines and could not be split into stable fields — the trailing per-token literal group(s) are positional word-slices of that unstructured message, not stable fields
  • note Time_Key set to "timestamp", but the timestamp shape was not recognized — fill in Time_Format (strptime) yourself; for epoch values consider Fluent Bit's time_as_integer / a Lua filter instead

Vector

[transforms.aws_vpc_flow_parse]
type = "remap"
inputs = ["REPLACE_WITH_SOURCE"]
source = '''
. |= parse_regex!(.message, r'2 (?P<number2>-?\d+(?:\.\d+)?) (?P<literal>(?:[A-Za-z]+-\d+[A-Za-z]+\d+[A-Za-z]+\d+|[A-Za-z]+-\d+[A-Za-z]+\d+[A-Za-z]+\d+[A-Za-z]+\d+)) (?P<ip1>\d{1,3}(?:\.\d{1,3}){3}) (?P<ip2>\d{1,3}(?:\.\d{1,3}){3}) (?P<number3>-?\d+(?:\.\d+)?) (?P<number4>-?\d+(?:\.\d+)?) (?P<number5>-?\d+(?:\.\d+)?) (?P<number6>-?\d+(?:\.\d+)?) (?P<number7>-?\d+(?:\.\d+)?) (?P<timestamp>\d+) (?P<timestamp2>\d+) (?P<literal2>[A-Za-z]+) OK')
'''
  • note [regex] the message tail diverges across lines and could not be split into stable fields — the trailing per-token literal group(s) are positional word-slices of that unstructured message, not stable fields

Loki

# promtail pipeline for "aws-vpc-flow" (generated by LogForge)
# Add these stages under a scrape_config in your promtail config:
#   scrape_configs:
#     - job_name: aws-vpc-flow
#       pipeline_stages:
# (the stages below are indented to sit under pipeline_stages)
pipeline_stages:
  - regex:
      expression: '^2 (?P<number2>-?\d+(?:\.\d+)?) (?P<literal>(?:[A-Za-z]+-\d+[A-Za-z]+\d+[A-Za-z]+\d+|[A-Za-z]+-\d+[A-Za-z]+\d+[A-Za-z]+\d+[A-Za-z]+\d+)) (?P<ip1>\d{1,3}(?:\.\d{1,3}){3}) (?P<ip2>\d{1,3}(?:\.\d{1,3}){3}) (?P<number3>-?\d+(?:\.\d+)?) (?P<number4>-?\d+(?:\.\d+)?) (?P<number5>-?\d+(?:\.\d+)?) (?P<number6>-?\d+(?:\.\d+)?) (?P<number7>-?\d+(?:\.\d+)?) (?P<timestamp>\d+) (?P<timestamp2>\d+) (?P<literal2>[A-Za-z]+) OK$'
  • note regex: the message tail diverges across lines and could not be split into stable fields — the trailing per-token literal group(s) are positional word-slices of that unstructured message, not stable fields
  • note no low-cardinality field found to promote to a Loki label — omitted the `- labels:` stage; every captured field stays in the extracted map for later stages
  • note left in the extracted map (NOT promoted to labels — high cardinality would explode Loki streams): number2, literal, ip1, ip2, number3, number4, number5, number6, number7, timestamp, timestamp2, literal2

syslog-ng

parser p_aws_vpc_flow {
    regexp-parser(
        prefix(".aws_vpc_flow.")
        patterns("2 (?<number2>-?\\d+(?:\\.\\d+)?) (?<literal>(?:[A-Za-z]+-\\d+[A-Za-z]+\\d+[A-Za-z]+\\d+|[A-Za-z]+-\\d+[A-Za-z]+\\d+[A-Za-z]+\\d+[A-Za-z]+\\d+)) (?<ip1>\\d{1,3}(?:\\.\\d{1,3}){3}) (?<ip2>\\d{1,3}(?:\\.\\d{1,3}){3}) (?<number3>-?\\d+(?:\\.\\d+)?) (?<number4>-?\\d+(?:\\.\\d+)?) (?<number5>-?\\d+(?:\\.\\d+)?) (?<number6>-?\\d+(?:\\.\\d+)?) (?<number7>-?\\d+(?:\\.\\d+)?) (?<timestamp>\\d+) (?<timestamp2>\\d+) (?<literal2>[A-Za-z]+) OK")
    );
};
  • note regex: the message tail diverges across lines and could not be split into stable fields — the trailing per-token literal group(s) are positional word-slices of that unstructured message, not stable fields
  • note captured fields are stored as name-value pairs under the prefix ".aws_vpc_flow." (e.g. a group (?<srcip>…) becomes ".aws_vpc_flow.srcip")

FAQ

What are the columns in a default VPC Flow Log line?
The version-2 default is, in order: version, account-id, interface-id, srcaddr, dstaddr, srcport, dstport, protocol, packets, bytes, start, end, action, log-status. They are space-delimited and strictly positional, so the parser reads by column position — there are no keys.
Why is the protocol a number like 6 or 17?
It is the IANA protocol number, not a name: 6 is TCP, 17 is UDP, 1 is ICMP. Any rule that thinks in protocol names must map these numbers first. This is a common source of misparsed flow logs when someone expects "TCP" and gets "6".
What do the start and end fields represent?
They are Unix epoch seconds bounding the aggregation window for the flow, because VPC Flow Logs aggregate traffic over a capture window rather than logging each packet. So packets and bytes are totals across that window, not per-packet values — keep that in mind when computing rates.
Does the column order ever change?
Yes. VPC Flow Logs support a custom format where you choose the fields and their order (adding tcp-flags, vpc-id, pkt-srcaddr, flow-direction, and others). When a custom format is in use the default positional layout no longer applies — the format string is authoritative and the parser must follow it, not the version-2 default.

Try it on your own AWS VPC Flow 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 →