Parse auditd logs with Python before they hit your SIEM

Raw audit records are unreadable and expensive to index. Normalize them with 80 lines of Python and cut ingest cost.

Jul 15, 2025·Updated ·5 min readBeginner·By SecOpsLog · command-tested
bash — representative: one event, four records (fields elided for width; the fixture log carries them in full)
grep "audit(1757667721.324:8841)" /var/log/audit/audit.log
type=SYSCALL msg=audit(1757667721.324:8841): arch=c000003e syscall=257 success=yes exit=3 … auid=1000 uid=0 gid=0 euid=0 … comm="vim" exe="/usr/bin/vim" key="identity"
type=CWD msg=audit(1757667721.324:8841): cwd="/root"
type=PATH msg=audit(1757667721.324:8841): item=0 name="/etc/" inode=2 … nametype=PARENT
type=PATH msg=audit(1757667721.324:8841): item=1 name="/etc/passwd" inode=8391 … nametype=NORMAL
the question "who edited /etc/passwd" needs fields from three of these four lines

The audit log is precise and hostile. One logical event, a process opening a file, is written as several records that share a serial number and are otherwise independent lines of key=value pairs: the syscall record has the identity (auid is the login user, uid is who they became), the CWD record has the directory, and one PATH record per path component has the file. ausearch -i joins them for a single host and turns numbers into names. When the question spans a fleet, or the answer has to land in Loki or a SIEM as one object per event, forty lines of Python do the same join and produce JSON.

The shape of the records

RecordCarriesField to keep
SYSCALLwho, what call, whether it succeeded, which binaryauid, uid, syscall, success, exe, comm, key
CWDthe working directory the path is relative tocwd
PATH (one per item)each path the syscall touched: parent directory, the file, a rename targetname, nametype (NORMAL, CREATE, DELETE, PARENT)
PROCTITLEthe command line, hex-encoded when it contains spacesproctitle (decode from hex)
EXECVEargv of an executed program, one field per argumenta0, a1, …

Join by serial, keep every PATH

Two details decide whether the parser is right. Values are sometimes quoted (name="/etc/passwd") and sometimes not (auid=1000), so the field regex has to accept both. And a single event carries several PATH records, so the parser must collect them as a list instead of letting the last one overwrite the rest; the record that matters is usually the one with nametype=NORMAL or CREATE, not the PARENT directory that comes first.

parse_audit.py
import collections, json, re, sys
FIELD = re.compile(r'(\w+)=(?:"([^"]*)"|(\S+))')
HEAD = re.compile(r'^type=(\w+) msg=audit\((\d+\.\d+):(\d+)\):')
UNSET = "4294967295"
def parse(lines):
"""Yield one dict per audit event, joining every record that shares a serial."""
events = collections.OrderedDict()
for line in lines:
head = HEAD.match(line)
if not head:
continue
rtype, ts, serial = head.groups()
fields = {k: q or u for k, q, u in FIELD.findall(line[head.end():])}
ev = events.setdefault(serial, {"serial": serial, "time": float(ts), "paths": []})
if rtype == "PATH":
ev["paths"].append({"name": fields.get("name"), "nametype": fields.get("nametype")})
elif rtype == "SYSCALL":
ev.update({k: fields.get(k) for k in ("auid", "uid", "syscall", "success", "exe", "comm", "key")})
elif rtype == "CWD":
ev["cwd"] = fields.get("cwd")
return events.values()
if __name__ == "__main__":
for ev in parse(open(sys.argv[1] if len(sys.argv) > 1 else "/var/log/audit/audit.log")):
print(json.dumps(ev))

The OrderedDict keyed by serial is the whole join; records for one event are adjacent in practice but the parser does not rely on it. Everything else is choosing which fields to keep, and the list is deliberately short: a SIEM ingest bill is proportional to what you ship, and the fields above answer the identity questions without the fifty others a SYSCALL record carries.

Ask the question

who.py
from parse_audit import parse, UNSET
for ev in parse(open("/var/log/audit/audit.log")):
if ev.get("key") != "identity" or ev.get("auid") in (None, UNSET):
continue # not a watched file, or not a logged-in human
target = next((p["name"] for p in ev["paths"] if p["nametype"] in ("NORMAL", "CREATE", "DELETE")), None)
print(f'{ev["time"]:.0f} auid={ev["auid"]} as uid={ev["uid"]} {ev["comm"]} -> {target} ({ev["success"]})')
bash — observed: who.py over the synthetic log (Python 3.13, four events in, two humans out)observed
python3 who.py
1757667721 auid=1000 as uid=0 vim -> /etc/passwd (yes)
1757671104 auid=1000 as uid=0 useradd -> /etc/passwd (yes)
login user 1000 became root twice and edited the file; auid survives sudo, uid does not. The daemon write (auid unset) and the network syscall (a different key) are in the log but not in this list
bash — representative: the same question with no code, on a host that runs auditd
ausearch -k identity -ts today -i | grep -E "^type=(SYSCALL|PATH)" | grep -E "auid=|nametype=NORMAL"
type=SYSCALL … auid=alice uid=root … comm=vim exe=/usr/bin/vim key=identity
type=PATH … name=/etc/passwd … nametype=NORMAL
for one host and one question, ausearch -i already joins and interprets; the script earns its keep at fleet scale

auid is the field that makes the answer meaningful: it is set at login and inherited through su and sudo, so a change made as root still names the person. The sentinel 4294967295 (unset) marks processes with no login session, which is every daemon, and filtering it out is what turns the output into a list of humans. ausearch --format csv or --format text is the no-code route to a readable export, and -i is the flag that turns auid=1000 into auid=alice on the host that knows the mapping.

Ship one object per event

Printing JSON lines is the bridge to everything downstream: an Alloy or Vector pipeline tails the output (or the parser runs as a sidecar), the key becomes a label, auid becomes structured metadata, and "who touched /etc/shadow on any host this month" is a saved query rather than an ssh loop. For a live stream instead of a file, journalctl -u auditd -o cat or the audit dispatcher's syslog plugin provide the same lines on stdin, and parse() does not care which.

Parsed audit events are still audit events
The JSON contains command lines, file paths and who did what; it is as sensitive as the raw log and easier to search. Restrict who can read the destination, ship before local rotation deletes the source, and run the parser as a user with read access to the log rather than as root. Without the watch rules that produce the events in the first place, none of this has anything to parse.
Script vs ausearch
ausearch / aureport
One host, one question
Interpreted names with -i
Time windows, keys, uids built in
Nothing to maintain
A parser
Fleet-wide, through the log platform
Enrichment: hostname, environment, owner
Custom joins (who plus what plus where)
One JSON object per event for ingest
What was run for this article
parse_audit.py and who.py exactly as printed here, run with Python 3.13.15 (the python:3.13-slim image, Docker Engine 28.5.2, linux/arm64) against a synthetic four-event audit.log made up for the fixture (build/evidence/py-auditd). The observed block is who.py's output on that log; four assertions check that who.py prints the two human edits and nothing else, that the parser joins each event's records by serial and keeps both of the vim event's PATH records, that the daemon write (auid 4294967295) and the network-key syscall are dropped by the filters, and that the field regex reads quoted and unquoted values alike. The record-format block and the ausearch block are representative: the runner has no auditd, and nothing in the log is real.

The rules that generate these events, with a key per question and the filters that keep the volume readable, are in auditd rules that satisfy the auditor. Where the JSON lands and how it is queried next to application logs is in logs in Loki.

Go deeper in a coursePython for security automationLog parsing, APIs and small CLI tools with real error handling.View course

Related posts

Quick reference