CoursesPython for security automationFiles and paths with pathlib

Files and paths with pathlib

Read, write, and walk the filesystem cleanly.

Intermediate18 min · lesson 3 of 14

A file path is an address. /var/log/nginx/access.log tells the computer which building, which floor, and which room to open. For years, Python programs built these addresses by gluing text together with slashes, and that broke in quiet, annoying ways: a missing slash in one spot, a Windows backslash in another, a doubled // somewhere else. The pathlib module (a module is a bundle of ready-made Python code you import, and this one has shipped with every standard Python install since version 3.4) treats a path as a real object you can build, question, and act on, rather than a fragile run of text. In security automation work the wrong path is expensive: you read a file you never meant to touch, or you hand an attacker a file you never meant to expose. Getting this part right is worth the few minutes it takes.

Build a path with /, not string glue

The old way reached for os.path.join("/var/log", "nginx", "access.log") to stitch the pieces together with the right separator for the operating system. pathlib does the same job, but it borrows the division sign (/) so the code on the page looks like the path it produces. You start with a base Path, then keep dividing by the next piece. Python drops in the separators for you, whether the machine runs Linux, macOS, or Windows, so you never hand-type a slash and never end up with a mangled /var/log//nginx.

paths.py
from pathlib import Path
base = Path("/var/log")
target = base / "nginx" / "access.log"
print(target)
print(type(target))
~/secopslog — bash
$ python3 paths.py
/var/log/nginx/access.log <class 'pathlib.PosixPath'>

The printed type is PosixPath, the Linux and macOS flavor of a path (POSIX is the family of standards those systems share). Run the same code on Windows and you get a WindowsPath that prints with backslashes instead. That is the whole idea. You wrote one expression, and pathlib picked the right shape for the computer it landed on. Nothing in your script has to know or care which operating system it runs on.

Ask a path about itself

A path is like a shipping label. The whole label is the address, but most of the time you want one field off it: the parcel's name, what kind of thing it holds, the shelf it sits on. pathlib hands you each field as a plain attribute. .name is the final piece, .suffix is the file extension including the dot, .stem is the name with that extension stripped off, and .parent is the folder holding the file. They come back ready to use, some as text and some as Path objects, so you feed them straight into the next step without splitting strings on dots or slashes yourself.

parts.py
from pathlib import Path
p = Path("/var/log/nginx/access.log")
print("name ", p.name)
print("suffix", p.suffix)
print("stem ", p.stem)
print("parent", p.parent)
~/secopslog — bash
$ python3 parts.py
name access.log suffix .log stem access parent /var/log/nginx

A folder to poke at

The rest of the lesson runs against a small tree of pretend log files, so every command below has real output you can check against. Three .log files sit inside two subfolders, and one .txt file rides along to prove the filters actually filter. Here is the whole tree on disk.

~/secopslog — bash
$ find logs_demo -type f | sort
logs_demo/app/errors.log logs_demo/app/service.log logs_demo/nginx/access.log logs_demo/notes.txt

Exists? File? Folder? Read it, write it

Before you open a file, you usually want two answers: is it there, and is it the kind of thing you think it is? .exists() gives you the first as a yes or no. .is_file() and .is_dir() tell a real file apart from a folder, which stops you from trying to read a directory as though it were text. For small files, .read_text() hands back the whole contents as one string, and .write_text() drops a string onto disk, making the file or overwriting whatever was there. Each call opens the file, does the read or write, and closes it again, all in one line.

check.py
from pathlib import Path
p = Path("logs_demo/nginx/access.log")
print("exists ", p.exists())
print("is_file", p.is_file())
print("is_dir ", p.is_dir())
first = p.read_text().splitlines()[0]
print("line 1 ", first)
report = Path("review_summary.txt")
report.write_text("access.log reviewed\n")
print("wrote ", report.read_text().strip())
~/secopslog — bash
$ python3 check.py
exists True is_file True is_dir False line 1 127.0.0.1 - - GET /login 200 wrote access.log reviewed
read_text() pulls the whole file into memory
.read_text() is fine for a config file or a short report, but it loads every byte at once. Point it at a 4 GB access log and your program tries to hold all 4 GB as text in RAM (random-access memory, the fast working memory the computer uses for whatever it is doing right now), and the operating system may kill the process for hogging it. For anything that could be big, read it line by line instead, which is the next section.

Find files: iterdir, glob, rglob

Three tools cover almost every file search you will write. .iterdir() opens one folder and lists what sits directly inside it, like pulling out a single drawer. .glob(pattern) matches the names in that same folder against a shell-style wildcard, so *.log means any name that ends in .log (the * is a stand-in for any run of characters). .rglob(pattern) runs the exact same match but walks every subfolder underneath as well, like opening every drawer in the building. The r is for recursive, which means the search repeats itself down through each nested folder without you writing the loop.

find_logs.py
from pathlib import Path
root = Path("logs_demo")
print("iterdir (top level only):")
for child in sorted(root.iterdir()):
print(" ", child.name)
print("glob '*.log' (top level):")
print(" ", [p.as_posix() for p in sorted(root.glob("*.log"))])
print("rglob '*.log' (every subfolder):")
for p in sorted(root.rglob("*.log")):
print(" ", p.as_posix())
~/secopslog — bash
$ python3 find_logs.py
iterdir (top level only): app nginx notes.txt glob '*.log' (top level): [] rglob '*.log' (every subfolder): logs_demo/app/errors.log logs_demo/app/service.log logs_demo/nginx/access.log

Look at the glob('*.log') line: an empty list. The .log files live one level down, inside app and nginx, and plain glob only ever looks in the folder you called it on. rglob is the one that reaches into the subfolders and turns up all three. Mixing these two up is a common reason a script quietly reports nothing while the files are sitting right where you left them.

Read a big file line by line

When a file might be enormous, you read it one line at a time and let Python throw each line away as soon as you are done with it. The shape with open(p) as f: opens the file and promises to close it again, even if your code blows up halfway through. Looping with for line in f: pulls in a single line, runs your check, then moves to the next one, so a ten-gigabyte log costs you one line of memory at a time instead of ten gigabytes all at once. A Path object plugs straight into open(), so you pass it in with nothing to convert. Here is a keyword scan across every log file in the tree.

stream.py
from pathlib import Path
root = Path("logs_demo")
needle = "ERROR"
for log in sorted(root.rglob("*.log")):
hits = 0
with open(log) as f:
for line in f:
if needle in line:
hits += 1
if hits:
print(f"{hits:>3} {log.as_posix()}")
~/secopslog — bash
$ python3 stream.py
2 logs_demo/app/errors.log

resolve() and the ../../etc/passwd trap

This is where file code turns into security code. resolve() takes a messy path and returns the one true absolute path (the full address starting from the root of the disk), cleaning up . and .. steps along the way and following any symbolic links (a symbolic link, or symlink, is a signpost file that points at another location) to wherever they really lead. Here is the catch. .. means go up one folder, and an attacker who controls part of a path can string enough of them together to climb out of the folder you meant to keep them inside. A request for ../../etc/passwd aimed at your uploads folder is a try at reading the system's list of user accounts. The fix is to resolve the joined path first, then check that the result still sits inside your base folder using .is_relative_to() (added in Python 3.9).

safepath.py
from pathlib import Path
BASE = Path("/srv/uploads").resolve()
def safe_path(user_input: str) -> Path:
candidate = (BASE / user_input).resolve()
if not candidate.is_relative_to(BASE):
raise ValueError(f"path escapes {BASE}: {user_input!r}")
return candidate
for req in ["report.txt", "2026/audit.log", "../../etc/passwd"]:
try:
print("OK ", safe_path(req))
except ValueError as e:
print("DENY", e)
~/secopslog — bash
$ python3 safepath.py
OK /srv/uploads/report.txt OK /srv/uploads/2026/audit.log DENY path escapes /srv/uploads: '../../etc/passwd'
Never trust a path built from user input
Blocking the literal text .. is not enough on its own. Percent-encoded characters, an absolute path pasted in whole, and symlinks all walk straight past a naive string filter. Always join the untrusted piece onto a known base, call .resolve() so .. steps and symlinks collapse down to one real location, then reject anything whose result is not .is_relative_to() your base. Order matters: resolve first, check second. If you check before you resolve, a crafted .. still counts as relative to the base and slips through, because the string has not been cleaned up yet.
Containing a user-supplied path
candidate = (BASE / user_input).resolve()
clean up the path and follow any symlinks to one real absolute location
stays inside BASE
candidate.is_relative_to(BASE) is True
safe: open and read the file
climbs out with ../
candidate.is_relative_to(BASE) is False
reject: raise ValueError and log the attempt
Resolve the joined path, then verify it never left the base folder.
Quick check
01safepath.py denies the request ../../etc/passwd against BASE = Path('/srv/uploads'). A teammate rewrites safe_path() for a new upload service. Which rewrite still blocks that same request?
Incorrect — A text filter only judges the characters it was handed. Encoded input, a full absolute path, or a symlink pointing somewhere else all read as clean while still landing outside the folder.
Incorrect — Order decides this one. Test before resolve() runs and you are judging an uncleaned string that still looks like it sits under BASE, so the guard waves it through.
Correct — resolve() flattens the upward hops and follows any symlink to one real location, and is_relative_to then answers the only question worth asking: did the result stay inside BASE?
Incorrect — A suffix tells you what a file is called, not where it sits. safe_path() never inspects the extension, and a path that climbs out of BASE can end in .log just as easily.
02find_logs.py prints an empty list for glob('*.log') on logs_demo, then prints three paths for rglob('*.log'). A teammate reads the empty list as proof the log files are missing. What actually explains it?
Correct — glob('*.log') has nothing to match at the top level because the tree keeps its logs inside app and nginx. Swap in rglob and the identical pattern reaches all three.
Incorrect — Patterns are matched relative to the folder you called the method on, so that call would go hunting for logs_demo/logs_demo. Reaching deeper is rglob's job, not the pattern's.
Incorrect — A relative Path is measured from the working directory of the process, never your home folder. The search began in the right place and simply found no match at that level.
Incorrect — *.log never matched those two names to begin with, since neither app nor nginx ends in .log. Nothing was found and then filtered out; nothing matched at all.
03A nightly job runs text = path.read_text() and then loops over text.splitlines() counting ERROR lines. It passes against logs_demo and gets killed by the operating system when pointed at a 4 GB access log. Which change fixes it?
Incorrect — read_bytes() walks the same file end to end and hands you 4 GB of bytes in place of 4 GB of text. Nothing about that call is incremental.
Incorrect — splitlines() does add a second copy, but the process already died holding the first one. The read itself is the part that has to change.
Incorrect — Splitting the work by file helps when many small logs are involved. Here one file is 4 GB on its own, so a single read_text() call still asks for the whole thing.
Correct — Each turn of the loop pulls in one line, your check runs, and Python lets it go. File size stops mattering because the footprint never grows past a line.

Put it together: a log report

This last script is the kind of thing you would actually keep around. Point it at a directory, and it finds every .log file underneath, counts the lines in each, adds up the bytes, and prints a tidy table. It leans on rglob to gather the files, the streaming loop to count lines without loading whole files, .stat().st_size for the byte count of each file, and .relative_to(root) so the table shows short names instead of long absolute paths.

logreport.py
import sys
from pathlib import Path
def count_lines(path: Path) -> int:
n = 0
with open(path) as f:
for line in f:
n += 1
return n
def main(root_arg: str) -> None:
root = Path(root_arg)
if not root.is_dir():
print(f"not a directory: {root}")
raise SystemExit(1)
total_bytes = 0
total_lines = 0
logs = sorted(root.rglob("*.log"))
print(f"log report for {root.as_posix()}/")
print(f"{'file':<24}{'lines':>7}{'bytes':>9}")
for p in logs:
lines = count_lines(p)
size = p.stat().st_size
total_bytes += size
total_lines += lines
print(f"{p.relative_to(root).as_posix():<24}{lines:>7}{size:>9}")
print("-" * 40)
print(f"{f'{len(logs)} files':<24}{total_lines:>7}{total_bytes:>9}")
if __name__ == "__main__":
main(sys.argv[1])
~/secopslog — bash
$ python3 logreport.py logs_demo
log report for logs_demo/ file lines bytes app/errors.log 3 77 app/service.log 2 34 nginx/access.log 3 95 ---------------------------------------- 3 files 8 206

From here you can bolt real checks onto the same skeleton: flag any log that grew past a size you set, match each line against a list of suspicious patterns, or write the table out with write_text() so a scheduled job can mail it to you every morning. The path handling underneath does not change as you add those, which is the reason to build on pathlib instead of strings you taped together by hand.

Try this

Work through “Put it together: a log report” 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: read_text() pulls the whole file into memory. 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