Maximuz23 commited on
Commit
967c101
·
verified ·
1 Parent(s): 952f052
Files changed (3) hide show
  1. README.md +6 -4
  2. app.py +108 -49
  3. robot.png +0 -0
README.md CHANGED
@@ -19,12 +19,14 @@ actor or a non-existent CVE — it **refuses to fabricate** (evidence-gated hone
19
  ## How it works (RAG)
20
  1. Your message is routed to an entity: actor / CVE / technique / malware / report.
21
  2. The matching record is retrieved from the bundled corpus (`corpus.json`, built from
22
- real MITRE ATT&CK + CISA KEV/NVD data) and formatted into the exact template the model
23
- was trained on.
24
- 3. TextScout analyzes that record. Empty lookup honest refusal.
 
25
 
26
  ## Files
27
- `Dockerfile` · `app.py` · `requirements.txt` · `corpus.json` (retrieval data).
 
28
 
29
  ## Push updates
30
  With the `hf` CLI authenticated, from the `osint-project` root:
 
19
  ## How it works (RAG)
20
  1. Your message is routed to an entity: actor / CVE / technique / malware / report.
21
  2. The matching record is retrieved from the bundled corpus (`corpus.json`, built from
22
+ real MITRE ATT&CK + CISA KEV/NVD data). **Any CVE not in the snapshot is fetched LIVE
23
+ from NVD + CISA KEV** — so fresh CVEs that were never in train/valid/test work too.
24
+ 3. The record is formatted into the exact template the model was trained on; TextScout
25
+ analyzes it. Empty lookup (unknown actor, non-existent CVE) → honest refusal.
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:
app.py CHANGED
@@ -1,21 +1,23 @@
1
  """
2
  TextScout — red-team threat-intel chatbot (Streamlit, for Hugging Face Spaces).
3
 
4
- Flow per turn: user asks in natural language -> RAG step retrieves the matching
5
- real record from the bundled corpus (MITRE ATT&CK groups/techniques/software,
6
- CISA KEV/NVD CVEs) -> builds the EXACT training-format prompt -> TextScout
7
- (Llama-3.2-3B + LoRA adapter Maximuz23/Text-OSINT) analyzes it. If retrieval is
8
- empty (unknown actor / non-existent CVE) the record is an empty lookup and the
9
- model refuses instead of fabricating — evidence-gated honesty, shown live.
10
-
11
- The record templates below mirror scripts/build_grounded_records.py byte-for-byte;
12
- the SYSTEM_PROMPT mirrors ai-test.ipynb c03. Do not drift either or the model
13
- goes off-distribution.
14
  """
15
  import os
16
  import re
17
  import json
18
  import pathlib
 
 
19
 
20
  import streamlit as st
21
  import torch
@@ -28,6 +30,8 @@ USE_GPU = torch.cuda.is_available()
28
  BASE_MODEL = "unsloth/Llama-3.2-3B-Instruct-bnb-4bit" if USE_GPU else "unsloth/Llama-3.2-3B-Instruct"
29
  MAX_NEW_TOKENS_DEFAULT = 384
30
  HF_TOKEN = os.environ.get("HF_TOKEN")
 
 
31
 
32
  SYSTEM_PROMPT = (
33
  "You are an expert cybersecurity analyst specializing in Text OSINT and threat "
@@ -45,9 +49,7 @@ REFUSAL_HINTS = re.compile(
45
  re.I,
46
  )
47
 
48
- CORPUS_PATH = pathlib.Path(__file__).parent / "corpus.json"
49
 
50
- # --- model -----------------------------------------------------------------------
51
  @st.cache_resource(show_spinner="Loading TextScout (Llama-3.2-3B + adapter)… first load is slow.")
52
  def load_model():
53
  tok = AutoTokenizer.from_pretrained(BASE_MODEL, token=HF_TOKEN)
@@ -58,7 +60,6 @@ def load_model():
58
  if USE_GPU:
59
  kwargs.update(device_map={"": 0}, torch_dtype=torch.float16)
60
  else:
61
- # bf16 ~6GB (fits the free 16GB CPU Space) and runs on CPU, unlike fp16.
62
  kwargs.update(torch_dtype=torch.bfloat16, low_cpu_mem_usage=True)
63
  base = AutoModelForCausalLM.from_pretrained(BASE_MODEL, **kwargs)
64
  base.config.use_cache = True
@@ -156,7 +157,65 @@ def t_soft(s):
156
  f"Documented user groups: {', '.join(g) if g else 'none recorded'}")
157
 
158
 
159
- # --- retrieval (RAG) -------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  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)
161
  ACTOR_KW = re.compile(r"\b(actor|group|apt|adversary|profile|who is|what did|techniques does|ttps|campaign)\b", re.I)
162
  REPORT_KW = re.compile(r"\b(extract|report|ioc|indicator|infrastructure|c2|c&c|phish)\b", re.I)
@@ -173,15 +232,14 @@ def load_corpus():
173
  actor_idx, soft_idx = {}, {}
174
  for a in c["actors"]:
175
  for key in [a["name"], *a.get("aliases", [])]:
176
- actor_idx.setdefault(key.lower(), a)
 
177
  for s in c["software"]:
178
- if len(s["name"]) >= 5: # drop short/common tool names (net, at, Reg…)
179
  soft_idx.setdefault(s["name"].lower(), s)
180
  cve_idx = {x["cve_id"].upper(): x for x in c["cves"]}
181
  tech_idx = {x["attack_id"].upper(): x for x in c["techniques"]}
182
- actor_re = _name_regex(list(actor_idx))
183
- soft_re = _name_regex(list(soft_idx))
184
- return actor_idx, cve_idx, tech_idx, soft_idx, actor_re, soft_re
185
 
186
 
187
  def _longest(rx, q):
@@ -205,14 +263,14 @@ def _guess_name(q):
205
 
206
  HELP = (
207
  "I'm **TextScout** — I answer from retrieved threat-intel records. Try: "
208
- "**profile an actor** (APT28), **assess a CVE** (CVE-2021-44228), **explain a technique** "
209
- "(T1059.001), **identify malware** (Cobalt Strike), or **extract IOCs from a report**. "
210
- "Pick a suggestion to see it."
211
  )
212
 
213
 
214
  def route(q):
215
- """Return (user_prompt, retrieved_record_or_None, source_label, help_or_None)."""
216
  actor_idx, cve_idx, tech_idx, soft_idx, actor_re, soft_re = load_corpus()
217
 
218
  m = re.search(r"CVE-\d{4}-\d{4,}", q, re.I)
@@ -220,14 +278,20 @@ def route(q):
220
  cid = m.group(0).upper()
221
  if cid in cve_idx:
222
  rec = cve_idx[cid]; p = rec.get("_prompt") or t_cve(rec)
223
- return p, p, "NVD + CISA KEV", None
224
- p = t_cve_empty(cid); return p, p, "NVD + CISA KEV (no record)", None
 
 
 
 
 
 
225
 
226
  m = re.search(r"\bT\d{4}(?:\.\d{3})?\b", q)
227
  if m and m.group(0).upper() in tech_idx:
228
  p = t_tech(tech_idx[m.group(0).upper()]); return p, p, "MITRE ATT&CK", None
229
 
230
- if IOC_RE.search(q): # report extraction — no external lookup
231
  return q, None, "the report you provided", None
232
 
233
  a = _longest(actor_re, q)
@@ -238,7 +302,7 @@ def route(q):
238
  rec = actor_idx[a.lower()]; p = rec.get("_prompt") or t_actor(rec)
239
  return p, p, "MITRE ATT&CK", None
240
 
241
- if ACTOR_KW.search(q): # actor intent, no corpus match -> honest refusal
242
  p = t_actor_empty(_guess_name(q)); return p, p, "MITRE ATT&CK (no record)", None
243
  if REPORT_KW.search(q):
244
  return q, None, "the report you provided", None
@@ -246,16 +310,13 @@ def route(q):
246
 
247
 
248
  # --- UI --------------------------------------------------------------------------
249
- st.set_page_config(page_title="TextScout", page_icon="🛰️", layout="centered")
250
- st.title("🛰️ TextScout")
251
- st.caption(
252
- "Red-team threat-intel assistant. Ask in plain language — TextScout retrieves the matching "
253
- "MITRE ATT&CK / CVE record and analyzes it, and **refuses to fabricate** when there's no record."
254
- )
255
 
256
  with st.sidebar:
257
- st.subheader("Model")
258
- st.markdown(f"- **Base:** `{BASE_MODEL}`\n- **Adapter:** `{HF_REPO}`\n- **Device:** `{'GPU' if USE_GPU else 'CPU (free)'}`")
259
  st.divider()
260
  max_tokens = st.slider("Max new tokens", 128, 512, MAX_NEW_TOKENS_DEFAULT, 32,
261
  help="Lower for snappier CPU responses.")
@@ -268,7 +329,7 @@ SUGGESTIONS = {
268
  "🧬 What does Kimsuky do?": "What techniques does the threat actor Kimsuky use?",
269
  "🛠️ Who uses Cobalt Strike?": "Which threat actors use the Cobalt Strike tool?",
270
  "📖 Explain technique T1059.001": "Explain MITRE ATT&CK technique T1059.001",
271
- "🔓 Assess CVE-2021-44228": "Assess CVE-2021-44228 for offensive relevance",
272
  "🌐 Extract IOCs from a report": ("Extract the IOCs from this abuse.ch report and assess red "
273
  "team relevance: hxxp://27.204.192.167:41628/i was flagged "
274
  "as a malware_download host."),
@@ -277,7 +338,6 @@ SUGGESTIONS = {
277
  if "messages" not in st.session_state:
278
  st.session_state.messages = []
279
 
280
- # empty-state suggestion chips
281
  clicked = None
282
  if not st.session_state.messages:
283
  st.markdown("##### Try one of these 👇")
@@ -286,29 +346,28 @@ if not st.session_state.messages:
286
  if cols[i % 2].button(label, use_container_width=True):
287
  clicked = text
288
 
289
- # render history
290
  for msg in st.session_state.messages:
291
- with st.chat_message(msg["role"], avatar="🛰️" if msg["role"] == "assistant" else None):
292
  if msg.get("retrieved"):
293
- with st.expander(f"🔎 RAG — record retrieved from {msg.get('source','source')}"):
294
  st.code(msg["retrieved"])
295
  if msg.get("badge"):
296
  st.caption(msg["badge"])
297
  st.markdown(msg["content"])
298
 
299
- user_input = st.chat_input("Ask TextScout… e.g. profile APT28, assess CVE-2021-44228") or clicked
300
 
301
  if user_input:
302
  st.session_state.messages.append({"role": "user", "content": user_input})
303
- prompt, retrieved, source, help_text = route(user_input)
304
- if help_text:
305
- answer, badge = help_text, None
306
- else:
307
- with st.chat_message("assistant", avatar="🛰️"):
308
- with st.spinner(f"🔎 Retrieving from {source} → TextScout analyzing…"):
309
  answer = generate(prompt, max_new_tokens=max_tokens)
310
- badge = ("🛡️ No record retrieved → refused (honesty guardrail)"
311
- if REFUSAL_HINTS.search(answer) else "✅ Grounded in the retrieved record")
312
  st.session_state.messages.append({"role": "assistant", "content": answer,
313
  "retrieved": retrieved, "source": source, "badge": badge})
314
  st.rerun()
 
1
  """
2
  TextScout — red-team threat-intel chatbot (Streamlit, for Hugging Face Spaces).
3
 
4
+ Per turn: user asks in natural language -> RAG retrieves the matching record
5
+ (bundled MITRE ATT&CK / KEV snapshot, or LIVE NVD + CISA KEV for any CVE not in
6
+ the snapshot) -> builds the EXACT training-format prompt -> the fine-tuned model
7
+ (Llama-3.2-3B + LoRA Maximuz23/Text-OSINT) analyzes it. Empty lookup -> the model
8
+ refuses instead of fabricating.
9
+
10
+ Nothing is hardcoded: the answer is always model.generate(); only the retrieved
11
+ record (real data) and a one-line help message are non-model text. Record
12
+ templates mirror scripts/build_grounded_records.py; SYSTEM_PROMPT mirrors
13
+ ai-test.ipynb c03 — do not drift either.
14
  """
15
  import os
16
  import re
17
  import json
18
  import pathlib
19
+ import urllib.request
20
+ import urllib.parse
21
 
22
  import streamlit as st
23
  import torch
 
30
  BASE_MODEL = "unsloth/Llama-3.2-3B-Instruct-bnb-4bit" if USE_GPU else "unsloth/Llama-3.2-3B-Instruct"
31
  MAX_NEW_TOKENS_DEFAULT = 384
32
  HF_TOKEN = os.environ.get("HF_TOKEN")
33
+ ROBOT = str(pathlib.Path(__file__).parent / "robot.png")
34
+ CORPUS_PATH = pathlib.Path(__file__).parent / "corpus.json"
35
 
36
  SYSTEM_PROMPT = (
37
  "You are an expert cybersecurity analyst specializing in Text OSINT and threat "
 
49
  re.I,
50
  )
51
 
 
52
 
 
53
  @st.cache_resource(show_spinner="Loading TextScout (Llama-3.2-3B + adapter)… first load is slow.")
54
  def load_model():
55
  tok = AutoTokenizer.from_pretrained(BASE_MODEL, token=HF_TOKEN)
 
60
  if USE_GPU:
61
  kwargs.update(device_map={"": 0}, torch_dtype=torch.float16)
62
  else:
 
63
  kwargs.update(torch_dtype=torch.bfloat16, low_cpu_mem_usage=True)
64
  base = AutoModelForCausalLM.from_pretrained(BASE_MODEL, **kwargs)
65
  base.config.use_cache = True
 
157
  f"Documented user groups: {', '.join(g) if g else 'none recorded'}")
158
 
159
 
160
+ # --- live retrieval: NVD + CISA KEV (fresh CVEs not in the bundle) ---------------
161
+ def _get_json(url, headers=None, timeout=15):
162
+ req = urllib.request.Request(url, headers={"User-Agent": "TextScout-demo", **(headers or {})})
163
+ with urllib.request.urlopen(req, timeout=timeout) as r:
164
+ return json.loads(r.read())
165
+
166
+
167
+ @st.cache_data(ttl=3600, show_spinner=False)
168
+ def _kev_catalog():
169
+ try:
170
+ data = _get_json("https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json")
171
+ return {v["cveID"].upper(): v for v in data.get("vulnerabilities", [])}
172
+ except Exception:
173
+ return {} # KEV is supplementary; degrade gracefully
174
+
175
+
176
+ @st.cache_data(ttl=3600, show_spinner=False)
177
+ def _nvd_lookup(cid):
178
+ # raises on network error (so we can show "try again" rather than a false refusal);
179
+ # returns None only when NVD answers with zero records.
180
+ headers = {"apiKey": os.environ["NVD_API_KEY"]} if os.environ.get("NVD_API_KEY") else None
181
+ url = "https://services.nvd.nist.gov/rest/json/cves/2.0?cveId=" + urllib.parse.quote(cid)
182
+ vulns = _get_json(url, headers).get("vulnerabilities") or []
183
+ return vulns[0]["cve"] if vulns else None
184
+
185
+
186
+ def live_cve(cid):
187
+ """Build a CVE record from live NVD + KEV, or None if genuinely not found."""
188
+ nvd = _nvd_lookup(cid)
189
+ kev = _kev_catalog().get(cid)
190
+ if not nvd and not kev:
191
+ return None
192
+ desc, cvss, cwes = "", "", []
193
+ if nvd:
194
+ desc = next((x["value"] for x in nvd.get("descriptions", []) if x["lang"] == "en"), "")
195
+ metrics = nvd.get("metrics", {})
196
+ for k in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"):
197
+ if metrics.get(k):
198
+ cd = metrics[k][0]["cvssData"]
199
+ cvss = f"{cd.get('baseScore', '')} {cd.get('baseSeverity', '')} {cd.get('vectorString', '')}".strip()
200
+ break
201
+ cwes = list(dict.fromkeys(
202
+ x["value"] for w in nvd.get("weaknesses", []) for x in w.get("description", [])
203
+ if x.get("value", "").startswith("CWE")))
204
+ return {
205
+ "cve_id": cid,
206
+ "name": (kev.get("vulnerabilityName") if kev else "") or "",
207
+ "vendor": kev.get("vendorProject", "") if kev else "",
208
+ "product": kev.get("product", "") if kev else "",
209
+ "nvd_description": desc,
210
+ "kev_description": kev.get("shortDescription", "") if kev else "",
211
+ "cvss": cvss,
212
+ "cwes": cwes,
213
+ "ransomware_use": kev.get("knownRansomwareCampaignUse", "") if kev else "",
214
+ "required_action": kev.get("requiredAction", "") if kev else "",
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)
221
  REPORT_KW = re.compile(r"\b(extract|report|ioc|indicator|infrastructure|c2|c&c|phish)\b", re.I)
 
232
  actor_idx, soft_idx = {}, {}
233
  for a in c["actors"]:
234
  for key in [a["name"], *a.get("aliases", [])]:
235
+ if key:
236
+ actor_idx.setdefault(key.lower(), a)
237
  for s in c["software"]:
238
+ if len(s["name"]) >= 5:
239
  soft_idx.setdefault(s["name"].lower(), s)
240
  cve_idx = {x["cve_id"].upper(): x for x in c["cves"]}
241
  tech_idx = {x["attack_id"].upper(): x for x in c["techniques"]}
242
+ return actor_idx, cve_idx, tech_idx, soft_idx, _name_regex(list(actor_idx)), _name_regex(list(soft_idx))
 
 
243
 
244
 
245
  def _longest(rx, q):
 
263
 
264
  HELP = (
265
  "I'm **TextScout** — I answer from retrieved threat-intel records. Try: "
266
+ "**profile an actor** (APT28), **assess a CVE** (any id fresh ones go to live NVD), "
267
+ "**explain a technique** (T1059.001), **identify malware** (Cobalt Strike), or "
268
+ "**extract IOCs from a report**. Pick a suggestion to see it."
269
  )
270
 
271
 
272
  def route(q):
273
+ """Return (user_prompt, retrieved_record_or_None, source_label, note_or_None)."""
274
  actor_idx, cve_idx, tech_idx, soft_idx, actor_re, soft_re = load_corpus()
275
 
276
  m = re.search(r"CVE-\d{4}-\d{4,}", q, re.I)
 
278
  cid = m.group(0).upper()
279
  if cid in cve_idx:
280
  rec = cve_idx[cid]; p = rec.get("_prompt") or t_cve(rec)
281
+ return p, p, "bundled MITRE/KEV record", None
282
+ try:
283
+ rec = live_cve(cid)
284
+ except Exception:
285
+ return None, None, None, "⚠️ Live NVD/CISA KEV lookup failed — please try again in a moment."
286
+ if rec:
287
+ p = t_cve(rec); return p, p, "live NVD + CISA KEV", None
288
+ p = t_cve_empty(cid); return p, p, "live NVD + CISA KEV (no record)", None
289
 
290
  m = re.search(r"\bT\d{4}(?:\.\d{3})?\b", q)
291
  if m and m.group(0).upper() in tech_idx:
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)
 
302
  rec = actor_idx[a.lower()]; p = rec.get("_prompt") or t_actor(rec)
303
  return p, p, "MITRE ATT&CK", None
304
 
305
+ if ACTOR_KW.search(q):
306
  p = t_actor_empty(_guess_name(q)); return p, p, "MITRE ATT&CK (no record)", None
307
  if REPORT_KW.search(q):
308
  return q, None, "the report you provided", None
 
310
 
311
 
312
  # --- UI --------------------------------------------------------------------------
313
+ st.set_page_config(page_title="TextScout", page_icon=ROBOT, layout="centered")
314
+ st.markdown("<h1 style='text-align:center; margin:0.2rem 0 1.4rem'>TextScout</h1>",
315
+ unsafe_allow_html=True)
 
 
 
316
 
317
  with st.sidebar:
318
+ st.image(ROBOT, width=64)
319
+ st.markdown("## TextScout")
320
  st.divider()
321
  max_tokens = st.slider("Max new tokens", 128, 512, MAX_NEW_TOKENS_DEFAULT, 32,
322
  help="Lower for snappier CPU responses.")
 
329
  "🧬 What does Kimsuky do?": "What techniques does the threat actor Kimsuky use?",
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."),
 
338
  if "messages" not in st.session_state:
339
  st.session_state.messages = []
340
 
 
341
  clicked = None
342
  if not st.session_state.messages:
343
  st.markdown("##### Try one of these 👇")
 
346
  if cols[i % 2].button(label, use_container_width=True):
347
  clicked = text
348
 
 
349
  for msg in st.session_state.messages:
350
+ with st.chat_message(msg["role"], avatar=ROBOT if msg["role"] == "assistant" else None):
351
  if msg.get("retrieved"):
352
+ with st.expander(f"🔎 RAG — record retrieved from {msg.get('source', 'source')}"):
353
  st.code(msg["retrieved"])
354
  if msg.get("badge"):
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})
362
+ with st.chat_message("assistant", avatar=ROBOT):
363
+ with st.spinner("🔎 Retrieving + analyzing…"):
364
+ prompt, retrieved, source, note = route(user_input)
365
+ if note:
366
+ answer, badge = note, None
367
+ else:
368
  answer = generate(prompt, max_new_tokens=max_tokens)
369
+ badge = ("🛡️ No record retrieved → refused (honesty guardrail)"
370
+ if REFUSAL_HINTS.search(answer) else "✅ Grounded in the retrieved record")
371
  st.session_state.messages.append({"role": "assistant", "content": answer,
372
  "retrieved": retrieved, "source": source, "badge": badge})
373
  st.rerun()
robot.png ADDED