osintengine 1.0.2__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.
- osintengine-1.0.2.dist-info/METADATA +311 -0
- osintengine-1.0.2.dist-info/RECORD +103 -0
- osintengine-1.0.2.dist-info/WHEEL +5 -0
- osintengine-1.0.2.dist-info/entry_points.txt +2 -0
- osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
- osintengine-1.0.2.dist-info/top_level.txt +2 -0
- src/watson/__init__.py +10 -0
- src/watson/agent/__init__.py +96 -0
- src/watson/agents/__init__.py +101 -0
- src/watson/agents/protocol.py +196 -0
- src/watson/auth/__init__.py +68 -0
- src/watson/auth/store.py +145 -0
- src/watson/conversation.py +68 -0
- src/watson/core/__init__.py +0 -0
- src/watson/core/models.py +96 -0
- src/watson/ethics.py +205 -0
- src/watson/exports.py +182 -0
- src/watson/graph/__init__.py +69 -0
- src/watson/graph/entities.py +207 -0
- src/watson/graph/graph.py +489 -0
- src/watson/graph/osint_framework.py +266 -0
- src/watson/graph/relationships.py +116 -0
- src/watson/graph/transforms.py +787 -0
- src/watson/infra/__init__.py +43 -0
- src/watson/infra/cache.py +171 -0
- src/watson/infra/ratelimit.py +125 -0
- src/watson/infra/resilience.py +239 -0
- src/watson/infra/retry.py +186 -0
- src/watson/metrics.py +128 -0
- src/watson/opsec/__init__.py +290 -0
- src/watson/orchestration/__init__.py +23 -0
- src/watson/orchestration/engine.py +5587 -0
- src/watson/orchestration/executor.py +96 -0
- src/watson/orchestration/intent.py +139 -0
- src/watson/orchestration/intent_classifier.py +45 -0
- src/watson/orchestration/llm_config.py +160 -0
- src/watson/orchestration/resolution.py +555 -0
- src/watson/orchestration/scraper.py +94 -0
- src/watson/orchestration/synthesis.py +726 -0
- src/watson/orchestration/target_profile.py +748 -0
- src/watson/persistence/__init__.py +18 -0
- src/watson/persistence/models.py +161 -0
- src/watson/persistence/store.py +230 -0
- src/watson/pipeline/__init__.py +16 -0
- src/watson/pipeline/pre_synthesis.py +621 -0
- src/watson/search.py +103 -0
- src/watson/serializers/stix.py +451 -0
- src/watson/tools/__init__.py +31 -0
- src/watson/tools/base.py +97 -0
- src/watson/tools/blockchain.py +359 -0
- src/watson/tools/captcha.py +276 -0
- src/watson/tools/conflict.py +107 -0
- src/watson/tools/corporate.py +287 -0
- src/watson/tools/darkweb.py +144 -0
- src/watson/tools/geolocation.py +347 -0
- src/watson/tools/image_video.py +108 -0
- src/watson/tools/marinetraffic.py +142 -0
- src/watson/tools/people.py +476 -0
- src/watson/tools/registry.py +56 -0
- src/watson/tools/satellite.py +118 -0
- src/watson/tools/scraper.py +745 -0
- src/watson/tools/shodan.py +129 -0
- src/watson/tools/social_media.py +160 -0
- src/watson/tools/websites.py +291 -0
- src/watson/tools/wikidata.py +398 -0
- src/watson/utils/__init__.py +1 -0
- src/watson/utils/helpers.py +49 -0
- src/watson/utils/http.py +119 -0
- src/watson/verification/__init__.py +245 -0
- watson/__init__.py +6 -0
- watson/agents/__init__.py +20 -0
- watson/agents/base.py +103 -0
- watson/agents/direct.py +225 -0
- watson/agents/hermes.py +275 -0
- watson/agents/hermes_mcp.py +292 -0
- watson/api_keys.py +176 -0
- watson/browser_scraper.py +481 -0
- watson/cli.py +836 -0
- watson/crossref.py +175 -0
- watson/ethics.py +20 -0
- watson/graph.py +406 -0
- watson/mcp_server.py +601 -0
- watson/memory.py +552 -0
- watson/neo4j_graph.py +124 -0
- watson/opsec/__init__.py +307 -0
- watson/reporter.py +537 -0
- watson/scheduler.py +486 -0
- watson/serializers/stix.py +451 -0
- watson/terminal.py +410 -0
- watson/toolkit.py +252 -0
- watson/toolkit_api.py +347 -0
- watson/toolkit_automation.py +95 -0
- watson/toolkit_registry.py +345 -0
- watson/verification/__init__.py +251 -0
- watson/web/__init__.py +1 -0
- watson/web/app.py +1650 -0
- watson/web/middleware.py +301 -0
- watson/web/static/assets/index-B7hPOc0z.js +278 -0
- watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
- watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
- watson/web/static/index.html +14 -0
- watson/web/templates/chat.html +2 -0
- watson/web/templates/investigation-map.html +429 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""STIX 2.1 Serializer — convert Watson findings to structured threat intelligence.
|
|
2
|
+
|
|
3
|
+
Produces valid STIX 2.1 bundles with:
|
|
4
|
+
- Report → SDO with object_refs
|
|
5
|
+
- Finding → Indicator (pattern-based) + ObservedData
|
|
6
|
+
- Entity → Identity (individual/organization), Location, DomainName, IPv4Addr
|
|
7
|
+
- Relationships between all objects
|
|
8
|
+
- External references for source URLs
|
|
9
|
+
- Confidence mapping: CONFIRMED→85, PROBABLE→65, POSSIBLE→40, UNLIKELY→15, UNVERIFIED→0
|
|
10
|
+
|
|
11
|
+
Reference: https://docs.oasis-open.org/cti/stix/v2.1/os/stix-v2.1-os.html
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import uuid
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("watson.stix")
|
|
26
|
+
|
|
27
|
+
# ── Confidence mapping ────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
_TIER_CONFIDENCE: dict[str, int] = {
|
|
30
|
+
"CONFIRMED": 85,
|
|
31
|
+
"PROBABLE": 65,
|
|
32
|
+
"POSSIBLE": 40,
|
|
33
|
+
"UNLIKELY": 15,
|
|
34
|
+
"UNVERIFIED": 0,
|
|
35
|
+
"UNSUBSTANTIATED": 0,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# ── Entity type → STIX identity_class ─────────────────────────
|
|
39
|
+
|
|
40
|
+
_ENTITY_STIX_CLASS: dict[str, str] = {
|
|
41
|
+
"person": "individual",
|
|
42
|
+
"organization": "organization",
|
|
43
|
+
"company": "organization",
|
|
44
|
+
"government": "organization",
|
|
45
|
+
"unknown": "unknown",
|
|
46
|
+
"": "unknown",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# ── STIX ID generation ────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def _stix_id(stix_type: str, value: str = "") -> str:
|
|
52
|
+
"""Generate deterministic STIX ID from type + value, or random UUID."""
|
|
53
|
+
if value:
|
|
54
|
+
# Namespace-based UUID for reproducibility
|
|
55
|
+
ns = uuid.uuid5(uuid.NAMESPACE_DNS, f"watson-osint:{stix_type}")
|
|
56
|
+
return f"{stix_type}--{uuid.uuid5(ns, value)}"
|
|
57
|
+
return f"{stix_type}--{uuid.uuid4()}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── IP regex ──────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
_IPV4_RE = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
|
|
63
|
+
_IPV6_RE = re.compile(r"^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_ip(value: str) -> str | None:
|
|
67
|
+
"""Return 'ipv4', 'ipv6', or None."""
|
|
68
|
+
v = value.strip()
|
|
69
|
+
if _IPV4_RE.match(v):
|
|
70
|
+
return "ipv4-addr"
|
|
71
|
+
if _IPV6_RE.match(v):
|
|
72
|
+
return "ipv6-addr"
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_domain(value: str) -> bool:
|
|
77
|
+
"""Rough domain detection."""
|
|
78
|
+
v = value.strip().lower()
|
|
79
|
+
if not v or " " in v or len(v) > 253:
|
|
80
|
+
return False
|
|
81
|
+
return "." in v and not v.startswith(("http://", "https://"))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _is_email(value: str) -> bool:
|
|
85
|
+
return bool(re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", value.strip()))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_url(value: str) -> bool:
|
|
89
|
+
return value.strip().startswith(("http://", "https://"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── Main serializer ───────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
class STIXSerializer:
|
|
95
|
+
"""Convert Watson investigation results to STIX 2.1 bundles."""
|
|
96
|
+
|
|
97
|
+
def __init__(self):
|
|
98
|
+
self._objects: list[dict[str, Any]] = []
|
|
99
|
+
self._seen_ids: set[str] = set()
|
|
100
|
+
self._report_refs: list[str] = []
|
|
101
|
+
self._entity_cache: dict[str, str] = {} # value → stix_id
|
|
102
|
+
|
|
103
|
+
def serialize(
|
|
104
|
+
self,
|
|
105
|
+
query: str,
|
|
106
|
+
case_id: str,
|
|
107
|
+
target_type: str,
|
|
108
|
+
findings: list[Any],
|
|
109
|
+
entities: list[dict] | None = None,
|
|
110
|
+
brief: dict | None = None,
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
"""Produce a STIX 2.1 bundle from investigation results."""
|
|
113
|
+
self._objects = []
|
|
114
|
+
self._seen_ids = set()
|
|
115
|
+
self._report_refs = []
|
|
116
|
+
self._entity_cache = {}
|
|
117
|
+
|
|
118
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
119
|
+
|
|
120
|
+
# ── 1. Report SDO ──
|
|
121
|
+
report_id = _stix_id("report", case_id)
|
|
122
|
+
report = {
|
|
123
|
+
"type": "report",
|
|
124
|
+
"id": report_id,
|
|
125
|
+
"spec_version": "2.1",
|
|
126
|
+
"created": now,
|
|
127
|
+
"modified": now,
|
|
128
|
+
"name": f"Watson Investigation: {query[:80]}",
|
|
129
|
+
"description": brief.get("executive_summary", "") if brief else f"OSINT investigation of {query}",
|
|
130
|
+
"report_types": ["investigation"],
|
|
131
|
+
"published": now,
|
|
132
|
+
"object_refs": self._report_refs, # will be filled
|
|
133
|
+
"labels": ["osint", target_type],
|
|
134
|
+
"external_references": [],
|
|
135
|
+
}
|
|
136
|
+
self._add_object(report)
|
|
137
|
+
|
|
138
|
+
# ── 2. Target identity ──
|
|
139
|
+
target_id = self._entity_for(query, target_type, brief)
|
|
140
|
+
self._report_refs.append(target_id)
|
|
141
|
+
self._relate(report_id, "investigates", target_id)
|
|
142
|
+
|
|
143
|
+
# ── 3. Findings → Indicators & ObservedData ──
|
|
144
|
+
for f in findings:
|
|
145
|
+
self._serialize_finding(f, report_id, target_id)
|
|
146
|
+
|
|
147
|
+
# ── 4. Entities → Identities, Locations, SCOs ──
|
|
148
|
+
if entities:
|
|
149
|
+
for ent in entities:
|
|
150
|
+
ent_id = self._serialize_entity(ent)
|
|
151
|
+
if ent_id:
|
|
152
|
+
self._report_refs.append(ent_id)
|
|
153
|
+
self._relate(report_id, "references", ent_id)
|
|
154
|
+
|
|
155
|
+
# ── 5. Fix up report object_refs ──
|
|
156
|
+
report["object_refs"] = self._report_refs
|
|
157
|
+
|
|
158
|
+
bundle = {
|
|
159
|
+
"type": "bundle",
|
|
160
|
+
"id": _stix_id("bundle", case_id),
|
|
161
|
+
"spec_version": "2.1",
|
|
162
|
+
"objects": self._objects,
|
|
163
|
+
}
|
|
164
|
+
return bundle
|
|
165
|
+
|
|
166
|
+
def to_json(self, **kwargs) -> str:
|
|
167
|
+
bundle = self.serialize(**kwargs)
|
|
168
|
+
return json.dumps(bundle, indent=2, default=str)
|
|
169
|
+
|
|
170
|
+
def to_file(self, path: Path | str, **kwargs) -> Path:
|
|
171
|
+
path = Path(path)
|
|
172
|
+
path.write_text(self.to_json(**kwargs))
|
|
173
|
+
logger.info("stix_exported: %s", path)
|
|
174
|
+
return path
|
|
175
|
+
|
|
176
|
+
# ── Internal ──────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
def _add_object(self, obj: dict) -> None:
|
|
179
|
+
oid = obj.get("id", "")
|
|
180
|
+
if oid and oid not in self._seen_ids:
|
|
181
|
+
self._seen_ids.add(oid)
|
|
182
|
+
self._objects.append(obj)
|
|
183
|
+
|
|
184
|
+
def _relate(self, source_id: str, rel_type: str, target_id: str) -> str | None:
|
|
185
|
+
if not source_id or not target_id:
|
|
186
|
+
return None
|
|
187
|
+
rid = _stix_id("relationship", f"{source_id}|{rel_type}|{target_id}")
|
|
188
|
+
rel = {
|
|
189
|
+
"type": "relationship",
|
|
190
|
+
"id": rid,
|
|
191
|
+
"spec_version": "2.1",
|
|
192
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
193
|
+
"modified": datetime.now(timezone.utc).isoformat(),
|
|
194
|
+
"relationship_type": rel_type,
|
|
195
|
+
"source_ref": source_id,
|
|
196
|
+
"target_ref": target_id,
|
|
197
|
+
}
|
|
198
|
+
self._add_object(rel)
|
|
199
|
+
return rid
|
|
200
|
+
|
|
201
|
+
def _confidence(self, tier: str) -> int:
|
|
202
|
+
return _TIER_CONFIDENCE.get(tier, 30)
|
|
203
|
+
|
|
204
|
+
def _entity_for(self, value: str, etype: str = "unknown", brief: dict | None = None) -> str:
|
|
205
|
+
"""Get or create a STIX identity for an entity value."""
|
|
206
|
+
key = f"{value}|{etype}"
|
|
207
|
+
if key in self._entity_cache:
|
|
208
|
+
return self._entity_cache[key]
|
|
209
|
+
|
|
210
|
+
identity_class = _ENTITY_STIX_CLASS.get(etype, "unknown")
|
|
211
|
+
eid = _stix_id("identity", value)
|
|
212
|
+
|
|
213
|
+
identity = {
|
|
214
|
+
"type": "identity",
|
|
215
|
+
"id": eid,
|
|
216
|
+
"spec_version": "2.1",
|
|
217
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
218
|
+
"modified": datetime.now(timezone.utc).isoformat(),
|
|
219
|
+
"name": value[:200],
|
|
220
|
+
"identity_class": identity_class,
|
|
221
|
+
"description": f"Target entity of type: {etype}",
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
# Add sectors/labels from brief if available
|
|
225
|
+
if brief and identity_class == "organization":
|
|
226
|
+
risk_themes = brief.get("risk_themes", [])
|
|
227
|
+
if risk_themes:
|
|
228
|
+
identity["sectors"] = [t.get("theme", "")[:80] for t in risk_themes[:3]]
|
|
229
|
+
identity["labels"] = ["risk-associated"] + [
|
|
230
|
+
t.get("severity", "").lower() for t in risk_themes if t.get("severity")
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
self._add_object(identity)
|
|
234
|
+
self._entity_cache[key] = eid
|
|
235
|
+
return eid
|
|
236
|
+
|
|
237
|
+
def _serialize_finding(self, finding: Any, report_id: str, target_id: str) -> None:
|
|
238
|
+
"""Convert a Watson Finding to STIX Indicator + ObservedData."""
|
|
239
|
+
title = getattr(finding, "title", str(finding))[:200]
|
|
240
|
+
desc = getattr(finding, "description", "")[:500]
|
|
241
|
+
tier = getattr(finding, "tier", "UNVERIFIED")
|
|
242
|
+
source_url = getattr(finding, "source_url", "")
|
|
243
|
+
src_type = getattr(finding, "source_type", "osint")
|
|
244
|
+
confidence_val = self._confidence(tier) if tier else 30
|
|
245
|
+
fid = _stix_id("indicator", getattr(finding, "id", str(uuid.uuid4())))
|
|
246
|
+
|
|
247
|
+
# ── Indicator (pattern-based) ──
|
|
248
|
+
# Build a STIX pattern from finding text
|
|
249
|
+
pattern = self._build_pattern(title, desc)
|
|
250
|
+
indicator = {
|
|
251
|
+
"type": "indicator",
|
|
252
|
+
"id": fid,
|
|
253
|
+
"spec_version": "2.1",
|
|
254
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
255
|
+
"modified": datetime.now(timezone.utc).isoformat(),
|
|
256
|
+
"name": title,
|
|
257
|
+
"description": desc or title,
|
|
258
|
+
"indicator_types": ["osint-report"],
|
|
259
|
+
"pattern": pattern,
|
|
260
|
+
"pattern_type": "stix",
|
|
261
|
+
"valid_from": datetime.now(timezone.utc).isoformat(),
|
|
262
|
+
"confidence": confidence_val,
|
|
263
|
+
"labels": [src_type, tier.lower() if tier else "unverified"],
|
|
264
|
+
}
|
|
265
|
+
if source_url:
|
|
266
|
+
indicator["external_references"] = [{
|
|
267
|
+
"source_name": "OSINT Source",
|
|
268
|
+
"url": source_url,
|
|
269
|
+
"description": "Original investigation source",
|
|
270
|
+
}]
|
|
271
|
+
self._add_object(indicator)
|
|
272
|
+
self._report_refs.append(fid)
|
|
273
|
+
self._relate(fid, "derived-from", target_id)
|
|
274
|
+
|
|
275
|
+
# ── ObservedData (structured facts) ──
|
|
276
|
+
# Extract IPs, domains, emails from the finding text
|
|
277
|
+
observables = self._extract_observables(desc)
|
|
278
|
+
if observables:
|
|
279
|
+
odata_id = _stix_id("observed-data", fid)
|
|
280
|
+
observed = {
|
|
281
|
+
"type": "observed-data",
|
|
282
|
+
"id": odata_id,
|
|
283
|
+
"spec_version": "2.1",
|
|
284
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
285
|
+
"modified": datetime.now(timezone.utc).isoformat(),
|
|
286
|
+
"first_observed": datetime.now(timezone.utc).isoformat(),
|
|
287
|
+
"last_observed": datetime.now(timezone.utc).isoformat(),
|
|
288
|
+
"number_observed": 1,
|
|
289
|
+
"objects": observables,
|
|
290
|
+
}
|
|
291
|
+
self._add_object(observed)
|
|
292
|
+
self._report_refs.append(odata_id)
|
|
293
|
+
self._relate(fid, "based-on", odata_id)
|
|
294
|
+
|
|
295
|
+
def _serialize_entity(self, entity: dict | Any) -> str | None:
|
|
296
|
+
"""Serialize a Watson entity to STIX."""
|
|
297
|
+
if isinstance(entity, dict):
|
|
298
|
+
val = entity.get("value", entity.get("name", entity.get("canonical", "")))
|
|
299
|
+
etype = entity.get("type", "unknown")
|
|
300
|
+
elif hasattr(entity, "value"):
|
|
301
|
+
val = getattr(entity, "value", "") or ""
|
|
302
|
+
etype = getattr(entity, "type", "unknown")
|
|
303
|
+
else:
|
|
304
|
+
val = str(entity)
|
|
305
|
+
etype = "unknown"
|
|
306
|
+
|
|
307
|
+
if not val or not val.strip():
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
v = val.strip()
|
|
311
|
+
|
|
312
|
+
# IP address → IPv4Addr / IPv6Addr SCO
|
|
313
|
+
ip_type = _is_ip(v)
|
|
314
|
+
if ip_type:
|
|
315
|
+
ip_id = _stix_id(ip_type, v)
|
|
316
|
+
ip_obj = {
|
|
317
|
+
"type": ip_type,
|
|
318
|
+
"id": ip_id,
|
|
319
|
+
"spec_version": "2.1",
|
|
320
|
+
"value": v,
|
|
321
|
+
}
|
|
322
|
+
self._add_object(ip_obj)
|
|
323
|
+
return ip_id
|
|
324
|
+
|
|
325
|
+
# Domain → DomainName SCO
|
|
326
|
+
if _is_domain(v):
|
|
327
|
+
dom_id = _stix_id("domain-name", v)
|
|
328
|
+
domain = {
|
|
329
|
+
"type": "domain-name",
|
|
330
|
+
"id": dom_id,
|
|
331
|
+
"spec_version": "2.1",
|
|
332
|
+
"value": v,
|
|
333
|
+
}
|
|
334
|
+
self._add_object(domain)
|
|
335
|
+
return dom_id
|
|
336
|
+
|
|
337
|
+
# Email → EmailAddr SCO
|
|
338
|
+
if _is_email(v):
|
|
339
|
+
email_id = _stix_id("email-addr", v)
|
|
340
|
+
email_obj = {
|
|
341
|
+
"type": "email-addr",
|
|
342
|
+
"id": email_id,
|
|
343
|
+
"spec_version": "2.1",
|
|
344
|
+
"value": v,
|
|
345
|
+
}
|
|
346
|
+
self._add_object(email_obj)
|
|
347
|
+
return email_id
|
|
348
|
+
|
|
349
|
+
# URL → URL SCO
|
|
350
|
+
if _is_url(v):
|
|
351
|
+
url_id = _stix_id("url", v)
|
|
352
|
+
url_obj = {
|
|
353
|
+
"type": "url",
|
|
354
|
+
"id": url_id,
|
|
355
|
+
"spec_version": "2.1",
|
|
356
|
+
"value": v,
|
|
357
|
+
}
|
|
358
|
+
self._add_object(url_obj)
|
|
359
|
+
return url_id
|
|
360
|
+
|
|
361
|
+
# Default → Identity
|
|
362
|
+
return self._entity_for(v, etype)
|
|
363
|
+
|
|
364
|
+
def _build_pattern(self, title: str, desc: str) -> str:
|
|
365
|
+
"""Build a STIX pattern string from finding text."""
|
|
366
|
+
# Extract first significant noun phrase
|
|
367
|
+
text = f"{title} {desc}"[:500]
|
|
368
|
+
# Simple: mark as osint-report indicator with description
|
|
369
|
+
return f"[indicator:pattern_type = 'osint-report' AND indicator:description MATCHES '{self._escape_pattern(desc[:100])}']"
|
|
370
|
+
|
|
371
|
+
@staticmethod
|
|
372
|
+
def _escape_pattern(text: str) -> str:
|
|
373
|
+
"""Escape single quotes for STIX pattern."""
|
|
374
|
+
return text.replace("'", "\\'").replace("\n", " ")[:200]
|
|
375
|
+
|
|
376
|
+
def _extract_observables(self, text: str) -> dict[str, dict]:
|
|
377
|
+
"""Extract IPs, domains, emails from text as STIX Cyber Observable objects."""
|
|
378
|
+
objects: dict[str, dict] = {}
|
|
379
|
+
if not text:
|
|
380
|
+
return objects
|
|
381
|
+
|
|
382
|
+
# Extract IPs
|
|
383
|
+
for ip_match in re.finditer(_IPV4_RE, text):
|
|
384
|
+
ip = ip_match.group(0)
|
|
385
|
+
if ip not in objects:
|
|
386
|
+
objects[f"0"] = {
|
|
387
|
+
"type": "ipv4-addr",
|
|
388
|
+
"value": ip,
|
|
389
|
+
}
|
|
390
|
+
break # just one per finding
|
|
391
|
+
|
|
392
|
+
# Extract domains (rough)
|
|
393
|
+
domain_match = re.search(r'(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}', text)
|
|
394
|
+
if domain_match:
|
|
395
|
+
dom = domain_match.group(0).lower()
|
|
396
|
+
if dom not in objects:
|
|
397
|
+
key = f"{len(objects)}"
|
|
398
|
+
objects[key] = {
|
|
399
|
+
"type": "domain-name",
|
|
400
|
+
"value": dom,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
# Extract emails
|
|
404
|
+
email_match = re.search(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', text)
|
|
405
|
+
if email_match:
|
|
406
|
+
email = email_match.group(0)
|
|
407
|
+
key = f"{len(objects)}"
|
|
408
|
+
objects[key] = {
|
|
409
|
+
"type": "email-addr",
|
|
410
|
+
"value": email,
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return objects
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ── Convenience export function ───────────────────────────────
|
|
417
|
+
|
|
418
|
+
def export_stix(
|
|
419
|
+
query: str,
|
|
420
|
+
case_id: str,
|
|
421
|
+
target_type: str,
|
|
422
|
+
findings: list[Any],
|
|
423
|
+
entities: list[dict] | None = None,
|
|
424
|
+
brief: dict | None = None,
|
|
425
|
+
output_path: Path | str | None = None,
|
|
426
|
+
) -> tuple[dict, Path | None]:
|
|
427
|
+
"""Export investigation results as STIX 2.1.
|
|
428
|
+
|
|
429
|
+
Returns (bundle_dict, output_path_or_None).
|
|
430
|
+
"""
|
|
431
|
+
serializer = STIXSerializer()
|
|
432
|
+
bundle = serializer.serialize(
|
|
433
|
+
query=query,
|
|
434
|
+
case_id=case_id,
|
|
435
|
+
target_type=target_type,
|
|
436
|
+
findings=findings,
|
|
437
|
+
entities=entities,
|
|
438
|
+
brief=brief,
|
|
439
|
+
)
|
|
440
|
+
path = None
|
|
441
|
+
if output_path:
|
|
442
|
+
path = serializer.to_file(
|
|
443
|
+
output_path,
|
|
444
|
+
query=query,
|
|
445
|
+
case_id=case_id,
|
|
446
|
+
target_type=target_type,
|
|
447
|
+
findings=findings,
|
|
448
|
+
entities=entities,
|
|
449
|
+
brief=brief,
|
|
450
|
+
)
|
|
451
|
+
return bundle, path
|