Security · python · 27 copies
IOC Extractor
Pulls indicators of compromise out of unstructured text: logs, emails, threat advisories, incident notes or pasted output. Extracts IPv4/IPv6, domains, URLs, email addresses, MD5/SHA1/SHA256 hashes, CVEs, Bitcoin addresses, Windows file paths and registry keys. Refangs defanged notation on the way in (evil[.]com, hxxp://, user[at]domain) and defangs on the way out so results are safe to paste into a ticket. Filters the noise that makes raw regex extraction unusable: private and documentation IP ranges, filenames that look like domains, and the vendor domains present in every corporate log. Groups domains by registrable name to surface DGA-style subdomain sprawl. Stdlib only, no dependencies.
- Linux
- Windows
- macOS
# No extra packages — uses Python stdlib only#!/usr/bin/env python3
"""
IOC Extractor
READ THE DOCUMENTATION BEFORE YOU RUN THIS:
https://permitany.com/scripts/ioc-extractor
It covers what the script does, whether it is safe to run, the
inventory file and credentials it needs, how to read the output,
and what to do when something fails.
# No extra packages — uses Python stdlib only
"""
import sys
# A Windows console is cp1252 by default: printing an arrow or a check mark
# raises UnicodeEncodeError and kills the run halfway through a scan.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
import argparse
import csv
import ipaddress
import json
import os
import re
from collections import Counter, defaultdict
from datetime import datetime, timezone
# --------------------------------------------------------------------------
# Patterns
# --------------------------------------------------------------------------
# Defanging is not standardised. These are the forms seen in real advisories
# and threat reports, normalised before extraction so a defanged indicator is
# found rather than missed.
DEFANG_SUBS = [
(re.compile(r"\[\.\]|\(\.\)|\{\.\}|\[dot\]|\(dot\)", re.I), "."),
(re.compile(r"\[:\]|\(:\)|\[colon\]", re.I), ":"),
(re.compile(r"\[@\]|\(@\)|\[at\]|\(at\)", re.I), "@"),
(re.compile(r"\bhxxp(s?)\b", re.I), r"http\1"),
(re.compile(r"\bfxp\b", re.I), "ftp"),
(re.compile(r"\[://\]"), "://"),
]
PATTERNS = {
# Bounded by non-digit/dot so "1.2.3.4.5" and version strings do not match.
"ipv4": re.compile(r"(?<![\d.])((?:\d{1,3}\.){3}\d{1,3})(?![\d.])"),
"ipv6": re.compile(r"(?<![:.\w])((?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{1,4})(?![:.\w])"),
"url": re.compile(r"\b((?:https?|ftp)://[^\s<>\"'\)\]]{4,2048})", re.I),
"email": re.compile(r"\b([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,24})\b"),
"domain": re.compile(r"\b((?:[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,24})\b"),
"md5": re.compile(r"\b([A-Fa-f0-9]{32})\b"),
"sha1": re.compile(r"\b([A-Fa-f0-9]{40})\b"),
"sha256": re.compile(r"\b([A-Fa-f0-9]{64})\b"),
"cve": re.compile(r"\b(CVE-\d{4}-\d{4,7})\b", re.I),
"btc": re.compile(r"\b((?:bc1[a-z0-9]{25,62})|(?:[13][a-km-zA-HJ-NP-Z1-9]{25,34}))\b"),
"winpath": re.compile(r"\b([A-Za-z]:\\\\?(?:[^\\/:*?\"<>|\r\n]+\\\\?){1,12}[^\\/:*?\"<>|\r\n\s]*)"),
"registry": re.compile(r"\b(HK(?:EY_)?(?:LM|CU|CR|U|CC|LOCAL_MACHINE|CURRENT_USER|CLASSES_ROOT|USERS|CURRENT_CONFIG)\\[^\s\"'<>|]{3,250})", re.I),
}
# Extensions that make a filename look like a domain: "setup.exe" matches the
# domain pattern, and "exe" is not a TLD. Checked against the IANA-style suffix
# list below rather than maintained as an exclusion list.
NON_TLD_SUFFIXES = {
"exe", "dll", "sys", "bat", "cmd", "ps1", "vbs", "js", "jar", "py", "sh",
"txt", "log", "cfg", "conf", "ini", "xml", "json", "yaml", "yml", "csv",
"zip", "rar", "7z", "gz", "tar", "iso", "img", "bin", "dat", "db", "sql",
"png", "jpg", "jpeg", "gif", "bmp", "svg", "ico", "pdf", "doc", "docx",
"xls", "xlsx", "ppt", "pptx", "rtf", "tmp", "bak", "old", "lock", "pid",
"so", "dylib", "o", "a", "class", "php", "asp", "aspx", "jsp", "html",
"htm", "css", "scss", "ts", "tsx", "jsx", "md", "rst", "toml", "lnk",
"local", "localdomain", "internal", "invalid", "example", "test",
}
# Domains that appear in almost every log and are never the indicator.
DEFAULT_DOMAIN_NOISE = {
"microsoft.com", "windows.com", "windowsupdate.com", "msftncsi.com",
"msftconnecttest.com", "office.com", "office365.com", "live.com",
"google.com", "gstatic.com", "googleapis.com", "gvt1.com", "gvt2.com",
"mozilla.org", "mozilla.com", "apple.com", "icloud.com", "akamai.net",
"akamaiedge.net", "akadns.net", "cloudflare.com", "cloudfront.net",
"amazonaws.com", "azure.com", "azureedge.net", "digicert.com",
"verisign.com", "globalsign.com", "sectigo.com", "letsencrypt.org",
"w3.org", "schema.org", "ietf.org", "iana.org", "example.com",
}
HASH_KINDS = ("md5", "sha1", "sha256")
# --------------------------------------------------------------------------
# Helpers
# --------------------------------------------------------------------------
def refang(text):
"""Normalise defanged notation so defanged indicators are extracted."""
for pattern, repl in DEFANG_SUBS:
text = pattern.sub(repl, text)
return text
def defang(value, kind):
"""Make an indicator safe to paste into a ticket or a chat window."""
if kind in ("ipv4", "domain", "url", "email"):
value = value.replace("http://", "hxxp://").replace("https://", "hxxps://")
value = value.replace("@", "[@]")
# Only the last dot of a bare domain needs breaking for readability,
# but tooling expects every dot broken, so break them all.
value = value.replace(".", "[.]")
return value
def classify_ip(value):
"""Return (is_public, label). Private/reserved addresses are rarely IOCs."""
try:
addr = ipaddress.ip_address(value)
except ValueError:
return None, "invalid"
if addr.is_private:
return False, "private"
if addr.is_loopback:
return False, "loopback"
if addr.is_link_local:
return False, "link-local"
if addr.is_multicast:
return False, "multicast"
if addr.is_reserved or addr.is_unspecified:
return False, "reserved"
return True, "public"
def plausible_domain(value, extra_noise):
"""
Reject the three big sources of domain false positives: filenames whose
extension is not a TLD, anything that is actually an IP, and the vendor
domains that appear in every log.
"""
low = value.lower().rstrip(".")
if not low or len(low) > 253:
return False, "malformed"
suffix = low.rsplit(".", 1)[-1]
if suffix in NON_TLD_SUFFIXES:
return False, "filename"
if suffix.isdigit():
return False, "ip-like"
if len(suffix) < 2:
return False, "malformed"
for noise in DEFAULT_DOMAIN_NOISE | extra_noise:
if low == noise or low.endswith("." + noise):
return False, "common"
return True, "candidate"
def registrable(domain):
"""Crude eTLD+1. Good enough for grouping; not a public-suffix parser."""
parts = domain.lower().rstrip(".").split(".")
if len(parts) <= 2:
return ".".join(parts)
# Handle the common two-label suffixes without shipping a suffix list.
two = ".".join(parts[-2:])
if two in {"co.uk", "com.mx", "com.br", "com.au", "co.jp", "co.nz",
"com.ar", "org.uk", "gov.uk", "ac.uk", "com.cn", "net.au"}:
return ".".join(parts[-3:])
return two
def read_sources(paths, stdin_text):
"""Yield (label, text). A directory is walked; '-' means stdin."""
if stdin_text is not None:
yield "<stdin>", stdin_text
for p in paths:
if os.path.isdir(p):
for root, _dirs, files in os.walk(p):
for name in files:
full = os.path.join(root, name)
try:
with open(full, "r", encoding="utf-8", errors="replace") as fh:
yield full, fh.read()
except (OSError, UnicodeError) as exc:
print(f" ! skipped {full}: {exc}", file=sys.stderr)
else:
try:
with open(p, "r", encoding="utf-8", errors="replace") as fh:
yield p, fh.read()
except (OSError, UnicodeError) as exc:
print(f" ! skipped {p}: {exc}", file=sys.stderr)
# --------------------------------------------------------------------------
# Extraction
# --------------------------------------------------------------------------
def extract(text, extra_noise, include_private, want):
"""
Return {kind: {value: {"count": n, "note": str}}}.
Order matters: URLs and emails are extracted before bare domains so the
hostname inside a URL is attributed to the URL, and hashes are matched
longest-first so a sha256 is not also reported as a truncated md5.
"""
found = defaultdict(dict)
rejected = Counter()
def add(kind, value, note=""):
slot = found[kind].setdefault(value, {"count": 0, "note": note})
slot["count"] += 1
# Hashes: longest first, and mask each match so shorter patterns cannot
# re-match a substring of a longer hash.
masked = text
for kind in ("sha256", "sha1", "md5"):
if kind not in want:
continue
for m in PATTERNS[kind].finditer(masked):
add(kind, m.group(1).lower())
masked = PATTERNS[kind].sub(lambda m: "#" * len(m.group(0)), masked)
if "url" in want:
for m in PATTERNS["url"].finditer(text):
add("url", m.group(1).rstrip(".,;)"))
if "email" in want:
for m in PATTERNS["email"].finditer(text):
add("email", m.group(1).lower())
if "ipv4" in want:
for m in PATTERNS["ipv4"].finditer(text):
value = m.group(1)
is_public, label = classify_ip(value)
if is_public is None:
rejected["ipv4:invalid"] += 1
continue
if not is_public and not include_private:
rejected[f"ipv4:{label}"] += 1
continue
add("ipv4", value, label)
if "ipv6" in want:
for m in PATTERNS["ipv6"].finditer(text):
value = m.group(1)
is_public, label = classify_ip(value)
if is_public is None:
rejected["ipv6:invalid"] += 1
continue
if not is_public and not include_private:
rejected[f"ipv6:{label}"] += 1
continue
add("ipv6", value, label)
if "domain" in want:
# Hostnames already captured inside URLs and emails are not reported
# again as bare domains — they are the same indicator.
seen_in_context = set()
for url in found.get("url", {}):
host = re.sub(r"^\w+://", "", url).split("/")[0].split(":")[0]
seen_in_context.add(host.lower())
for addr in found.get("email", {}):
seen_in_context.add(addr.split("@")[-1].lower())
for m in PATTERNS["domain"].finditer(text):
value = m.group(1).lower().rstrip(".")
ok, why = plausible_domain(value, extra_noise)
if not ok:
rejected[f"domain:{why}"] += 1
continue
if value in seen_in_context:
continue
add("domain", value)
for kind in ("cve", "btc", "winpath", "registry"):
if kind not in want:
continue
for m in PATTERNS[kind].finditer(text):
value = m.group(1)
add(kind, value.upper() if kind == "cve" else value)
return found, rejected
# --------------------------------------------------------------------------
# Reporting
# --------------------------------------------------------------------------
def merge(into, new, source):
for kind, values in new.items():
for value, meta in values.items():
slot = into[kind].setdefault(
value, {"count": 0, "note": meta["note"], "sources": set()}
)
slot["count"] += meta["count"]
slot["sources"].add(source)
def print_report(results, rejected, args, sources_read):
print()
print("=" * 72)
print(" IOC EXTRACTION")
print("=" * 72)
print(f" Sources read : {sources_read}")
print(f" Generated : {datetime.now(timezone.utc).isoformat(timespec='seconds')}")
print(f" Output : {'defanged' if args.defang else 'raw'}")
print("=" * 72)
total = 0
for kind in ("ipv4", "ipv6", "domain", "url", "email",
"sha256", "sha1", "md5", "cve", "btc", "winpath", "registry"):
values = results.get(kind)
if not values:
continue
total += len(values)
print(f"\n {kind.upper()} ({len(values)} unique)")
print(" " + "-" * 68)
ordered = sorted(values.items(), key=lambda kv: (-kv[1]["count"], kv[0]))
for value, meta in ordered[: args.top]:
shown = defang(value, kind) if args.defang else value
note = f" [{meta['note']}]" if meta["note"] else ""
srcs = ""
if len(meta["sources"]) > 1:
srcs = f" ({len(meta['sources'])} files)"
print(f" {meta['count']:>5}x {shown}{note}{srcs}")
if len(ordered) > args.top:
print(f" ... {len(ordered) - args.top} more (raise --top or use --output)")
# Grouping domains by registrable name surfaces DGA-like clusters and
# subdomain sprawl that a flat list hides.
domains = results.get("domain", {})
if len(domains) > 3:
groups = Counter(registrable(d) for d in domains)
multi = [(g, n) for g, n in groups.most_common() if n > 1]
if multi:
print(f"\n DOMAIN CLUSTERS (same registrable domain)")
print(" " + "-" * 68)
for g, n in multi[:15]:
shown = defang(g, "domain") if args.defang else g
print(f" {n:>5} subdomains under {shown}")
if rejected and args.verbose:
print(f"\n FILTERED OUT")
print(" " + "-" * 68)
for why, n in rejected.most_common():
print(f" {n:>5} {why}")
print()
print("=" * 72)
print(f" {total} unique indicators across {len(results)} types")
if not args.include_private:
priv = sum(n for w, n in rejected.items() if ":" in w and
w.split(":")[1] in ("private", "loopback", "link-local",
"multicast", "reserved"))
if priv:
print(f" {priv} private/reserved addresses excluded (--include-private to keep)")
print("=" * 72)
print()
return total
def write_output(path, results, args):
serialisable = {
kind: [
{
"value": v,
"defanged": defang(v, kind),
"count": meta["count"],
"note": meta["note"],
"sources": sorted(meta["sources"]),
}
for v, meta in sorted(values.items(), key=lambda kv: -kv[1]["count"])
]
for kind, values in results.items()
if values
}
if path.lower().endswith(".csv"):
with open(path, "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["type", "value", "defanged", "count", "note", "sources"])
for kind, rows in serialisable.items():
for r in rows:
w.writerow([kind, r["value"], r["defanged"], r["count"],
r["note"], ";".join(r["sources"])])
else:
with open(path, "w", encoding="utf-8") as fh:
json.dump(
{
"generated_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"indicators": serialisable,
},
fh,
indent=2,
)
print(f" Report written to {path}\n")
# --------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(
description="Extract indicators of compromise from unstructured text.",
epilog="Reads files, directories or stdin. Exit 0 = nothing found, "
"1 = indicators found, 2 = error.",
)
ap.add_argument("paths", nargs="*",
help="Files or directories to scan. Omit to read stdin.")
ap.add_argument("--types", default="all",
help="Comma-separated: ipv4,ipv6,domain,url,email,md5,sha1,"
"sha256,cve,btc,winpath,registry (default: all)")
ap.add_argument("--defang", action="store_true",
help="Defang output so it is safe to paste into a ticket")
ap.add_argument("--include-private", action="store_true",
help="Keep RFC1918 and other reserved addresses")
ap.add_argument("--exclude-domains", default="",
help="Comma-separated extra domains to treat as noise")
ap.add_argument("--top", type=int, default=25,
help="Max rows per type in the console report (default: 25)")
ap.add_argument("--output", help="Write full results to a .json or .csv file")
ap.add_argument("--verbose", action="store_true",
help="Show what was filtered out and why")
args = ap.parse_args()
known = set(PATTERNS)
if args.types.strip().lower() == "all":
want = known
else:
want = {t.strip().lower() for t in args.types.split(",") if t.strip()}
unknown = want - known
if unknown:
print(f"Unknown type(s): {', '.join(sorted(unknown))}", file=sys.stderr)
print(f"Valid: {', '.join(sorted(known))}", file=sys.stderr)
return 2
stdin_text = None
if not args.paths:
if sys.stdin.isatty():
ap.print_help()
return 2
stdin_text = sys.stdin.read()
extra_noise = {d.strip().lower() for d in args.exclude_domains.split(",") if d.strip()}
results = defaultdict(dict)
rejected = Counter()
sources_read = 0
for label, text in read_sources(args.paths, stdin_text):
sources_read += 1
found, rej = extract(refang(text), extra_noise, args.include_private, want)
merge(results, found, label)
rejected.update(rej)
if sources_read == 0:
print("No readable input.", file=sys.stderr)
return 2
total = print_report(results, rejected, args, sources_read)
if args.output:
write_output(args.output, results, args)
# Exit 1 when indicators were found, so this can gate a pipeline or fire a
# cron alert. Exiting 0 regardless would make the script unusable in CI.
return 1 if total else 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
# Caught explicitly rather than swallowed by a bare except, so Ctrl-C
# still works during a long directory walk.
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
pathstextFiles or directories to scan. Omit to read from stdin, which is how you pipe a log straight in.
--typestextLimit extraction to these indicator types, comma-separated: ip, domain, url, hash, email.
--defangbooleanDefang the output (hxxp://, 1.2.3[.]4) so it is safe to paste into a ticket or an email without anyone clicking it.
--include-privatebooleanKeep RFC 1918 and other reserved addresses, which are filtered out by default as noise.
--exclude-domainstextComma-separated extra domains to treat as noise, on top of the built-in list. Add your own infrastructure here.
--topnumberShow only the N most frequent indicators of each type.
--outputtextWrite the full results to a .json or .csv file.
--verbosebooleanShow what was filtered out and why. Worth running once to confirm the noise filter is not discarding something you need.
What This Solves
An advisory arrives, or a colleague forwards a report, or you have a proxy log and a bad feeling. Somewhere in that text are the indicators you need: addresses, domains, URLs, hashes. Getting them out by hand is twenty minutes of careful copying, and careful copying is where a transposed character in a SHA-256 comes from - which then goes into a block list and matches nothing, forever, silently. This reads files, whole directory trees or standard input and pulls out every indicator it finds, deduplicated and counted. Three things make it more than a regex: It refangs the input before extracting. Advisories write indicators defensively - `evil[.]com`, `hxxps://`. A naive extractor misses every one of them. This un-defangs first, so defensively written sources work. It can defang the output. So what you paste into a ticket or an email cannot be clicked by the next person who reads it. It filters the noise, and tells you what it filtered. Private addresses, filenames that look like domains, common domains. With `--verbose` you see what was dropped and why, which is what lets you trust the filter rather than wonder about it. Pairs with `threat-intel-lookup`, which takes the list and checks each indicator's reputation.
Is It Safe to Run
Completely. It reads text and prints indicators. It makes **no network connections at all** - it does not resolve a domain, does not look anything up, and does not contact any reputation service. Everything happens locally on the text you gave it. That matters more than it sounds during a targeted intrusion: you can extract indicators from evidence without telling anyone you have them. The lookup is a separate, deliberate step with `threat-intel-lookup`, which does contact third parties. It modifies nothing. Input files are read, never written. One thing to be careful with, as always in this work: **the output contains live indicators.** Use `--defang` before pasting into a ticket, a chat or an email, so nobody downstream clicks a URL out of reflex.
Before You Start
1. Python 3.7 or newer: python --version That is all. **No dependencies beyond the standard library** - nothing to install, which makes this the easiest script here to drop onto an analysis host or a locked-down jump box.
Your First Run
Point it at a file: python ioc_extractor.py incident-notes.txt That prints every indicator it found, grouped by type and counted. Then add the two flags you will almost always want: python ioc_extractor.py incident-notes.txt --defang --verbose `--defang` makes the output safe to paste anywhere. `--verbose` shows what the noise filter removed - run it this way the first few times, so you know what the filter is doing before you rely on it. Piping works too, which is how it is most often used in practice: grep "blocked" proxy.log | python ioc_extractor.py --defang
What the Output Means
$ python ioc_extractor.py incident-notes.txt --defang --verbose IOC EXTRACTION Sources read : 1 Generated : 2026-10-09T04:48:03+00:00 Output : defanged DOMAIN (2 unique) -------------------------------------------------------------------- 1x a1b2c3[.]cdn-update-srv[.]win 1x d4e5f6[.]cdn-update-srv[.]win URL (2 unique) -------------------------------------------------------------------- 1x hxxps://cdn-update-srv[.]win/payload[.]bin 1x hxxps://www[.]microsoft[.]com/update EMAIL (1 unique) -------------------------------------------------------------------- 1x attacker[@]evil-domain[.]top SHA256 (1 unique) -------------------------------------------------------------------- 1x 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 MD5 (1 unique) -------------------------------------------------------------------- 1x d41d8cd98f00b204e9800998ecf8427e CVE (1 unique) -------------------------------------------------------------------- 1x CVE-2026-1234 BTC (1 unique) -------------------------------------------------------------------- 1x bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq WINPATH (1 unique) -------------------------------------------------------------------- 1x C:\Users\jdoe\AppData\Local\Temp\setup.exe REGISTRY (1 unique) -------------------------------------------------------------------- 1x HKLM\Software\Microsoft\Windows\CurrentVersion\Run\updater FILTERED OUT -------------------------------------------------------------------- 6 ipv4:private 2 domain:filename 1 domain:common 11 unique indicators across 9 types 6 private/reserved addresses excluded (--include-private to keep) Reading it: Output : defanged Printed in the header so you know, at a glance, whether what you are about to copy is safe to paste. Worth checking before you paste. The `1x` prefix How many times that indicator appeared across all sources. **Frequency is signal.** A domain appearing once in an advisory is a mention; the same domain appearing 400 times in your proxy log is your answer. Grouping by type So you can feed the hashes to one place and the addresses to another without re-sorting them by hand. FILTERED OUT Only with `--verbose`, and the most useful part of the output the first time you run it. `ipv4:private` is RFC 1918, `domain:filename` is something like `setup.exe` that matches the domain pattern, `domain:common` is a well-known domain treated as noise. **If something you needed was filtered**, this tells you so, and `--include-private` or `--exclude-domains` adjusts it. The closing count Unique indicators, not total occurrences. One honest limit: this extracts indicators, it does not judge them. `www.microsoft.com` appears in the output above because it was in the text - extraction is not accusation. Deciding which of these matter is the next step, and `threat-intel-lookup` is where it starts. Input modes AND options No dependencies beyond the Python standard library. Python 3.7 or newer. python ioc_extractor.py <file-or-directory> [options] It reads files, whole directory trees, or standard input: python ioc_extractor.py report.pdf.txt python ioc_extractor.py ./incident-2026-10/ # walks the tree cat mail.eml | python ioc_extractor.py # stdin grep "blocked" proxy.log | python ioc_extractor.py --defang What it extracts ipv4 IPv4 addresses sha1 SHA-1 hashes ipv6 IPv6 addresses sha256 SHA-256 hashes domain Domain names cve CVE identifiers url http/https/ftp URLs btc Bitcoin addresses email Email addresses winpath Windows file paths md5 MD5 hashes registry Registry key paths Restrict with --types: python ioc_extractor.py report.txt --types ipv4,domain,sha256 DEFANGING — IT WORKS BOTH WAYS Input is REFANGED before extraction, so indicators written defensively in an advisory are still found. All of these are recognised: evil[.]com evil(.)com evil{.}com evil[dot]com hxxp:// hxxps:// fxp:// 1.2.3[.]4 user[at]evil[.]com 10.0.0.1[:]443 Output is DEFANGED with --defang, so you can paste it into a ticket, a chat window or an email without anyone's security tooling turning it into a live link or quarantining the message: 45.33.32.156 -> 45[.]33[.]32[.]156 https://x.co -> hxxps://x[.]co The noise filtering, AND why it matters A plain regex extractor run over a log file returns every internal IP, every vendor domain in a User-Agent string, and every filename that happens to look like a domain. The output is unusable and people stop running it. Filtered out by default: * Private and reserved addresses -- RFC1918, loopback, link-local, multicast, and the documentation ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24). Keep them with --include-private when you are analysing internal lateral movement. * Filenames that match the domain pattern -- setup.exe, config.ini, report.pdf. Checked against a suffix list; "exe" is not a TLD. * Vendor domains that appear in every corporate log -- microsoft.com, windowsupdate.com, google.com, akamai.net, cloudflare.com and ~30 more. Add your own with --exclude-domains. * Hostnames already captured inside a URL or an email address are not reported a second time as bare domains -- they are the same indicator. * Hashes are matched longest-first, so a SHA-256 is not also reported as a pair of overlapping MD5s. Run with --verbose to see exactly what was filtered and why. If something you needed was dropped, that output tells you which rule dropped it. python ioc_extractor.py logs/ --verbose --include-private python ioc_extractor.py logs/ --exclude-domains "corp.local,vendor.example.com" Domain clusters When more than three domains are found, the report groups them by registrable domain. This surfaces DGA-style subdomain sprawl and C2 infrastructure that a flat alphabetical list hides: DOMAIN CLUSTERS (same registrable domain) -------------------------------------------------------------------- 14 subdomains under cdn-update-srv[.]win 3 subdomains under content-delivery[.]top Machine-readable output python ioc_extractor.py logs/ --output iocs.json python ioc_extractor.py logs/ --output iocs.csv JSON carries both the raw and the defanged form of every indicator, its occurrence count, the classification note, and which files it came from: { "generated_utc": "2026-10-09T04:48:30+00:00", "indicators": { "ipv4": [ { "value": "45.33.32.156", "defanged": "45[.]33[.]32[.]156", "count": 2, "note": "public", "sources": ["proxy.log", "firewall.log"] } ] } } CSV has one row per indicator with the same columns -- open it in Excel, or feed it to a threat-intel lookup.
Exit Codes for Cron and CI
0 Ran successfully, no indicators found 1 Ran successfully, indicators found 2 Error -- unknown --types value, no readable input 130 Interrupted with Ctrl-C Exit 1 on a finding is deliberate: it lets the script gate a pipeline or fire a cron alert. A script that exits 0 whether or not it found something cannot be used for either. # Fail a CI job if an IOC appears in build logs python ioc_extractor.py build.log || echo "indicators present" # Nightly sweep that only mails you when there is something python ioc_extractor.py /var/log/ --output /tmp/ioc.json && exit 0 Typical workflow 1. Pull the indicators out of an advisory or a report: python ioc_extractor.py vendor-advisory.txt --defang --output ioc.json 2. Hunt for them in your own telemetry. The CSV columns drop straight into a SIEM lookup table or a watchlist. 3. Extract from your own logs during an incident, with private addresses kept so lateral movement is visible: python ioc_extractor.py ./incident-logs/ --include-private --output found.csv 4. Feed the public IPs and domains to the reputation checker: https://permitany.com/scripts/threat-intel-lookup
When Something Fails
"It found nothing but I can see an IP in the file" It is probably in a private or documentation range. Run with --verbose to confirm, then add --include-private. "It is reporting our own internal domains" Add them: --exclude-domains "corp.local,internal.example.com" "It missed a defanged indicator" Check the notation against the refang list above. If it is a form not listed, the indicator will be missed rather than mangled -- the script does not guess. "Too many rows on screen" The console report caps at --top rows per type (default 25). The full set always goes to --output regardless of --top. "UnicodeDecodeError on a binary file" Files are read with errors="replace", so binaries are scanned as text rather than crashing. Point it at the text you actually want to reduce noise.