Automate a Phishing Triage Playbook End to End
The cat triage line: every phish gets stamped SAFE or HOOKED, and the robot hauls the hooked ones to quarantine. Two minutes, zero coffee.
Two hundred phish, one analyst
Phishing is still how most break-ins start, and every company has a report button in the inbox — which means the security team wakes up to a pile of "is this legit?" emails. Back in the SOAR post we did the math: one reported phish takes a human analyst about 45 minutes to triage properly. Open the email, extract the links, check each link's reputation, detonate the suspicious ones, find out who else got the same message, delete it from every inbox, block the sender, open a ticket.
Now multiply by a few hundred reports a week. That's not a workload, that's a drowning. This post is the rescue plan: the full triage recipe, the real tools that run it, and a working Python playbook you can run on your laptop.
Meet the stack
Nothing in this playbook happens in a vacuum. Every step is a real tool doing its job:
- The report button is the intake. In most companies that's Microsoft Defender for Office 365 (or Proofpoint) — the employee clicks "report phishing" and the message lands in a submissions queue with its headers intact. Headers matter: they're the crime scene.
- The SOAR is the conductor. Palo Alto Cortex XSOAR or Splunk SOAR is the platform that runs the recipe — it calls the APIs, makes the decisions, and keeps the audit trail. Our Python script is a pocket-sized SOAR.
- Reputation is the background check. VirusTotal and URLScan.io tell you whether a link or file has been seen before and what the verdict was. Millions of samples a day; your phish is probably not original.
- The sandbox is the bomb squad. Unknown link? Detonate it in ANY.RUN, Joe Sandbox, or Defender's own automated detonation — a sacrificial browser opens the page and reports what it does, so no human ever has to click it.
- The mailbox is the blast radius. One report usually means dozens of recipients. Defender's Threat Explorer (or Exchange Online) hunts down every copy and purges them before anyone else clicks.
- The ticket is the memory. ServiceNow (ITSM) records what happened, what was done, and who approved it — because "I think I blocked it" is not an incident record.
- The EDR is the follow-up. CrowdStrike Falcon or SentinelOne answers the scary question: did anyone click before we contained it? If yes, the endpoint gets isolated and the identity team gets involved.
- The identity provider is the reset button. If credentials were harvested, Microsoft Entra ID forces the password reset and kills the sessions — the phish dies even if the password lived.
Same theme as every post in this series: no tool works alone. The playbook is the thing that makes them work together.
The recipe: what the playbook actually does
Here is the triage recipe, in order. Every mature SOC runs some version of this:
- Ingest — pull the reported message (with full headers) out of the submissions queue.
- Extract — pull out every URL, attachment, and sender domain. These are your indicators.
- Reputation — check each indicator against VirusTotal and URLScan. Known-bad? Skip to containment. Known-clean? Close it out.
- Detonate — anything unknown goes to the sandbox. Verdict comes back in a minute or two.
- Hunt — search the mail environment for the same message. Who else got it? Who opened it?
- Contain — purge the message from every inbox, block the sender domain at the firewall and web gateway, isolate any endpoint where someone clicked.
- Ticket — write the case: what it was, what you found, what you did.
- Close or escalate — clean verdict closes automatically; anything weird goes to a human with all the evidence attached.
Automate everything the recipe can decide. Escalate everything that smells like judgment — the weird-but-legit vendor invoice, the exec's "urgent" wire request. And never auto-purge without an undo: false positives you can reverse are a learning cost; false positives you can't are a career event.
The lab: build it in Python
Time to conduct. This script runs steps 1–4 and 7 of the recipe against a real .eml file, using the real VirusTotal and URLScan APIs. Steps 5–6 (mailbox hunt and purge) need your company's mail tenant, so the script emits them as a containment checklist — in a real SOAR those become API calls to Defender and the firewall.
Setup: pip install requests, then grab free API keys from virustotal.com and urlscan.io (both have free tiers) and export them. Test safely: use a benign newsletter export, or a known-bad test URL from URLScan's own examples — never feed it a live phish you haven't contained.
#!/usr/bin/env python3
"""
phish_triage.py — LV 7 lab: a tiny phishing triage playbook.
The recipe (and the enterprise tool each step stands in for):
1. INGEST read a reported .eml file -> Defender for Office 365 / Proofpoint
2. EXTRACT pull out URLs -> SOAR enrichment (Cortex XSOAR / Splunk SOAR)
3. REPUTATION check each URL on VirusTotal -> VirusTotal / URLScan.io integrations
4. DETONATE submit unknowns to URLScan -> ANY.RUN / Joe Sandbox / Defender detonation
5. PURGE list who else got the message -> Defender Threat Explorer / Exchange Online
6. TICKET write the case file -> ServiceNow ITSM
$ export VT_API_KEY="..." URLSCAN_API_KEY="..."
$ python3 phish_triage.py suspicious.eml
"""
import base64, email, json, os, re, sys, time
from email import policy
from email.parser import BytesParser
import requests
VT_KEY = os.environ.get("VT_API_KEY")
URLSCAN_KEY = os.environ.get("URLSCAN_API_KEY")
URL_RE = re.compile(r"https?://[^\s'\"<>]+", re.IGNORECASE)
def extract_urls(eml_path):
"""Step 2: pull every URL out of the reported message."""
with open(eml_path, "rb") as f:
msg = BytesParser(policy=policy.default).parse(f)
body = msg.get_body(preferencelist=("plain", "html"))
text = body.get_content() if body else ""
urls = {u.rstrip(").,;:") for u in URL_RE.findall(text)}
return msg["subject"], msg["from"], sorted(urls)
def vt_lookup(url):
"""Step 3: ask VirusTotal what it knows about this URL."""
url_id = base64.urlsafe_b64encode(url.encode()).decode().rstrip("=")
r = requests.get(f"https://www.virustotal.com/api/v3/urls/{url_id}",
headers={"x-apikey": VT_KEY}, timeout=20)
if r.status_code == 404:
return {"known": False}
r.raise_for_status()
stats = r.json()["data"]["attributes"]["last_analysis_stats"]
return {"known": True, "malicious": stats.get("malicious", 0),
"suspicious": stats.get("suspicious", 0)}
def urlscan_submit(url):
"""Step 4: detonate the unknown URL in a sandbox, poll for the verdict."""
r = requests.post("https://urlscan.io/api/v1/scan/",
headers={"API-Key": URLSCAN_KEY},
json={"url": url, "visibility": "public"}, timeout=20)
r.raise_for_status()
api_url = r.json()["api"]
for _ in range(12): # poll ~2 minutes
time.sleep(10)
res = requests.get(api_url, timeout=20)
if res.status_code == 200:
v = res.json().get("verdicts", {}).get("overall", {})
return {"malicious": v.get("malicious", False),
"score": v.get("score", 0)}
return {"malicious": None, "note": "scan timed out"}
def triage(eml_path):
"""The playbook: ingest, extract, reputation, detonate, ticket."""
subject, sender, urls = extract_urls(eml_path) # steps 1+2
findings = []
for url in urls:
rep = vt_lookup(url) if VT_KEY else {"known": False, "skipped": True}
if not rep.get("known") and URLSCAN_KEY:
rep.update(urlscan_submit(url)) # step 4
verdict = ("MALICIOUS" if rep.get("malicious")
else "UNKNOWN" if not rep.get("known") else "CLEAN")
findings.append({"url": url, "verdict": verdict, "detail": rep})
case = { # step 7: the ticket
"title": f"Phishing triage: {subject}",
"reporter": str(sender),
"urls_found": len(urls),
"malicious_urls": [f["url"] for f in findings
if f["verdict"] == "MALICIOUS"],
"findings": findings,
# Steps 5-6 run in the real tools; the script emits the checklist:
"next_actions": ["purge message from all mailboxes "
"(Defender Threat Explorer)",
"block sender domain at the firewall",
"check EDR: did anyone click? "
"(CrowdStrike / SentinelOne)",
"force password reset if creds were entered "
"(Entra ID)"],
}
with open("case.json", "w") as f:
json.dump(case, f, indent=2)
return case
if __name__ == "__main__":
print(json.dumps(triage(sys.argv[1]), indent=2))
A typical run against a reported phish prints a case file like this — two URLs extracted, one known-bad on VirusTotal, one unknown that URLScan detonated and flagged, and a containment checklist ready for the tools that own steps 5 and 6:
{
"title": "Phishing triage: URGENT: verify your account",
"reporter": "billing@paynow-secure.com",
"urls_found": 2,
"malicious_urls": ["http://bit.ly/secure-klc"],
"next_actions": ["purge message from all mailboxes ...", "..."]
}
What the script stands in for
Your laptop script is the rehearsal; the enterprise is the performance. The mapping is direct:
extract_urls→ the SOAR's enrichment task (Cortex XSOAR calls it "Extract Indicators"). Same job, more sources: attachments, headers, reply-to chains.vt_lookup→ the VirusTotal/URLScan reputation integrations every SOAR ships with. Same API, parallelized across thousands of indicators.urlscan_submit→ detonation at scale — ANY.RUN or Defender's automated investigation, with screenshots, network traffic, and dropped files instead of just a verdict.case.json→ a real ServiceNow incident, created via API with the evidence attached and the analyst assigned.next_actions→ the containment tasks the SOAR executes itself: mailbox purge via Defender, domain block via the Palo Alto firewall API, password reset via Entra ID, endpoint isolation via the EDR.
The logic doesn't change when you scale it. The 45-minute manual triage and the two-minute playbook are the same recipe — one is just conducted by a very fast, very literal cat.
Try it yourself
Four ways to level this lab up:
- Export a real report. Most mail clients can save a message as
.eml. Run the script on a benign newsletter first — everything should come back CLEAN, and you'll see what a healthy verdict looks like. - Add attachments. Hash each attachment with SHA256 and query VirusTotal's
/files/{hash}endpoint. Same pattern as URLs, new indicator type. - Write a real ticket. Turn
case.jsoninto a ServiceNow incident via the Table API — one POST and your stub becomes a tracked case. - Close the loop. Stub out the purge step as a Microsoft Graph call (
/messagessearch + delete). You won't run it without a tenant, but writing the call teaches you exactly what the SOAR does for real.
Next in this series at Level 8: "Detection engineering: the pyramid of pain." You automated the response — now learn why attackers keep changing their lures, and which parts of your detection they can't change without starting over.