MRPH·LAB

TX·02 · OSINT / PYTHON · LOGGED 2026·06 · 10 MIN

Building an IP-intelligence enrichment pipeline in Python

You've got a text file full of IP addresses pulled from a log and an hour to figure out which ones matter. Here's a small pipeline that turns bare IPs into a ranked triage sheet using free sources, a local cache, and some manners. Under 150 lines, no API keys required.

What enrichment actually means

An IP address on its own tells you almost nothing. enrichment is the unglamorous work of attaching context to it: what ports the host exposes, whether it has known vulnerabilities, what network it lives in, whether it's a data-center box or a residential line, whether it's flagged as a proxy. None of those facts is a verdict by itself. Together they let you rank a hundred addresses and spend your attention on the five that deserve it.

The input here is the kind of list every homelab and small ops team generates constantly: fail2ban bans, firewall drops, weird entries in an auth log. The output is a CSV sorted by a risk score, which is a thing you can actually act on before your coffee goes cold.

Ground rules before you query anything

Two lines I don't cross, and the pipeline is built around both. First, I only look up addresses I have a legitimate reason to care about, meaning they showed up in my own logs touching my own systems. Second, everything here is passive: we're reading data that services like Shodan have already aggregated, not probing anyone's host ourselves. Nothing in this article sends a single packet to the IPs being investigated.

And when you use someone's free API, behave like a guest. Identify yourself with a real User-Agent, honor rate limits before you hit them, back off when told, and cache so you never ask the same question twice. This isn't just etiquette; polite pipelines are also faster and more reliable, because they don't get banned halfway through a run.

The shape of the pipeline

Five stages, each one a plain function: read and validate the input, check the cache, enrich from two sources, score, write the report. Starting with reading, because garbage in a log file is a certainty, not a possibility:

pipeline.py · input
import ipaddress
import sys

def load_ips(path):
    """Read one IP per line; dedupe, validate, ignore junk."""
    seen = set()
    with open(path) as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            try:
                ip = ipaddress.ip_address(line)
            except ValueError:
                print(f"skipping junk line: {line!r}", file=sys.stderr)
                continue
            if ip.is_global:          # private/reserved space: nothing to enrich
                seen.add(str(ip))
    return sorted(seen)

The ipaddress module from the standard library does the validation for free, and the is_global check quietly drops RFC 1918 addresses before you embarrass yourself asking Shodan about 192.168.1.50.

Cache first, ask questions later

The cache goes in before any network code gets written, not after. Log files repeat themselves, reruns happen, and scripts crash halfway. A SQLite file keyed on (ip, source) means every question gets asked exactly once a week, no matter how many times the script runs:

pipeline.py · cache
import json
import sqlite3
import time

DB = sqlite3.connect("enrich_cache.db")
DB.execute("""
    CREATE TABLE IF NOT EXISTS cache (
        ip         TEXT NOT NULL,
        source     TEXT NOT NULL,
        data       TEXT NOT NULL,
        fetched_at REAL NOT NULL,
        PRIMARY KEY (ip, source)
    )
""")

MAX_AGE = 7 * 24 * 3600   # a week is plenty for triage work

def cache_get(ip, source):
    row = DB.execute(
        "SELECT data, fetched_at FROM cache WHERE ip = ? AND source = ?",
        (ip, source),
    ).fetchone()
    if row and time.time() - row[1] < MAX_AGE:
        return json.loads(row[0])
    return None

def cache_put(ip, source, data):
    DB.execute(
        "REPLACE INTO cache (ip, source, data, fetched_at) VALUES (?, ?, ?, ?)",
        (ip, source, json.dumps(data), time.time()),
    )
    DB.commit()

On my bench, a rerun over a 400-address list goes from six minutes to under a second once the cache is warm. That difference is what makes it practical to iterate on the scoring logic, which is where the actual analysis happens.

Talking to APIs politely

One fetch function handles the manners for every source: a User-Agent that says who you are, a timeout so a dead API can't hang the run, exponential backoff when you're told to slow down, and 404 treated as an answer rather than an error:

pipeline.py · fetch
import requests

SESSION = requests.Session()
SESSION.headers["User-Agent"] = "ip-triage/0.2 (personal lab; [email protected])"

def fetch(url, tries=4):
    delay = 2.0
    for _ in range(tries):
        resp = SESSION.get(url, timeout=10)
        if resp.status_code == 404:
            return {}          # "no data on this host" is a result; cache it
        if resp.status_code == 429:
            time.sleep(float(resp.headers.get("Retry-After", delay)))
            delay *= 2
            continue
        resp.raise_for_status()
        return resp.json()
    raise RuntimeError(f"gave up on {url}")

The sources

Two free, keyless sources cover a surprising amount of ground. InternetDB is Shodan's fast lookup service: open ports, known CVEs, and tags for any address, no account needed. ip-api adds geolocation, the owning network, and proxy/hosting flags, free for non-commercial use at 45 requests a minute. That limit is why there's a sleep in the second function; staying under it by design beats getting throttled by surprise:

pipeline.py · sources
def internetdb(ip):
    cached = cache_get(ip, "internetdb")
    if cached is not None:
        return cached
    data = fetch(f"https://internetdb.shodan.io/{ip}")
    cache_put(ip, "internetdb", data)
    return data

def geo(ip):
    cached = cache_get(ip, "ip-api")
    if cached is not None:
        return cached
    fields = "status,country,as,proxy,hosting"
    data = fetch(f"http://ip-api.com/json/{ip}?fields={fields}")
    cache_put(ip, "ip-api", data)
    time.sleep(1.4)   # free tier allows 45/min; this keeps us at ~42
    return data

Both functions check the cache before touching the network, so the sleep only costs you on cold lookups. When you outgrow the free tier, keyed sources like AbuseIPDB or GreyNoise drop into this same pattern: one function, cache on both ends, manners in the middle.

Scoring for triage

Scoring is where people overthink it. You don't need a machine-learning model to rank a log file; you need a handful of weighted facts and the honesty to call the result an opinion rather than a verdict:

pipeline.py · scoring
RISKY_PORTS = {23, 445, 1433, 3389, 5900}   # telnet, smb, mssql, rdp, vnc

def score(intel, geo_row):
    pts = 0
    ports = set(intel.get("ports", []))

    pts += 3 * len(intel.get("vulns", []))     # known CVEs are the loudest signal
    pts += 2 * len(ports & RISKY_PORTS)        # classic abuse-friendly services
    pts += min(len(ports), 5)                  # very open hosts are rarely innocent

    if geo_row.get("proxy"):
        pts += 4                               # anonymization infrastructure
    if geo_row.get("hosting"):
        pts += 2                               # data-center box, not a person

    return pts

Every weight in there is an argument you should have with yourself. Is a host behind a proxy flag worse than one with an open RDP port? For my logs, where most noise is rented scanner boxes, yes. For yours, maybe not. The point of keeping the function this small is that changing your mind costs thirty seconds and a cache-warm rerun.

Output a human can use

The report is a CSV sorted by score, highest first, because the entire job of this pipeline is to decide what you look at first:

pipeline.py · report
import csv

def main(path):
    rows = []
    for ip in load_ips(path):
        intel = internetdb(ip)
        g = geo(ip)
        rows.append({
            "ip": ip,
            "score": score(intel, g),
            "ports": " ".join(map(str, intel.get("ports", []))),
            "vulns": " ".join(intel.get("vulns", [])),
            "country": g.get("country", ""),
            "asn": g.get("as", ""),
            "proxy": g.get("proxy", False),
            "hosting": g.get("hosting", False),
        })

    rows.sort(key=lambda r: r["score"], reverse=True)
    with open("triage.csv", "w", newline="") as f:
        w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        w.writeheader()
        w.writerows(rows)
    print(f"wrote triage.csv ({len(rows)} hosts)")

if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "ips.txt")

The first time I ran this against a week of fail2ban bans from my own VPS, the top of the sheet was exactly what you'd expect: hosting-provider addresses with open Telnet and a stack of CVEs, the standard rented scanner fleet. The interesting find was further down, a residential address with no exposure at all, which turned out to be my own phone on cellular failing Wi-Fi calling authentication. Triage works both directions.

Where to take it

The single-threaded version with the ip-api sleep processes about 40 fresh addresses a minute, which is fine for a daily log review. When the lists get bigger, the upgrade path is asyncio with a semaphore per source, so each API gets its own concurrency budget and the manners survive the speedup. After that: keyed sources for reputation data, a flag that diffs today's report against yesterday's, and piping high scores into whatever alerts you.

But the honest advice is that the cache and the manners scale further than you'd think, and the scoring function is where the real returns live. Tools don't do analysis. They just clear the ground so you can.