255 lines
9.8 KiB
Python
255 lines
9.8 KiB
Python
#!/opt/threatmarket/venv/bin/python3
|
|
"""THREATMARKET — threat-intel marketplace. Metadata public, payloads paid."""
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import secrets
|
|
import sqlite3
|
|
import time
|
|
|
|
import requests
|
|
from flask import Flask, Response, jsonify, redirect, render_template_string, request, send_file
|
|
|
|
BASE = os.environ.get("TM_BASE", "/opt/threatmarket")
|
|
ARCHIVE = os.path.join(BASE, "archive")
|
|
MANIFEST = os.path.join(ARCHIVE, "manifest.json")
|
|
DB = os.path.join(BASE, "threatmarket.db")
|
|
|
|
BTCPAY_URL = os.environ.get("BTCPAY_URL", "https://10.30.20.140").rstrip("/")
|
|
BTCPAY_STORE = os.environ["BTCPAY_STORE"]
|
|
BTCPAY_KEY = os.environ["BTCPAY_KEY"]
|
|
WEBHOOK_SECRET = os.environ["BTCPAY_WEBHOOK_SECRET"]
|
|
PACK_PRICE_USD = os.environ.get("TM_PRICE_USD", "12.00")
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
|
def db():
|
|
con = sqlite3.connect(DB, timeout=10)
|
|
con.row_factory = sqlite3.Row
|
|
return con
|
|
|
|
|
|
def init_db():
|
|
with db() as con:
|
|
con.execute(
|
|
"""CREATE TABLE IF NOT EXISTS purchases (
|
|
token TEXT PRIMARY KEY, pack_id TEXT, invoice_id TEXT UNIQUE,
|
|
status TEXT, created INTEGER, downloaded INTEGER DEFAULT 0)"""
|
|
)
|
|
|
|
|
|
def packs():
|
|
with open(MANIFEST) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def get_pack(pack_id):
|
|
for p in packs():
|
|
if p["pack_id"] == pack_id:
|
|
return p
|
|
return None
|
|
|
|
|
|
def file_sha256(path):
|
|
h = hashlib.sha256()
|
|
with open(path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(1 << 16), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def btcpay_invoice(pack_id, token):
|
|
r = requests.post(
|
|
f"{BTCPAY_URL}/api/v1/stores/{BTCPAY_STORE}/invoices",
|
|
headers={"Authorization": f"token {BTCPAY_KEY}"},
|
|
json={
|
|
"amount": PACK_PRICE_USD,
|
|
"currency": "USD",
|
|
"metadata": {"orderId": token, "itemDesc": f"THREATMARKET intel pack {pack_id}"},
|
|
"checkout": {"redirectURL": f"http://10.30.20.102/status/{token}", "redirectAutomatically": True},
|
|
},
|
|
verify=False,
|
|
timeout=30,
|
|
)
|
|
r.raise_for_status()
|
|
return r.json()
|
|
|
|
|
|
# ---------- pages ----------
|
|
BASE_CSS = """
|
|
body{background:#0a0e14;color:#c9d1d9;font-family:'SF Mono',ui-monospace,Menlo,monospace;margin:0}
|
|
a{color:#58a6ff;text-decoration:none}a:hover{text-decoration:underline}
|
|
.wrap{max-width:960px;margin:0 auto;padding:40px 24px}
|
|
h1{color:#f0f6fc;letter-spacing:.18em}h1 .tm{color:#3fb950}
|
|
.tag{color:#8b949e;font-size:14px}
|
|
.card{background:#11161f;border:1px solid #1f2733;border-radius:10px;padding:18px 22px;margin:14px 0}
|
|
.card:hover{border-color:#3fb950}
|
|
.grid{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}
|
|
.badge{background:#12261e;color:#3fb950;border:1px solid #1f4d33;border-radius:99px;padding:2px 10px;font-size:12px;margin-left:6px}
|
|
.count{color:#58a6ff;font-weight:bold}
|
|
.btn{display:inline-block;background:#238636;color:#fff;border-radius:8px;padding:10px 22px;font-weight:bold}
|
|
.btn:hover{text-decoration:none;background:#2ea043}
|
|
.price{color:#e3b341;font-size:20px;font-weight:bold}
|
|
footer{border-top:1px solid #1f2733;margin-top:48px;padding:22px 24px;text-align:center;color:#8b949e;font-size:13px}
|
|
.sha{color:#8b949e;font-size:11px;word-break:break-all}
|
|
"""
|
|
|
|
FOOTER = """
|
|
<footer>
|
|
THREATMARKET · real scans, real data, zero fabrication<br>
|
|
<a href="https://buymeacoffee.com/r26xrthzttg" style="color:#e3b341">☕ Buy Me a Coffee</a>
|
|
</footer>"""
|
|
|
|
|
|
def page(title, body):
|
|
return render_template_string(
|
|
"<!doctype html><html><head><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'>"
|
|
"<title>{{t}}</title><style>{{css}}</style></head><body><div class=wrap>{{body|safe}}</div>{{footer}}</body></html>",
|
|
t=title, css=BASE_CSS, body=body, footer=FOOTER,
|
|
)
|
|
|
|
|
|
@app.get("/")
|
|
def index():
|
|
rows = ""
|
|
for p in packs():
|
|
cats = "".join(f'<span class="badge">{c}</span>' for c in p["categories"])
|
|
counts = " · ".join(f'{k}: <span class="count">{v}</span>' for k, v in p["finding_counts"].items())
|
|
rows += f"""<div class=card><div class=grid>
|
|
<div><a href=/buy/{p['pack_id']}><b>{p['domain']}</b></a>{cats}<br>
|
|
<span class=tag>{p['date']} · {counts}</span><br>
|
|
<span class=sha>sha256 {p['sha256'][:32]}… · {p['size_bytes']//1024} KB</span></div>
|
|
<div style=text-align:right><div class=price>${PACK_PRICE_USD}</div><a class=btn href=/buy/{p['pack_id']}>Buy pack</a></div>
|
|
</div></div>"""
|
|
body = f"""<h1>THREAT<span class=tm>MARKET</span></h1>
|
|
<p class=tag>Threat-intel packs harvested by a real scanning fleet. Certificate-transparency
|
|
logs, DNS liveness, exposed-service inventory. Metadata below is public; payloads unlock after BTC payment.</p>
|
|
{rows or '<p class=tag>No packs archived yet — the pipeline runs 2x daily.</p>'}"""
|
|
return page("THREATMARKET", body)
|
|
|
|
|
|
@app.get("/archive")
|
|
def archive():
|
|
out = [
|
|
{k: p[k] for k in ("pack_id", "domain", "date", "categories", "finding_counts", "size_bytes", "sha256")}
|
|
for p in packs()
|
|
]
|
|
return jsonify(out)
|
|
|
|
|
|
@app.get("/buy/<pack_id>")
|
|
def buy_page(pack_id):
|
|
p = get_pack(pack_id)
|
|
if not p:
|
|
return page("404", "<h1>Pack not found</h1>"), 404
|
|
counts = " · ".join(f"{k}: {v}" for k, v in p["finding_counts"].items())
|
|
body = f"""<h1>Pack: {p['domain']}</h1>
|
|
<div class=card><span class=tag>{p['date']} · {counts}</span><br>
|
|
<span class=sha>sha256 {p['sha256']} · {p['size_bytes']//1024} KB zip</span></div>
|
|
<p>Price <span class=price>${PACK_PRICE_USD}</span> · paid in BTC over BTCPay.
|
|
After settlement you get a one-time download link for the pack zip.</p>
|
|
<form method=post action=/buy/{pack_id}><button class=btn type=submit>Pay ${PACK_PRICE_USD} with Bitcoin</button></form>"""
|
|
return page(f"Buy {pack_id}", body)
|
|
|
|
|
|
@app.post("/buy/<pack_id>")
|
|
def buy_go(pack_id):
|
|
p = get_pack(pack_id)
|
|
if not p:
|
|
return page("404", "<h1>Pack not found</h1>"), 404
|
|
token = secrets.token_urlsafe(24)
|
|
inv = btcpay_invoice(pack_id, token)
|
|
with db() as con:
|
|
con.execute(
|
|
"INSERT INTO purchases (token, pack_id, invoice_id, status, created) VALUES (?,?,?,?,?)",
|
|
(token, pack_id, inv["id"], "pending", int(time.time())),
|
|
)
|
|
return redirect(inv["checkoutLink"])
|
|
|
|
|
|
@app.get("/status/<token>")
|
|
def status(token):
|
|
with db() as con:
|
|
row = con.execute("SELECT * FROM purchases WHERE token=?", (token,)).fetchone()
|
|
if not row:
|
|
return page("404", "<h1>Unknown order</h1>"), 404
|
|
if row["status"] == "paid" and not row["downloaded"]:
|
|
body = f"""<h1>Payment confirmed ✅</h1><p>Your one-time download is ready.
|
|
The link dies after the first successful download.</p>
|
|
<a class=btn href=/download/{row['pack_id']}/{token}>Download {row['pack_id']}.zip</a>"""
|
|
elif row["status"] == "paid":
|
|
body = "<h1>Already downloaded</h1><p>This one-time link has been consumed.</p>"
|
|
else:
|
|
body = f"""<h1>Awaiting payment…</h1><p>Invoice <code>{row['invoice_id']}</code> is
|
|
{row['status']}. This page refreshes every 15s.</p>
|
|
<meta http-equiv=refresh content=15>"""
|
|
return page("Order status", body)
|
|
|
|
|
|
@app.get("/download/<pack_id>/<token>")
|
|
def download(pack_id, token):
|
|
with db() as con:
|
|
row = con.execute("SELECT * FROM purchases WHERE token=?", (token,)).fetchone()
|
|
if not row or row["pack_id"] != pack_id:
|
|
return jsonify(error="invalid token"), 403
|
|
if row["status"] != "paid":
|
|
return jsonify(error="payment not confirmed"), 402
|
|
if row["downloaded"]:
|
|
return jsonify(error="one-time link already used"), 410
|
|
con.execute("UPDATE purchases SET downloaded=1 WHERE token=?", (token,))
|
|
p = get_pack(pack_id)
|
|
path = os.path.join(ARCHIVE, p["file"])
|
|
return send_file(path, as_attachment=True, download_name=f"{pack_id}.zip")
|
|
|
|
|
|
@app.post("/webhook/btcpay")
|
|
def webhook():
|
|
sig = request.headers.get("BTCPay-Sig", "")
|
|
expected = "sha256=" + hmac.new(WEBHOOK_SECRET.encode(), request.get_data(), hashlib.sha256).hexdigest()
|
|
if not hmac.compare_digest(sig, expected):
|
|
return jsonify(error="bad signature"), 400
|
|
data = request.get_json(force=True)
|
|
if data.get("type") == "InvoiceSettled":
|
|
inv = data["invoiceId"]
|
|
with db() as con:
|
|
con.execute("UPDATE purchases SET status='paid' WHERE invoice_id=? AND status='pending'", (inv,))
|
|
return "", 200
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
return jsonify(status="ok", service="threatmarket", packs=len(packs()))
|
|
|
|
|
|
@app.get("/robots.txt")
|
|
def robots():
|
|
return Response("User-agent: *\nAllow: /\nDisallow: /download/\n", mimetype="text/plain")
|
|
|
|
|
|
@app.get("/llms.txt")
|
|
def llms():
|
|
return Response(
|
|
"# THREATMARKET\n\n"
|
|
"Threat-intel marketplace selling archived reconnaissance packs (certificate-transparency "
|
|
"subdomain enumeration, DNS liveness, exposed-service inventory) collected by real fleet scans.\n\n"
|
|
"## Endpoints\n"
|
|
"- GET /: landing page, pack catalog with metadata only\n"
|
|
"- GET /archive: JSON list of packs (domain, date, categories, finding counts; no payloads)\n"
|
|
"- GET|POST /buy/{PACK_ID}: create a BTCPay invoice, pay in BTC\n"
|
|
"- GET /status/{TOKEN}: order status; one-time download link after settlement\n"
|
|
"- GET /download/{PACK_ID}/{TOKEN}: one-time zip download, requires webhook-confirmed payment\n"
|
|
"- GET /health: service health JSON\n\n"
|
|
"## Rules\n"
|
|
"- Public pages expose metadata/counts only; payloads require payment.\n"
|
|
"- Every finding comes from a real scan; empty scans ship as empty. Zero fabricated data.\n",
|
|
mimetype="text/plain",
|
|
)
|
|
|
|
|
|
init_db()
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host="127.0.0.1", port=8500)
|