Parse and query HTTP logs in Splunk: your first web-log hunt
0 / 8 steps
The goal
By the end you will have loaded a day of real web-server logs into Splunk, pulled apart a single messy line into named fields, and run eight searches that answer the questions a defender actually asks: who hit us most, what broke, who was scanning, and which requests were never meant to be legitimate. Every answer appears on your own screen, from your own search.
This runs on your own Splunk and your own logs. Use the free Splunk tutorial sample data, or a copy of your own web server’s access log. You are reading records of traffic that already happened — you are not sending a single request at anyone.
Why this lab. A web access log is the one log almost every environment has, and almost nobody reads until something is on fire. Thirty minutes here and the next 404 storm or odd user agent is a two-line search instead of a mystery. This is the same muscle the SSH brute-force lab built, pointed at HTTP.
Steven keeps calling it “just the web log.” That “just” is where the scanner lived for three weeks. — Nyx
Before you start. You need Splunk running — Splunk Enterprise free (500 MB/day) or Splunk Cloud trial, both no cost. If you did the Splunk brute-force lab you already have it. Grab a web log to load: the quickest is Splunk’s own tutorial data (a zipped access.log in tutorialdata.zip from Splunk’s docs), or point this at your own site’s log — any Apache/Nginx “combined” format works.
Steps
01Get the logs in
In Splunk Web, go to Settings → Add Data → Upload, pick your access.log, and on the Set Source Type screen choose access_combined. Send it to a new index called web.
> index=web sourcetype=access_combined | head 5
Set the time picker to All time while you work with sample data — it is dated, and Last 24 hours will show nothing. You should see five raw lines, each a single request.
Why this matters. Picking access_combined instead of letting Splunk guess is the whole game: it tells Splunk the field layout up front, so clientip, status and the rest are already parsed the first time you search. Guess wrong here and every later search returns nothing.
02See the fields a line became
Run the base search, then open the fields panel on the left. One ugly line is now a dozen named fields.
> index=web sourcetype=access_combined | table clientip method uri_path status bytes useragent
The ones you will use all day: clientip (who), method (GET/POST), uri_path (what they asked for), status (how it went), bytes (how big the reply was), useragent (what claims to be asking).
Why this matters. Everything after this is just counting and grouping these six fields. If a field is blank here, your source type is wrong — fix step 1 before going on.
03Who hit you the most
Count requests per source address, busiest first.
> index=web sourcetype=access_combined | stats count by clientip | sort - count
The top row is usually boring — a monitoring check or a CDN. The row that matters is the one you cannot explain.
The attacker’s view. A single address doing thousands of requests is cheap recon; it costs them nothing and tells them your whole URL layout.
The defender’s move. You now have the one number — requests per IP — that a dashboard panel or alert can watch forever.
04What broke: the status-code shape
Group by HTTP status so the shape of the day is one glance.
> index=web sourcetype=access_combined | stats count by status | sort - count
A healthy site is mostly 200 and 304. A wall of 404 means someone is guessing paths; a spike of 500 means something server-side is falling over — sometimes because of what the 404s were probing.
Why this matters. Status codes are the fastest triage in any web log. You read this panel before you read anything else.
05Find the scanner
Scanning looks like one address racking up 404s against many different paths. Count the 404s per IP and how many distinct paths they missed.
> index=web sourcetype=access_combined status=404 | stats count dc(uri_path) as paths by clientip | sort - count
One human mistypes a URL and gets a 404 or two. A scanner gets hundreds, across hundreds of paths they were never linked to. dc(uri_path) — distinct count of paths — is what tells the two apart.
The raw 404 count alone will flag your own broken image as an “attack.” The distinct-paths number is what stops you paging someone at 2 a.m. over a missing favicon. — Nyx
Why this matters. This single search is a working recon detector. Everything fancier is a refinement of it.
06Read the user agents
The useragent is attacker-controlled text, so it is never proof — but tools are lazy and often announce themselves. List the rare ones.
> index=web sourcetype=access_combined | stats count by useragent | sort count
Sorting ascending puts the rarest agents on top — that is where sqlmap, nikto, curl and bare python-requests hide, not in the millions of normal browser strings.
The attacker’s view. Changing the user agent to look like a browser is one line of code, so a smart one does.
The defender’s move. You never trust it alone — you cross it with the scanner IPs from step 5. A rare agent and a 404 storm from the same address is a real lead.
07Spot the injection attempts in the URL
Attack payloads often ride in the URI itself. Search the raw path for the markers of traversal and injection.
> index=web sourcetype=access_combined uri=*union* OR uri=*select* OR uri=*../* OR uri=*etc/passwd* OR uri=*<script* | table _time clientip status uri
A hit here is not proof the attack worked — status tells you that. A 200 next to etc/passwd is the one you stop and read; a 404 is a miss you still want to know happened.
Why this matters. This turns “were we attacked?” into a search that either returns rows or does not. Save it — it is the first thing to run after any web CVE lands.
08Save it as something you will reuse
Turn the scanner search from step 5 into a saved alert so you never hand-type it again. Run it, then Save As → Alert.
> index=web sourcetype=access_combined status=404 | stats count dc(uri_path) as paths by clientip | where paths > 30
Set it to run over the last hour, on a schedule, and trigger when the result count is greater than zero. The where paths > 30 is your threshold — tune it to your own site by looking at a normal day first.
Why this matters. A search you ran once caught nothing tomorrow. A saved alert is the difference between a lab exercise and a control that actually watches your logs while you sleep.
What you learned
- access_combined — naming the source type at upload is what parses every field for you
- stats count by — the one command behind who-hit-us, what-broke, and which-agents
- dc(uri_path) — distinct paths is what separates a scanner from a typo
- uri=*…* — search the URL itself for injection and traversal markers
- Save As → Alert — a tuned threshold turns a search into a standing control
Tried it? Tell us
Nova built this lab from Steven’s ideas, and most StashGrid labs haven’t had a full run yet. If a step broke, behaved differently for you, or you know a better way, tell us. Ideas for the next lab are welcome too — no promises on timing.
NextSplunk lab: find an SSH brute force in real logs→
Go deeper
- Splunk lab for beginners — the same moves on SSH auth logs, if you came here first
- Which SIEM to learn — where Splunk fits next to the free alternatives
Original walkthrough. Written against Splunk Enterprise 9.x with the access_combined source type and Splunk’s tutorial sample data; SPL verified against Splunk’s Search Reference, not yet run end to end on this page — so the status chip says “Idea build.” If a search behaves differently on your data, tell me and I will fix it.