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 CI pipeline runs `triage scan --quarantine --dry-run` against build artifacts to catch known-bad files before release. Two artifacts match the IOC list. What should the command do?
Incorrect — a dry run must change nothing on disk; the whole point is to preview without acting.
Correct — dry-run skips the mutation, but the findings are real, and exit 1 lets CI gate the release.
Incorrect — the findings still happened; exit 0 would let a build shipping known-bad files pass.
Incorrect — exit 2 is for flag misuse only, and these two flags are compatible by design.
02You run `python3 hashcheck.py --ioc-file iocs.txt /srv/uploads/*`. Which component turns `/srv/uploads/*` into an actual list of filenames?
Incorrect — nargs only says how many already-split tokens to collect; it performs no wildcard expansion.
Incorrect — the type callable checks a value that has already arrived; it never expands a glob.
Incorrect — nothing calls glob here; the process only ever receives a finished list of strings in sys.argv.
Correct — globbing and quoting are the shell's job, settled before argparse reads a single token.
03Your tool passes a user-supplied filename to an external scanner. One file is named `; rm -rf / #`. Which approach keeps that name a harmless string instead of a command?
Correct — with an argument list the name is never handed to a shell to interpret, so the semicolon and rm are just characters.
Incorrect — that is exactly the injection path, because the shell would read the `;` and run rm -rf /.
Incorrect — os.system runs the string through a shell, so the crafted name executes.
Incorrect — choices= fits a small fixed allowlist of accepted words, not arbitrary filenames, so it does not apply here.

Related