Skip to main content
Defensive Security · lesson 6 of 8
Defensive Security intermediate Lesson 6 of 8

Also part of: Cloud Security

Building an Incident Timeline from Auth, Web and IDS Logs

Merge SSH auth logs, a web access log and Suricata alerts into one UTC timeline in Python, and fix the timezone bug that puts an attack in the wrong order.

By SkillByExample · Published

defensive-security incident-response timeline timezones python dfir

An incident report has to answer “what happened, in what order”. The evidence lives in different logs, written by different programs, with different ideas of what a timestamp is. In this lesson one source probes your web server and then guesses its way into your SSH server. You’ll merge the three logs that recorded it into one timeline, and watch the order come out wrong until every timestamp has a timezone. Tested with Docker Engine 29.8.2, Python 3.12.15 (python:3.12-slim), Suricata 8.0.7 (jasonish/suricata:8.0), and the lab images from the earlier lessons in this topic.

One incident, three logs

This reuses the ds-lab network, the sshd-lab, ssh-client and capture-lab images, logins.sh from the auth logs lesson and traffic.sh from the triage lesson. The triage lesson replaced local.rules, so put the .env rule from the Suricata lesson in a file of its own:

# env.rules: the .env rule from the Suricata lesson
alert http any any -> any any (msg:"LAB request for a .env file"; flow:established,to_server; http.uri; content:"/.env"; endswith; classtype:attempted-recon; sid:1000001; rev:2;)

The web server gets one change: its clock is set to Indian Standard Time, UTC+5:30, with a POSIX TZ string. Servers set to local time are common, and this one stands in for all of them.

docker rm -f sshd web tap 2>/dev/null
docker run -d --name sshd --hostname sshd --network ds-lab --ip 172.30.0.10 sshd-lab
docker run -d --name web --network ds-lab --ip 172.30.0.11 -e TZ=IST-5:30 python:3.12-slim python -m http.server 8000
docker run -d --name tap --network container:web -v "$PWD":/work capture-lab tcpdump -i eth0 -U -w /work/incident.pcap port 8000

Then the attack: a web probe followed straight away by SSH guessing.

$ docker run --rm --network ds-lab --ip 172.30.0.20 -v "$PWD":/work -w /work capture-lab sh traffic.sh probe
probe done
$ docker run --rm --network ds-lab --ip 172.30.0.20 -v "$PWD":/work -w /work ssh-client sh logins.sh
alice / password1 -> denied
alice / letmein -> denied
alice / 123456 -> denied
admin / admin -> denied
oracle / oracle -> denied
alice / correct-horse -> ok

Collect the three logs: the web server’s access log (Python’s http.server writes it to stderr, so it’s in docker logs), the SSH server’s auth.log, and Suricata’s eve.json from the capture.

docker stop tap
docker logs web > access.log 2>&1
docker cp sshd:/var/log/auth.log auth.log
rm -rf out && mkdir out
docker run --rm --entrypoint suricata -v "$PWD":/work -w /work jasonish/suricata:8.0 -r incident.pcap -S env.rules -k none -l out
$ cat access.log
172.30.0.20 - - [14/Oct/2026 14:34:48] code 404, message File not found
172.30.0.20 - - [14/Oct/2026 14:34:48] "GET /.env HTTP/1.1" 404 -
172.30.0.20 - - [14/Oct/2026 14:34:48] code 404, message File not found
172.30.0.20 - - [14/Oct/2026 14:34:48] "GET /admin HTTP/1.1" 404 -
172.30.0.20 - - [14/Oct/2026 14:34:48] code 404, message File not found
172.30.0.20 - - [14/Oct/2026 14:34:48] "GET /backup.zip HTTP/1.1" 404 -

Three timestamp styles are now in play. auth.log has 2026-10-14T09:04:52.464054+00:00: full date, microseconds and an offset. eve.json has 2026-10-14T09:04:48.370246+0000: the same, with the offset written without a colon. The access log has 14/Oct/2026 14:34:48: whole seconds and no zone at all. It says 14:34 because that’s the web server’s local time, though it happened at 09:04 UTC.

Reading each source

One parser per log, each yielding (time, source, what), so the timeline code doesn’t care where an event came from.

# sources.py: read each log into (time, source, what happened)
import json, re
from datetime import datetime

def auth(path="auth.log"):
    for line in open(path):
        if re.search(r"Failed password|Accepted", line):
            ts, _, rest = line.split(" ", 2)
            what = rest.split(": ", 1)[1].split(" port ")[0]
            yield datetime.fromisoformat(ts), "ssh", what

ACCESS = re.compile(r'\[(.+?)\] "(\S+ \S+)[^"]*" (\d+)')

def web(path="access.log"):
    for line in open(path):
        if m := ACCESS.search(line):
            when = datetime.strptime(m[1], "%d/%b/%Y %H:%M:%S")
            yield when, "web", f"{m[2]} -> {m[3]}"

def ids(path="out/eve.json"):
    for line in open(path):
        e = json.loads(line)
        if e["event_type"] == "alert":
            yield datetime.fromisoformat(e["timestamp"]), "ids", e["alert"]["signature"]

datetime.fromisoformat reads both offset styles, +00:00 and +0000, since Python 3.11. The access log needs strptime with an explicit format, and the result has no timezone: it’s a naive datetime.

Attempt 1: just sort

# timeline1.py: merge the three logs and sort
from sources import auth, web, ids

events = [*auth(), *web(), *ids()]
for when, source, what in sorted(events):
    print(when, source, what)
$ docker run --rm -v "$PWD":/work -w /work python:3.12-slim python timeline1.py
Traceback (most recent call last):
  File "/work/timeline1.py", line 5, in <module>
    for when, source, what in sorted(events):
                              ^^^^^^^^^^^^^^
TypeError: can't compare offset-naive and offset-aware datetimes

Python refuses to compare a time with a zone against a time without one, because the answer depends on information it doesn’t have. That error is doing you a favour.

Attempt 2: make the error go away

The common fix you’ll find for that error is to strip the zone from everything.

# timeline2.py: drop the timezones so the times compare
from sources import auth, web, ids

events = [(when.replace(tzinfo=None), source, what)
          for when, source, what in [*auth(), *web(), *ids()]]
for when, source, what in sorted(events):
    print(when.strftime("%H:%M:%S"), f"{source:<4}", what)
$ docker run --rm -v "$PWD":/work -w /work python:3.12-slim python timeline2.py
09:04:48 ids  LAB request for a .env file
09:04:52 ssh  Failed password for alice from 172.30.0.20
09:04:55 ssh  Failed password for alice from 172.30.0.20
09:04:58 ssh  Failed password for alice from 172.30.0.20
09:05:00 ssh  Failed password for invalid user admin from 172.30.0.20
09:05:04 ssh  Failed password for invalid user oracle from 172.30.0.20
09:05:05 ssh  Accepted password for alice from 172.30.0.20
14:34:48 web  GET /.env -> 404
14:34:48 web  GET /admin -> 404
14:34:48 web  GET /backup.zip -> 404

No error, and a believable story: an IDS alert, the SSH attack, the login, and then at 14:34 some web requests, five and a half hours later. It’s wrong. The web requests came first; they only look late because their 14:34 is IST and everything else is UTC. Someone reading this timeline would conclude the web probe happened after the break-in, maybe by a different actor, and would miss that the probe was the reconnaissance for it.

replace(tzinfo=None) doesn’t convert anything. It throws the zone away and keeps the digits, so every time is now read as if it were in the same zone, whatever zone it was really in.

Attempt 3: give every time a zone, then convert

The access log doesn’t say what zone it’s in, so you have to find out: from the server’s configuration, or by matching one request against another source, as the IDS alert allows here. Once you know, attach it and convert everything to UTC.

# timeline3.py: give every time a zone, then convert all of them to UTC
from datetime import timedelta, timezone
from sources import auth, web, ids

WEB_TZ = timezone(timedelta(hours=5, minutes=30))  # the web server's clock: TZ=IST-5:30

def utc(when, assumed):
    if when.tzinfo is None:
        when = when.replace(tzinfo=assumed)
    return when.astimezone(timezone.utc)

events = [(utc(w, None), s, x) for w, s, x in [*auth(), *ids()]]
events += [(utc(w, WEB_TZ), s, x) for w, s, x in web()]
for when, source, what in sorted(events):
    print(when.strftime("%H:%M:%S"), f"{source:<4}", what)
$ docker run --rm -v "$PWD":/work -w /work python:3.12-slim python timeline3.py
09:04:48 web  GET /.env -> 404
09:04:48 web  GET /admin -> 404
09:04:48 web  GET /backup.zip -> 404
09:04:48 ids  LAB request for a .env file
09:04:52 ssh  Failed password for alice from 172.30.0.20
09:04:55 ssh  Failed password for alice from 172.30.0.20
09:04:58 ssh  Failed password for alice from 172.30.0.20
09:05:00 ssh  Failed password for invalid user admin from 172.30.0.20
09:05:04 ssh  Failed password for invalid user oracle from 172.30.0.20
09:05:05 ssh  Accepted password for alice from 172.30.0.20

Now the story is right: three probes for sensitive files, the IDS alert for one of them, four seconds of quiet, then password guessing and a login, all from 172.30.0.20. utc() only assumes a zone for times that have none, and only the web source gets an assumption. Times that already carry an offset are converted, never overwritten.

Look closely at the first four lines, though. The IDS alert is for /.env, the first request, yet it sorts after /admin and /backup.zip. The access log only records whole seconds, so all three requests are stamped 09:04:48.000000, while Suricata recorded 09:04:48.370246. Within one second, the order of events from a coarse source and a precise one is unknown. Say so in the report rather than trusting the sort.

The next lesson turns from logs to files: how to examine a suspicious file safely.

Practice

  1. Print each event’s time as an offset from the first event, T+ 4.5s, instead of a clock time. How long did the whole incident take?
Solution
# p1.py: elapsed time since the first event
from datetime import timedelta, timezone
from sources import auth, web, ids

WEB_TZ = timezone(timedelta(hours=5, minutes=30))

def utc(when, assumed):
    if when.tzinfo is None:
        when = when.replace(tzinfo=assumed)
    return when.astimezone(timezone.utc)

events = [(utc(w, None), s, x) for w, s, x in [*auth(), *ids()]]
events += [(utc(w, WEB_TZ), s, x) for w, s, x in web()]
events.sort()
start = events[0][0]
for when, source, what in events:
    print(f"T+{(when - start).total_seconds():5.1f}s", f"{source:<4}", what)
$ docker run --rm -v "$PWD":/work -w /work python:3.12-slim python p1.py
T+  0.0s web  GET /.env -> 404
T+  0.0s web  GET /admin -> 404
T+  0.0s web  GET /backup.zip -> 404
T+  0.4s ids  LAB request for a .env file
T+  4.5s ssh  Failed password for alice from 172.30.0.20
T+  7.3s ssh  Failed password for alice from 172.30.0.20
T+ 10.4s ssh  Failed password for alice from 172.30.0.20
T+ 12.5s ssh  Failed password for invalid user admin from 172.30.0.20
T+ 16.4s ssh  Failed password for invalid user oracle from 172.30.0.20
T+ 17.9s ssh  Accepted password for alice from 172.30.0.20

About 18 seconds from first probe to a working login. Relative times make the pace visible: failures two to four seconds apart, each gap mostly the server’s own delay after a wrong password, is the pace of a script, not a person typing. The T+0.4s for the IDS alert is the access log’s rounding again.

  1. Classic syslog lines look like Oct 14 08:27:58 sshd sshd-session[11]: .... Parse that timestamp with strptime and print the year and timezone you get.
Solution
# p2.py: a classic syslog timestamp has no year and no zone
from datetime import datetime

classic = datetime.strptime("Oct 14 08:27:58", "%b %d %H:%M:%S")
modern = datetime.fromisoformat("2026-10-14T08:27:58.566045+00:00")
print(repr(classic))
print(classic.year, classic.tzinfo)
print(modern.year, modern.tzinfo)
$ docker run --rm -v "$PWD":/work -w /work python:3.12-slim python p2.py
datetime.datetime(1900, 10, 14, 8, 27, 58)
1900 None
2026 UTC

The classic format has no year, so Python fills in 1900, and those events sort before everything else in a merged timeline. It has no zone either, so it needs the same treatment as the access log: find the server’s zone, attach it, convert. Logs that span New Year also need the year worked out from the file date. This is why modern rsyslog defaults to RFC 3339 timestamps.

  1. Suppose you guess the web server’s zone wrong and use timedelta(hours=5) (UTC+5, Pakistan) instead of UTC+5:30. Where do the web events land?
Solution

They’re converted to 09:34:48 UTC, half an hour late, and sort after the SSH login: the same wrong story as attempt 2, just less obviously wrong. A plausible offset is more dangerous than an absurd one. Check the assumption against an independent source. Here the IDS alert for /.env at 09:04:48.37 UTC pins the web log down to the second, and any assumed zone that doesn’t line up with it is wrong.

Frequently Asked Questions

What is an incident timeline?
An incident timeline is a single list of events from every relevant log, in the order they happened, with one time standard. It turns scattered evidence such as auth logs, web logs and IDS alerts into a sequence that shows how an attack progressed, from reconnaissance to access. Most incident reports are built around one.
Why should incident timelines use UTC?
Servers, applications and appliances record time in different zones, and some don't record a zone at all. Converting every timestamp to UTC removes daylight-saving gaps and offset mistakes, so events from different systems sort correctly. Keep the original timestamp alongside the converted one, so anyone can check the conversion.
Why does Python say can't compare offset-naive and offset-aware datetimes?
One of the datetimes has a timezone and the other doesn't, so Python can't know which is earlier. Removing the timezone with replace(tzinfo=None) silences the error but compares local clock digits as if they were in the same zone. Instead, attach the correct zone to the naive time with replace(tzinfo=...) and convert everything with astimezone(timezone.utc).
How do you find the timezone of a log that doesn't record one?
Check the server's configuration, such as its TZ setting or /etc/localtime, and confirm it against an event that appears in another log with a known zone. A request seen both in a web access log and in an IDS or proxy log is ideal, because the two timestamps must refer to the same moment. Treat any assumed zone as an assumption in the report.