Examples · Cloud & Cybersecurity
Attacks in an access log
Gemma reads the access log of a web shop and names the attackers, as JSON. Then grep and awk check every claim. The patterns were right, a number was wrong, and the worst attack had already succeeded.
Intermediate · 40 min
The idea
A web server writes one line for every request: who asked, when, for what, and how it went. An attack leaves a trail there: a run of failed logins, strange characters in a URL, a script trying files that should not exist.
A model is good at seeing the trail, and bad at counting. So they share the work:
- the model reads the log and names the attackers, as JSON;
grepandawkcheck every claim, with numbers you can trust.
What you need
A key and the openai library, as in Your first API call in Python, and a terminal with grep and awk: Linux, macOS, or WSL on Windows.
The model
Gemma 4 26B (gemma-4-26b): in each of our runs it found the three attacks and left the customer alone. gpt-oss-120b got one count right that Gemma got wrong, but in both of our runs it called the customer an attacker.
The log
Seven minutes of the access log of a small web shop, in nginx's usual format. The addresses come from ranges kept for documentation, so they belong to nobody. Save it as access.log:
198.51.100.23 - - [06/Oct/2026:09:00:03 +0200] "GET / HTTP/1.1" 200 5120 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/131.0"
198.51.100.23 - - [06/Oct/2026:09:00:04 +0200] "GET /css/site.css HTTP/1.1" 200 18233 "https://shop.example.com/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/131.0"
198.51.100.23 - - [06/Oct/2026:09:00:09 +0200] "GET /products?id=12 HTTP/1.1" 200 3410 "https://shop.example.com/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/131.0"
198.51.100.23 - - [06/Oct/2026:09:00:21 +0200] "GET /login HTTP/1.1" 200 2210 "https://shop.example.com/products?id=12" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/131.0"
198.51.100.23 - - [06/Oct/2026:09:00:34 +0200] "POST /login HTTP/1.1" 302 0 "https://shop.example.com/login" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/131.0"
198.51.100.23 - - [06/Oct/2026:09:00:35 +0200] "GET /account HTTP/1.1" 200 4012 "https://shop.example.com/login" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/131.0"
198.51.100.61 - - [06/Oct/2026:09:00:48 +0200] "GET / HTTP/1.1" 200 5120 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari/604.1"
198.51.100.61 - - [06/Oct/2026:09:00:52 +0200] "GET /products?id=7 HTTP/1.1" 200 3388 "https://shop.example.com/" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari/604.1"
198.51.100.61 - - [06/Oct/2026:09:01:10 +0200] "GET /products?id=9 HTTP/1.1" 200 3502 "https://shop.example.com/products?id=7" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari/604.1"
203.0.113.45 - - [06/Oct/2026:09:01:35 +0200] "GET /.env HTTP/1.1" 404 153 "-" "python-requests/2.32.3"
203.0.113.45 - - [06/Oct/2026:09:01:36 +0200] "GET /wp-login.php HTTP/1.1" 404 153 "-" "python-requests/2.32.3"
203.0.113.45 - - [06/Oct/2026:09:01:37 +0200] "GET /phpmyadmin/ HTTP/1.1" 404 153 "-" "python-requests/2.32.3"
203.0.113.45 - - [06/Oct/2026:09:01:38 +0200] "GET /.git/config HTTP/1.1" 200 312 "-" "python-requests/2.32.3"
203.0.113.45 - - [06/Oct/2026:09:01:39 +0200] "GET /.git/HEAD HTTP/1.1" 200 23 "-" "python-requests/2.32.3"
203.0.113.45 - - [06/Oct/2026:09:01:40 +0200] "GET /backup.sql HTTP/1.1" 404 153 "-" "python-requests/2.32.3"
203.0.113.45 - - [06/Oct/2026:09:01:41 +0200] "GET /admin/ HTTP/1.1" 403 153 "-" "python-requests/2.32.3"
203.0.113.45 - - [06/Oct/2026:09:01:42 +0200] "GET /server-status HTTP/1.1" 403 153 "-" "python-requests/2.32.3"
198.51.100.61 - - [06/Oct/2026:09:02:00 +0200] "GET /cart HTTP/1.1" 200 2875 "https://shop.example.com/products?id=9" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari/604.1"
192.0.2.77 - - [06/Oct/2026:09:02:20 +0200] "GET /products?id=12 HTTP/1.1" 200 3410 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/131.0"
192.0.2.77 - - [06/Oct/2026:09:02:31 +0200] "GET /products?id=12%27 HTTP/1.1" 500 612 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/131.0"
192.0.2.77 - - [06/Oct/2026:09:02:43 +0200] "GET /products?id=12%20OR%201%3D1 HTTP/1.1" 200 48211 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/131.0"
192.0.2.77 - - [06/Oct/2026:09:02:58 +0200] "GET /products?id=12%20UNION%20SELECT%20email,password_hash%20FROM%20customers-- HTTP/1.1" 200 9120 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/131.0"
198.51.100.88 - - [06/Oct/2026:09:03:20 +0200] "GET /login HTTP/1.1" 200 2210 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari/604.1"
198.51.100.88 - - [06/Oct/2026:09:03:34 +0200] "POST /login HTTP/1.1" 401 2290 "https://shop.example.com/login" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari/604.1"
198.51.100.88 - - [06/Oct/2026:09:03:51 +0200] "POST /login HTTP/1.1" 401 2290 "https://shop.example.com/login" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari/604.1"
198.51.100.88 - - [06/Oct/2026:09:04:10 +0200] "POST /login HTTP/1.1" 302 0 "https://shop.example.com/login" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari/604.1"
198.51.100.88 - - [06/Oct/2026:09:04:11 +0200] "GET /account HTTP/1.1" 200 3987 "https://shop.example.com/login" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari/604.1"
192.0.2.150 - - [06/Oct/2026:09:05:00 +0200] "POST /login HTTP/1.1" 401 2290 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:03 +0200] "POST /login HTTP/1.1" 401 2290 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:07 +0200] "POST /login HTTP/1.1" 401 2290 "-" "python-requests/2.32.3"
198.51.100.23 - - [06/Oct/2026:09:05:10 +0200] "GET /products?id=3 HTTP/1.1" 200 3299 "https://shop.example.com/account" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/131.0"
192.0.2.150 - - [06/Oct/2026:09:05:12 +0200] "POST /login HTTP/1.1" 401 2290 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:15 +0200] "POST /login HTTP/1.1" 401 2290 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:19 +0200] "POST /login HTTP/1.1" 401 2290 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:24 +0200] "POST /login HTTP/1.1" 401 2290 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:27 +0200] "POST /login HTTP/1.1" 401 2290 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:31 +0200] "POST /login HTTP/1.1" 401 2290 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:36 +0200] "POST /login HTTP/1.1" 401 2290 "-" "python-requests/2.32.3"
198.51.100.23 - - [06/Oct/2026:09:05:38 +0200] "GET /img/p3.jpg HTTP/1.1" 200 70211 "https://shop.example.com/products?id=3" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/131.0"
192.0.2.150 - - [06/Oct/2026:09:05:39 +0200] "POST /login HTTP/1.1" 401 2290 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:43 +0200] "POST /login HTTP/1.1" 401 2290 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:48 +0200] "POST /login HTTP/1.1" 401 2290 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:51 +0200] "POST /login HTTP/1.1" 401 2290 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:55 +0200] "POST /login HTTP/1.1" 302 0 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:56 +0200] "GET /account HTTP/1.1" 200 4012 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:59 +0200] "GET /account/orders/export.csv HTTP/1.1" 200 88412 "-" "python-requests/2.32.3"
198.51.100.61 - - [06/Oct/2026:09:06:42 +0200] "POST /cart/checkout HTTP/1.1" 302 0 "https://shop.example.com/cart" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Safari/604.1"
One line, from left to right: the address, two dashes, the time, the request, the status (200 OK, 302 redirect, 401 wrong password, 403 forbidden, 404 not found, 500 server error), the size of the answer in bytes, the page the visitor came from, and the browser.
Three attacks are hidden in there, and one customer who got the password wrong twice. Can you find them before the model does?
The program
Save it as find_attacks.py, next to the log:
import json
import os
import re
from openai import OpenAI
client = OpenAI(
base_url="https://api.ai.itsincom.org/v1",
api_key=os.environ["API_KEY"],
)
with open("access.log") as f:
log = f.read()
# The addresses in the log are the only answers allowed: the model cannot name one that is not there.
addresses = sorted(set(re.findall(r"^(\S+) ", log, re.M)))
schema = {
"type": "object",
"properties": {
"attacks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"ip": {"type": "string", "enum": addresses},
"kind": {"type": "string", "enum": ["scan", "sql_injection", "brute_force", "other"]},
"requests": {"type": "integer"},
"succeeded": {"type": "boolean"},
"evidence": {"type": "string"},
},
"required": ["ip", "kind", "requests", "succeeded", "evidence"],
},
},
},
"required": ["attacks"],
}
answer = client.chat.completions.create(
model="gemma-4-26b",
temperature=0,
messages=[
{"role": "system", "content": "You are a security analyst. You read the access log of a web shop "
"(nginx, combined format) and find the attacks. For each attacker: the IP "
"address, the kind of attack, how many requests it made, whether it "
"succeeded, judging from the status codes and the sizes of the answers, "
"and the evidence. A customer who mistypes a password is not an attack."},
{"role": "user", "content": log},
],
response_format={"type": "json_schema", "json_schema": {"name": "attacks", "schema": schema}},
)
for a in json.loads(answer.choices[0].message.content)["attacks"]:
print(f"{a['ip']:15} {a['kind']:14} requests: {a['requests']:<3} succeeded: {a['succeeded']}")
print(f" {a['evidence']}")
Run it with python3 find_attacks.py. It takes a few seconds.
What you should see
203.0.113.45 scan requests: 8 succeeded: True
The attacker used a python-requests script to probe for sensitive files. While most requests returned 404 (Not Found) or 403 (Forbidden), they successfully accessed '.git/config' and '.git/HEAD' (Status 200), indicating a successful directory/file traversal or information disclosure attack.
192.0.2.77 sql_injection requests: 4 succeeded: True
The attacker attempted to manipulate the 'id' parameter using special characters ('%27'), boolean logic ('OR 1=1'), and UNION statements. The attack succeeded, as evidenced by the 200 status codes and significantly larger response sizes (48211 and 9120 bytes) compared to a normal product request, suggesting data was exfiltrated.
192.0.2.150 brute_force requests: 13 succeeded: True
A rapid succession of POST requests to '/login' using a python-requests script. After multiple 401 (Unauthorized) errors, the attacker received a 302 (Redirect) followed by a 200 (OK) on the '/account' page, and successfully downloaded a large file '/account/orders/export.csv' (88412 bytes), indicating a successful account takeover.
The same three attackers, with the same numbers, in each of our seven runs; only the words of the evidence change. Now check what it says.
Check it with grep and awk
awk splits every line at the spaces: $1 is the address, $6 the method (with a " in front), $7 the path, $9 the status and $10 the size.
How many requests each address made:
awk '{print $1}' access.log | sort | uniq -c | sort -rn
17 192.0.2.150
8 203.0.113.45
8 198.51.100.23
5 198.51.100.88
5 198.51.100.61
4 192.0.2.77
The model wrote 13 for 192.0.2.150, in every run: it is 17. The pattern was right and the number was not. The numbers in your report come from awk, never from the model.
Who got the password wrong:
awk '$9 == 401 {print $1}' access.log | sort | uniq -c | sort -rn
14 192.0.2.150
2 198.51.100.88
198.51.100.88 failed too. Look at its requests:
grep '^198.51.100.88 ' access.log | awk '{print $4, $6, $7, $9}'
[06/Oct/2026:09:03:20 "GET /login 200
[06/Oct/2026:09:03:34 "POST /login 401
[06/Oct/2026:09:03:51 "POST /login 401
[06/Oct/2026:09:04:10 "POST /login 302
[06/Oct/2026:09:04:11 "GET /account 200
A phone opens the login page, gets the password wrong twice, gets in at the third try and opens its account: a person. 192.0.2.150 is a script (python-requests) that never opens the login page and tries 14 times in 51 seconds.
What happened after the 14 failures:
grep '^192.0.2.150 ' access.log | awk '$9 != 401'
192.0.2.150 - - [06/Oct/2026:09:05:55 +0200] "POST /login HTTP/1.1" 302 0 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:56 +0200] "GET /account HTTP/1.1" 200 4012 "-" "python-requests/2.32.3"
192.0.2.150 - - [06/Oct/2026:09:05:59 +0200] "GET /account/orders/export.csv HTTP/1.1" 200 88412 "-" "python-requests/2.32.3"
The 15th try worked: the 302 sends the script to /account, and three seconds later it downloads 88 KB of orders. This is the most serious of the three: an account was taken over, and its data left the shop.
How big the product pages are:
awk '$7 ~ /^\/products/ {print $1, $9, $10, $7}' access.log
198.51.100.23 200 3410 /products?id=12
198.51.100.61 200 3388 /products?id=7
198.51.100.61 200 3502 /products?id=9
192.0.2.77 200 3410 /products?id=12
192.0.2.77 500 612 /products?id=12%27
192.0.2.77 200 48211 /products?id=12%20OR%201%3D1
192.0.2.77 200 9120 /products?id=12%20UNION%20SELECT%20email,password_hash%20FROM%20customers--
198.51.100.23 200 3299 /products?id=3
A product page is about 3.4 KB. A single quote (%27 in a URL) breaks the page with a 500: what is written in the URL ends up inside the database query. With OR 1=1 the page is 14 times bigger, and a UNION SELECT of emails and password hashes answers 200. The model is right: treat it as a leak.
What the scanner found:
grep '^203.0.113.45 ' access.log | awk '$9 == 200'
Six of its eight tries failed, but /.git/config and /.git/HEAD answered 200: the site's git repository is online, and the code and its history may be readable too. The model hesitated between “traversal” and “information disclosure”: it is the second, nothing was traversed. Read the evidence, not only the label.
One sentence keeps the customer out
The system message ends with “A customer who mistypes a password is not an attack.” Without it, in both of our runs, Gemma reported 198.51.100.88 as a brute force. A model does not know what is normal on your site: tell it. And check the result anyway: with that same sentence, gpt-oss-120b still reported the customer.
What the log cannot tell you
- Which account. The username travels in the body of the POST, and nginx does not write it down. When we asked which account it was, none of the three models made up a name. The shop's own application log has it.
- What the 48 KB contained. The log has the size of an answer, not its content.
- Who is behind an address. A script can run on a rented server, or through a VPN. Blocking the address helps for an hour.
Good to know
- A real log is big. These 47 lines were about 4,000 tokens. A busy shop writes millions of lines a day: filter them with awk first (only the 401s, only the POSTs, only one address), and send the model the part you want it to read.
- Logs are personal data. An IP address can lead to a person. Practise on logs you made yourself, like this one, or on the logs of your own lab.
- The schema allows only the addresses in the log, so the model cannot name one that is not there. It can still get a number or a reason wrong: the schema checks the shape, awk checks the facts.
temperature=0makes answers alike, not identical: compare a few runs before you trust a detail.
Exercise
- Add this line at the end of
access.logand run the program again:192.0.2.99 - - [06/Oct/2026:09:07:01 +0200] "GET /download?file=../../../../etc/passwd HTTP/1.1" 403 153 "-" "curl/8.5.0". When we tried, the evidence said “path traversal… blocked” and the label saidsql_injection: the list had no right word, so the model picked a wrong one. Add"path_traversal"to the list. - Write the awk line that prints every address with more than five failed logins. It is what fail2ban does, line after line, on a live server.
- Remove the sentence about the customer from the system message, and run the program twice. Then try
gpt-oss-120b: does it count 17? - Write down what happened, for your manager, with An incident report that invents nothing.
