CLI tools with argparse

Flags, exit codes, and --dry-run.

Intermediate25 min · lesson 8 of 14

A security script with no command-line interface is a sealed appliance. To change which folder it scans or where it writes findings, you pop the lid and resolder the wiring, which really means you edit constants in the source and hope you caught every one. A command-line interface (CLI, the words and switches you type after a program's name) is the labeled control panel bolted to the front: named switches, a printed manual, and a fuse that trips before bad input reaches the moving parts. argparse, a module that ships inside Python's standard library, wires up that panel for you. You declare the arguments your program accepts, and it checks them, converts them into real Python types, and writes the --help manual on its own.

What a parsed CLI buys you

The problem it kills is boring and expensive. Scripts driven by hand-edited constants get copied, and every copy drifts. Someone bumps TARGET_DIR for a one-off Friday job, forgets to change it back, and next week's scheduled run quietly sweeps the wrong network share. Push that setting onto the command line and the drift has nowhere to hide. Every run becomes a full, readable record: in your shell history, in the cron entry (cron is the Linux job scheduler), in the CI logs (CI, or continuous integration, is the system that builds and tests your code automatically), and in the incident timeline you eventually hand an auditor.

The second thing you buy is a checkpoint at the door. type= converts and sanity-checks each value the instant it lands. choices= pins an argument to an allowlist of accepted words. required=True refuses to run half-configured. Bad input is turned away before it can reach code that might pass it to a shell or an API (application programming interface, the set of calls another program or service exposes). The third thing is exit codes, the single number a program leaves behind when it finishes so the next program knows what happened. argparse exits with status 2 when you misuse the flags themselves. Well-behaved tools keep 0 for a clean run and 1 for 'I found something worth your attention'. That tiny contract is what cron, CI, and every wrapper script read to decide what happens next.

How the shell and argparse split the work

By the time Python wakes up, the shell has already finished its half of the work. It took your line of text, split it on spaces, expanded any * wildcards into actual filenames, resolved $VARIABLES, and stripped the quotes. What your process receives is sys.argv, a plain Python list of strings. Globbing (expanding wildcards like * into filenames) and quoting are the shell's job, settled before argparse reads a single character.

ArgumentParser is a registry, like the guest list at a door. Each add_argument() call writes one entry: the argument's names, how many values it swallows (nargs), how to convert them (type), and where to file the result. parse_args() then walks the tokens left to right, checks each one against the list, runs the conversions, and sets the finished values as plain attributes on a Namespace object.

Two things follow that matter for defense. First, an argument is inert data while it sits inside your process. A filename like ; rm -rf / is a harmless string right up until your own code hands it to a shell. Second, failure is loud and early. An unknown flag, a missing required option, or a conversion that raises will print the usage line to stderr (the standard error stream, the channel programs use for diagnostics) and quit with exit code 2 before one line of your real logic runs.

From typed command to validated data
1You type a command
words and switches after the program name
2Shell splits and expands
globs, $VARS, and quotes resolved first
3Process receives sys.argv
a plain list of strings
4parse_args walks the tokens
matches the registry, runs each type callable
5Namespace of clean values
or exit 2 to stderr on bad input
6Your logic runs
only ever on validated data

A real triage tool

Here is a complete tool. It hashes files with SHA-256 (a cryptographic fingerprint: the same bytes always produce the same 64-character digest, and there is no practical way to forge a different file that matches a given one) and flags any file whose fingerprint shows up in an IOC list (IOC, indicators of compromise, here one known-bad hash per line). Watch the small existing_file function. It is a custom type callable, and by raising argparse.ArgumentTypeError it turns a bad path into a clean usage message instead of an ugly stack trace. By the time main() runs, every path in args.target is guaranteed to exist.

hashcheck.py
#!/usr/bin/env python3
"""hashcheck: flag files whose SHA-256 appears in an IOC list."""
import argparse
import hashlib
from pathlib import Path
def sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def existing_file(value: str) -> Path:
p = Path(value)
if not p.is_file():
raise argparse.ArgumentTypeError(f"{value!r} is not a readable file")
return p
def main() -> int:
parser = argparse.ArgumentParser(
prog="hashcheck",
description="Compare file hashes against a known-bad IOC list.",
)
parser.add_argument("target", nargs="+", type=existing_file,
help="files to check")
parser.add_argument("--ioc-file", required=True, type=existing_file,
help="file with one SHA-256 per line")
parser.add_argument("--format", choices=["text", "json"], default="text",
help="output format (default: %(default)s)")
parser.add_argument("-v", "--verbose", action="count", default=0,
help="increase log detail (-v, -vv)")
args = parser.parse_args()
known_bad = {ln.strip().lower() for ln in args.ioc_file.read_text().splitlines()
if ln.strip()}
hits = [p for p in args.target if sha256(p) in known_bad]
for p in hits:
print(f"MATCH {p}")
return 1 if hits else 0
if __name__ == "__main__":
raise SystemExit(main())

Run it and poke at the failure modes. The %(default)s token drops the real default straight into the help text, so the manual can never lie about what the program actually does.

~/secopslog — bash
$ python3 hashcheck.py --ioc-file iocs.txt invoice.pdf dropper.bin echo $?
MATCH dropper.bin 1
$ python3 hashcheck.py --ioc-file iocs.txt dropper.bin --format yaml echo $?
usage: hashcheck [-h] --ioc-file IOC_FILE [--format {text,json}] [-v] target [target ...] hashcheck: error: argument --format: invalid choice: 'yaml' (choose from 'text', 'json') 2
$ python3 hashcheck.py -h
usage: hashcheck [-h] --ioc-file IOC_FILE [--format {text,json}] [-v] target [target ...] Compare file hashes against a known-bad IOC list. positional arguments: target files to check options: -h, --help show this help message and exit --ioc-file IOC_FILE file with one SHA-256 per line --format {text,json} output format (default: text) -v, --verbose increase log detail (-v, -vv)

Look before you touch: --dry-run

A tool that only prints matches is safe by nature. The moment it starts moving or deleting files, one stale IOC list or one fat-fingered path can quarantine a production binary at 3 a.m. --dry-run is the safety catch: the tool runs every check and prints exactly what it would do, but changes nothing on disk. Wire it as a plain store_true flag and branch on it right before the step that mutates anything.

triage.py
def cmd_scan(args) -> int:
known_bad = load_iocs(args.ioc_file)
hits = [p for p in args.path if sha256(p) in known_bad]
for p in hits:
if not args.quarantine:
print(f"MATCH {p}")
elif args.dry_run: # the promise: change nothing
print(f"WOULD quarantine {p}")
else:
dest = QUARANTINE_DIR / p.name
p.rename(dest) # the only line that mutates state
print(f"quarantined {p} -> {dest}")
return 1 if hits else 0

Run the dry pass, read it, then run for real. One detail trips people up: a dry run that finds bad files still exits 1. It found something. Only the file-moving was skipped, not the verdict, so a CI job can gate a release on the dry run and never touch the artifacts it is judging.

~/secopslog — bash
$ python3 triage.py scan --ioc-file iocs.txt --quarantine --dry-run /srv/uploads/* echo $?
WOULD quarantine /srv/uploads/dropper.bin 1
$ python3 triage.py scan --ioc-file iocs.txt --quarantine /srv/uploads/* echo $?
quarantined /srv/uploads/dropper.bin -> /var/quarantine/dropper.bin 1
A dry run has to be honest
--dry-run is a promise your code must keep. If any mutating call (moving a file, killing a process, posting to an API) fires before you check the flag, the promise is already broken, and someone will trust a preview that quietly changed the system. Put the if args.dry_run: branch directly in front of every side effect, and keep the exit code truthful: a dry run that found bad files still exits 1.

Growing verbs with subcommands

Tools grow verbs the way git commit and git push do. add_subparsers() gives each verb its own parser, its own flags, and its own help page. You attach a handler to each verb with set_defaults(func=...) and dispatch through args.func(args). Set required=True on the subparsers, and running the bare tool prints the list of verbs instead of doing nothing in silence. argparse.BooleanOptionalAction builds a matched pair of switches, --quarantine and --no-quarantine, so turning the behavior off is spelled out in the logs rather than guessed from a flag that is not there.

triage.py
parser = argparse.ArgumentParser(prog="triage")
sub = parser.add_subparsers(dest="command", required=True)
scan = sub.add_parser("scan", help="hash files against IOC lists")
scan.add_argument("path", nargs="+", type=existing_file)
scan.add_argument("--ioc-file", required=True, type=existing_file)
scan.add_argument("--quarantine", action=argparse.BooleanOptionalAction,
default=False) # builds --quarantine / --no-quarantine
scan.add_argument("--dry-run", action="store_true",
help="print actions, change nothing")
scan.set_defaults(func=cmd_scan)
report = sub.add_parser("report", help="summarise previous runs")
report.add_argument("--since", default="24h")
report.set_defaults(func=cmd_report)
args = parser.parse_args()
raise SystemExit(args.func(args))
~/secopslog — bash
$ python3 triage.py
usage: triage [-h] {scan,report} ... triage: error: the following arguments are required: command

Hardening the boundary

Most of the hardening at this layer is restraint. Reach for a choices= allowlist over a free-form string anywhere a value picks behavior. Validate paths inside type callables, not deep in the business logic, so mistakes come back as tidy usage errors. And if your tool launches other programs, never glue a shell string together out of arguments. Hand subprocess.run a list of separate arguments, so a filename like ; rm -rf / stays a filename and never gets a chance to become a command.

Secrets never belong in argv
Every argument you pass is visible to every other user on the machine. ps aux (the command that lists running processes) shows it, /proc/<pid>/cmdline (a live file the kernel exposes for each running process) holds it, your shell history saves it, and CI logs echo it back. Never add an --api-key or --password flag. Read secrets from an environment variable through os.environ (a process's private set of key-value settings) or from a file locked to 0600 permissions (readable and writable only by its owner). If you must accept a flag at all, take a path to the secret, never the secret itself.

When to stay in the standard library

argparse has real edges. Arguments that depend on each other are awkward. add_mutually_exclusive_group() handles 'A or B, not both', but 'A requires B' means a hand-written check after parsing. Tab completion in the shell needs the third-party argcomplete package. And fifteen subcommands stuffed into one file become a wall of setup code, so split each verb into its own module. Click and Typer trade these aches for a friendlier style driven by decorators and type hints.

For security tooling, staying in the standard library is itself a feature. An argparse script runs on any box that has Python: a hardened bastion host (the locked-down jump box that fronts a sensitive network), an air-gapped forensics laptop (one kept off every network on purpose), a container with no route to any package index. Nothing to install, nothing to vet. Zero third-party imports means zero extra supply-chain surface in a tool your team runs with elevated privileges. The moment you add Click, Typer, or rich, you take on code written by strangers that ships inside your tool and inherits its access. Deciding when that trade pays off, pinning exactly what you adopt, and watching it for known vulnerabilities is the work of the next lesson: dependency hygiene.

Quick check
01Your release pipeline runs triage scan --ioc-file iocs.txt --quarantine --dry-run build/*, and two artifacts hash to entries in the IOC list. What should the run do?
Incorrect — The rename is the single line in cmd_scan that alters the system, and the dry-run branch sits in front of it exactly so that line is never reached.
Incorrect — Status 2 is what argparse returns when the flags themselves are wrong, and the scan subparser declares --quarantine and --dry-run as an ordinary pair you can combine.
Correct — The preview holds back the rename, not the judgement; two hits are still two hits, so the run ends with the status a pipeline reads as 'something turned up'.
Incorrect — Status 0 is the promise that the run was clean, so returning it here lets a release ship artifacts you have just proved are on the known-bad list.
02You run python3 hashcheck.py --ioc-file iocs.txt /srv/uploads/*. Which piece of the stack turns /srv/uploads/* into an actual list of filenames?
Correct — The process is handed sys.argv as a finished list of strings, so whatever produced those names ran one layer earlier, before the interpreter loaded.
Incorrect — nargs="+" only states how many already-separated tokens to gather into args.target; it has no way of knowing a wildcard was ever typed.
Incorrect — existing_file is handed one value at a time and answers is_file() about that value alone, so it can reject a bad path but never discover extra ones.
Incorrect — By the time parse_args() runs, the token list is fixed; the parser converts values and files them onto the Namespace, and no step of that touches patterns.
03Your triage tool hands a user-supplied filename to an external scanner it launches. One uploaded file is genuinely named ; rm -rf / #. Which approach keeps that name harmless?
Incorrect — choices= fits values drawn from a short fixed set, the way --format accepts only text or json; user filenames are unbounded, so there is no list to write.
Incorrect — That callable answers one question, whether the path is a readable file, and a file really named ; rm -rf / # answers yes and sails straight through.
Incorrect — Hand-quoting is the trap: shell=True gives the whole line to a shell to interpret, and one case you did not anticipate turns the semicolon into a separator.
Correct — With a list, the arguments reach the child program already separated, so there is no shell in the picture to notice the semicolon or act on rm.

Try this

Work through “When to stay in the standard library” 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 dry run has to be honest. 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