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.
Files changed (103) hide show
  1. osintengine-1.0.2.dist-info/METADATA +311 -0
  2. osintengine-1.0.2.dist-info/RECORD +103 -0
  3. osintengine-1.0.2.dist-info/WHEEL +5 -0
  4. osintengine-1.0.2.dist-info/entry_points.txt +2 -0
  5. osintengine-1.0.2.dist-info/licenses/LICENSE +202 -0
  6. osintengine-1.0.2.dist-info/top_level.txt +2 -0
  7. src/watson/__init__.py +10 -0
  8. src/watson/agent/__init__.py +96 -0
  9. src/watson/agents/__init__.py +101 -0
  10. src/watson/agents/protocol.py +196 -0
  11. src/watson/auth/__init__.py +68 -0
  12. src/watson/auth/store.py +145 -0
  13. src/watson/conversation.py +68 -0
  14. src/watson/core/__init__.py +0 -0
  15. src/watson/core/models.py +96 -0
  16. src/watson/ethics.py +205 -0
  17. src/watson/exports.py +182 -0
  18. src/watson/graph/__init__.py +69 -0
  19. src/watson/graph/entities.py +207 -0
  20. src/watson/graph/graph.py +489 -0
  21. src/watson/graph/osint_framework.py +266 -0
  22. src/watson/graph/relationships.py +116 -0
  23. src/watson/graph/transforms.py +787 -0
  24. src/watson/infra/__init__.py +43 -0
  25. src/watson/infra/cache.py +171 -0
  26. src/watson/infra/ratelimit.py +125 -0
  27. src/watson/infra/resilience.py +239 -0
  28. src/watson/infra/retry.py +186 -0
  29. src/watson/metrics.py +128 -0
  30. src/watson/opsec/__init__.py +290 -0
  31. src/watson/orchestration/__init__.py +23 -0
  32. src/watson/orchestration/engine.py +5587 -0
  33. src/watson/orchestration/executor.py +96 -0
  34. src/watson/orchestration/intent.py +139 -0
  35. src/watson/orchestration/intent_classifier.py +45 -0
  36. src/watson/orchestration/llm_config.py +160 -0
  37. src/watson/orchestration/resolution.py +555 -0
  38. src/watson/orchestration/scraper.py +94 -0
  39. src/watson/orchestration/synthesis.py +726 -0
  40. src/watson/orchestration/target_profile.py +748 -0
  41. src/watson/persistence/__init__.py +18 -0
  42. src/watson/persistence/models.py +161 -0
  43. src/watson/persistence/store.py +230 -0
  44. src/watson/pipeline/__init__.py +16 -0
  45. src/watson/pipeline/pre_synthesis.py +621 -0
  46. src/watson/search.py +103 -0
  47. src/watson/serializers/stix.py +451 -0
  48. src/watson/tools/__init__.py +31 -0
  49. src/watson/tools/base.py +97 -0
  50. src/watson/tools/blockchain.py +359 -0
  51. src/watson/tools/captcha.py +276 -0
  52. src/watson/tools/conflict.py +107 -0
  53. src/watson/tools/corporate.py +287 -0
  54. src/watson/tools/darkweb.py +144 -0
  55. src/watson/tools/geolocation.py +347 -0
  56. src/watson/tools/image_video.py +108 -0
  57. src/watson/tools/marinetraffic.py +142 -0
  58. src/watson/tools/people.py +476 -0
  59. src/watson/tools/registry.py +56 -0
  60. src/watson/tools/satellite.py +118 -0
  61. src/watson/tools/scraper.py +745 -0
  62. src/watson/tools/shodan.py +129 -0
  63. src/watson/tools/social_media.py +160 -0
  64. src/watson/tools/websites.py +291 -0
  65. src/watson/tools/wikidata.py +398 -0
  66. src/watson/utils/__init__.py +1 -0
  67. src/watson/utils/helpers.py +49 -0
  68. src/watson/utils/http.py +119 -0
  69. src/watson/verification/__init__.py +245 -0
  70. watson/__init__.py +6 -0
  71. watson/agents/__init__.py +20 -0
  72. watson/agents/base.py +103 -0
  73. watson/agents/direct.py +225 -0
  74. watson/agents/hermes.py +275 -0
  75. watson/agents/hermes_mcp.py +292 -0
  76. watson/api_keys.py +176 -0
  77. watson/browser_scraper.py +481 -0
  78. watson/cli.py +836 -0
  79. watson/crossref.py +175 -0
  80. watson/ethics.py +20 -0
  81. watson/graph.py +406 -0
  82. watson/mcp_server.py +601 -0
  83. watson/memory.py +552 -0
  84. watson/neo4j_graph.py +124 -0
  85. watson/opsec/__init__.py +307 -0
  86. watson/reporter.py +537 -0
  87. watson/scheduler.py +486 -0
  88. watson/serializers/stix.py +451 -0
  89. watson/terminal.py +410 -0
  90. watson/toolkit.py +252 -0
  91. watson/toolkit_api.py +347 -0
  92. watson/toolkit_automation.py +95 -0
  93. watson/toolkit_registry.py +345 -0
  94. watson/verification/__init__.py +251 -0
  95. watson/web/__init__.py +1 -0
  96. watson/web/app.py +1650 -0
  97. watson/web/middleware.py +301 -0
  98. watson/web/static/assets/index-B7hPOc0z.js +278 -0
  99. watson/web/static/assets/index-CJ6FP8Mp.css +1 -0
  100. watson/web/static/assets/watson-logo-Dk9tawHb.png +0 -0
  101. watson/web/static/index.html +14 -0
  102. watson/web/templates/chat.html +2 -0
  103. watson/web/templates/investigation-map.html +429 -0
watson/scheduler.py ADDED
@@ -0,0 +1,486 @@
1
+ """Autonomous Scheduler — recurring target monitoring for Watson.
2
+
3
+ Schedules investigations to run on a timer. Each tick:
4
+ 1. Runs the full investigation pipeline (reason → dispatch → cross-ref)
5
+ 2. Compares findings against past results (memory engine diff)
6
+ 3. If new findings or risk level changes → alerts
7
+ 4. Saves results to memory
8
+
9
+ Jobs are stored in ~/.watson/scheduler.db with SQLite.
10
+ The scheduler runs in a background thread, waking every 30s to check.
11
+
12
+ Inspired by Hermes Agent's cronjob system — fully autonomous,
13
+ no user interaction needed during runs.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import logging
21
+ import sqlite3
22
+ import threading
23
+ import time
24
+ import uuid
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Callable, Optional
29
+
30
+ logger = logging.getLogger("watson.scheduler")
31
+
32
+
33
+ @dataclass
34
+ class ScheduledJob:
35
+ """A recurring investigation target."""
36
+ id: str
37
+ query: str
38
+ interval_minutes: int
39
+ target_type: str = "unknown"
40
+ enabled: bool = True
41
+ last_run: str | None = None
42
+ next_run: str | None = None
43
+ run_count: int = 0
44
+ alert_on_change: bool = True
45
+ alert_on_new_findings: bool = True
46
+ findings_threshold: int = 1 # min new findings to trigger alert
47
+ created_at: str = ""
48
+ metadata: dict = field(default_factory=dict)
49
+
50
+
51
+ class SchedulerDB:
52
+ """SQLite-backed job store."""
53
+
54
+ def __init__(self, db_path: Path):
55
+ self._db_path = db_path
56
+ db_path.parent.mkdir(parents=True, exist_ok=True)
57
+ self._init()
58
+
59
+ def _init(self):
60
+ with sqlite3.connect(str(self._db_path)) as conn:
61
+ conn.execute("""
62
+ CREATE TABLE IF NOT EXISTS jobs (
63
+ id TEXT PRIMARY KEY,
64
+ query TEXT NOT NULL,
65
+ interval_minutes INTEGER NOT NULL DEFAULT 60,
66
+ target_type TEXT DEFAULT 'unknown',
67
+ enabled INTEGER DEFAULT 1,
68
+ last_run TEXT,
69
+ next_run TEXT,
70
+ run_count INTEGER DEFAULT 0,
71
+ alert_on_change INTEGER DEFAULT 1,
72
+ alert_on_new_findings INTEGER DEFAULT 1,
73
+ findings_threshold INTEGER DEFAULT 1,
74
+ created_at TEXT NOT NULL,
75
+ metadata_json TEXT DEFAULT '{}'
76
+ )
77
+ """)
78
+ conn.execute("""
79
+ CREATE TABLE IF NOT EXISTS run_history (
80
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
81
+ job_id TEXT NOT NULL,
82
+ run_at TEXT NOT NULL,
83
+ findings_count INTEGER DEFAULT 0,
84
+ new_findings_count INTEGER DEFAULT 0,
85
+ risk_level_before TEXT,
86
+ risk_level_after TEXT,
87
+ duration_seconds REAL,
88
+ error TEXT,
89
+ FOREIGN KEY (job_id) REFERENCES jobs(id)
90
+ )
91
+ """)
92
+ conn.commit()
93
+
94
+ def create(self, job: ScheduledJob) -> str:
95
+ now = datetime.now(timezone.utc).isoformat()
96
+ with sqlite3.connect(str(self._db_path)) as conn:
97
+ conn.execute(
98
+ """INSERT INTO jobs
99
+ (id, query, interval_minutes, target_type, enabled,
100
+ last_run, next_run, run_count, alert_on_change,
101
+ alert_on_new_findings, findings_threshold, created_at, metadata_json)
102
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
103
+ (
104
+ job.id,
105
+ job.query,
106
+ job.interval_minutes,
107
+ job.target_type,
108
+ 1 if job.enabled else 0,
109
+ job.last_run,
110
+ job.next_run or now,
111
+ job.run_count,
112
+ 1 if job.alert_on_change else 0,
113
+ 1 if job.alert_on_new_findings else 0,
114
+ job.findings_threshold,
115
+ job.created_at or now,
116
+ json.dumps(job.metadata),
117
+ ),
118
+ )
119
+ conn.commit()
120
+ return job.id
121
+
122
+ def list_all(self) -> list[ScheduledJob]:
123
+ with sqlite3.connect(str(self._db_path)) as conn:
124
+ rows = conn.execute(
125
+ "SELECT * FROM jobs ORDER BY created_at DESC"
126
+ ).fetchall()
127
+
128
+ jobs = []
129
+ for r in rows:
130
+ jobs.append(ScheduledJob(
131
+ id=r[0], query=r[1], interval_minutes=r[2], target_type=r[3],
132
+ enabled=bool(r[4]), last_run=r[5], next_run=r[6], run_count=r[7],
133
+ alert_on_change=bool(r[8]), alert_on_new_findings=bool(r[9]),
134
+ findings_threshold=r[10], created_at=r[11],
135
+ metadata=json.loads(r[12]) if r[12] else {},
136
+ ))
137
+ return jobs
138
+
139
+ def get(self, job_id: str) -> ScheduledJob | None:
140
+ with sqlite3.connect(str(self._db_path)) as conn:
141
+ row = conn.execute(
142
+ "SELECT * FROM jobs WHERE id = ?", (job_id,)
143
+ ).fetchone()
144
+ if not row:
145
+ return None
146
+ return ScheduledJob(
147
+ id=row[0], query=row[1], interval_minutes=row[2], target_type=row[3],
148
+ enabled=bool(row[4]), last_run=row[5], next_run=row[6], run_count=row[7],
149
+ alert_on_change=bool(row[8]), alert_on_new_findings=bool(row[9]),
150
+ findings_threshold=row[10], created_at=row[11],
151
+ metadata=json.loads(row[12]) if row[12] else {},
152
+ )
153
+
154
+ def update(self, job_id: str, **kwargs):
155
+ allowed = {
156
+ "enabled", "last_run", "next_run", "run_count", "interval_minutes",
157
+ "alert_on_change", "alert_on_new_findings", "findings_threshold",
158
+ }
159
+ updates = {k: v for k, v in kwargs.items() if k in allowed}
160
+ if not updates:
161
+ return
162
+
163
+ set_clause = ", ".join(f"{k} = ?" for k in updates)
164
+ values = list(updates.values()) + [job_id]
165
+
166
+ with sqlite3.connect(str(self._db_path)) as conn:
167
+ conn.execute(
168
+ f"UPDATE jobs SET {set_clause} WHERE id = ?", values
169
+ )
170
+ conn.commit()
171
+
172
+ def delete(self, job_id: str):
173
+ with sqlite3.connect(str(self._db_path)) as conn:
174
+ conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
175
+ conn.execute("DELETE FROM run_history WHERE job_id = ?", (job_id,))
176
+ conn.commit()
177
+
178
+ def log_run(
179
+ self, job_id: str, findings_count: int, new_findings: int,
180
+ risk_before: str, risk_after: str, duration: float, error: str = "",
181
+ ):
182
+ now = datetime.now(timezone.utc).isoformat()
183
+ with sqlite3.connect(str(self._db_path)) as conn:
184
+ conn.execute(
185
+ """INSERT INTO run_history
186
+ (job_id, run_at, findings_count, new_findings_count,
187
+ risk_level_before, risk_level_after, duration_seconds, error)
188
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
189
+ (job_id, now, findings_count, new_findings, risk_before,
190
+ risk_after, duration, error),
191
+ )
192
+ conn.commit()
193
+
194
+ def get_history(self, job_id: str, limit: int = 20) -> list[dict]:
195
+ with sqlite3.connect(str(self._db_path)) as conn:
196
+ rows = conn.execute(
197
+ """SELECT run_at, findings_count, new_findings_count,
198
+ risk_level_before, risk_level_after, duration_seconds, error
199
+ FROM run_history WHERE job_id = ?
200
+ ORDER BY run_at DESC LIMIT ?""",
201
+ (job_id, limit),
202
+ ).fetchall()
203
+ return [
204
+ {
205
+ "run_at": r[0], "findings_count": r[1],
206
+ "new_findings_count": r[2], "risk_before": r[3],
207
+ "risk_after": r[4], "duration": r[5], "error": r[6],
208
+ }
209
+ for r in rows
210
+ ]
211
+
212
+
213
+ class Scheduler:
214
+ """Background scheduler for recurring OSINT investigations.
215
+
216
+ Usage:
217
+ sched = Scheduler(investigate_fn=my_investigate_function)
218
+ sched.start() # begins checking every 30s
219
+ """
220
+
221
+ def __init__(
222
+ self,
223
+ db_path: Path | None = None,
224
+ investigate_fn: Callable | None = None,
225
+ on_alert: Callable | None = None,
226
+ ):
227
+ if db_path is None:
228
+ db_path = Path.home() / ".watson" / "scheduler.db"
229
+ self._db = SchedulerDB(db_path)
230
+ self._investigate_fn = investigate_fn
231
+ self._on_alert = on_alert
232
+ self._thread: threading.Thread | None = None
233
+ self._running = False
234
+ self._lock = threading.Lock()
235
+
236
+ @property
237
+ def running(self) -> bool:
238
+ return self._running
239
+
240
+ def start(self):
241
+ """Start the scheduler in a background thread."""
242
+ if self._running:
243
+ return
244
+ self._running = True
245
+ self._thread = threading.Thread(target=self._loop, daemon=True)
246
+ self._thread.start()
247
+ logger.info("Scheduler started")
248
+
249
+ def stop(self):
250
+ """Stop the scheduler."""
251
+ self._running = False
252
+ if self._thread:
253
+ self._thread.join(timeout=5)
254
+ logger.info("Scheduler stopped")
255
+
256
+ def _loop(self):
257
+ """Main scheduler loop — checks for due jobs every 30s."""
258
+ while self._running:
259
+ try:
260
+ self._tick()
261
+ except Exception as e:
262
+ logger.error(f"Scheduler tick error: {e}")
263
+ time.sleep(30)
264
+
265
+ def _tick(self):
266
+ """Check for and execute due jobs."""
267
+ jobs = self._db.list_all()
268
+ now_utc = datetime.now(timezone.utc)
269
+
270
+ for job in jobs:
271
+ if not job.enabled:
272
+ continue
273
+ if not job.next_run:
274
+ continue
275
+
276
+ next_run = datetime.fromisoformat(job.next_run.replace("Z", "+00:00"))
277
+ if now_utc < next_run:
278
+ continue
279
+
280
+ # Job is due — execute with lock
281
+ with self._lock:
282
+ try:
283
+ self._execute_job(job)
284
+ except Exception as e:
285
+ logger.error(f"Job {job.id} failed: {e}")
286
+ self._db.log_run(
287
+ job.id, 0, 0, "", "", 0, str(e)[:200]
288
+ )
289
+
290
+ def _execute_job(self, job: ScheduledJob):
291
+ """Execute a single scheduled investigation."""
292
+ start = time.time()
293
+
294
+ # Update run tracking
295
+ now = datetime.now(timezone.utc)
296
+ self._db.update(
297
+ job.id,
298
+ last_run=now.isoformat(),
299
+ run_count=job.run_count + 1,
300
+ )
301
+
302
+ # Get previous risk level from memory
303
+ try:
304
+ from .memory import memory as mem
305
+ prev_ctx = mem.get_context_for_target(job.query)
306
+ prev_risk = prev_ctx["past_investigations"][0]["risk_level"] if prev_ctx and prev_ctx["past_investigations"] else "unknown"
307
+ except Exception:
308
+ prev_risk = "unknown"
309
+
310
+ error = ""
311
+
312
+ try:
313
+ # Run investigation
314
+ if self._investigate_fn:
315
+ result = self._investigate_fn(job.query)
316
+ else:
317
+ result = {}
318
+
319
+ duration = time.time() - start
320
+ findings_count = result.get("findings", 0) if result else 0
321
+ new_risk = result.get("risk_level", "unknown") if result else "unknown"
322
+
323
+ # Determine new findings (compared to previous run)
324
+ new_findings = self._count_new_findings(job.id, result)
325
+
326
+ # Log
327
+ self._db.log_run(
328
+ job.id, findings_count, new_findings,
329
+ prev_risk, new_risk, duration, "",
330
+ )
331
+
332
+ # Alert if needed
333
+ if self._on_alert:
334
+ should_alert = False
335
+ reason = ""
336
+
337
+ if job.alert_on_new_findings and new_findings >= job.findings_threshold:
338
+ should_alert = True
339
+ reason = f"{new_findings} new findings"
340
+ if job.alert_on_change and new_risk != prev_risk and prev_risk != "unknown":
341
+ should_alert = True
342
+ reason = f"Risk changed from {prev_risk} → {new_risk}"
343
+
344
+ if should_alert:
345
+ self._on_alert({
346
+ "job_id": job.id,
347
+ "query": job.query,
348
+ "reason": reason,
349
+ "findings_count": findings_count,
350
+ "new_findings": new_findings,
351
+ "risk_before": prev_risk,
352
+ "risk_after": new_risk,
353
+ "timestamp": now.isoformat(),
354
+ })
355
+
356
+ except Exception as e:
357
+ duration = time.time() - start
358
+ error = str(e)[:200]
359
+ self._db.log_run(job.id, 0, 0, prev_risk, "error", duration, error)
360
+
361
+ # Schedule next run
362
+ next_run = datetime.now(timezone.utc)
363
+ from datetime import timedelta
364
+ next_run += timedelta(minutes=job.interval_minutes)
365
+ self._db.update(job.id, next_run=next_run.isoformat())
366
+
367
+ def _count_new_findings(self, job_id: str, result: dict | None) -> int:
368
+ """Count findings that are new compared to the last run."""
369
+ if not result:
370
+ return 0
371
+
372
+ history = self._db.get_history(job_id, limit=1)
373
+ if not history or history[0]["findings_count"] == 0:
374
+ return result.get("findings", 0)
375
+
376
+ # Simple heuristic: if findings count changed significantly
377
+ current = result.get("findings", 0)
378
+ previous = history[0]["findings_count"]
379
+ diff = max(0, current - previous)
380
+ return diff
381
+
382
+ # ── Public API ────────────────────────────────────────────────
383
+
384
+ def add_job(
385
+ self,
386
+ query: str,
387
+ interval_minutes: int = 60,
388
+ target_type: str = "unknown",
389
+ alert_on_change: bool = True,
390
+ alert_on_new_findings: bool = True,
391
+ findings_threshold: int = 1,
392
+ metadata: dict | None = None,
393
+ ) -> str:
394
+ """Schedule a new recurring investigation.
395
+
396
+ Args:
397
+ query: Investigation target
398
+ interval_minutes: How often to run (minimum: 5)
399
+ target_type: person/domain/company/etc.
400
+ alert_on_change: Alert if risk level changes
401
+ alert_on_new_findings: Alert if new findings appear
402
+ findings_threshold: Min new findings to trigger alert
403
+ metadata: Optional extra data
404
+ """
405
+ interval_minutes = max(5, interval_minutes)
406
+
407
+ now = datetime.now(timezone.utc)
408
+ next_run = now.isoformat()
409
+
410
+ job = ScheduledJob(
411
+ id=str(uuid.uuid4())[:12],
412
+ query=query,
413
+ interval_minutes=interval_minutes,
414
+ target_type=target_type,
415
+ enabled=True,
416
+ last_run=None,
417
+ next_run=next_run,
418
+ run_count=0,
419
+ alert_on_change=alert_on_change,
420
+ alert_on_new_findings=alert_on_new_findings,
421
+ findings_threshold=findings_threshold,
422
+ created_at=now.isoformat(),
423
+ metadata=metadata or {},
424
+ )
425
+ return self._db.create(job)
426
+
427
+ def list_jobs(self) -> list[dict]:
428
+ """List all scheduled jobs."""
429
+ jobs = self._db.list_all()
430
+ return [
431
+ {
432
+ "id": j.id,
433
+ "query": j.query,
434
+ "interval_minutes": j.interval_minutes,
435
+ "target_type": j.target_type,
436
+ "enabled": j.enabled,
437
+ "last_run": j.last_run,
438
+ "next_run": j.next_run,
439
+ "run_count": j.run_count,
440
+ "alert_on_change": j.alert_on_change,
441
+ "alert_on_new_findings": j.alert_on_new_findings,
442
+ "created_at": j.created_at,
443
+ }
444
+ for j in jobs
445
+ ]
446
+
447
+ def get_job(self, job_id: str) -> dict | None:
448
+ """Get a job with its run history."""
449
+ job = self._db.get(job_id)
450
+ if not job:
451
+ return None
452
+ history = self._db.get_history(job_id)
453
+ return {
454
+ "id": job.id,
455
+ "query": job.query,
456
+ "interval_minutes": job.interval_minutes,
457
+ "target_type": job.target_type,
458
+ "enabled": job.enabled,
459
+ "last_run": job.last_run,
460
+ "next_run": job.next_run,
461
+ "run_count": job.run_count,
462
+ "alert_on_change": job.alert_on_change,
463
+ "alert_on_new_findings": job.alert_on_new_findings,
464
+ "findings_threshold": job.findings_threshold,
465
+ "created_at": job.created_at,
466
+ "history": history,
467
+ }
468
+
469
+ def toggle_job(self, job_id: str, enabled: bool):
470
+ """Enable or disable a job."""
471
+ self._db.update(job_id, enabled=enabled)
472
+
473
+ def remove_job(self, job_id: str):
474
+ """Delete a scheduled job."""
475
+ self._db.delete(job_id)
476
+
477
+ def run_now(self, job_id: str):
478
+ """Force an immediate run of a scheduled job."""
479
+ job = self._db.get(job_id)
480
+ if not job:
481
+ return
482
+ # Set next_run to now so it executes on next tick
483
+ self._db.update(
484
+ job_id,
485
+ next_run=datetime.now(timezone.utc).isoformat(),
486
+ )