bokamba / logforge / parse / Cisco ASA firewall

$ logforge parse cisco-asa

Parse Cisco ASA firewall logs → regex, Grok, Wazuh & rsyslog

Cisco ASA firewalls emit syslog, and the defining feature of every message is the mnemonic tag %ASA-<severity>-<message-id>: that opens the ASA-specific portion of the line. After the standard syslog header (a timestamp and the device name or IP), you get something like %ASA-4-106023: — where 4 is the syslog severity (0 emergency through 7 debug) and 106023 is the message ID. That numeric message ID is the key to the whole line: it, not the free text, tells you what happened. 106023 is 'Deny protocol src … dst … by access-group', 302013/302014 are TCP connection built and torn down, 302015/302016 are the UDP equivalents, 106100 is an ACL permit/deny hit with counters, and 305011/305012 cover NAT/xlate creation. Learn the message ID and the rest of the line becomes predictable.

The parsing trap is exactly that per-message-id variability: the text after the mnemonic is free-form and its layout changes with the message ID, so there is no single field template that fits all ASA lines. A 106023 deny reads 'Deny tcp src outside:203.0.113.45/51234 dst inside:192.0.2.10/443 by access-group "outside_access_in"', while a 302013 built-connection line reads 'Built inbound TCP connection 12345 for outside:203.0.113.45/51234 (203.0.113.45/51234) to inside:192.0.2.10/443 (192.0.2.10/443)'. Both encode addresses as interface:ip/port — the interface name (outside, inside, dmz) is prepended to the address with a colon, and the port follows a slash — but the surrounding words, the connection ID, and the presence of a NAT-translated address in parentheses all depend on the message ID. A correct parser therefore branches on the message ID first and applies a per-ID extraction, rather than trying one regex for everything.

For detection the load-bearing pieces are the severity (level 3-4 messages are where denies and problems cluster), the message ID (group and alert by it), and the interface:ip/port five-tuple you pull from the body. 106023 denies per source IP surface scanning and blocked lateral movement; a flood of 302013/302014 built/torn pairs profiles a host's connection behavior; 106100 with its hit counters feeds ACL tuning. Because the interface name is embedded in each address (outside: vs inside:), you also get directionality for free — which side originated the connection — without a separate field.

Open this in LogForge →

What a Cisco ASA firewall line looks like

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

<166>%ASA-4-106023: Deny tcp src outside:203.0.113.45/51234 dst inside:192.0.2.10/443 by access-group "outside_access_in"
<164>%ASA-4-106023: Deny udp src outside:198.51.100.23/40012 dst inside:192.0.2.20/53 by access-group "outside_access_in"

Detected fields

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

  • literal : literal
  • _lit1 : literal · literal
  • literal2 : literal
  • _lit2 : literal · literal
  • literal3 : literal
  • _lit3 : literal · literal
  • literal4 : literal
  • _lit4 : literal · literal
  • _lit5 : literal · literal
  • quoted_string : quoted_string · literal

Regex (named capture groups)

# sample: <166>%ASA-4-106023: Deny tcp src outside:203.0.113.45/51234 dst inside:192.0.2.10/443 by access-group "outside_access_in"
# groups: literal=<166>%ASA-4-106023, literal2=tcp, literal3=outside:203.0.113.45/51234, literal4=inside:192.0.2.10/443
^(?<literal><\d+>%[A-Za-z]+-\d+-\d+): Deny (?<literal2>[A-Za-z]+) src (?<literal3>[A-Za-z]+:\d+\.\d+\.\d+\.\d+/\d+) dst (?<literal4>[A-Za-z]+:\d+\.\d+\.\d+\.\d+/\d+) by access-group "outside_access_in"$

Grok pattern (Logstash / Elastic)

%{DATA:literal}: Deny %{NOTSPACE:literal2} src %{NOTSPACE:literal3} dst %{NOTSPACE:literal4} by access-group "outside_access_in
  • note constant field "quoted_string" embedded as literal anchor "outside_access_in" (varying=false)

Wazuh decoder (OS_Regex XML)

<!--
  Generated by LogForge - Wazuh decoder (OS_Regex dialect, not PCRE)
  sample: &lt;166>%ASA-4-106023: Deny tcp src outside:203.0.113.45/51234 dst inside:192.0.2.10/443 by access-group "outside_access_in"
  test with: /var/ossec/bin/wazuh-logtest
-->

<decoder name="cisco-asa-freeform">
  <prematch>^</prematch>
</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="cisco-asa,">
  <rule id="100000" level="3">
    <decoded_as>cisco-asa-freeform</decoded_as>
    <description>cisco-asa: decoded event</description>
  </rule>
</group>
  • note could not derive a distinctive <prematch>; the emitted anchor is permissive — tune before deploying
  • note field "literal" has no safe OS_Regex pattern before the ":" terminator — template truncated; field(s) omitted: literal, _lit1, literal2, _lit2, literal3, _lit3, literal4, _lit4, _lit5, quoted_string
  • note no varying fields could be captured — parent decoder emitted alone
  • 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
# cisco_asa — liblognorm v2 rulebase (generated by LogForge)
# Usage with rsyslog (mmnormalize runs liblognorm):
#   module(load="mmnormalize")
#   action(type="mmnormalize" rulebase="/etc/rsyslog.d/cisco_asa.rb" useRawMsg="on")
# Literal "%" is escaped as "%%"; raw tabs are written as \x09.
rule=cisco_asa:%literal:char-to{"extradata":":"}%: Deny %literal2:word% src %literal3:word% dst %literal4:word% by access-group "outside_access_in"
  • note trailing literal "\"" reconstructed from line 1
  • note chosen parser types: literal=char-to(:), literal2=word, literal3=word, literal4=word

Splunk

# props.conf  (search-time extraction)
[<REPLACE_WITH_SOURCETYPE>]
EXTRACT-logforge = (?<literal><\d+>%[A-Za-z]+-\d+-\d+): Deny (?<literal2>[A-Za-z]+) src (?<literal3>[A-Za-z]+:\d+\.\d+\.\d+\.\d+/\d+) dst (?<literal4>[A-Za-z]+:\d+\.\d+\.\d+\.\d+/\d+) by access-group "outside_access_in"

# Quick search-time test in SPL:
# | rex field=_raw "(?<literal><\\d+>%[A-Za-z]+-\\d+-\\d+): Deny (?<literal2>[A-Za-z]+) src (?<literal3>[A-Za-z]+:\\d+\\.\\d+\\.\\d+\\.\\d+/\\d+) dst (?<literal4>[A-Za-z]+:\\d+\\.\\d+\\.\\d+\\.\\d+/\\d+) by access-group \"outside_access_in\""
  • 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/cisco-asa
{
  "description": "LogForge-generated ingest pipeline for cisco-asa",
  "processors": [
    {
      "grok": {
        "field": "message",
        "patterns": [
          "%{DATA:literal}: Deny %{NOTSPACE:literal2} src %{NOTSPACE:literal3} dst %{NOTSPACE:literal4} by access-group \"outside_access_in"
        ]
      }
    }
  ]
}
  • note grok: constant field "quoted_string" embedded as literal anchor "outside_access_in" (varying=false)
  • note test in Kibana Dev Tools with: POST _ingest/pipeline/cisco-asa/_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 "cisco-asa-parse"
when
  has_field("message")
then
  let gp = grok(pattern: "%{DATA:literal}: Deny %{NOTSPACE:literal2} src %{NOTSPACE:literal3} dst %{NOTSPACE:literal4} by access-group \"outside_access_in", 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": "cisco-asa",
      "extractor_type": "grok",
      "converters": [],
      "order": 0,
      "cursor_strategy": "copy",
      "source_field": "message",
      "target_field": "",
      "extractor_config": {
        "grok_pattern": "%{DATA:literal}: Deny %{NOTSPACE:literal2} src %{NOTSPACE:literal3} dst %{NOTSPACE:literal4} by access-group \"outside_access_in",
        "named_captures_only": true
      },
      "condition_type": "none",
      "condition_value": ""
    }
  ],
  "version": "5.0.0"
}
  • note grok: constant field "quoted_string" embedded as literal anchor "outside_access_in" (varying=false)
  • 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 %{notSpace:literal}: Deny %{notSpace:literal2} src %{notSpace:literal3} dst %{notSpace:literal4} by access-group "outside_access_in
  • note emitted rule name is "logforge_rule"; rename it to match your "cisco-asa" convention if desired
  • note constant field "quoted_string" embedded as literal anchor "outside_access_in" (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        cisco-asa
    Format      regex
    Regex       ^(?<literal><\d+>%[A-Za-z]+-\d+-\d+): Deny (?<literal2>[A-Za-z]+) src (?<literal3>[A-Za-z]+:\d+\.\d+\.\d+\.\d+/\d+) dst (?<literal4>[A-Za-z]+:\d+\.\d+\.\d+\.\d+/\d+) by access-group "outside_access_in"$
    # 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 /^(?<literal><\d+>%[A-Za-z]+-\d+-\d+): Deny (?<literal2>[A-Za-z]+) src (?<literal3>[A-Za-z]+:\d+\.\d+\.\d+\.\d+\/\d+) dst (?<literal4>[A-Za-z]+:\d+\.\d+\.\d+\.\d+\/\d+) by access-group "outside_access_in"$/
#   </parse>

Vector

[transforms.cisco_asa_parse]
type = "remap"
inputs = ["REPLACE_WITH_SOURCE"]
source = '''
. |= parse_regex!(.message, r'(?P<literal><\d+>%[A-Za-z]+-\d+-\d+): Deny (?P<literal2>[A-Za-z]+) src (?P<literal3>[A-Za-z]+:\d+\.\d+\.\d+\.\d+/\d+) dst (?P<literal4>[A-Za-z]+:\d+\.\d+\.\d+\.\d+/\d+) by access-group "outside_access_in"')
'''

Loki

# promtail pipeline for "cisco-asa" (generated by LogForge)
# Add these stages under a scrape_config in your promtail config:
#   scrape_configs:
#     - job_name: cisco-asa
#       pipeline_stages:
# (the stages below are indented to sit under pipeline_stages)
pipeline_stages:
  - regex:
      expression: '^(?P<literal><\d+>%[A-Za-z]+-\d+-\d+): Deny (?P<literal2>[A-Za-z]+) src (?P<literal3>[A-Za-z]+:\d+\.\d+\.\d+\.\d+/\d+) dst (?P<literal4>[A-Za-z]+:\d+\.\d+\.\d+\.\d+/\d+) by access-group "outside_access_in"$'
  • 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): literal, literal2, literal3, literal4

syslog-ng

parser p_cisco_asa {
    regexp-parser(
        prefix(".cisco_asa.")
        patterns("(?<literal><\\d+>%[A-Za-z]+-\\d+-\\d+): Deny (?<literal2>[A-Za-z]+) src (?<literal3>[A-Za-z]+:\\d+\\.\\d+\\.\\d+\\.\\d+/\\d+) dst (?<literal4>[A-Za-z]+:\\d+\\.\\d+\\.\\d+\\.\\d+/\\d+) by access-group \"outside_access_in\"")
    );
};
  • note captured fields are stored as name-value pairs under the prefix ".cisco_asa." (e.g. a group (?<srcip>…) becomes ".cisco_asa.srcip")

FAQ

What is the %ASA-x-nnnnnn tag in a Cisco ASA log?
It is the ASA message identifier. %ASA is the device class, the single digit after it is the syslog severity (0 emergency … 7 debug), and the six-digit number is the message ID that names the specific event — 106023 for an ACL deny, 302013 for a TCP connection built, and so on. The message ID, not the free text, is what you branch on when parsing.
Why can I not write one regex for all ASA messages?
Because the text after the mnemonic is free-form and its structure differs per message ID. A 106023 deny, a 302013 connection-built, and a 106100 ACL-hit line arrange their fields differently. Extract the severity and message ID with a common pattern, then apply a per-message-id sub-pattern to the body — order-independent field templates keyed on the ID.
How are addresses formatted in ASA logs?
As interface:ip/port. The ASA prepends the interface name (outside, inside, dmz) to the address with a colon and appends the port after a slash — e.g. outside:203.0.113.45/51234. For NAT/xlate messages a translated address often follows in parentheses. This means you get the traffic direction from the interface name without a separate field.
Which message IDs matter most for security monitoring?
106023 (deny by access-group) and 106100 (ACL permit/deny with hit counts) for policy enforcement, 302013/302014 (TCP built/teardown) and 302015/302016 (UDP) for connection tracking, and 305011/305012 for NAT/xlate. Alerting on 106023 grouped by source IP is the classic scan- and blocked-traffic detection.

Try it on your own Cisco ASA 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 →