update
Browse files
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)
|
| 23 |
-
|
| 24 |
-
3.
|
|
|
|
| 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 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
(Llama-3.2-3B + LoRA
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 177 |
for s in c["software"]:
|
| 178 |
-
if len(s["name"]) >= 5:
|
| 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 |
-
|
| 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** (
|
| 209 |
-
"(T1059.001), **identify malware** (Cobalt Strike), or
|
| 210 |
-
"Pick a suggestion to see it."
|
| 211 |
)
|
| 212 |
|
| 213 |
|
| 214 |
def route(q):
|
| 215 |
-
"""Return (user_prompt, retrieved_record_or_None, source_label,
|
| 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, "
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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):
|
| 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):
|
| 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=
|
| 250 |
-
st.
|
| 251 |
-
|
| 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.
|
| 258 |
-
st.markdown(
|
| 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-
|
| 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=
|
| 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-
|
| 300 |
|
| 301 |
if user_input:
|
| 302 |
st.session_state.messages.append({"role": "user", "content": user_input})
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
answer = generate(prompt, max_new_tokens=max_tokens)
|
| 310 |
-
|
| 311 |
-
|
| 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
|