update
Browse files- README.md +29 -27
- app.py +246 -111
- corpus.json +0 -0
- examples.json +0 -7
- src/streamlit_app.py +0 -40
README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 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 |
-
#
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
**refuses to fabricate**
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
##
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
| 25 |
```bash
|
| 26 |
-
hf upload
|
| 27 |
```
|
| 28 |
-
|
| 29 |
-
`HF_TOKEN` (Settings → Variables and secrets) with a read token.
|
| 30 |
|
| 31 |
## CPU vs GPU
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 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`
|
| 41 |
-
|
| 42 |
-
|
| 43 |
|
| 44 |
## Phase 2 (optional)
|
| 45 |
-
|
| 46 |
-
|
| 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 found — an 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 |
-
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
The
|
| 10 |
-
|
| 11 |
-
|
| 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")
|
| 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"
|
| 47 |
re.I,
|
| 48 |
)
|
| 49 |
|
| 50 |
-
|
| 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 |
-
|
|
|
|
| 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
|
| 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
|
| 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":
|
| 89 |
]
|
| 90 |
inputs = tok.apply_chat_template(
|
| 91 |
messages, tokenize=True, add_generation_prompt=True,
|
| 92 |
return_tensors="pt", return_dict=True,
|
| 93 |
)
|
| 94 |
-
|
| 95 |
-
|
| 96 |
with torch.no_grad():
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
st.caption(
|
| 117 |
-
"
|
| 118 |
-
"
|
| 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 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
| 173 |
else:
|
| 174 |
-
with st.
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
badge(
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|