ti2dac 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. ti2dac/__init__.py +3 -0
  2. ti2dac/aistatus.py +169 -0
  3. ti2dac/attack.py +169 -0
  4. ti2dac/chains.py +211 -0
  5. ti2dac/cli.py +951 -0
  6. ti2dac/clients.py +149 -0
  7. ti2dac/condense.py +228 -0
  8. ti2dac/config.py +320 -0
  9. ti2dac/correlate.py +97 -0
  10. ti2dac/data/attack_index.json +1 -0
  11. ti2dac/data/detections-ci.yml +42 -0
  12. ti2dac/data/wazuh_parents.json +133 -0
  13. ti2dac/duplicates.py +126 -0
  14. ti2dac/feedback.py +369 -0
  15. ti2dac/health.py +47 -0
  16. ti2dac/ingest.py +325 -0
  17. ti2dac/iocs.py +293 -0
  18. ti2dac/llm.py +598 -0
  19. ti2dac/locks.py +34 -0
  20. ti2dac/modelchoice.py +162 -0
  21. ti2dac/models.py +422 -0
  22. ti2dac/obs.py +131 -0
  23. ti2dac/pipeline.py +1025 -0
  24. ti2dac/prompts.py +183 -0
  25. ti2dac/quality.py +79 -0
  26. ti2dac/redact.py +279 -0
  27. ti2dac/regexes.py +193 -0
  28. ti2dac/render.py +664 -0
  29. ti2dac/replay.py +302 -0
  30. ti2dac/review.py +328 -0
  31. ti2dac/safety.py +213 -0
  32. ti2dac/sigma_tools.py +586 -0
  33. ti2dac/targets.py +384 -0
  34. ti2dac/users.py +99 -0
  35. ti2dac/wazuh.py +301 -0
  36. ti2dac/web/__init__.py +0 -0
  37. ti2dac/web/app.py +793 -0
  38. ti2dac/web/jobs.py +358 -0
  39. ti2dac/web/static/app.css +631 -0
  40. ti2dac/web/static/app.js +276 -0
  41. ti2dac/web/static/chevron.svg +1 -0
  42. ti2dac/web/static/favicon.svg +1 -0
  43. ti2dac/web/templates/_ui.html +107 -0
  44. ti2dac/web/templates/ai.html +207 -0
  45. ti2dac/web/templates/api.html +93 -0
  46. ti2dac/web/templates/base.html +74 -0
  47. ti2dac/web/templates/feedback.html +100 -0
  48. ti2dac/web/templates/index.html +196 -0
  49. ti2dac/web/templates/job.html +51 -0
  50. ti2dac/web/templates/run.html +514 -0
  51. ti2dac-0.1.0.dist-info/METADATA +180 -0
  52. ti2dac-0.1.0.dist-info/RECORD +56 -0
  53. ti2dac-0.1.0.dist-info/WHEEL +5 -0
  54. ti2dac-0.1.0.dist-info/entry_points.txt +2 -0
  55. ti2dac-0.1.0.dist-info/licenses/LICENSE +21 -0
  56. ti2dac-0.1.0.dist-info/top_level.txt +1 -0
ti2dac/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ti2dac - Threat Intelligence to Detection-as-Code."""
2
+
3
+ __version__ = "0.1.0"
ti2dac/aistatus.py ADDED
@@ -0,0 +1,169 @@
1
+ """AI provider health: limit detection, circuit breaker and request counting.
2
+
3
+ Shared by the CLI and the web UI through <state_dir>/ai_status.json, so that once a provider says
4
+ "quota exhausted" (or our own daily cap is reached) NO process sends further requests until the reset
5
+ time. Hammering a provider after it refused is wasteful and can look like abuse to the provider.
6
+
7
+ States per provider:
8
+ ok last call succeeded
9
+ rate_limited short-term limit (per-minute); calls resume after retry_at
10
+ quota_exhausted daily/monthly quota, credit or spend limit; calls blocked until retry_at (or manual reset)
11
+ daily_cap our own `ai.daily_request_limit` reached; resets at local midnight
12
+ auth_error key missing/invalid; blocked until reset
13
+ refused the model declined a request (informational; does not block)
14
+ unavailable provider down/overloaded after retries (informational; does not block)
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from datetime import datetime, timedelta, timezone
21
+ from pathlib import Path
22
+
23
+ from .locks import file_lock
24
+
25
+ BLOCKING = {"rate_limited", "quota_exhausted", "daily_cap", "auth_error"}
26
+ DAILY_COUNTERS = ("requests_today", "input_tokens_today", "output_tokens_today", "cache_read_tokens_today", "cost_today_usd")
27
+ TOTAL_COUNTERS = ("input_tokens_total", "output_tokens_total", "cost_total_usd")
28
+
29
+
30
+ def _now() -> datetime:
31
+ return datetime.now(timezone.utc)
32
+
33
+
34
+ def _path(state_dir: str | Path) -> Path:
35
+ return Path(state_dir) / "ai_status.json"
36
+
37
+
38
+ def _locked(state_dir: str | Path):
39
+ """Read-modify-write of ai_status.json: the server, its job workers and the CLI all count requests."""
40
+ return file_lock(Path(state_dir) / "ai_status.lock")
41
+
42
+
43
+ def load(state_dir: str | Path) -> dict:
44
+ try:
45
+ return json.loads(_path(state_dir).read_text())
46
+ except (OSError, ValueError):
47
+ return {}
48
+
49
+
50
+ def _save(state_dir: str | Path, data: dict) -> None:
51
+ p = _path(state_dir)
52
+ p.parent.mkdir(parents=True, exist_ok=True)
53
+ tmp = p.with_suffix(f".{os.getpid()}.tmp")
54
+ tmp.write_text(json.dumps(data, indent=1))
55
+ tmp.replace(p)
56
+
57
+
58
+ def provider_status(state_dir: str | Path, provider: str) -> dict:
59
+ s = load(state_dir).get(provider, {})
60
+ today = datetime.now().date().isoformat()
61
+ if s.get("day") != today:
62
+ s = {**s, "day": today, **{k: 0 for k in DAILY_COUNTERS}}
63
+ if s.get("state") == "daily_cap":
64
+ s["state"] = "ok"
65
+ return s
66
+
67
+
68
+ def update(state_dir: str | Path, provider: str, **fields) -> dict:
69
+ with _locked(state_dir):
70
+ data = load(state_dir)
71
+ cur = provider_status(state_dir, provider) if provider in data else {
72
+ "day": datetime.now().date().isoformat(), "requests_today": 0}
73
+ cur.update(fields)
74
+ cur["updated_at"] = _now().isoformat(timespec="seconds")
75
+ data[provider] = cur
76
+ _save(state_dir, data)
77
+ return cur
78
+
79
+
80
+ def count_request(state_dir: str | Path, provider: str) -> int:
81
+ with _locked(state_dir):
82
+ data = load(state_dir)
83
+ cur = provider_status(state_dir, provider)
84
+ cur["requests_today"] = int(cur.get("requests_today", 0)) + 1
85
+ data[provider] = cur
86
+ _save(state_dir, data)
87
+ return cur["requests_today"]
88
+
89
+
90
+ def add_usage(state_dir: str | Path, provider: str, input_tokens: int, output_tokens: int,
91
+ cache_read_tokens: int, cost_usd: float) -> None:
92
+ """Token accounting per provider: today (resets at local midnight) and since the state dir was created."""
93
+ with _locked(state_dir):
94
+ data = load(state_dir)
95
+ cur = provider_status(state_dir, provider)
96
+ for key, val in (("input_tokens", input_tokens), ("output_tokens", output_tokens)):
97
+ cur[f"{key}_today"] = int(cur.get(f"{key}_today", 0)) + val
98
+ cur[f"{key}_total"] = int(cur.get(f"{key}_total", 0)) + val
99
+ cur["cache_read_tokens_today"] = int(cur.get("cache_read_tokens_today", 0)) + cache_read_tokens
100
+ cur["cost_today_usd"] = round(float(cur.get("cost_today_usd", 0)) + cost_usd, 4)
101
+ cur["cost_total_usd"] = round(float(cur.get("cost_total_usd", 0)) + cost_usd, 4)
102
+ data[provider] = cur
103
+ _save(state_dir, data)
104
+
105
+
106
+ def record_ok(state_dir: str | Path, provider: str) -> None:
107
+ update(state_dir, provider, state="ok", message="", retry_at="", last_success=_now().isoformat(timespec="seconds"))
108
+
109
+
110
+ def record_block(state_dir: str | Path, provider: str, state: str, message: str, retry_after_s: float | None) -> None:
111
+ retry_at = (_now() + timedelta(seconds=retry_after_s)).isoformat(timespec="seconds") if retry_after_s else ""
112
+ update(state_dir, provider, state=state, message=message[:500], retry_at=retry_at)
113
+
114
+
115
+ def reset(state_dir: str | Path, provider: str | None = None) -> None:
116
+ with _locked(state_dir):
117
+ data = load(state_dir)
118
+ for name in ([provider] if provider else list(data)):
119
+ if name in data:
120
+ data[name].update(state="ok", message="", retry_at="")
121
+ _save(state_dir, data)
122
+
123
+
124
+ def block_reason(state_dir: str | Path, provider: str, daily_limit: int = 0) -> str:
125
+ """Why calls to `provider` are blocked right now ('' = allowed). Never contacts the provider."""
126
+ s = provider_status(state_dir, provider)
127
+ state = s.get("state", "ok")
128
+ if daily_limit and int(s.get("requests_today", 0)) >= daily_limit:
129
+ return (f"{provider}: daily request cap reached ({s.get('requests_today')}/{daily_limit}, "
130
+ "ai.daily_request_limit). Resets at local midnight, or raise the cap.")
131
+ if state not in BLOCKING:
132
+ return ""
133
+ retry_at = s.get("retry_at", "")
134
+ if retry_at:
135
+ try:
136
+ if datetime.fromisoformat(retry_at) <= _now():
137
+ return "" # the provider's own reset time has passed
138
+ except ValueError:
139
+ pass
140
+ elif state == "rate_limited":
141
+ return ""
142
+ when = f" Retry after {retry_at} (UTC)." if retry_at else " Fix the cause, then reset with `ti2dac ai-status --reset` or the AI page."
143
+ return f"{provider}: {state.replace('_', ' ')}. {s.get('message', '')}".strip() + when
144
+
145
+
146
+ def human_wait(retry_at: str) -> str:
147
+ try:
148
+ secs = (datetime.fromisoformat(retry_at) - _now()).total_seconds()
149
+ except (ValueError, TypeError):
150
+ return ""
151
+ if secs <= 0:
152
+ return "now"
153
+ h, m = int(secs // 3600), int(secs % 3600 // 60)
154
+ return f"{h}h {m}m" if h else f"{m}m"
155
+
156
+
157
+ def key_status(provider: str) -> str:
158
+ """Where credentials would come from (never the key itself)."""
159
+ if provider == "gemini":
160
+ for var in ("GEMINI_API_KEY", "GOOGLE_API_KEY"):
161
+ if os.environ.get(var):
162
+ return f"{var} is set"
163
+ return "not configured"
164
+ for var in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"):
165
+ if os.environ.get(var):
166
+ return f"{var} is set"
167
+ if os.environ.get("ANTHROPIC_PROFILE") or (Path.home() / ".config" / "anthropic").is_dir():
168
+ return "`ant auth login` profile found"
169
+ return "not configured"
ti2dac/attack.py ADDED
@@ -0,0 +1,169 @@
1
+ """MITRE ATT&CK lookups, mapping validation and Navigator layer export.
2
+
3
+ The full STIX bundle is ~55 MB; we ship a compact index (id -> name/tactics/flags) built
4
+ from it. Refresh with ``ti2dac attack-update`` (writes to the state dir, which overrides
5
+ the bundled copy).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from functools import lru_cache
11
+ from pathlib import Path
12
+
13
+ import httpx
14
+
15
+ STIX_URL = "https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/enterprise-attack/enterprise-attack.json"
16
+ _BUNDLED = Path(__file__).parent / "data" / "attack_index.json"
17
+ _OVERRIDE: Path | None = None
18
+
19
+
20
+ def set_override_dir(state_dir: str | Path) -> None:
21
+ global _OVERRIDE
22
+ _OVERRIDE = Path(state_dir) / "attack_index.json"
23
+ load_index.cache_clear()
24
+ _successor_map.cache_clear()
25
+
26
+
27
+ def build_index(stix: dict) -> dict:
28
+ techniques: dict[str, dict] = {}
29
+ version = ""
30
+ stix_ids: dict[str, str] = {} # STIX id -> ATT&CK id, for revoked-by relationships
31
+ revoked_by: dict[str, str] = {} # STIX id -> STIX id
32
+ for obj in stix.get("objects", []):
33
+ if obj.get("type") == "relationship" and obj.get("relationship_type") == "revoked-by":
34
+ revoked_by[obj.get("source_ref", "")] = obj.get("target_ref", "")
35
+ if obj.get("type") == "x-mitre-collection":
36
+ version = obj.get("x_mitre_version", version)
37
+ if obj.get("type") != "attack-pattern":
38
+ continue
39
+ ext = next((r for r in obj.get("external_references", []) if r.get("source_name") == "mitre-attack"), None)
40
+ if not ext:
41
+ continue
42
+ stix_ids[obj.get("id", "")] = ext["external_id"]
43
+ techniques[ext["external_id"]] = {
44
+ "name": obj.get("name", ""),
45
+ "tactics": [p["phase_name"] for p in obj.get("kill_chain_phases", []) if p.get("kill_chain_name") == "mitre-attack"],
46
+ "platforms": obj.get("x_mitre_platforms", []),
47
+ "deprecated": bool(obj.get("x_mitre_deprecated")),
48
+ "revoked": bool(obj.get("revoked")),
49
+ }
50
+ for src, dst in revoked_by.items():
51
+ if src in stix_ids and dst in stix_ids:
52
+ techniques[stix_ids[src]]["revoked_by"] = stix_ids[dst]
53
+ return {"attack_version": version, "techniques": techniques}
54
+
55
+
56
+ def update_index(dest_dir: str | Path, source: str | None = None) -> Path:
57
+ if source and Path(source).is_file():
58
+ stix = json.loads(Path(source).read_text())
59
+ else:
60
+ r = httpx.get(source or STIX_URL, timeout=120, follow_redirects=True)
61
+ r.raise_for_status()
62
+ stix = r.json()
63
+ dest = Path(dest_dir) / "attack_index.json"
64
+ dest.parent.mkdir(parents=True, exist_ok=True)
65
+ dest.write_text(json.dumps(build_index(stix), separators=(",", ":"), sort_keys=True))
66
+ load_index.cache_clear()
67
+ _successor_map.cache_clear()
68
+ return dest
69
+
70
+
71
+ @lru_cache(maxsize=1)
72
+ def load_index() -> dict:
73
+ for p in (_OVERRIDE, _BUNDLED):
74
+ if p and p.is_file():
75
+ return json.loads(p.read_text())
76
+ return {"attack_version": "", "techniques": {}}
77
+
78
+
79
+ def lookup(tid: str) -> dict | None:
80
+ return load_index()["techniques"].get(tid.upper())
81
+
82
+
83
+ def resolve_revoked(tid: str) -> tuple[str, str]:
84
+ """(current id, note) for a revoked technique: its revoked-by successor, or else the one live technique with the
85
+ same name (ATT&CK v20 moved e.g. T1562.001 "Disable or Modify Tools" to T1685 under defense-impairment).
86
+ Unchanged id and '' when the technique is live, unknown, or has no clear successor."""
87
+ idx = load_index()["techniques"]
88
+ t = idx.get(tid.upper())
89
+ if not t or not t.get("revoked"):
90
+ return tid, ""
91
+ new = t.get("revoked_by")
92
+ if not (new and new in idx and not idx[new]["revoked"] and not idx[new]["deprecated"]):
93
+ same = [k for k, v in idx.items() if k != tid.upper() and not v["revoked"] and not v["deprecated"]
94
+ and v["name"].lower() == t["name"].lower()]
95
+ new = same[0] if len(same) == 1 else None
96
+ if not new:
97
+ return tid, ""
98
+ return new, f"{tid} is revoked in ATT&CK; replaced by {new} ({idx[new]['name']})"
99
+
100
+
101
+ def predecessors(tid: str) -> list[str]:
102
+ """Revoked techniques whose successor is `tid` (T1685 -> ['T1562.001']): lessons and history recorded under
103
+ the old id still apply to the new one."""
104
+ return _successor_map().get(tid.upper(), [])
105
+
106
+
107
+ @lru_cache(maxsize=1)
108
+ def _successor_map() -> dict[str, list[str]]:
109
+ out: dict[str, list[str]] = {}
110
+ for k, v in load_index()["techniques"].items():
111
+ if v.get("revoked"):
112
+ new, _ = resolve_revoked(k)
113
+ if new != k:
114
+ out.setdefault(new, []).append(k)
115
+ return out
116
+
117
+
118
+ def validate_mapping(tid: str, name: str, tactic: str) -> tuple[bool, str, str, str]:
119
+ """Returns (valid, note, corrected_name, corrected_tactic)."""
120
+ idx = load_index()["techniques"]
121
+ if not idx:
122
+ return True, "ATT&CK index unavailable; mapping not verified", name, tactic
123
+ t = idx.get(tid.upper())
124
+ if t is None:
125
+ parent = idx.get(tid.split(".")[0].upper())
126
+ if parent:
127
+ return False, f"{tid} does not exist; parent {tid.split('.')[0]} ({parent['name']}) does", name, tactic
128
+ return False, f"{tid} is not an ATT&CK Enterprise technique", name, tactic
129
+ notes = []
130
+ if t["revoked"] or t["deprecated"]:
131
+ notes.append(f"{tid} is {'revoked' if t['revoked'] else 'deprecated'} in ATT&CK")
132
+ if tactic not in t["tactics"] and t["tactics"]:
133
+ notes.append(f"tactic '{tactic}' not listed for {tid}; using '{t['tactics'][0]}'")
134
+ tactic = t["tactics"][0]
135
+ if t["name"].lower() not in name.lower():
136
+ name = t["name"]
137
+ return not (t["revoked"] or t["deprecated"]), "; ".join(notes), name, tactic
138
+
139
+
140
+ def navigator_layer(name: str, description: str, techniques: list[dict]) -> dict:
141
+ """techniques: [{id, tactic, comment, score}]"""
142
+ return {
143
+ "name": name[:120],
144
+ "versions": {"attack": load_index().get("attack_version") or "17", "navigator": "5.1.0", "layer": "4.5"},
145
+ "domain": "enterprise-attack",
146
+ "description": description[:500],
147
+ "sorting": 3,
148
+ "hideDisabled": False,
149
+ "techniques": [
150
+ {
151
+ "techniqueID": t["id"],
152
+ "tactic": t["tactic"],
153
+ "score": t.get("score", 1),
154
+ "color": "",
155
+ "comment": t.get("comment", ""),
156
+ "enabled": True,
157
+ "showSubtechniques": True,
158
+ }
159
+ for t in techniques
160
+ ],
161
+ "gradient": {"colors": ["#ffe766", "#ff6666"], "minValue": 0, "maxValue": 3},
162
+ "legendItems": [
163
+ {"label": "Detection generated (draft)", "color": "#ffe766"},
164
+ {"label": "Detection validated in lab", "color": "#ff6666"},
165
+ ],
166
+ "showTacticRowBackground": True,
167
+ "tacticRowBackground": "#205b8f",
168
+ "selectSubtechniquesWithParent": False,
169
+ }
ti2dac/chains.py ADDED
@@ -0,0 +1,211 @@
1
+ """Multi-event detections ("brute force, then a successful logon, then a new scheduled task within 10 minutes").
2
+
3
+ Language models are reliable at single-event logic and unreliable at stateful joins, so the join is never
4
+ written by the model: each step is an already generated (and validated) single-event rule, and this module
5
+ joins them with a Sigma correlation rule (temporal / temporal_ordered) and, where a pySigma backend cannot
6
+ convert that, with a fixed per-language template (KQL union + summarize, EQL sequence).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+
12
+ from . import sigma_tools as st
13
+ from .correlate import HOST_COLUMN, KQL_TARGETS
14
+ from .models import ChainPackage, DetectionPackage, QueryResult
15
+ from .targets import TARGETS, make_backend
16
+
17
+ _TIME_COLUMN = {"defender_xdr": "Timestamp"}
18
+
19
+
20
+ def join_field(base_doc: dict, join_on: str) -> str:
21
+ """The Sigma field carrying the host / user / source IP for a rule's log source."""
22
+ ls = base_doc.get("logsource") or {}
23
+ product, category, service = ls.get("product", ""), ls.get("category", ""), ls.get("service", "")
24
+ if join_on == "host":
25
+ return "Computer" if product == "windows" else "Hostname"
26
+ if join_on == "user":
27
+ if product == "windows" and service in ("security", "system"):
28
+ return "TargetUserName"
29
+ return "User"
30
+ if category == "firewall":
31
+ return "src_ip"
32
+ if category in ("webserver", "proxy"):
33
+ return "c-ip"
34
+ if product == "windows" and service == "security":
35
+ return "IpAddress"
36
+ return "SourceIp"
37
+
38
+
39
+ def _name(slug: str) -> str:
40
+ return "ti2dac_" + slug.replace("-", "_")
41
+
42
+
43
+ def build_yaml(steps: list[DetectionPackage], *, name: str, ordered: bool, within: str, join_on: str,
44
+ author: str) -> tuple[str, list[dict], list[str], list[str]]:
45
+ """(rule YAML, step base docs, step join fields, warnings)."""
46
+ docs, fields, warnings = [], [], []
47
+ for p in steps:
48
+ base = dict(st.first_doc(p.rule_yaml))
49
+ if len(st.load_docs(p.rule_yaml)) > 1:
50
+ warnings.append(f"{p.slug} is a threshold rule; the chain uses its single event, not its threshold")
51
+ base["name"] = _name(p.slug)
52
+ base["level"] = "informational" # each step alone is context; the chain is the alert
53
+ docs.append(base)
54
+ fields.append(join_field(base, join_on))
55
+ key = "|".join(p.rule_id for p in steps) + f"|{within}|{join_on}|{ordered}"
56
+ corr = {
57
+ "title": f"{name} (sequence)",
58
+ "id": str(uuid.uuid5(st.RULE_NAMESPACE, "chain:" + key)),
59
+ "status": "experimental",
60
+ "description": f"{' then '.join(p.draft.sigma.title for p in steps)}, on the same {join_on.replace('_', ' ')} "
61
+ f"within {within}. Built by ti2dac from separately validated rules.",
62
+ "author": author,
63
+ "tags": sorted({t for d in docs for t in d.get("tags") or []}),
64
+ "correlation": {
65
+ "type": "temporal_ordered" if ordered else "temporal",
66
+ "rules": [d["name"] for d in docs],
67
+ "group-by": ["ti2dac_join"],
68
+ "timespan": within,
69
+ "aliases": {"ti2dac_join": {d["name"]: f for d, f in zip(docs, fields)}},
70
+ },
71
+ "falsepositives": ["Each step alone is common; review the combination"],
72
+ "level": "high",
73
+ }
74
+ return st.dump_docs([*docs, corr]), docs, fields, warnings
75
+
76
+
77
+ def _kql(target: str, step_queries: list[str], keys: list[str], ordered: bool, within: str) -> tuple[str, str]:
78
+ t = _TIME_COLUMN.get(target, "TimeGenerated")
79
+ lets = [f"let Step{i} = {q}\n| extend JoinKey = tostring({k}), Step = {i};"
80
+ for i, (q, k) in enumerate(zip(step_queries, keys), 1)]
81
+ n = len(step_queries)
82
+ aggs = ["Steps = dcount(Step)", f"FirstSeen = min({t})", f"LastSeen = max({t})"]
83
+ where = [f"Steps == {n}"]
84
+ note = "Built by ti2dac (deterministic template, not AI). Fixed time bins: run every half-window."
85
+ if ordered:
86
+ aggs += [f"First{i} = minif({t}, Step == {i}), Last{i} = maxif({t}, Step == {i})" for i in range(1, n + 1)]
87
+ where += [f"First{i} <= Last{i + 1}" for i in range(1, n)]
88
+ note += " Order check is approximate (the first step i is before the last step i+1)."
89
+ q = "\n".join(lets) + f"\nunion {', '.join(f'Step{i}' for i in range(1, n + 1))}\n" \
90
+ f"| summarize {', '.join(aggs)} by JoinKey, bin({t}, {within})\n| where {' and '.join(where)}"
91
+ return q, note
92
+
93
+
94
+ def _spl(step_queries: list[str], keys: list[str], ordered: bool, within: str) -> tuple[str, str]:
95
+ subs = "\n".join(f" [search {q} | eval step={i}, join_key={k}]" for i, (q, k) in enumerate(zip(step_queries, keys), 1))
96
+ n = len(step_queries)
97
+ aggs = ["dc(step) as steps", "min(_time) as first_seen", "max(_time) as last_seen"]
98
+ where = [f"steps={n}"]
99
+ note = "Built by ti2dac (deterministic template, not AI). Fixed time bins: run every half-window."
100
+ if ordered:
101
+ aggs += [f"min(eval(if(step={i}, _time, null()))) as first{i} max(eval(if(step={i}, _time, null()))) as last{i}"
102
+ for i in range(1, n + 1)]
103
+ where += [f"first{i}<=last{i + 1}" for i in range(1, n)]
104
+ note += " Order check is approximate (the first step i is before the last step i+1)."
105
+ q = (f"| multisearch\n{subs}\n| bin _time span={within}\n| stats {' '.join(aggs)} by join_key _time\n"
106
+ f"| where {' AND '.join(where)}")
107
+ return q, note
108
+
109
+
110
+ def _esql(step_queries: list[str], keys: list[str], ordered: bool, within: str) -> tuple[str, str]:
111
+ import re
112
+
113
+ conds, sources = [], []
114
+ for q in step_queries:
115
+ m = re.fullmatch(r"(?s)\s*from\s+(\S+)(?:\s+metadata\s+[\w, ]+?)?\s*\|\s*where\s+(.+)", q)
116
+ if not m or "|" in m.group(2):
117
+ raise ValueError("unexpected ES|QL query shape")
118
+ sources.append(m.group(1))
119
+ conds.append(m.group(2).strip())
120
+ unit = {"s": "seconds", "m": "minutes", "h": "hours", "d": "days"}[within[-1]]
121
+ step = "case(" + ", ".join(f"{c}, {i}" for i, c in enumerate(conds, 1)) + ")"
122
+ key = "case(" + ", ".join(f"step == {i}, {k}" for i, k in enumerate(keys, 1)) + ")"
123
+ source = ",".join(dict.fromkeys(x for s in sources for x in s.split(","))) # every step's index pattern
124
+ q = (f"from {source} metadata _id, _index\n| where {' or '.join(f'({c})' for c in conds)}\n"
125
+ f"| eval step = {step}\n| eval join_key = {key}, timebucket = date_trunc({within[:-1]} {unit}, @timestamp)\n"
126
+ f"| stats steps = count_distinct(step), first_seen = min(@timestamp), last_seen = max(@timestamp) by timebucket, join_key\n"
127
+ f"| where steps == {len(conds)}")
128
+ note = "Built by ti2dac (deterministic template, not AI). Fixed time bins: run every half-window."
129
+ if ordered:
130
+ note += " Step order is not enforced here; use the EQL version for an ordered sequence."
131
+ return q, note
132
+
133
+
134
+ def _eql(step_queries: list[str], keys: list[str], ordered: bool, within: str) -> tuple[str, str]:
135
+ body = "\n".join(f" [{q}] by {k}" for q, k in zip(step_queries, keys))
136
+ note = "Built by ti2dac (deterministic template, not AI)."
137
+ if not ordered:
138
+ note += " EQL sequences are ordered; the steps are in report order."
139
+ return f"sequence with maxspan={within}\n{body}", note
140
+
141
+
142
+ _TEMPLATES = {**{t: None for t in KQL_TARGETS}, "elastic_eql": _eql, "splunk": _spl, "elastic_esql": _esql}
143
+
144
+
145
+ def queries(rule_yaml: str, docs: list[dict], fields: list[str], join_on: str, ordered: bool, within: str,
146
+ target_ids: list[str], client=None, index_overrides: dict[str, str] | None = None) -> list[QueryResult]:
147
+ from sigma.collection import SigmaCollection
148
+
149
+ out = []
150
+ product = (docs[0].get("logsource") or {}).get("product", "")
151
+ for tid in target_ids:
152
+ t = TARGETS.get(tid)
153
+ if t is None:
154
+ continue
155
+ res = QueryResult(target=tid, label=t.label, language=t.language, method="pysigma",
156
+ client=client.name if client is not None else "")
157
+ try: # 1. the backend's own correlation support
158
+ q = make_backend(t, product, client).convert(SigmaCollection.from_yaml(rule_yaml))
159
+ res.query = (q if isinstance(q, str) else "\n".join(map(str, q))).strip() or None
160
+ if res.query:
161
+ res.query = st.apply_index_override(tid, res.query, (index_overrides or {}).get(tid, ""))
162
+ if res.query:
163
+ out.append(res)
164
+ continue
165
+ except Exception as e:
166
+ res.error = st._short_error(e)
167
+ if tid not in _TEMPLATES:
168
+ res.error = f"no multi-event template for {tid} ({res.error or 'empty result'}); not sent to the AI"
169
+ res.validation = "failed"
170
+ out.append(res)
171
+ continue
172
+ # 2. ti2dac template over the separately converted steps
173
+ step_q, keys = [], []
174
+ for d, f in zip(docs, fields):
175
+ (q1,) = st.convert(st.dump_docs([{k: v for k, v in d.items() if k != "name"}]), [tid], index_overrides, client)
176
+ key = HOST_COLUMN.get(tid) if join_on == "host" else st.map_field(d, f, tid, client)
177
+ if not q1.query or not key or key.startswith("UNSUPPORTED"):
178
+ res.error = f"step {d.get('title')!r} does not convert for {tid}: {q1.error or key}"
179
+ break
180
+ step_q.append(q1.query)
181
+ keys.append(key)
182
+ else:
183
+ try:
184
+ q, note = (_kql(tid, step_q, keys, ordered, within) if tid in KQL_TARGETS
185
+ else _TEMPLATES[tid](step_q, keys, ordered, within))
186
+ res.query, res.error, res.method, res.notes = q, None, "template", note
187
+ except ValueError as e:
188
+ res.error = f"{tid} template: {e}"
189
+ if res.error:
190
+ res.validation = "failed"
191
+ out.append(res)
192
+ return out
193
+
194
+
195
+ def build(steps: list[DetectionPackage], *, slug: str, name: str, ordered: bool, within: str, join_on: str,
196
+ source: str, evidence: str, settings, clients=()) -> ChainPackage:
197
+ rule_yaml, docs, fields, warnings = build_yaml(steps, name=name, ordered=ordered, within=within,
198
+ join_on=join_on, author=settings.author)
199
+ ok, issues = st.validate_rule(rule_yaml)
200
+ if not ok:
201
+ raise ValueError(f"chain {name!r} is not a valid Sigma correlation: {'; '.join(issues)[:300]}")
202
+ qs = queries(rule_yaml, docs, fields, join_on, ordered, within, settings.targets, None, settings.index_overrides)
203
+ for c in clients:
204
+ qs += queries(rule_yaml, docs, fields, join_on, ordered, within, c.targets or settings.targets, c, c.index_overrides)
205
+ unknown = [f"{d['name']}: {f}" for d, f in zip(docs, fields) if f in ("Hostname", "SourceIp", "User")
206
+ and (d.get("logsource") or {}).get("product") != "windows"]
207
+ if unknown:
208
+ warnings.append(f"join field guessed for non-Windows steps ({', '.join(unknown)}); check it in the rule file")
209
+ return ChainPackage(slug=slug, name=name, steps=[p.slug for p in steps], ordered=ordered, within=within,
210
+ join_on=join_on, source=source, evidence=evidence, rule_yaml=rule_yaml, queries=qs,
211
+ warnings=warnings)