update
Browse files
README.md
CHANGED
|
@@ -26,7 +26,13 @@ actor or a non-existent CVE — it **refuses to fabricate** (evidence-gated hone
|
|
| 26 |
|
| 27 |
## Files
|
| 28 |
`Dockerfile` · `app.py` · `requirements.txt` · `corpus.json` (retrieval data) · `robot.png`.
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
## Push updates
|
| 32 |
With the `hf` CLI authenticated, from the `osint-project` root:
|
|
|
|
| 26 |
|
| 27 |
## Files
|
| 28 |
`Dockerfile` · `app.py` · `requirements.txt` · `corpus.json` (retrieval data) · `robot.png`.
|
| 29 |
+
|
| 30 |
+
## Optional Space secrets
|
| 31 |
+
- `NVD_API_KEY` — higher NVD rate limit (CVE lookups work without it).
|
| 32 |
+
- `THREATFOX_API_KEY`, `OTX_API_KEY`, `VT_API_KEY` — enable **live IOC enrichment**: a
|
| 33 |
+
pasted IP / domain / hash / URL is looked up across abuse.ch ThreatFox, AlienVault OTX,
|
| 34 |
+
and VirusTotal, formatted into a `[Threat Report]`, and analyzed. Without them the app
|
| 35 |
+
still runs (the IOC text passes straight to the model).
|
| 36 |
|
| 37 |
## Push updates
|
| 38 |
With the `hf` CLI authenticated, from the `osint-project` root:
|
app.py
CHANGED
|
@@ -215,6 +215,137 @@ def live_cve(cid):
|
|
| 215 |
}
|
| 216 |
|
| 217 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
# --- bundled retrieval -----------------------------------------------------------
|
| 219 |
IOC_RE = re.compile(r"hxxp|https?://|\b\d{1,3}\[?\.\]?\d{1,3}\[?\.\]?\d{1,3}\[?\.\]?\d{1,3}\b|\b[a-f0-9]{32,64}\b", re.I)
|
| 220 |
ACTOR_KW = re.compile(r"\b(actor|group|apt|adversary|profile|who is|what did|techniques does|ttps|campaign)\b", re.I)
|
|
@@ -292,6 +423,11 @@ def route(q):
|
|
| 292 |
p = t_tech(tech_idx[m.group(0).upper()]); return p, p, "MITRE ATT&CK", None
|
| 293 |
|
| 294 |
if IOC_RE.search(q):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
return q, None, "the report you provided", None
|
| 296 |
|
| 297 |
a = _longest(actor_re, q)
|
|
@@ -330,9 +466,8 @@ SUGGESTIONS = {
|
|
| 330 |
"🛠️ Who uses Cobalt Strike?": "Which threat actors use the Cobalt Strike tool?",
|
| 331 |
"📖 Explain technique T1059.001": "Explain MITRE ATT&CK technique T1059.001",
|
| 332 |
"🔓 Assess CVE-2025-0282 (live)": "Assess CVE-2025-0282 for offensive relevance",
|
| 333 |
-
"🌐
|
| 334 |
-
|
| 335 |
-
"as a malware_download host."),
|
| 336 |
}
|
| 337 |
|
| 338 |
if "messages" not in st.session_state:
|
|
@@ -355,7 +490,7 @@ for msg in st.session_state.messages:
|
|
| 355 |
st.caption(msg["badge"])
|
| 356 |
st.markdown(msg["content"])
|
| 357 |
|
| 358 |
-
user_input = st.chat_input("Ask TextScout…
|
| 359 |
|
| 360 |
if user_input:
|
| 361 |
st.session_state.messages.append({"role": "user", "content": user_input})
|
|
|
|
| 215 |
}
|
| 216 |
|
| 217 |
|
| 218 |
+
# --- live IOC enrichment: ThreatFox + OTX + VirusTotal (uses Space secrets) ------
|
| 219 |
+
def _refang(s):
|
| 220 |
+
return s.replace("[.]", ".").replace("[:]", ":").replace("hxxps", "https").replace("hxxp", "http")
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def detect_ioc(q):
|
| 224 |
+
q = _refang(q) # normalise defanged IOCs (hxxp, [.]) first
|
| 225 |
+
m = re.search(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", q)
|
| 226 |
+
if m:
|
| 227 |
+
return m.group(0), "ip"
|
| 228 |
+
m = re.search(r"\b[a-fA-F0-9]{64}\b|\b[a-fA-F0-9]{40}\b|\b[a-fA-F0-9]{32}\b", q)
|
| 229 |
+
if m:
|
| 230 |
+
return m.group(0), "file"
|
| 231 |
+
m = re.search(r"https?://([^\s/:]+)", q, re.I)
|
| 232 |
+
if m:
|
| 233 |
+
host = m.group(1)
|
| 234 |
+
return host, ("ip" if re.match(r"\d+\.\d+\.\d+\.\d+", host) else "domain")
|
| 235 |
+
return None, None
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _have_ioc_keys():
|
| 239 |
+
return any(os.environ.get(k) for k in ("THREATFOX_API_KEY", "OTX_API_KEY", "VT_API_KEY"))
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _safe(fn, *a):
|
| 243 |
+
try:
|
| 244 |
+
return fn(*a)
|
| 245 |
+
except Exception:
|
| 246 |
+
return None
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
@st.cache_data(ttl=900, show_spinner=False)
|
| 250 |
+
def _threatfox(ioc):
|
| 251 |
+
key = os.environ.get("THREATFOX_API_KEY")
|
| 252 |
+
if not key:
|
| 253 |
+
return None
|
| 254 |
+
body = json.dumps({"query": "search_ioc", "search_term": ioc}).encode()
|
| 255 |
+
req = urllib.request.Request("https://threatfox-api.abuse.ch/api/v1/", data=body,
|
| 256 |
+
headers={"Auth-Key": key, "Content-Type": "application/json", "User-Agent": "TextScout-demo"})
|
| 257 |
+
d = json.loads(urllib.request.urlopen(req, timeout=15).read())
|
| 258 |
+
rows = d.get("data") if d.get("query_status") == "ok" else None
|
| 259 |
+
if not isinstance(rows, list) or not rows:
|
| 260 |
+
return None
|
| 261 |
+
return {
|
| 262 |
+
"families": sorted({r.get("malware_printable") or r.get("malware") for r in rows if r.get("malware")}),
|
| 263 |
+
"threat_types": sorted({r.get("threat_type_desc") or r.get("threat_type") for r in rows if r.get("threat_type")}),
|
| 264 |
+
"confidence": max((r.get("confidence_level") or 0) for r in rows),
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
@st.cache_data(ttl=900, show_spinner=False)
|
| 269 |
+
def _otx(ioc, ioc_type):
|
| 270 |
+
key = os.environ.get("OTX_API_KEY")
|
| 271 |
+
section = {"ip": "IPv4", "domain": "domain", "url": "url", "file": "file"}.get(ioc_type)
|
| 272 |
+
if not key or not section:
|
| 273 |
+
return None
|
| 274 |
+
url = f"https://otx.alienvault.com/api/v1/indicators/{section}/{urllib.parse.quote(ioc, safe='')}/general"
|
| 275 |
+
req = urllib.request.Request(url, headers={"X-OTX-API-KEY": key, "User-Agent": "TextScout-demo"})
|
| 276 |
+
pi = json.loads(urllib.request.urlopen(req, timeout=15).read()).get("pulse_info") or {}
|
| 277 |
+
pulses = pi.get("pulses") or []
|
| 278 |
+
|
| 279 |
+
def ids(p):
|
| 280 |
+
return [a if isinstance(a, str) else a.get("id", "") for a in (p.get("attack_ids") or [])]
|
| 281 |
+
fams = {m if isinstance(m, str) else m.get("display_name", "")
|
| 282 |
+
for p in pulses for m in (p.get("malware_families") or [])} - {""}
|
| 283 |
+
return {
|
| 284 |
+
"pulse_count": pi.get("count", 0),
|
| 285 |
+
"families": sorted(fams),
|
| 286 |
+
"attack": sorted({a for p in pulses for a in ids(p) if a}),
|
| 287 |
+
"adversary": next((p.get("adversary") for p in pulses if p.get("adversary")), ""),
|
| 288 |
+
"industries": sorted({i for p in pulses for i in (p.get("industries") or [])}),
|
| 289 |
+
"countries": sorted({c for p in pulses for c in (p.get("targeted_countries") or [])}),
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
@st.cache_data(ttl=900, show_spinner=False)
|
| 294 |
+
def _vt(ioc, ioc_type):
|
| 295 |
+
key = os.environ.get("VT_API_KEY")
|
| 296 |
+
if not key:
|
| 297 |
+
return None
|
| 298 |
+
if ioc_type == "ip":
|
| 299 |
+
path = "ip_addresses/" + ioc
|
| 300 |
+
elif ioc_type == "domain":
|
| 301 |
+
path = "domains/" + ioc
|
| 302 |
+
elif ioc_type == "file":
|
| 303 |
+
path = "files/" + ioc
|
| 304 |
+
elif ioc_type == "url":
|
| 305 |
+
import base64
|
| 306 |
+
path = "urls/" + base64.urlsafe_b64encode(ioc.encode()).decode().strip("=")
|
| 307 |
+
else:
|
| 308 |
+
return None
|
| 309 |
+
req = urllib.request.Request("https://www.virustotal.com/api/v3/" + path,
|
| 310 |
+
headers={"x-apikey": key, "User-Agent": "TextScout-demo"})
|
| 311 |
+
a = (json.loads(urllib.request.urlopen(req, timeout=15).read()).get("data") or {}).get("attributes") or {}
|
| 312 |
+
s = a.get("last_analysis_stats") or {}
|
| 313 |
+
return {"malicious": s.get("malicious", 0), "total": sum(s.values()) if s else 0}
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def enrich_ioc(ioc, ioc_type):
|
| 317 |
+
tf = _safe(_threatfox, ioc)
|
| 318 |
+
otx = _safe(_otx, ioc, ioc_type) or {}
|
| 319 |
+
vt = _safe(_vt, ioc, ioc_type)
|
| 320 |
+
families = sorted(set((tf or {}).get("families", []) + otx.get("families", [])))
|
| 321 |
+
bits = []
|
| 322 |
+
if tf:
|
| 323 |
+
d = f"abuse.ch ThreatFox: {', '.join(tf['families']) or 'tracked indicator'}"
|
| 324 |
+
if tf.get("threat_types"):
|
| 325 |
+
d += f" ({', '.join(tf['threat_types'])})"
|
| 326 |
+
if tf.get("confidence"):
|
| 327 |
+
d += f", confidence {tf['confidence']}%"
|
| 328 |
+
bits.append(d + ".")
|
| 329 |
+
if vt and vt.get("total"):
|
| 330 |
+
bits.append(f"VirusTotal: {vt['malicious']}/{vt['total']} engines flagged it malicious.")
|
| 331 |
+
if otx.get("pulse_count"):
|
| 332 |
+
bits.append(f"AlienVault OTX: referenced in {otx['pulse_count']} threat pulse(s).")
|
| 333 |
+
if not bits:
|
| 334 |
+
bits.append("No current threat-intelligence records for this indicator across ThreatFox, OTX, or VirusTotal.")
|
| 335 |
+
label = {"ip": "IP Addresses", "domain": "Domains", "url": "URLs", "file": "File Hashes"}.get(ioc_type, "Indicators")
|
| 336 |
+
return ("Analyze this threat intelligence report. Produce a structured red-team summary using only "
|
| 337 |
+
"what the report states. Do not invent actors, malware, or indicators.\n\n"
|
| 338 |
+
"[Threat Report]\n"
|
| 339 |
+
f"Title: Live IOC enrichment — {ioc}\n\n"
|
| 340 |
+
f"{' '.join(bits)}\n\n"
|
| 341 |
+
f"Reported indicators:\n {label}: {ioc}\n"
|
| 342 |
+
f"Analyst-tagged ATT&CK: {', '.join(otx.get('attack', [])) or 'none tagged'}\n"
|
| 343 |
+
f"Malware families: {', '.join(families) or 'none named'}\n"
|
| 344 |
+
f"Attributed actor: {otx.get('adversary') or 'none specified'}\n"
|
| 345 |
+
f"Targeted industries: {', '.join(otx.get('industries', [])) or 'not specified'}\n"
|
| 346 |
+
f"Targeted countries: {', '.join(otx.get('countries', [])) or 'not specified'}")
|
| 347 |
+
|
| 348 |
+
|
| 349 |
# --- bundled retrieval -----------------------------------------------------------
|
| 350 |
IOC_RE = re.compile(r"hxxp|https?://|\b\d{1,3}\[?\.\]?\d{1,3}\[?\.\]?\d{1,3}\[?\.\]?\d{1,3}\b|\b[a-f0-9]{32,64}\b", re.I)
|
| 351 |
ACTOR_KW = re.compile(r"\b(actor|group|apt|adversary|profile|who is|what did|techniques does|ttps|campaign)\b", re.I)
|
|
|
|
| 423 |
p = t_tech(tech_idx[m.group(0).upper()]); return p, p, "MITRE ATT&CK", None
|
| 424 |
|
| 425 |
if IOC_RE.search(q):
|
| 426 |
+
ioc, ioc_type = detect_ioc(q)
|
| 427 |
+
if ioc and _have_ioc_keys():
|
| 428 |
+
p = _safe(enrich_ioc, ioc, ioc_type)
|
| 429 |
+
if p:
|
| 430 |
+
return p, p, "live ThreatFox + OTX + VirusTotal", None
|
| 431 |
return q, None, "the report you provided", None
|
| 432 |
|
| 433 |
a = _longest(actor_re, q)
|
|
|
|
| 466 |
"🛠️ Who uses Cobalt Strike?": "Which threat actors use the Cobalt Strike tool?",
|
| 467 |
"📖 Explain technique T1059.001": "Explain MITRE ATT&CK technique T1059.001",
|
| 468 |
"🔓 Assess CVE-2025-0282 (live)": "Assess CVE-2025-0282 for offensive relevance",
|
| 469 |
+
"🌐 Look up a live IOC": ("Look up live threat intelligence for the indicator "
|
| 470 |
+
"27.204.192.167 and assess its red team relevance."),
|
|
|
|
| 471 |
}
|
| 472 |
|
| 473 |
if "messages" not in st.session_state:
|
|
|
|
| 490 |
st.caption(msg["badge"])
|
| 491 |
st.markdown(msg["content"])
|
| 492 |
|
| 493 |
+
user_input = st.chat_input("Ask TextScout… profile APT28 · assess CVE-2025-0282 · look up an IP/domain/hash") or clicked
|
| 494 |
|
| 495 |
if user_input:
|
| 496 |
st.session_state.messages.append({"role": "user", "content": user_input})
|