CoursesPython for security automationText and regex for logs & output

Text and regex for logs & output

Extract fields without fragile string slicing.

Intermediate22 min · lesson 4 of 14

A regular expression (a written pattern that describes what text should look like, nearly always shortened to 'regex') works like the Find box in a text editor, except what you type can describe a shape instead of an exact word. Instead of hunting for the literal string 203.0.113.42, you describe 'four numbers joined by dots' and catch every address in the file at once. For a DevSecOps engineer (someone who builds security checks straight into the pipeline that ships software), that is the difference between reading a log by eye and writing a tool that chews through ten thousand lines a second.

A pattern is a tiny search language

Python comes with regex support built in, in a standard-library module called re (short for 'regular expressions'). You hand it a pattern, it hands you back matches. A small set of building blocks covers most log work. \d means any digit. \w means any letter, digit, or underscore. \s means any whitespace, like a space or a tab. And a plain dot means any character at all. Put a count after a piece and you say how many you want: \d+ is one or more digits, and \d{1,3} is between one and three of them. Wrap a piece in round brackets, (like this), and you tell the engine to remember whatever matched so you can pull it out later. That last move is what turns a smear of text into labeled fields you can actually use.

Compile the pattern once, reuse it forever

Every time Python runs a pattern, it first turns your pattern text into a small internal program, the way a recipe gets prepped before anyone starts cooking. re.compile() does that prep once and hands back a reusable object. Saving a search filter in your email works the same way: you write the query once instead of retyping it every morning. If you call re.search(pattern, text) inside a loop over a million log lines, Python has to look the pattern up on every pass. It keeps a small cache of recent ones, but you should not lean on that. Compile the pattern once, above the loop, and every line after that reuses the finished version. On a big file that is a real speed win, and the code reads better because the pattern now has a name you chose.

search, match, findall, finditer: four ways to look

These four look almost the same and behave very differently. match only checks the very start of the string, as if your pattern had an invisible 'must begin right here' pin on its front. search scans the whole string and stops at the first hit. findall returns every hit as a plain list of strings and forgets where each one sat. finditer returns every hit as a match object you can question, including its exact position. For counting and pulling fields out of logs, finditer is usually the one you want, because each result carries both its captured groups and its spot in the line.

methods.py
import re
text = "user=root ip=10.0.0.9 user=deploy ip=192.168.1.5"
pat = re.compile(r"ip=(\d+\.\d+\.\d+\.\d+)")
print("match :", pat.match(text))
print("search :", pat.search(text))
print("findall:", pat.findall(text))
print("finditer:")
for m in pat.finditer(text):
print(" ", m.group(1), "at", m.span())
~/secopslog — bash
$ python3 methods.py
match : None search : <re.Match object; span=(10, 21), match='ip=10.0.0.9'> findall: ['10.0.0.9', '192.168.1.5'] finditer: 10.0.0.9 at (10, 21) 192.168.1.5 at (34, 48)

The first line shows match returning None, because the string opens with user=, not ip=, and match refuses to look past the front door. search found the first ip= further along and stopped there. findall handed back both addresses as a flat list. finditer returned the same two plus their character positions (the span), which is what you need if you want to blank them out in place before sharing a log with someone outside your team.

Pulling fields out of a real log line

A capture group is a labeled bucket. You wrap part of the pattern in round brackets, and whatever matches drops into that bucket for you to grab afterward. Plain groups are numbered 1, 2, 3 by the order their opening bracket shows up, which gets confusing the moment you have more than two. Named groups fix that: write (?P<ip>...) and the bucket is called ip instead of 'group 3'. Below is a line straight from an SSH (Secure Shell, the standard encrypted way to log in to a machine over the network) server's auth.log, the file Linux writes every login attempt into. The pattern next to it pulls out three things: the timestamp, the username someone tried, and the IP (Internet Protocol, the numeric address every machine on a network is reachable at) address the attempt came from.

authparse.py
import re
line = "Feb 10 13:45:11 web01 sshd[24800]: Failed password for invalid user admin from 203.0.113.42 port 54021 ssh2"
pattern = re.compile(
r"(?P<ts>\w{3}\s+\d+\s[\d:]+)\s" # timestamp: month day time
r"\S+\ssshd\[\d+\]:\s" # host + process
r"Failed password for (?:invalid user )?"
r"(?P<user>\S+)\s" # username that was tried
r"from (?P<ip>\d+\.\d+\.\d+\.\d+)" # source IP address
)
m = pattern.search(line)
if m:
print("timestamp:", m.group("ts"))
print("user: ", m.group("user"))
print("ip: ", m.group("ip"))
~/secopslog — bash
$ python3 authparse.py
timestamp: Feb 10 13:45:11 user: admin ip: 203.0.113.42

The (?:invalid user )? piece earns its keep quietly. (?:...) is a group that matches without making a bucket, and the trailing question mark means zero or one of it. A bad-username attempt reads 'Failed password for invalid user admin', while a real account that fumbled its password reads 'Failed password for root'. One pattern handles both shapes without a second line of code.

A match is not validation
Pulling an IP-shaped string out of a log does not make it a real or safe address. The pattern \d+\.\d+\.\d+\.\d+ happily matches 999.1.2.3, which is not a valid address at all. Regex confirms the shape and nothing more. Before you feed an extracted value into a shell command, a database query, or a firewall rule, check it with a real parser (Python's ipaddress module for addresses) and never splice it straight into a command string. Attackers write log lines on purpose to see what a careless parser downstream will do with them.
From raw log text to structured fields
1Raw log line
one long unstructured string
2re.compile(pattern)
translate the pattern once
3finditer(text)
scan and yield every hit
4match object
m.group('ip'), m.group('user')
5Structured fields
ip, user, timestamp ready to use
One compiled pattern turns thousands of unstructured lines into clean, named records you can count, sort, and alert on.

VERBOSE and MULTILINE: patterns you can actually read

That pattern was already dense, and production ones get worse. A cramped pattern reads like a sentence printed with every space and comma stripped out. Two flags give you the spacing and the margin notes back. re.VERBOSE tells the engine to ignore the whitespace you put inside the pattern and to allow # comments, so you can spread the pattern over several lines with a note beside each part. The catch: a real space now has to be written as \ (backslash-space) or \s, because ordinary spaces get thrown away. re.MULTILINE changes what ^ and $ mean. Normally ^ is the start of the whole string; with re.MULTILINE it matches the start of every line, and $ matches the end of every line. Together they let you write a pattern you can read and run it across a whole block of log at once. Here is a small tool that counts failed SSH logins per source address, the first thing you would reach for to catch someone trying passwords in bulk.

brute_count.py
import re
from collections import Counter
log = """\
Feb 10 13:45:11 web01 sshd[24800]: Failed password for invalid user admin from 203.0.113.42 port 54021 ssh2
Feb 10 13:45:13 web01 sshd[24801]: Failed password for root from 203.0.113.42 port 54044 ssh2
Feb 10 13:45:14 web01 sshd[24802]: Accepted password for deploy from 198.51.100.7 port 51992 ssh2
Feb 10 13:45:19 web01 sshd[24803]: Failed password for root from 203.0.113.42 port 54070 ssh2
Feb 10 13:45:22 web01 sshd[24804]: Failed password for invalid user oracle from 45.83.64.9 port 33110 ssh2
Feb 10 13:45:25 web01 sshd[24805]: Failed password for root from 45.83.64.9 port 33144 ssh2"""
fail = re.compile(r"""
^.*Failed\ password\ for\ # keep only failed-login lines
(?:invalid\ user\ )? # optional 'invalid user' prefix
(?P<user>\S+)\ from\ # the username that was tried
(?P<ip>\d+\.\d+\.\d+\.\d+) # the source IP address
""", re.VERBOSE | re.MULTILINE)
counts = Counter(m.group("ip") for m in fail.finditer(log))
for ip, n in counts.most_common():
print(f"{n:>3} {ip}")
~/secopslog — bash
$ python3 brute_count.py
3 203.0.113.42 2 45.83.64.9

Three failures from 203.0.113.42, two from 45.83.64.9, and the one successful Accepted login is left out because the pattern only matches Failed password lines. That sorted count is a working brute-force detector in a handful of lines, and it is the kind of thing you would run over a freshly rotated log every few minutes.

The trap: catastrophic backtracking (ReDoS)

A regex engine hunting for a match is like a person walking a maze. When a pattern can match the same stretch of text in more than one way, the engine may try route after route before it gives up. Most of the time that is cheap. But some patterns turn the maze into one with a doubling number of paths, so adding a few characters to the input multiplies the work wildly. Feed such a pattern a string that almost matches, and it can spin for seconds, minutes, or for all practical purposes forever, on a single line. That is a ReDoS (Regular expression Denial of Service): an attacker sends one short, carefully shaped string and pins a CPU (central processing unit, the chip that does the actual computing) core at full load.

The classic warning sign is a quantifier wrapped around something that already repeats, like (a+)+. Watch the time blow up as the input grows two characters at a step.

redos.py
import re, time
evil = re.compile(r"^(a+)+$") # nested quantifier: one group inside another
for n in (24, 26, 28):
text = "a" * n + "!" # n copies of 'a', then a char that can't match
start = time.perf_counter()
evil.search(text) # forced to try every possible split
print(f"{n} chars: {time.perf_counter() - start:5.2f}s")
~/secopslog — bash
$ python3 redos.py
24 chars: 0.73s 26 chars: 2.91s 28 chars: 12.65s

Twenty-four characters finished in under a second; twenty-eight took over twelve. Every two extra characters roughly quadrupled the work. Your exact numbers will differ by machine, but the curve is the point: the cost runs off the top of the page. A string of forty a's would outlast your patience, and remember that the input came from outside your system, chosen by someone who wants it to hurt.

The fixes are dull and they work. Do not nest quantifiers: ^a+$ does the same job in linear time and clears forty thousand characters in a fraction of a millisecond. Be specific instead of greedy where you can, so a bounded \w{1,64} or [^\s]+ beats an open .* sitting next to another star. Test any new pattern against a long, almost-matching string before you ship it, so you meet the cliff in a test run and not in production. Never point a hand-written pattern at attacker-controlled text without a timeout or a vetted library behind it. When you need regex on untrusted input at scale, the RE2 engine (Google's linear-time regex library, available for Python as the google-re2 package) is built so it can never backtrack, so it cannot blow up this way; it rejects the handful of pattern features that would force backtracking and runs everything else in guaranteed linear time. A validator that can be hung on demand is a denial-of-service switch you have handed to strangers.

safe.py
import re, time
safe = re.compile(r"^a+$") # no nested quantifier -> linear time
text = "a" * 40000 + "!" # 40k almost-matching characters
start = time.perf_counter()
safe.search(text)
print(f"took {time.perf_counter() - start:.4f}s on {len(text)} chars")
~/secopslog — bash
$ python3 safe.py
took 0.0003s on 40001 chars

Same input size that would have frozen the nested pattern, and the safe one barely registers. The only change was removing the nesting. That is the whole trick: give the engine one clear way to match instead of thousands.

Quick check
01You are about to run one of these patterns against usernames typed in by anyone on the internet. Which one hands a stranger a way to pin a CPU core at full load?
Incorrect — The {1,64} cap fixes how far each attempt can reach, so the engine has a small fixed set of routes and the cost climbs in a straight line.
Incorrect — A fixed prefix and one plain count leave a single route through the text, so even a long near miss finishes in roughly the time you expect.
Correct — The outer + multiplies a group that can already repeat, which is the shape redos.py uses; 28 near-miss characters there ran for over twelve seconds.
Incorrect — One count over one character class stays linear however long the input gets. Stacking counts is what explodes, not the plus sign by itself.
02Your parser lifts 999.1.2.3 out of a log line using \d+\.\d+\.\d+\.\d+ and is about to push it into a firewall block rule. What has that match actually proved?
Correct — The pattern checks arrangement, not arithmetic, which is why 999.1.2.3 sails through. A real parser is what tells you the address exists before you act on it.
Incorrect — \d+ counts digits and cares nothing about their value, which is exactly how 999 got through. There is no range check anywhere in that pattern.
Incorrect — What looks harmless today says nothing about tomorrow's log line, and attackers write those lines on purpose. Build the rule from parsed values, never from raw text.
Incorrect — A pattern matches whatever fits its shape, valid or not, and 999.1.2.3 fits perfectly. Getting a match back is the trap here, not the alarm.
03In methods.py the compiled pattern is ip=(\d+\.\d+\.\d+\.\d+) and the text begins user=root ip=10.0.0.9. pat.match(text) prints None while pat.search(text) returns a hit. Why?
Incorrect — re.MULTILINE only redefines what ^ and $ mean, and this pattern uses neither anchor, so setting it changes nothing about where match begins looking.
Correct — match tests one starting point and no other, which is why methods.py prints None for it and a hit for search on the very same text.
Incorrect — That describes fullmatch. match is content with a partial hit, as long as the hit begins at the first character, which is where this one fails.
Incorrect — match already behaves as though ^ were there, so adding one is redundant and still leaves it unable to begin anywhere but the first character.

Try this

Work through “The trap: catastrophic backtracking (ReDoS)” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: a match is not validation. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related