CLI tools with argparse
Flags, exit codes, and --dry-run.
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.
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.
#!/usr/bin/env python3"""hashcheck: flag files whose SHA-256 appears in an IOC list."""import argparseimport hashlibfrom pathlib import Pathdef 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 pdef 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 0if __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.
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.
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 nothingprint(f"WOULD quarantine {p}")else:dest = QUARANTINE_DIR / p.namep.rename(dest) # the only line that mutates stateprint(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.
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.
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-quarantinescan.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))
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.
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.