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.
- ti2dac/__init__.py +3 -0
- ti2dac/aistatus.py +169 -0
- ti2dac/attack.py +169 -0
- ti2dac/chains.py +211 -0
- ti2dac/cli.py +951 -0
- ti2dac/clients.py +149 -0
- ti2dac/condense.py +228 -0
- ti2dac/config.py +320 -0
- ti2dac/correlate.py +97 -0
- ti2dac/data/attack_index.json +1 -0
- ti2dac/data/detections-ci.yml +42 -0
- ti2dac/data/wazuh_parents.json +133 -0
- ti2dac/duplicates.py +126 -0
- ti2dac/feedback.py +369 -0
- ti2dac/health.py +47 -0
- ti2dac/ingest.py +325 -0
- ti2dac/iocs.py +293 -0
- ti2dac/llm.py +598 -0
- ti2dac/locks.py +34 -0
- ti2dac/modelchoice.py +162 -0
- ti2dac/models.py +422 -0
- ti2dac/obs.py +131 -0
- ti2dac/pipeline.py +1025 -0
- ti2dac/prompts.py +183 -0
- ti2dac/quality.py +79 -0
- ti2dac/redact.py +279 -0
- ti2dac/regexes.py +193 -0
- ti2dac/render.py +664 -0
- ti2dac/replay.py +302 -0
- ti2dac/review.py +328 -0
- ti2dac/safety.py +213 -0
- ti2dac/sigma_tools.py +586 -0
- ti2dac/targets.py +384 -0
- ti2dac/users.py +99 -0
- ti2dac/wazuh.py +301 -0
- ti2dac/web/__init__.py +0 -0
- ti2dac/web/app.py +793 -0
- ti2dac/web/jobs.py +358 -0
- ti2dac/web/static/app.css +631 -0
- ti2dac/web/static/app.js +276 -0
- ti2dac/web/static/chevron.svg +1 -0
- ti2dac/web/static/favicon.svg +1 -0
- ti2dac/web/templates/_ui.html +107 -0
- ti2dac/web/templates/ai.html +207 -0
- ti2dac/web/templates/api.html +93 -0
- ti2dac/web/templates/base.html +74 -0
- ti2dac/web/templates/feedback.html +100 -0
- ti2dac/web/templates/index.html +196 -0
- ti2dac/web/templates/job.html +51 -0
- ti2dac/web/templates/run.html +514 -0
- ti2dac-0.1.0.dist-info/METADATA +180 -0
- ti2dac-0.1.0.dist-info/RECORD +56 -0
- ti2dac-0.1.0.dist-info/WHEEL +5 -0
- ti2dac-0.1.0.dist-info/entry_points.txt +2 -0
- ti2dac-0.1.0.dist-info/licenses/LICENSE +21 -0
- ti2dac-0.1.0.dist-info/top_level.txt +1 -0
ti2dac/__init__.py
ADDED
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)
|