grounded-sigma-compiler / inference.py
nemanisri's picture
Upload 4 files
d428d56 verified
Raw
History Blame Contribute Delete
5.96 kB
#!/usr/bin/env python3
"""Run the Grounded Sigma Compiler adapter and verify its output."""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
from mlx_lm import generate, load
DEFAULT_MODEL = "mlx-community/Qwen2.5-7B-Instruct-4bit"
SYSTEM_PROMPT = (
"You are a defensive Sigma rule compiler. Convert the supplied grounded "
"detection specification into one valid JSON object. Preserve the supplied "
"telemetry fields, values, condition, log source and ATT&CK mappings exactly. "
"Never invent, remove, reinterpret or replace detection information. Return "
"JSON only and set requires_validation to true."
)
SOURCE_FIELDS = (
"title",
"description",
"logsource",
"detection",
"attack_techniques",
"false_positives",
"severity",
)
OUTPUT_FIELDS = SOURCE_FIELDS + ("requires_validation",)
def parse_args() -> argparse.Namespace:
script_dir = Path(__file__).resolve().parent
parser = argparse.ArgumentParser(
description="Compile a grounded detection specification into Sigma JSON."
)
parser.add_argument("--input", required=True, type=Path, help="Input JSON file")
parser.add_argument(
"--adapter",
type=Path,
default=script_dir / "adapter",
help="Adapter directory (default: ./adapter beside this script)",
)
parser.add_argument("--model", default=DEFAULT_MODEL, help="Base MLX model")
parser.add_argument("--output", type=Path, help="Write verified JSON here")
parser.add_argument("--max-tokens", type=int, default=2048)
return parser.parse_args()
def load_spec(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"Cannot read valid JSON from {path}: {exc}") from exc
if not isinstance(value, dict):
raise ValueError("Input must be one JSON object.")
missing = [field for field in SOURCE_FIELDS if field not in value]
extra = sorted(set(value) - set(SOURCE_FIELDS))
if missing:
raise ValueError(f"Missing required fields: {', '.join(missing)}")
if extra:
raise ValueError(f"Unexpected input fields: {', '.join(extra)}")
if not isinstance(value["logsource"], dict) or not value["logsource"]:
raise ValueError("logsource must be a non-empty object.")
if not isinstance(value["detection"], dict) or not value["detection"]:
raise ValueError("detection must be a non-empty object.")
if not isinstance(value["attack_techniques"], list):
raise ValueError("attack_techniques must be an array.")
if not isinstance(value["false_positives"], list):
raise ValueError("false_positives must be an array.")
return value
def parse_generated_json(text: str) -> dict[str, Any]:
cleaned = text.strip()
fenced = re.fullmatch(r"```(?:json)?\s*(.*?)\s*```", cleaned, re.DOTALL)
if fenced:
cleaned = fenced.group(1)
decoder = json.JSONDecoder()
start = cleaned.find("{")
if start < 0:
raise ValueError("Model output does not contain a JSON object.")
try:
value, end = decoder.raw_decode(cleaned[start:])
except json.JSONDecodeError as exc:
raise ValueError(f"Model returned invalid JSON: {exc}") from exc
if cleaned[start + end :].strip():
raise ValueError("Model returned extra text after the JSON object.")
if not isinstance(value, dict):
raise ValueError("Model output must be one JSON object.")
return value
def verify_output(spec: dict[str, Any], output: dict[str, Any]) -> None:
missing = [field for field in OUTPUT_FIELDS if field not in output]
extra = sorted(set(output) - set(OUTPUT_FIELDS))
errors: list[str] = []
if missing:
errors.append(f"missing fields: {', '.join(missing)}")
if extra:
errors.append(f"unexpected fields: {', '.join(extra)}")
for field in SOURCE_FIELDS:
if field not in output:
continue
if field == "description":
if str(output[field]).strip() != str(spec[field]).strip():
errors.append("description changed")
elif output[field] != spec[field]:
errors.append(f"{field} changed")
if output.get("requires_validation") is not True:
errors.append("requires_validation is not true")
if errors:
raise ValueError("Untrusted model output rejected: " + "; ".join(errors))
def main() -> int:
args = parse_args()
try:
spec = load_spec(args.input)
model, tokenizer = load(args.model, adapter_path=str(args.adapter))
user_prompt = (
"Compile this grounded detection specification into Sigma JSON:\n"
+ json.dumps(spec, ensure_ascii=False, separators=(",", ":"))
)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
]
prompt = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
raw = generate(
model,
tokenizer,
prompt=prompt,
max_tokens=args.max_tokens,
verbose=False,
)
output = parse_generated_json(raw)
verify_output(spec, output)
rendered = json.dumps(output, ensure_ascii=False, indent=2) + "\n"
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(rendered, encoding="utf-8")
print(f"Verified output written to {args.output}", file=sys.stderr)
else:
print(rendered, end="")
return 0
except (ValueError, OSError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())