Maximuz23 commited on
Commit
b97e90d
·
verified ·
1 Parent(s): 967c101
Files changed (2) hide show
  1. README.md +7 -1
  2. app.py +139 -4
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
- Optional Space secret `NVD_API_KEY` raises the NVD rate limit (works without it).
 
 
 
 
 
 
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
- "🌐 Extract IOCs from a report": ("Extract the IOCs from this abuse.ch report and assess red "
334
- "team relevance: hxxp://27.204.192.167:41628/i was flagged "
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… e.g. profile APT28, assess CVE-2025-0282") or clicked
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})