Dynamic IP Blocking with Palo Alto Networks Firewalls and Censys

Censys Solutions, Threat Hunting Module

TL;DR

Leverage the Censys Threat Hunting dataset to automatically populate a Palo Alto Networks (PAN‑OS) External Dynamic List (EDL) with fresh malicious IPs. A small Python service fetches indicators from Censys, deduplicates and risk‑scores them, and serves a plain‑text list that PAN‑OS subscribes to on an interval. You get a living, continuously updated block list instead of static, stale IOCs.

Why This Matters

Censys data is timely and actionable. Imagine a world where your block list reflects current, proactive threat response instead of dated IP/domain indicators. This post shows how to operationalize that with a light‑weight workflow that most security teams can deploy in under an hour.

Prerequisites

  • Access to Censys Threat Hunting (TH) dataset via the Censys Platform API
  • Python 3.9+ and pip
  • jq (optional, for quick CLI slicing)
  • Admin privileges on a Palo Alto Networks firewall (hardware or VM)
  • Admin privileges on a host to run the Python service (Linux recommended)

Install the SDK

pip install censys-platform

Set credentials (recommended via environment variables)

export CENSYS_API_KEY="<your-platform-api-key>"

What We’ll Build

  1. Fetcher & formatter: Python script that queries the TH dataset, filters for malicious IPs meeting your criteria, and writes ips.txt in newline‑separated form (one IP per line).
  2. Lightweight server: Expose ips.txt over HTTP(S) for the firewall to subscribe to.
  3. PAN‑OS EDL: Create an External Dynamic List of type IP pointing at the URL from step 2, then use it in a Security Policy to block.

Reference Architecture


+------------------+     HTTPS      +-----------------------+
| Censys Platform  | <------------> | Python Fetcher/Server |
| Threat Hunting   |                | (Flask or static host)|
+------------------+                +----------+------------+
                                               ^
                                               |
                                               | HTTP(S) pull every N min
                                               | 
                                     +---------+---------+
                                     | PAN‑OS Firewall   |
                                     | External Dynamic  |
                                     | List (EDL)        |
                                     +-------------------+

Step 1: Decide your selection criteria

Common filters:

  • Confidence / risk score ≥ threshold
  • Freshness (e.g., last seen ≤ 7 days)
  • Signal type (e.g., confirmed C2, brute‑force sources, mass scanners tied to exploitation m.o.)
  • Network scope exclusions (e.g., ignore RFC1918, partner ranges, IXPs/CDNs)

You can start permissive (e.g., last 24–48h + known malicious tags) and tighten as you monitor impact.

Step 2: Python fetcher & EDL file generator

Below is a reference implementation. It uses the (hypothetical) higher‑level client from censys-platform for clarity, and falls back to a generic REST call pattern if needed. Adjust the TH query parameters and fields to match your access and schema.

Files created: ips.txt (the EDL), last_run.json (basic metrics for monitoring).

#!/usr/bin/env python3
import os
import sys
import time
import ipaddress
import json
from datetime import datetime, timedelta
from typing import Iterable, Set
import requests
from flask import Flask, send_from_directory
API_KEY = os.getenv("CENSYS_API_KEY")
TH_ENDPOINT = os.getenv("CENSYS_TH_ENDPOINT", "https://api.censys.io/platform/
threat-hunting/search")
# Example filter knobs (tune for your org):
DAYS_BACK = int(os.getenv("TH_DAYS_BACK", "3"))
MIN_RISK = int(os.getenv("TH_MIN_RISK", "70"))
MAX_RESULTS = int(os.getenv("TH_MAX_RESULTS", "5000"))
BIND_HOST = os.getenv("EDL_BIND_HOST", "0.0.0.0")
PORT = int(os.getenv("EDL_PORT", "8080"))
OUTPUT_DIR = os.getenv("EDL_OUTPUT_DIR", os.getcwd())
OUTPUT_FILE = os.path.join(OUTPUT_DIR, "ips.txt")
# Optional allow/block refinements
EXCLUDE_PRIVATE = True
EXCLUDE_RANGES = [
    # (start, end) in CIDR; add partner ranges/CDNs you want to exclude
    "10.0.0.0/8",
    "172.16.0.0/12",
    "192.168.0.0/16",
]
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
def _is_public_ip(ip: str) -> bool:
    try:
        obj = ipaddress.ip_address(ip)
        return obj.is_global
    except ValueError:
        return False
def _excluded(ip: str) -> bool:
    # honor EXCLUDE_PRIVATE and EXCLUDE_RANGES
    try:
        ip_obj = ipaddress.ip_address(ip)
        if EXCLUDE_PRIVATE and not ip_obj.is_global:
            return True
        for cidr in EXCLUDE_RANGES:
            if ip_obj in ipaddress.ip_network(cidr, strict=False):
                return True
    except Exception:
        return True
    return False
    
def fetch_th_ips() -> Iterable[str]:
    """Fetch candidate IPs from Censys TH. Replace the payload with your TH query.
    Expected response format (example): {
        "results": [ {"ip": "x.x.x.x", "risk": 80, "last_seen":"2025-08-19T12:00:00Z", "tags": ["c2"]}, ... ]
    }
    """
    since = (datetime.utcnow() - timedelta(days=DAYS_BACK)).strftime("%Y-%m-%dT%H:%M:%SZ")
    payload = {
        "query": {
            "time": {"gte": since},
            "risk": {"gte": MIN_RISK},
            "type": "ip",
            "tags": ["malicious", "c2", "brute_force", "exploit"],
        },
        "fields": ["ip", "risk", "last_seen"],
        "limit": MAX_RESULTS,
    }
    resp = requests.post(TH_ENDPOINT, headers=headers, json=payload, timeout=60)
    resp.raise_for_status()
    data = resp.json()
    
    for row in data.get("results", []):
        ip = row.get("ip")
        if not ip:
            continue
        yield ip
        
def build_edl(ips: Iterable[str]) -> Set[str]:
    uniq: Set[str] = set()
    for ip in ips:
        if not _is_public_ip(ip):
            continue
        if _excluded(ip):
            continue
            uniq.add(ip)
        return uniq
        
def write_edl(ips: Set[str]) -> None:
    # Palo Alto expects one IP per line, no comments
    with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
        for ip in sorted(ips, key=lambda x: tuple(int(p) for p in x.split(".")) if "." in x else x):
            f.write(f"{ip}n")
        with open(os.path.join(OUTPUT_DIR, "last_run.json"), "w", encoding="utf-8") as m:
            json.dump({
                "generated_at": datetime.utcnow().isoformat() + "Z",
                "count": len(ips),
                "days_back": DAYS_BACK,
                "min_risk": MIN_RISK,
            }, m)
           
app = Flask(__name__)
@app.route("/ips.txt")
def edl_file():
    return send_from_directory(OUTPUT_DIR, "ips.txt", mimetype="text/plain")
    
@app.route("/")
def root():
    return "EDL server running. Use /ips.txt for the list.n"
    
def main():
    if not API_KEY:
        print("CENSYS_API_KEY is not set", file=sys.stderr)
        sys.exit(1)
    print("Fetching indicators from Censys TH…", file=sys.stderr)
    candidates = list(fetch_th_ips())
    edl = build_edl(candidates)
    write_edl(edl)
    print(f"Wrote {len(edl)} IPs to {OUTPUT_FILE}")
    app.run(host=BIND_HOST, port=PORT)
    
if __name__ == "__main__":
    main()
    

Quick test

python edl_server.py
curl -s https://localhost:8080/ips.txt | head

Optional: jq one‑liners while iterating

If you pull raw JSON first, jq can help you sanity‑check fields and totals:

# Count unique IPs with risk >= 70 seen in the last 3 days
jq -r '.results[] | select(.risk >= 70) | .ip' th.json | sort -u | wc -l
# Emit newline-separated IPs (deduped)
jq -r '.results[].ip' th.json | sort -u > ips.txt

Step 4: Use the EDL in a Security Policy rule

  1. Policies → Security → Add
  2. Name: Block-TH-IPs
  3. Source: any (or your zones)
  4. Destination: EDL-TH-IP (the list you created)
  5. Application/Service: as needed
  6. Action: Deny
  7. Move rule above any allow rules → Commit

Log & validate: Monitor Monitor → Threat/Traffic for matches referencing the EDL. Consider a staged mode first (alert-only or drop in a limited zone) if you’re cautious.

Step 3: PAN‑OS — Create the EDL

Web UI (typical flow on PAN‑OS 10.x/11.x):

  1. Objects → External Dynamic Lists → Add
  2. Type: IP List
  3. Source: http(s)://<your-edl-host>:8080/ips.txt
  4. Recurring: Every 15 minutes (tune for your risk tolerance)
  5. Certificate Profile: If using HTTPS with a private CA, select the proper profile
  6. OK → Commit

CLI equivalent (adjust the vsys and name):


configure
set external-list EDL-TH-IP type ip
set external-list EDL-TH-IP url https://<your-edl-host>:8080/ips.txt
set external-list EDL-TH-IP recurring weekly day-of-week Monday at 00:05
commit
exit

Note: Use HTTPS in production. Import your server cert into PAN‑OS and reference a Certificate Profile so the firewall can validate TLS.

Scheduling & Reliability

Systemd service (keeps server alive) and cron (periodically refresh the file before PAN‑OS pulls): /etc/systemd/system/edl.service


[Unit]
Description=EDL server for PAN-OS
After=network-online.target
[Service]
Environment=CENSYS_API_KEY=<redacted>
Environment=TH_DAYS_BACK=3
Environment=TH_MIN_RISK=70
ExecStart=/usr/bin/python3 /opt/edl/edl_server.py
WorkingDirectory=/opt/edl
Restart=always
[Install]
WantedBy=multi-user.target

crontab -e (refresh indicators every 10 minutes):


*/10 * * * * /usr/bin/curl -s https://127.0.0.1:8080/ > /dev/null

The server regenerates ips.txt on start. If you want continuous refresh without restart, add a timer thread to call fetch_th_ips() and write_edl() on an interval, or run a separate refresh.py that just rewrites ips.txt.

Safety Valves and Tuning

  • Cap list size with TH_MAX_RESULTS to avoid giant EDLs on day one.
  • Namespace exclusions: Add partner/CDN/IXP ranges to EXCLUDE_RANGES.
  • Audit mode: First, create a log-only rule referencing the EDL to measure potential impact.
  • Roll‑back: Keep the previous ips.txt as ips.txt.bak . If a rule blocks too much, revert quickly.
  • Warm‑up: Start with DAYS_BACK=1 and increase as confidence grows.

Troubleshooting

  • EDL shows 0 entries: Verify curl -v http(s)://<host>:8080/ips.txt from the firewall’s mgmt plane or a test host in the same network.
  • TLS errors: Import the EDL server’s CA into PAN‑OS and assign a Certificate Profile to the EDL object.
  • Over‑blocking: Loosen filters (raise MIN_RISK , lower DAYS_BACK ), review top talkers in logs, add exclusions.
  • Schema mismatches: Inspect a raw payload from the TH API ( print(resp.json()) ) and adjust the fetch_th_ips() parser.

Security Considerations

  • Store the API key in a secret store (e.g., systemd drop‑in, Vault, AWS SSM) rather than a flat file.
  • Prefer HTTPS with a valid cert for the EDL endpoint; rotate certificates on schedule.
  • Limit server exposure (bind to a management VLAN / firewall only).
  • Version your config and script in Git; tag deployments.

What’s Next

  • Maintain a second EDL for high‑confidence vs medium‑confidence and apply different policy actions.
  • Generate Panorama templates for multi‑firewall rollouts.
  • Export domains and URLs as additional EDLs (PAN‑OS supports multiple list types).
  • Enrich with whois/ASN/geolocation and alert when blocks hit critical assets.

Appendix: Minimal REST call snippet (if you don’t use the SDK)


import os, requests
API_KEY = os.getenv("CENSYS_API_KEY")
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}

resp = requests.post(
    "https://api.censys.io/platform/threat-hunting/search",
    headers=headers,
    json={"query": {"time": {"gte": "2025-08-18T00:00:00Z"}, "risk": {"gte":70}, "type": "ip"}, "limit": 1000}
)

print(resp.status_code, resp.json())
AUTHOR
Ashley Sequeira

Subscribe to our blog