"""SigmaForge training utility Space (ZeroGPU). Fine-tunes a code LLM to generate and repair Sigma detection rules using the SigmaForge dataset. Training runs in bounded ZeroGPU chunks: each chunk loads the latest LoRA adapter from the Hub, trains on a dataset shard, and pushes the updated adapter back. A final merge step publishes the merged model. All heavy compute happens on Hugging Face hardware. """ import json import os import shutil import tempfile import traceback import gradio as gr import spaces import torch from datasets import concatenate_datasets, load_dataset from huggingface_hub import HfApi, snapshot_download BASE_MODEL = "Qwen/Qwen2.5-Coder-1.5B-Instruct" DATASET_REPO = "alirezaaminzadeh/sigmaforge-detection-rules" OUTPUT_REPO = "alirezaaminzadeh/sigmaforge-rule-generator" ADAPTER_PATH = "adapter" TOKEN = os.environ["HF_TOKEN"] SEED = 20260809 MAX_LEN = 1024 api = HfApi(token=TOKEN) SYSTEM_PROMPT = ( "You are SigmaForge, an expert detection engineering assistant. " "You write and repair Sigma detection rules as valid Sigma YAML. " "Respond with the YAML rule only, no extra commentary." ) def build_training_dataset(): """Assemble chat-formatted samples from the three dataset configs.""" samples = [] d1 = load_dataset(DATASET_REPO, "description_to_sigma", split="train", token=TOKEN) for row in d1: hints = ", ".join( f"{k}={row[f'logsource_{k}']}" for k in ("product", "category", "service") if row[f"logsource_{k}"] ) user = f"Write a Sigma rule for the following detection.\n\nDetection: {row['description']}" if hints: user += f"\nLog source: {hints}" if row["attack_techniques"]: user += f"\nMITRE ATT&CK: {', '.join(row['attack_techniques'])}" samples.append((user, row["sigma_rule"])) d3 = load_dataset(DATASET_REPO, "sigma_repair", split="train", token=TOKEN) for row in d3: user = ( "The following Sigma rule fails validation. Fix it and return the corrected rule.\n\n" f"Rule:\n{row['broken_rule']}\n\nValidation errors:\n- " + "\n- ".join(row["validation_errors"]) ) samples.append((user, row["corrected_rule"])) d2 = load_dataset(DATASET_REPO, "logs_to_sigma", split="train", token=TOKEN) for i, row in enumerate(d2): if i >= 1200: break user = ( "Write a Sigma rule that matches the positive events and not the negative events.\n\n" f"Positive events:\n{row['positive_events']}\n\nNegative events:\n{row['negative_events']}" ) samples.append((user, row["expected_rule"])) return samples def _state() -> dict: try: import requests url = f"https://huggingface.co/{OUTPUT_REPO}/raw/main/training_state.json" r = requests.get(url, headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30) if r.status_code == 200: return r.json() except Exception: pass return {"chunks_done": 0, "samples_seen": 0} def _save_state(state: dict) -> None: with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: json.dump(state, f, indent=2) path = f.name api.upload_file(path_or_fileobj=path, path_in_repo="training_state.json", repo_id=OUTPUT_REPO) def _estimate_duration(shard_index: int, steps: int) -> int: # model load + tokenization overhead, plus per-step training cost return min(280, 100 + int(steps * 1.2)) @spaces.GPU(duration=_estimate_duration) def _train_chunk_gpu(shard_index: int, steps: int): from peft import LoraConfig, PeftModel from transformers import AutoModelForCausalLM, AutoTokenizer from trl import SFTConfig, SFTTrainer from datasets import Dataset samples = build_training_dataset() ds = Dataset.from_list( [ { "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": u}, {"role": "assistant", "content": a}, ] } for u, a in samples ] ).shuffle(seed=SEED) per_device = 4 grad_accum = 2 shard_size = steps * per_device * grad_accum start = shard_index * shard_size shard = ds.select(range(start % len(ds), min(start % len(ds) + shard_size, len(ds)))) tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL) model = AutoModelForCausalLM.from_pretrained(BASE_MODEL, torch_dtype=torch.bfloat16) peft_config = None try: adapter_dir = snapshot_download(OUTPUT_REPO, allow_patterns=[f"{ADAPTER_PATH}/*"], token=TOKEN) adapter_dir = os.path.join(adapter_dir, ADAPTER_PATH) if os.path.exists(os.path.join(adapter_dir, "adapter_config.json")): model = PeftModel.from_pretrained(model, adapter_dir, is_trainable=True) print("Resumed adapter from Hub") else: raise FileNotFoundError except Exception: from peft import LoraConfig peft_config = LoraConfig( r=16, lora_alpha=32, lora_dropout=0.05, target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"], ) print("Starting fresh LoRA adapter") out_dir = tempfile.mkdtemp() trainer = SFTTrainer( model=model, train_dataset=shard, peft_config=peft_config, processing_class=tokenizer, args=SFTConfig( output_dir=out_dir, max_length=MAX_LEN, per_device_train_batch_size=per_device, gradient_accumulation_steps=grad_accum, learning_rate=1e-4, lr_scheduler_type="constant", warmup_steps=10 if shard_index == 0 else 0, logging_steps=10, max_steps=steps, bf16=True, report_to=[], save_strategy="no", seed=SEED + shard_index, ), ) trainer.train() loss = None for entry in reversed(trainer.state.log_history): if "loss" in entry or "train_loss" in entry: loss = entry.get("train_loss", entry.get("loss")) break save_dir = os.path.join(out_dir, "adapter_out") trainer.model.save_pretrained(save_dir) api.upload_folder(folder_path=save_dir, path_in_repo=ADAPTER_PATH, repo_id=OUTPUT_REPO) shutil.rmtree(out_dir, ignore_errors=True) return {"loss": loss, "trained_samples": len(shard), "total_samples": len(ds)} def train_chunk(steps: float) -> str: try: steps = int(steps) state = _state() result = _train_chunk_gpu(state["chunks_done"], steps) state["chunks_done"] += 1 state["samples_seen"] += result["trained_samples"] state["last_loss"] = result["loss"] state["total_samples"] = result["total_samples"] _save_state(state) return json.dumps({"ok": True, **state}, indent=2) except Exception: return json.dumps({"ok": False, "error": traceback.format_exc()}) @spaces.GPU(duration=180) def _merge_gpu(out_dir: str) -> None: from peft import PeftModel from transformers import AutoModelForCausalLM, AutoTokenizer adapter_dir = snapshot_download(OUTPUT_REPO, allow_patterns=[f"{ADAPTER_PATH}/*"], token=TOKEN) adapter_dir = os.path.join(adapter_dir, ADAPTER_PATH) model = AutoModelForCausalLM.from_pretrained(BASE_MODEL, torch_dtype=torch.bfloat16).to("cuda") model = PeftModel.from_pretrained(model, adapter_dir) model = model.merge_and_unload() model = model.to("cpu") tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL) model.save_pretrained(out_dir) tokenizer.save_pretrained(out_dir) def merge_and_push() -> str: """Merge the LoRA adapter into the base model (on GPU) and push.""" try: out = tempfile.mkdtemp() _merge_gpu(out) api.upload_folder(folder_path=out, path_in_repo=".", repo_id=OUTPUT_REPO) shutil.rmtree(out, ignore_errors=True) return json.dumps({"ok": True, "merged_to": OUTPUT_REPO}) except Exception: return json.dumps({"ok": False, "error": traceback.format_exc()}) def status() -> str: return json.dumps(_state(), indent=2) # --------------------------------------------------------------------------- # Evaluation # --------------------------------------------------------------------------- @spaces.GPU(duration=280) def _eval_gpu(n_samples: int) -> dict: import re import yaml from transformers import AutoModelForCausalLM, AutoTokenizer from sigma.collection import SigmaCollection from sigma.backends.splunk import SplunkBackend from sigma.backends.elasticsearch import LuceneBackend tokenizer = AutoTokenizer.from_pretrained(OUTPUT_REPO) model = AutoModelForCausalLM.from_pretrained(OUTPUT_REPO, torch_dtype=torch.bfloat16).to("cuda") model.eval() test = load_dataset(DATASET_REPO, "description_to_sigma", split="test", token=TOKEN) test = test.shuffle(seed=SEED).select(range(min(n_samples, len(test)))) backends = {"splunk": SplunkBackend(), "elastic": LuceneBackend()} stats = { "n": 0, "valid_yaml": 0, "schema_pass": 0, "compile_splunk": 0, "compile_elastic": 0, "attack_tp": 0, "attack_fp": 0, "attack_fn": 0, } def extract_yaml(text: str) -> str: m = re.search(r"```(?:yaml|yml)?\s*(.*?)```", text, re.DOTALL) return m.group(1).strip() if m else text.strip() for row in test: hints = ", ".join( f"{k}={row[f'logsource_{k}']}" for k in ("product", "category", "service") if row[f"logsource_{k}"] ) user = f"Write a Sigma rule for the following detection.\n\nDetection: {row['description']}" if hints: user += f"\nLog source: {hints}" messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user}, ] enc = tokenizer.apply_chat_template( messages, add_generation_prompt=True, return_tensors="pt", return_dict=True ).to(model.device) with torch.no_grad(): out = model.generate( **enc, max_new_tokens=600, do_sample=False, pad_token_id=tokenizer.eos_token_id ) prompt_len = enc["input_ids"].shape[1] rule = extract_yaml(tokenizer.decode(out[0][prompt_len:], skip_special_tokens=True)) stats["n"] += 1 try: doc = yaml.safe_load(rule) assert isinstance(doc, dict) stats["valid_yaml"] += 1 except Exception: continue schema_ok = all(k in doc for k in ("title", "logsource", "detection")) and isinstance( doc.get("detection"), dict ) and "condition" in doc["detection"] if schema_ok: stats["schema_pass"] += 1 for name, backend in backends.items(): try: backend.convert(SigmaCollection.from_yaml(rule)) stats[f"compile_{name}"] += 1 except Exception: pass pred = set() for tag in doc.get("tags") or []: m = re.match(r"attack\.(t\d{4}(?:\.\d{3})?)", str(tag), re.IGNORECASE) if m: pred.add(m.group(1).upper()) gold = set(row["attack_techniques"]) stats["attack_tp"] += len(pred & gold) stats["attack_fp"] += len(pred - gold) stats["attack_fn"] += len(gold - pred) n = max(stats["n"], 1) precision = stats["attack_tp"] / max(stats["attack_tp"] + stats["attack_fp"], 1) recall = stats["attack_tp"] / max(stats["attack_tp"] + stats["attack_fn"], 1) f1 = 2 * precision * recall / max(precision + recall, 1e-9) return { "samples": stats["n"], "valid_yaml_rate": round(stats["valid_yaml"] / n, 4), "schema_pass_rate": round(stats["schema_pass"] / n, 4), "splunk_compilation_rate": round(stats["compile_splunk"] / n, 4), "elastic_compilation_rate": round(stats["compile_elastic"] / n, 4), "attack_mapping_precision": round(precision, 4), "attack_mapping_recall": round(recall, 4), "attack_mapping_f1": round(f1, 4), } def evaluate(n_samples: float) -> str: try: results = _eval_gpu(int(n_samples)) with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: json.dump(results, f, indent=2) path = f.name api.upload_file(path_or_fileobj=path, path_in_repo="eval_results.json", repo_id=OUTPUT_REPO) return json.dumps({"ok": True, **results}, indent=2) except Exception: return json.dumps({"ok": False, "error": traceback.format_exc()}) with gr.Blocks(title="SigmaForge Trainer") as demo: gr.Markdown("# SigmaForge Trainer\nInternal training utility (ZeroGPU chunked SFT).") steps_in = gr.Number(value=150, label="Steps for this chunk") out = gr.Textbox(label="Result", lines=14) gr.Button("Train chunk").click(train_chunk, inputs=steps_in, outputs=out, api_name="train_chunk") gr.Button("Merge and push").click(merge_and_push, outputs=out, api_name="merge_and_push") gr.Button("Status").click(status, outputs=out, api_name="status") n_eval = gr.Number(value=60, label="Eval samples") gr.Button("Evaluate").click(evaluate, inputs=n_eval, outputs=out, api_name="evaluate") demo.launch()