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
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
|
+
)
|