import json import re from pathlib import Path import gradio as gr import torch from transformers import AutoTokenizer from granite_switch.hf import GraniteSwitchForCausalLM MODEL_ID = "barha/granite-switch-4.0-350m-cti" ADAPTER_NAME = "cti-technique-mapping" # The adapter was trained on CTI sentences wrapped in this exact instruction # format (see the ETL: USER_PROMPT + the sentence inside ... tags). # Sending bare CTI text instead makes the model fall back to base behavior and # emit garbage (e.g. repeated ), so we must reproduce the format here. USER_PROMPT = "What ATT&CK technique does the following CTI procedure sentence describe?" # MITRE ATT&CK Enterprise technique ID -> human-readable name. Built from the # official mitre-attack/attack-stix-data Enterprise bundle (697 techniques + # sub-techniques), bundled as a static file so the Space needs no network at # inference and resolves any ID the adapter emits. _ID_TO_NAME = json.loads((Path(__file__).parent / "mitre_id_to_name.json").read_text()) # Match a MITRE technique ID anywhere in a string, e.g. T1059 or T1059.001. _TID_RE = re.compile(r"\bT\d{4}(?:\.\d{3})?\b") # Load the model and tokenizer once at startup. The CTI adapter is activated by # the <|cti-technique-mapping|> control token. The chat template only inserts # that token when `adapter_name` is passed to apply_chat_template — the default # render leaves it out, giving the plain base model. We run BOTH below. tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) # No device_map: the Space runs on single-device CPU (cpu-basic), and # device_map="auto" crashes here because accelerate's check_device_map can't # place the non-float `model.adapter_token_ids` buffer. Plain load → CPU. model = GraniteSwitchForCausalLM.from_pretrained( MODEL_ID, torch_dtype=torch.float32, ) model.eval() EXAMPLES = [ "The actor used PowerShell to download and execute a payload from a remote server.", "The malware created a scheduled task to maintain persistence across reboots.", "The threat actor dumped credentials from LSASS memory using a custom tool.", "The adversary encrypted files on the victim host and dropped a ransom note.", "The implant communicated with its command-and-control server over HTTPS on port 443.", ] def _generate(cti_text: str, use_adapter: bool, max_new_tokens: int) -> str: """Run one forward pass, with or without firing the CTI adapter.""" user_content = f"{USER_PROMPT}\n\n\n{cti_text}\n" messages = [{"role": "user", "content": user_content}] # Passing adapter_name fires the <|cti-technique-mapping|> control token in # the chat template; omitting it renders the plain prompt → base model. template_kwargs = {"adapter_name": ADAPTER_NAME} if use_adapter else {} enc = tokenizer.apply_chat_template( messages, add_generation_prompt=True, return_tensors="pt", return_dict=True, **template_kwargs, ).to(model.device) prompt_len = enc["input_ids"].shape[1] with torch.no_grad(): out = model.generate(**enc, max_new_tokens=max_new_tokens, do_sample=False) return tokenizer.decode(out[0, prompt_len:], skip_special_tokens=True).strip() def _resolve_name(technique_id: str) -> str: """Look up the MITRE name for an ID; '' if not a known technique.""" return _ID_TO_NAME.get(technique_id, "") def compare(cti_text: str): """Run the base model and the adapter on the same input, side by side. Returns (base_output, adapter_output) markdown strings. """ cti_text = (cti_text or "").strip() if not cti_text: msg = "_Enter some CTI text describing adversary behavior._" return msg, msg # Base model: no adapter. It tends to answer in prose, not a clean ID — give # it room to do so. This is the "before" half of the demo. base_raw = _generate(cti_text, use_adapter=False, max_new_tokens=48) base_md = base_raw or "_(no output)_" # Adapter: fires the control token; trained to emit exactly one technique ID. adapter_raw = _generate(cti_text, use_adapter=True, max_new_tokens=16) match = _TID_RE.search(adapter_raw) if match: tid = match.group(0) name = _resolve_name(tid) if name: adapter_md = f"### `{tid}`\n**{name}**" else: adapter_md = f"### `{tid}`\n_(name not in MITRE map)_" else: adapter_md = f"_(no technique ID returned)_\n\n```\n{adapter_raw}\n```" return base_md, adapter_md with gr.Blocks(title="Granite Switch · CTI Technique Mapping") as demo: gr.Markdown( """ # 🛡️ Granite Switch 4.0 350M — CTI Technique Mapping The **same base model** answers a **cyber threat intelligence (CTI)** prompt two ways. Without the adapter it rambles in prose; flip on the embedded **`cti-technique-mapping`** LoRA (via a single control token) and it emits the one matching **MITRE ATT&CK** technique ID — which we resolve to its name. Model: [`barha/granite-switch-4.0-350m-cti`](https://huggingface.co/barha/granite-switch-4.0-350m-cti) · Base: `ibm-granite/granite-4.0-350m` · Adapter: `cti-technique-mapping` (LoRA, attention + MLP) """ ) cti_input = gr.Textbox( label="CTI text", placeholder="Describe the observed adversary behavior…", lines=4, ) submit = gr.Button("Compare base vs adapter", variant="primary") with gr.Row(): with gr.Column(): gr.Markdown("#### 🟡 Base model (adapter OFF)") base_out = gr.Markdown() with gr.Column(): gr.Markdown("#### 🟢 Granite Switch (adapter ON)") adapter_out = gr.Markdown() gr.Examples( examples=[[e] for e in EXAMPLES], inputs=cti_input, outputs=[base_out, adapter_out], fn=compare, cache_examples=False, ) submit.click(compare, inputs=cti_input, outputs=[base_out, adapter_out]) cti_input.submit(compare, inputs=cti_input, outputs=[base_out, adapter_out]) if __name__ == "__main__": demo.launch()