-
Notifications
You must be signed in to change notification settings - Fork 42
Expand file tree
/
Copy pathbuild_actor_mentions.py
More file actions
171 lines (147 loc) · 5.3 KB
/
Copy pathbuild_actor_mentions.py
File metadata and controls
171 lines (147 loc) · 5.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
"""
Build public/actor-mentions.json.
For each MITRE-mapped threat actor in public/context-graph-data.json, find the
hunts in public/hunts-data.json whose prose (title / why / notes / references)
mentions the actor's display name or any alias.
Matching rules:
- case-insensitive
- word-boundary (\\b...\\b) — so "APT29" doesn't match inside "APT299"
- minimum search-term length: MIN_ALIAS_LEN characters
- per-actor alias denylist suppresses individual (actor_id, alias) pairs
that produce false positives (loaded from scripts/actor_alias_denylist.json)
Output shape:
{
"generated_at": "2026-05-23T12:00:00Z",
"min_alias_len": 4,
"mentions": {
"actor:G0016": ["H012", "H034"],
...
}
}
"""
from __future__ import annotations
import datetime as _dt
import json
import re
from pathlib import Path
from typing import Any, Iterable
MIN_ALIAS_LEN = 4
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_CONTEXT_GRAPH = REPO_ROOT / "public" / "context-graph-data.json"
DEFAULT_HUNTS = REPO_ROOT / "public" / "hunts-data.json"
DEFAULT_DENYLIST = REPO_ROOT / "scripts" / "actor_alias_denylist.json"
DEFAULT_OUTPUT = REPO_ROOT / "public" / "actor-mentions.json"
def load_actors(context_graph: dict[str, Any]) -> list[dict[str, Any]]:
"""Extract threat_actor nodes from a context-graph-data.json dict."""
return [n for n in context_graph.get("nodes", []) if n.get("type") == "threat_actor"]
def load_denylist(path: Path) -> dict[str, set[str]]:
"""Load (actor_id -> set of lowercased aliases to skip) from JSON."""
if not path.exists():
return {}
raw = json.loads(path.read_text())
return {
actor_id: {alias.lower() for alias in aliases}
for actor_id, aliases in raw.get("denylist", {}).items()
}
def search_terms_for(actor: dict[str, Any], denylist: dict[str, set[str]]) -> list[str]:
"""
Resolve the list of name/alias strings we'll search hunt prose for.
Filters out short terms and denylisted aliases. Deduplicates case-insensitively.
"""
actor_id = actor.get("id", "")
skip = denylist.get(actor_id, set())
candidates: list[str] = []
label = actor.get("label")
if isinstance(label, str):
candidates.append(label)
aliases = actor.get("aliases") or []
for a in aliases:
if isinstance(a, str):
candidates.append(a)
seen: set[str] = set()
out: list[str] = []
for term in candidates:
t = term.strip()
if len(t) < MIN_ALIAS_LEN:
continue
if t.lower() in skip:
continue
if t.lower() in seen:
continue
seen.add(t.lower())
out.append(t)
return out
def hunt_searchable_text(hunt: dict[str, Any]) -> str:
"""Concatenate the hunt fields we want to scan for actor mentions."""
parts: list[str] = []
for key in ("title", "why", "notes", "references"):
val = hunt.get(key)
if isinstance(val, str):
parts.append(val)
return "\n".join(parts)
def find_mentions(
actors: list[dict[str, Any]],
hunts: list[dict[str, Any]],
denylist: dict[str, set[str]],
) -> dict[str, list[str]]:
"""
Return {actor_id: [hunt_id, ...]} for actors with at least one mention.
Hunt IDs preserve the input hunt order; duplicates removed.
"""
# Precompute lowercased hunt text once.
hunt_text: list[tuple[str, str]] = [
(h.get("id", ""), hunt_searchable_text(h).lower()) for h in hunts
]
out: dict[str, list[str]] = {}
for actor in actors:
actor_id = actor.get("id", "")
if not actor_id:
continue
terms = search_terms_for(actor, denylist)
if not terms:
continue
# Build one alternation regex per actor for efficiency.
pattern = re.compile(
r"\b(?:" + "|".join(re.escape(t.lower()) for t in terms) + r")\b"
)
matched: list[str] = []
seen: set[str] = set()
for hunt_id, text in hunt_text:
if not hunt_id or hunt_id in seen:
continue
if pattern.search(text):
matched.append(hunt_id)
seen.add(hunt_id)
if matched:
out[actor_id] = matched
return out
def build(
context_graph_path: Path = DEFAULT_CONTEXT_GRAPH,
hunts_path: Path = DEFAULT_HUNTS,
denylist_path: Path = DEFAULT_DENYLIST,
output_path: Path = DEFAULT_OUTPUT,
*,
now: _dt.datetime | None = None,
) -> dict[str, Any]:
"""Run the full pipeline. Writes output_path and returns the written dict."""
context_graph = json.loads(context_graph_path.read_text())
hunts = json.loads(hunts_path.read_text())
denylist = load_denylist(denylist_path)
actors = load_actors(context_graph)
mentions = find_mentions(actors, hunts, denylist)
payload = {
"generated_at": (now or _dt.datetime.now(_dt.timezone.utc))
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z"),
"min_alias_len": MIN_ALIAS_LEN,
"mentions": mentions,
}
output_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
return payload
if __name__ == "__main__":
written = build()
print(
f"Wrote {DEFAULT_OUTPUT.relative_to(REPO_ROOT)} "
f"with {len(written['mentions'])} actors having mentions."
)