Maximuz23 commited on
Commit
952f052
·
verified ·
1 Parent(s): f57882e
Files changed (5) hide show
  1. README.md +29 -27
  2. app.py +246 -111
  3. corpus.json +0 -0
  4. examples.json +0 -7
  5. src/streamlit_app.py +0 -40
README.md CHANGED
@@ -1,6 +1,6 @@
1
  ---
2
- title: Text OSINT AI
3
- emoji: 🛡
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: docker
@@ -9,39 +9,41 @@ pinned: false
9
  license: bigscience-openrail-m
10
  ---
11
 
12
- # Text OSINT AI demo
13
 
14
- Live demo of the fine-tuned **Text OSINT AI** model: a Llama-3.2-3B LoRA adapter
15
- (`Maximuz23/Text-OSINT`) for red-team threat intelligence. It extracts IOCs,
16
- profiles threat actors, and maps MITRE ATT&CK from a supplied record and
17
- **refuses to fabricate** when the lookup is empty (evidence-gated honesty).
18
 
19
- This is a **Docker** Space. Files: `Dockerfile`, `app.py`, `requirements.txt`,
20
- `examples.json`. The container installs deps and runs
21
- `streamlit run app.py` on port 8501.
 
 
 
22
 
23
- ## Pushing updates
24
- From the `osint-project` root, with the `hf` CLI authenticated:
 
 
 
25
  ```bash
26
- hf upload <user>/<space> demo . --type space
27
  ```
28
- If the adapter repo `Maximuz23/Text-OSINT` is **private**, add a Space secret
29
- `HF_TOKEN` (Settings → Variables and secrets) with a read token.
30
 
31
  ## CPU vs GPU
32
- - **Free CPU** runs the 3B model; the first generation is ~30–60s (model load),
33
- then faster. Lower *Max new tokens* in the sidebar for a snappier live demo.
34
- - For instant responses, switch the Space hardware to **T4 small** (~$0.40/hr),
35
- change the `Dockerfile` base to an NVIDIA CUDA image, and uncomment
36
- `bitsandbytes` in `requirements.txt` (the GPU path uses the 4-bit base).
37
- **Pause the Space after the demo** so you stop paying.
38
 
39
  ## Contract — do not drift
40
- `app.py` mirrors `ai-test.ipynb` exactly: the system prompt, chat template,
41
- greedy decoding (`do_sample=False`), and 384 max new tokens. If you retrain and
42
- the eval contract changes, update both together or the model goes off-distribution.
43
 
44
  ## Phase 2 (optional)
45
- Add live-API enrichment: user types a CVE id / actor name → fetch NVD / CISA KEV /
46
- MITRE build the record in this same format → feed the model. Keeps the demo on
47
- "current ground truth" instead of pasted records. Needs NVD/OTX keys as secrets.
 
1
  ---
2
+ title: TextScout
3
+ emoji: 🛰
4
  colorFrom: indigo
5
  colorTo: blue
6
  sdk: docker
 
9
  license: bigscience-openrail-m
10
  ---
11
 
12
+ # TextScout red-team threat-intel chatbot
13
 
14
+ Ask in plain language. TextScout **retrieves** the matching real record (MITRE ATT&CK
15
+ groups / techniques / software, CISA KEV + NVD CVEs) and analyzes it with a fine-tuned
16
+ Llama-3.2-3B (LoRA adapter `Maximuz23/Text-OSINT`). When no record is foundan unknown
17
+ actor or a non-existent CVE — it **refuses to fabricate** (evidence-gated honesty).
18
 
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:
31
  ```bash
32
+ hf upload Maximuz23/TextScout demo . --type space --commit-message update
33
  ```
34
+ The adapter `Maximuz23/Text-OSINT` is public, so no Space secret is needed.
 
35
 
36
  ## CPU vs GPU
37
+ Free CPU runs the 3B model in bf16 (~6 GB) — the first generation is ~30–60s (model load),
38
+ then faster; lower *Max new tokens* for snappier replies. For instant responses use a
39
+ **T4 small** (~$0.40/hr): switch the `Dockerfile` base to a CUDA image and uncomment
40
+ `bitsandbytes` in `requirements.txt`. Pause the Space after the demo.
 
 
41
 
42
  ## Contract — do not drift
43
+ `app.py`'s `SYSTEM_PROMPT` and record templates mirror `ai-test.ipynb` c03 and
44
+ `scripts/build_grounded_records.py`. If you retrain and the contract changes, update both
45
+ or the model goes off-distribution.
46
 
47
  ## Phase 2 (optional)
48
+ Swap the bundled `corpus.json` for **live** retrieval (NVD / CISA KEV / MITRE APIs) so the
49
+ demo reflects current ground truth instead of a snapshot. Needs NVD/OTX keys as secrets.
 
app.py CHANGED
@@ -1,34 +1,33 @@
1
  """
2
- Text OSINT AI live demo (Streamlit, for Hugging Face Spaces).
3
 
4
- Loads the fine-tuned LoRA adapter `Maximuz23/Text-OSINT` on Llama-3.2-3B-Instruct
5
- and reproduces the EXACT inference contract from ai-test.ipynb (system prompt,
6
- chat template, greedy decoding, 384 new tokens). Keep these in sync with the
7
- notebook any drift puts the model off-distribution and the demo looks broken.
 
 
8
 
9
- The point of the demo is the honesty differentiator: the model extracts/structures
10
- intel from a real record, and *refuses* (instead of hallucinating) when the lookup
11
- is empty — fake CVE id, unknown actor, etc.
12
  """
13
  import os
 
14
  import json
15
- import time
16
  import pathlib
17
- import re
18
 
19
  import streamlit as st
20
  import torch
21
  from transformers import AutoModelForCausalLM, AutoTokenizer
22
  from peft import PeftModel
23
 
24
- # --- contract (mirror ai-test.ipynb cells 1 & 3) --------------------------------
25
  HF_REPO = "Maximuz23/Text-OSINT"
26
  USE_GPU = torch.cuda.is_available()
27
- # GPU Space -> the 4-bit base used in training (needs bitsandbytes).
28
- # Free CPU Space -> the 16-bit base (ungated); the adapter applies to either.
29
  BASE_MODEL = "unsloth/Llama-3.2-3B-Instruct-bnb-4bit" if USE_GPU else "unsloth/Llama-3.2-3B-Instruct"
30
  MAX_NEW_TOKENS_DEFAULT = 384
31
- HF_TOKEN = os.environ.get("HF_TOKEN") # set as a Space secret if the adapter repo is private
32
 
33
  SYSTEM_PROMPT = (
34
  "You are an expert cybersecurity analyst specializing in Text OSINT and threat "
@@ -40,24 +39,16 @@ SYSTEM_PROMPT = (
40
  "details. Judge by the evidence in the input, not by whether a name looks familiar."
41
  )
42
 
43
- # Light heuristic only for the on-screen badge (NOT the eval scorer).
44
  REFUSAL_HINTS = re.compile(
45
- r"no record (?:for|of|found)|won'?t fabricat|returns? no data|not indexed|"
46
- r"no authoritative record|cannot produce an assessment|no matching group",
47
  re.I,
48
  )
49
 
50
- EXAMPLES_PATH = pathlib.Path(__file__).parent / "examples.json"
51
- EXAMPLE_LABELS = {
52
- "real_cve": "Real CVE — Log4Shell → expect: structured assessment",
53
- "fake_cve": "Fake CVE — CVE-9999-987654 → expect: refusal",
54
- "real_actor": "Real actor — APT28 → expect: threat profile",
55
- "fake_actor": "Fake actor — APT-Lyrebird-77 → expect: refusal",
56
- "raw_report": "Raw report — abuse.ch malware URL → expect: IOC extraction",
57
- }
58
-
59
 
60
- @st.cache_resource(show_spinner="Loading Llama-3.2-3B + Text-OSINT adapter (first load is slow)…")
 
61
  def load_model():
62
  tok = AutoTokenizer.from_pretrained(BASE_MODEL, token=HF_TOKEN)
63
  if tok.pad_token is None:
@@ -67,8 +58,7 @@ def load_model():
67
  if USE_GPU:
68
  kwargs.update(device_map={"": 0}, torch_dtype=torch.float16)
69
  else:
70
- # bf16 halves RAM vs fp32 (~6GB for 3B, fits the free 16GB CPU Space) and,
71
- # unlike fp16, runs on CPU. low_cpu_mem_usage avoids a 2x spike at load.
72
  kwargs.update(torch_dtype=torch.bfloat16, low_cpu_mem_usage=True)
73
  base = AutoModelForCausalLM.from_pretrained(BASE_MODEL, **kwargs)
74
  base.config.use_cache = True
@@ -77,103 +67,248 @@ def load_model():
77
  return tok, model
78
 
79
 
80
- def _device(model):
81
- return next(model.parameters()).device
82
-
83
-
84
- def generate(prompt, use_adapter=True, max_new_tokens=MAX_NEW_TOKENS_DEFAULT):
85
  tok, model = load_model()
86
  messages = [
87
  {"role": "system", "content": SYSTEM_PROMPT},
88
- {"role": "user", "content": prompt},
89
  ]
90
  inputs = tok.apply_chat_template(
91
  messages, tokenize=True, add_generation_prompt=True,
92
  return_tensors="pt", return_dict=True,
93
  )
94
- inputs = {k: v.to(_device(model)) for k, v in inputs.items()}
95
- gen_kwargs = dict(max_new_tokens=max_new_tokens, do_sample=False, pad_token_id=tok.eos_token_id)
96
  with torch.no_grad():
97
- if use_adapter:
98
- out = model.generate(**inputs, **gen_kwargs)
99
- else:
100
- with model.disable_adapter():
101
- out = model.generate(**inputs, **gen_kwargs)
102
- in_len = inputs["input_ids"].shape[1]
103
- return tok.decode(out[0][in_len:], skip_special_tokens=True).strip()
104
-
105
-
106
- def badge(text):
107
- if REFUSAL_HINTS.search(text):
108
- st.warning("🛡️ **Refused** no source record (honesty guardrail held)")
109
- else:
110
- st.success(" **Extracted** from the record")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
 
113
- # --- UI -------------------------------------------------------------------------
114
- st.set_page_config(page_title="Text OSINT AI", page_icon="🛡️", layout="wide")
115
- st.title("🛡️ Text OSINT AI — red-team threat-intel assistant")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  st.caption(
117
- "Fine-tuned Llama-3.2-3B (LoRA adapter `Maximuz23/Text-OSINT`). It extracts IOCs, "
118
- "profiles actors, and maps MITRE ATT&CK from a supplied record and **refuses to "
119
- "fabricate** when the lookup is empty (fake CVE, unknown actor). Evidence-gated honesty."
120
  )
121
 
122
  with st.sidebar:
123
  st.subheader("Model")
124
- st.markdown(
125
- f"- **Base:** `{BASE_MODEL}`\n"
126
- f"- **Adapter:** `{HF_REPO}`\n"
127
- f"- **Device:** `{'GPU' if USE_GPU else 'CPU (free)'}`"
128
- )
129
  st.divider()
130
- max_tokens = st.slider(
131
- "Max new tokens", 128, 512, MAX_NEW_TOKENS_DEFAULT, 32,
132
- help="384 matches the eval. Lower it for faster CPU demos.",
133
- )
134
- show_base = st.checkbox(
135
- "Also run the BASE model (show what fine-tuning fixed)", value=False,
136
- help="Doubles latency. Best on a fake input: base waffles, fine-tune refuses.",
137
- )
138
- st.caption("Inputs use the same record format the model was trained on load an example to see it.")
139
-
140
- examples = json.loads(EXAMPLES_PATH.read_text())
141
- st.session_state.setdefault("input_text", examples["fake_cve"])
142
-
143
- col_in, col_pick = st.columns([3, 1])
144
- with col_pick:
145
- choice = st.selectbox("Try an example", list(EXAMPLE_LABELS), format_func=lambda k: EXAMPLE_LABELS[k])
146
- if st.button("⤵ Load example", use_container_width=True):
147
- st.session_state.input_text = examples[choice]
148
- with col_in:
149
- prompt = st.text_area("Record / report to analyze", key="input_text", height=240)
150
-
151
- if st.button("🔍 Analyze", type="primary"):
152
- if not prompt.strip():
153
- st.error("Paste a record or load an example first.")
154
- st.stop()
155
-
156
- if show_base:
157
- col_ft, col_base = st.columns(2)
158
- with col_ft:
159
- st.markdown("#### Fine-tuned (Text-OSINT)")
160
- with st.spinner("Generating…"):
161
- t0 = time.time()
162
- out = generate(prompt, use_adapter=True, max_new_tokens=max_tokens)
163
- badge(out)
164
- st.code(out, language="markdown")
165
- st.caption(f"{time.time() - t0:.1f}s")
166
- with col_base:
167
- st.markdown("#### Base Llama-3.2-3B (no adapter)")
168
- with st.spinner("Generating…"):
169
- t0 = time.time()
170
- out_b = generate(prompt, use_adapter=False, max_new_tokens=max_tokens)
171
- st.code(out_b, language="markdown")
172
- st.caption(f"{time.time() - t0:.1f}s")
 
 
 
173
  else:
174
- with st.spinner("Generating…"):
175
- t0 = time.time()
176
- out = generate(prompt, use_adapter=True, max_new_tokens=max_tokens)
177
- badge(out)
178
- st.code(out, language="markdown")
179
- st.caption(f"{time.time() - t0:.1f}s")
 
 
 
1
  """
2
+ TextScoutred-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
22
  from transformers import AutoModelForCausalLM, AutoTokenizer
23
  from peft import PeftModel
24
 
25
+ # --- model contract (mirror ai-test.ipynb cells 1 & 3) --------------------------
26
  HF_REPO = "Maximuz23/Text-OSINT"
27
  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 "
 
39
  "details. Judge by the evidence in the input, not by whether a name looks familiar."
40
  )
41
 
 
42
  REFUSAL_HINTS = re.compile(
43
+ r"no record (?:for|of|found)|won'?t fabricat|returns? no data|not indexed|no authoritative "
44
+ r"record|cannot produce an assessment|no matching group|i cannot profile",
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)
54
  if tok.pad_token is None:
 
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
 
67
  return tok, model
68
 
69
 
70
+ def generate(user_prompt, max_new_tokens=MAX_NEW_TOKENS_DEFAULT):
 
 
 
 
71
  tok, model = load_model()
72
  messages = [
73
  {"role": "system", "content": SYSTEM_PROMPT},
74
+ {"role": "user", "content": user_prompt},
75
  ]
76
  inputs = tok.apply_chat_template(
77
  messages, tokenize=True, add_generation_prompt=True,
78
  return_tensors="pt", return_dict=True,
79
  )
80
+ dev = next(model.parameters()).device
81
+ inputs = {k: v.to(dev) for k, v in inputs.items()}
82
  with torch.no_grad():
83
+ out = model.generate(**inputs, max_new_tokens=max_new_tokens,
84
+ do_sample=False, pad_token_id=tok.eos_token_id)
85
+ return tok.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True).strip()
86
+
87
+
88
+ # --- record templates (mirror scripts/build_grounded_records.py) -----------------
89
+ def t_actor(a):
90
+ aliases = ", ".join(a["aliases"]) if a.get("aliases") else "none recorded"
91
+ techs = a.get("top_techniques") or []
92
+ tb = "; ".join(f"{t['id']} {t['name']}" for t in techs) if techs else "none recorded"
93
+ return ("Use only the MITRE ATT&CK record below to produce a red-team threat profile. "
94
+ "Do not add any actor, technique, or attribution not present in the record.\n\n"
95
+ "[MITRE ATT&CK Group lookup]\n"
96
+ f"Name: {a['name']} ({a['attack_id']})\n"
97
+ f"Aliases: {aliases}\n"
98
+ f"Attributed techniques: {tb}\n"
99
+ f"Description: {a['description']}")
100
+
101
+
102
+ def t_actor_empty(name):
103
+ return ("Use only the MITRE ATT&CK record below to produce a red-team threat profile. "
104
+ "Do not add any actor, technique, or attribution not present in the record.\n\n"
105
+ "[MITRE ATT&CK Group lookup]\n"
106
+ f"Query: {name}\n"
107
+ "Result: no matching group found.")
108
+
109
+
110
+ def t_cve(c):
111
+ desc = c.get("nvd_description") or c.get("kev_description") or ""
112
+ cwes = ", ".join(c.get("cwes") or []) or "see description"
113
+ vp = f"{c.get('vendor') or ''} {c.get('product') or ''}".strip()
114
+ return ("Assess this CVE for offensive relevance using only the record below. "
115
+ "Do not add details not present.\n\n"
116
+ "[CVE Record]\n"
117
+ f"CVE: {c['cve_id']}\n"
118
+ f"Name: {c.get('name') or ''}\n"
119
+ f"Vendor/Product: {vp}\n"
120
+ f"Description: {desc}\n"
121
+ f"CVSS: {c.get('cvss') or ''}\n"
122
+ f"CWE: {cwes}\n"
123
+ f"KEV ransomware use: {c.get('ransomware_use') or ''}\n"
124
+ f"Required action: {c.get('required_action') or ''}")
125
+
126
+
127
+ def t_cve_empty(cid):
128
+ return ("Assess this CVE for offensive relevance using only the record below.\n\n"
129
+ "[CVE Record]\n"
130
+ f"CVE: {cid}\n"
131
+ "NVD lookup: no record found.\n"
132
+ "CISA KEV: not listed.")
133
+
134
+
135
+ def t_tech(t):
136
+ return ("Explain this MITRE ATT&CK technique and its offensive relevance. "
137
+ "Use only the record provided.\n\n"
138
+ "[MITRE ATT&CK Technique]\n"
139
+ f"ID: {t['attack_id']}\n"
140
+ f"Name: {t['name']}\n"
141
+ f"Tactics: {', '.join(t.get('tactics') or []) or 'not specified'}\n"
142
+ f"Platforms: {', '.join(t.get('platforms') or []) or 'not specified'}\n"
143
+ f"Description: {t['description']}")
144
+
145
+
146
+ def t_soft(s):
147
+ g = s.get("used_by_groups") or []
148
+ return ("Explain this malware/tool and identify which threat actors use it. "
149
+ "Use only the record provided.\n\n"
150
+ "[MITRE ATT&CK Software]\n"
151
+ f"ID: {s['attack_id']}\n"
152
+ f"Name: {s['name']}\n"
153
+ f"Type: {s.get('type') or ''}\n"
154
+ f"Platforms: {', '.join(s.get('platforms') or []) or 'not specified'}\n"
155
+ f"Description: {s['description']}\n"
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)
163
+
164
+
165
+ def _name_regex(names):
166
+ uniq = sorted({n for n in names if len(n) >= 3}, key=len, reverse=True)
167
+ return re.compile(r"(?<!\w)(" + "|".join(re.escape(n) for n in uniq) + r")(?!\w)", re.I)
168
+
169
+
170
+ @st.cache_resource(show_spinner=False)
171
+ def load_corpus():
172
+ c = json.loads(CORPUS_PATH.read_text(encoding="utf-8"))
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):
188
+ best = None
189
+ for m in rx.finditer(q):
190
+ if best is None or len(m.group(1)) > len(best):
191
+ best = m.group(1)
192
+ return best
193
+
194
+
195
+ def _guess_name(q):
196
+ m = re.search(r"APT[\s\-]?[\w-]+", q, re.I)
197
+ if m:
198
+ return m.group(0)
199
+ m = re.search(r"(?:actor|group|profile|about)\s+([A-Z][\w\- ]{2,40})", q)
200
+ if m:
201
+ return m.group(1).strip(" ?.")
202
+ caps = re.findall(r"\b[A-Z][\w-]+\b", q)
203
+ return " ".join(caps[:3]) if caps else q.strip()[:40]
204
+
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)
219
+ if m:
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)
234
+ s = _longest(soft_re, q)
235
+ if a or s:
236
+ if len(s or "") > len(a or ""):
237
+ p = t_soft(soft_idx[s.lower()]); return p, p, "MITRE ATT&CK Software", None
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
245
+ return None, None, None, HELP
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.")
262
+ if st.button("🗑️ Clear chat", use_container_width=True):
263
+ st.session_state.messages = []
264
+ st.rerun()
265
+
266
+ SUGGESTIONS = {
267
+ "🎯 Profile the actor APT28": "Profile the threat actor APT28",
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."),
275
+ }
276
+
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 👇")
284
+ cols = st.columns(2)
285
+ for i, (label, text) in enumerate(SUGGESTIONS.items()):
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()
corpus.json ADDED
The diff for this file is too large to render. See raw diff
 
examples.json DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "real_cve": "[CVE Record]\nCVE: CVE-2021-44228\nName: Apache Log4j2 Remote Code Execution Vulnerability\nVendor/Product: Apache Log4j2\nDescription: Apache Log4j2 contains a vulnerability where JNDI features do not protect against attacker-controlled JNDI-related endpoints, allowing for remote code execution.\nCISA KEV: listed (confirmed exploited in the wild)\nRequired action: For all affected software assets for which updates exist, the only acceptable remediation actions are: 1) Apply updates; OR 2) remove affected assets from agency networks. Temporary mitigations using one of the measures provided at https://www.cisa.gov/uscert/ed-22-02-apache-log4j-recommended-mitigation-measures are only acceptable until updates are available.\n\nAssess this CVE for offensive relevance.",
3
- "fake_cve": "[CVE Record]\nCVE: CVE-9999-987654\nNVD: this identifier is not indexed.\nKEV: not present.\n\nAssess this CVE for offensive relevance.",
4
- "real_actor": "[MITRE ATT&CK Group lookup]\nName: APT28 (G0007)\nAliases: APT28, IRON TWILIGHT, SNAKEMACKEREL, Swallowtail, Group 74, Sednit, Sofacy, Pawn Storm, Fancy Bear, STRONTIUM, Tsar Team, Threat Group-4127, TG-4127, Forest Blizzard, FROZENLAKE, GruesomeLarch\nAttributed techniques: T1001.001, T1003, T1003.001, T1003.003, T1005, T1014, T1021.002, T1025, T1027.013, T1030, T1036, T1036.005\nDescription: APT28 is a threat group that has been attributed to Russia's General Staff Main Intelligence Directorate (GRU) 85th Main Special Service Center (GTsSS) military unit 26165. This group has been active since at least 2004. APT28 reportedly compromised the Hillary Clinton campaign, the Democratic National Committee, and the Democratic Congressional Campaign Committee in 2016 in an attempt to interfere with the U.S. presidential election. In 2018, the US indicted five GRU Unit 26165 officers associated with APT28 for cyber operations (including close-access operations) conducted between 2014 and 2018 against the World Anti-Doping Agency (WADA), the US Anti-Doping Agency, a US nuclear facility, t\n\nProfile this threat actor and summarize how they operate.",
5
- "fake_actor": "[MITRE ATT&CK Group lookup]\nQuery: APT-Lyrebird-77\nResult: query returned no results.\n\nProfile this threat actor.",
6
- "raw_report": "This URL was reported to abuse.ch as a malware distribution point. Extract IOCs and explain its threat relevance:\n\nhxxps://wash8siteview.felo7wave[.]surf/software-distribution-dxnp2c7/meta-verify.index malware_download"
7
- }
 
 
 
 
 
 
 
 
src/streamlit_app.py DELETED
@@ -1,40 +0,0 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
4
- import streamlit as st
5
-
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))