delimit-cli 4.0.2 → 4.0.4

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.
@@ -0,0 +1,290 @@
1
+ """SIEM Streaming — forward audit trail events to external security tools.
2
+
3
+ LED-280: Enterprise CISOs need audit logs in their existing SIEM, not just our dashboard.
4
+ Supports Splunk HEC, Datadog Logs API, and AWS EventBridge.
5
+
6
+ Config stored at ~/.delimit/siem.json. Each integration can be enabled independently.
7
+ Events are forwarded in real-time as they're written to the audit trail.
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ logger = logging.getLogger("delimit.ai.siem_streaming")
18
+
19
+ SIEM_CONFIG_PATH = Path.home() / ".delimit" / "siem.json"
20
+ SIEM_LOG_PATH = Path.home() / ".delimit" / "siem_delivery.jsonl"
21
+
22
+ DEFAULT_CONFIG = {
23
+ "splunk": {
24
+ "enabled": False,
25
+ "hec_url": "",
26
+ "hec_token": "",
27
+ "index": "delimit",
28
+ "source": "delimit-governance",
29
+ "sourcetype": "_json",
30
+ },
31
+ "datadog": {
32
+ "enabled": False,
33
+ "api_key": "",
34
+ "site": "datadoghq.com",
35
+ "service": "delimit",
36
+ "source": "delimit-governance",
37
+ "tags": ["env:production"],
38
+ },
39
+ "eventbridge": {
40
+ "enabled": False,
41
+ "bus_name": "default",
42
+ "region": "us-east-1",
43
+ "source": "delimit.governance",
44
+ "detail_type": "GovernanceEvent",
45
+ },
46
+ "webhook": {
47
+ "enabled": False,
48
+ "url": "",
49
+ "headers": {},
50
+ "method": "POST",
51
+ },
52
+ }
53
+
54
+
55
+ def _load_config() -> Dict[str, Any]:
56
+ if SIEM_CONFIG_PATH.exists():
57
+ try:
58
+ return json.loads(SIEM_CONFIG_PATH.read_text())
59
+ except json.JSONDecodeError:
60
+ pass
61
+ return dict(DEFAULT_CONFIG)
62
+
63
+
64
+ def _save_config(config: Dict[str, Any]) -> None:
65
+ SIEM_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
66
+ SIEM_CONFIG_PATH.write_text(json.dumps(config, indent=2))
67
+
68
+
69
+ def _log_delivery(integration: str, event_id: str, status: str, error: str = "") -> None:
70
+ try:
71
+ SIEM_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
72
+ entry = {
73
+ "timestamp": time.time(),
74
+ "integration": integration,
75
+ "event_id": event_id,
76
+ "status": status,
77
+ "error": error,
78
+ }
79
+ with open(SIEM_LOG_PATH, "a") as f:
80
+ f.write(json.dumps(entry) + "\n")
81
+ except Exception:
82
+ pass
83
+
84
+
85
+ def configure(
86
+ integration: str,
87
+ settings: Optional[Dict[str, Any]] = None,
88
+ enabled: Optional[bool] = None,
89
+ ) -> Dict[str, Any]:
90
+ """Configure a SIEM integration.
91
+
92
+ Args:
93
+ integration: splunk, datadog, eventbridge, or webhook
94
+ settings: Key-value pairs to update (e.g. {"hec_url": "...", "hec_token": "..."})
95
+ enabled: Enable or disable the integration
96
+ """
97
+ if integration not in DEFAULT_CONFIG:
98
+ return {"error": f"Unknown integration: {integration}. Choose: splunk, datadog, eventbridge, webhook"}
99
+
100
+ config = _load_config()
101
+ if integration not in config:
102
+ config[integration] = dict(DEFAULT_CONFIG[integration])
103
+
104
+ if settings:
105
+ config[integration].update(settings)
106
+ if enabled is not None:
107
+ config[integration]["enabled"] = enabled
108
+
109
+ _save_config(config)
110
+
111
+ # Mask secrets in response
112
+ safe = dict(config[integration])
113
+ for key in ("hec_token", "api_key"):
114
+ if key in safe and safe[key]:
115
+ safe[key] = safe[key][:4] + "***" + safe[key][-4:]
116
+
117
+ return {
118
+ "status": "configured",
119
+ "integration": integration,
120
+ "config": safe,
121
+ }
122
+
123
+
124
+ def get_status() -> Dict[str, Any]:
125
+ """Get status of all SIEM integrations."""
126
+ config = _load_config()
127
+ integrations = {}
128
+
129
+ for name, settings in config.items():
130
+ if not isinstance(settings, dict):
131
+ continue
132
+ enabled = settings.get("enabled", False)
133
+ healthy = False
134
+
135
+ if enabled:
136
+ if name == "splunk":
137
+ healthy = bool(settings.get("hec_url") and settings.get("hec_token"))
138
+ elif name == "datadog":
139
+ healthy = bool(settings.get("api_key"))
140
+ elif name == "eventbridge":
141
+ healthy = bool(settings.get("bus_name"))
142
+ elif name == "webhook":
143
+ healthy = bool(settings.get("url"))
144
+
145
+ integrations[name] = {
146
+ "enabled": enabled,
147
+ "healthy": healthy if enabled else None,
148
+ "status": "active" if (enabled and healthy) else "misconfigured" if enabled else "disabled",
149
+ }
150
+
151
+ # Delivery stats
152
+ delivery_count = 0
153
+ delivery_errors = 0
154
+ if SIEM_LOG_PATH.exists():
155
+ for line in SIEM_LOG_PATH.read_text().strip().split("\n")[-100:]:
156
+ try:
157
+ entry = json.loads(line)
158
+ delivery_count += 1
159
+ if entry.get("status") == "error":
160
+ delivery_errors += 1
161
+ except json.JSONDecodeError:
162
+ continue
163
+
164
+ return {
165
+ "integrations": integrations,
166
+ "active_count": sum(1 for i in integrations.values() if i["status"] == "active"),
167
+ "total_deliveries": delivery_count,
168
+ "delivery_errors": delivery_errors,
169
+ }
170
+
171
+
172
+ def forward_event(event: Dict[str, Any]) -> Dict[str, Any]:
173
+ """Forward a governance event to all enabled SIEM integrations.
174
+
175
+ Called automatically by the audit trail when events are recorded.
176
+ Returns delivery status per integration.
177
+ """
178
+ config = _load_config()
179
+ results = {}
180
+ event_id = event.get("id", str(time.time()))
181
+
182
+ for name, settings in config.items():
183
+ if not isinstance(settings, dict) or not settings.get("enabled"):
184
+ continue
185
+
186
+ try:
187
+ if name == "splunk":
188
+ results[name] = _forward_splunk(event, settings, event_id)
189
+ elif name == "datadog":
190
+ results[name] = _forward_datadog(event, settings, event_id)
191
+ elif name == "eventbridge":
192
+ results[name] = _forward_eventbridge(event, settings, event_id)
193
+ elif name == "webhook":
194
+ results[name] = _forward_webhook(event, settings, event_id)
195
+ except Exception as e:
196
+ results[name] = {"status": "error", "error": str(e)}
197
+ _log_delivery(name, event_id, "error", str(e))
198
+
199
+ return {
200
+ "event_id": event_id,
201
+ "forwarded_to": list(results.keys()),
202
+ "results": results,
203
+ }
204
+
205
+
206
+ def _forward_splunk(event: Dict, settings: Dict, event_id: str) -> Dict:
207
+ """Forward to Splunk via HTTP Event Collector (HEC)."""
208
+ import urllib.request
209
+
210
+ payload = json.dumps({
211
+ "event": event,
212
+ "source": settings.get("source", "delimit-governance"),
213
+ "sourcetype": settings.get("sourcetype", "_json"),
214
+ "index": settings.get("index", "delimit"),
215
+ }).encode()
216
+
217
+ req = urllib.request.Request(
218
+ settings["hec_url"],
219
+ data=payload,
220
+ headers={
221
+ "Authorization": f"Splunk {settings['hec_token']}",
222
+ "Content-Type": "application/json",
223
+ },
224
+ )
225
+ resp = urllib.request.urlopen(req, timeout=10)
226
+ _log_delivery("splunk", event_id, "ok")
227
+ return {"status": "ok", "http_code": resp.status}
228
+
229
+
230
+ def _forward_datadog(event: Dict, settings: Dict, event_id: str) -> Dict:
231
+ """Forward to Datadog Logs API."""
232
+ import urllib.request
233
+
234
+ site = settings.get("site", "datadoghq.com")
235
+ payload = json.dumps([{
236
+ "ddsource": settings.get("source", "delimit-governance"),
237
+ "ddtags": ",".join(settings.get("tags", [])),
238
+ "service": settings.get("service", "delimit"),
239
+ "message": json.dumps(event),
240
+ }]).encode()
241
+
242
+ req = urllib.request.Request(
243
+ f"https://http-intake.logs.{site}/api/v2/logs",
244
+ data=payload,
245
+ headers={
246
+ "DD-API-KEY": settings["api_key"],
247
+ "Content-Type": "application/json",
248
+ },
249
+ )
250
+ resp = urllib.request.urlopen(req, timeout=10)
251
+ _log_delivery("datadog", event_id, "ok")
252
+ return {"status": "ok", "http_code": resp.status}
253
+
254
+
255
+ def _forward_eventbridge(event: Dict, settings: Dict, event_id: str) -> Dict:
256
+ """Forward to AWS EventBridge."""
257
+ try:
258
+ import boto3
259
+ client = boto3.client("events", region_name=settings.get("region", "us-east-1"))
260
+ response = client.put_events(Entries=[{
261
+ "Source": settings.get("source", "delimit.governance"),
262
+ "DetailType": settings.get("detail_type", "GovernanceEvent"),
263
+ "Detail": json.dumps(event),
264
+ "EventBusName": settings.get("bus_name", "default"),
265
+ }])
266
+ failed = response.get("FailedEntryCount", 0)
267
+ status = "ok" if failed == 0 else "partial"
268
+ _log_delivery("eventbridge", event_id, status)
269
+ return {"status": status, "failed_entries": failed}
270
+ except ImportError:
271
+ _log_delivery("eventbridge", event_id, "error", "boto3 not installed")
272
+ return {"status": "error", "error": "boto3 not installed. Run: pip install boto3"}
273
+
274
+
275
+ def _forward_webhook(event: Dict, settings: Dict, event_id: str) -> Dict:
276
+ """Forward to a generic webhook URL."""
277
+ import urllib.request
278
+
279
+ headers = {"Content-Type": "application/json"}
280
+ headers.update(settings.get("headers", {}))
281
+
282
+ req = urllib.request.Request(
283
+ settings["url"],
284
+ data=json.dumps(event).encode(),
285
+ headers=headers,
286
+ method=settings.get("method", "POST"),
287
+ )
288
+ resp = urllib.request.urlopen(req, timeout=10)
289
+ _log_delivery("webhook", event_id, "ok")
290
+ return {"status": "ok", "http_code": resp.status}
@@ -0,0 +1,189 @@
1
+ """
2
+ Social sensing daemon for Delimit.
3
+
4
+ Runs social discovery scans (X, Reddit, GitHub, Dev.to) on a regular interval.
5
+ Deduplicates findings and emits HTML draft emails for human approval.
6
+ Also monitors for direct replies to owned posts (LED-300).
7
+
8
+ Consensus 123: Part of the continuous sensing loop.
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import threading
15
+ import time
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ logger = logging.getLogger("delimit.ai.social_daemon")
21
+
22
+ # ── Configuration ────────────────────────────────────────────────────
23
+ # Default to 15 minutes (900 seconds)
24
+ SCAN_INTERVAL = int(os.environ.get("DELIMIT_SOCIAL_SCAN_INTERVAL", "900"))
25
+ MAX_CONSECUTIVE_FAILURES = 3
26
+
27
+ ALERTS_DIR = Path.home() / ".delimit" / "alerts"
28
+ ALERT_FILE = ALERTS_DIR / "social_daemon.json"
29
+ DAEMON_STATE = Path.home() / ".delimit" / "social_daemon_state.json"
30
+ OWNER_ACTION_SUMMARY = Path.home() / ".delimit" / "owner_action_summary.json"
31
+
32
+ class SocialDaemonState:
33
+ """Thread-safe state for the social sensing daemon."""
34
+
35
+ def __init__(self):
36
+ self.running = False
37
+ self.last_scan: Optional[str] = None
38
+ self.targets_found: int = 0
39
+ self.consecutive_failures: int = 0
40
+ self.total_scans: int = 0
41
+ self.stopped_reason: Optional[str] = None
42
+ self._lock = threading.Lock()
43
+ self._thread: Optional[threading.Thread] = None
44
+ self._stop_event = threading.Event()
45
+
46
+ def to_dict(self) -> Dict[str, Any]:
47
+ with self._lock:
48
+ return {
49
+ "running": self.running,
50
+ "last_scan": self.last_scan,
51
+ "targets_found": self.targets_found,
52
+ "consecutive_failures": self.consecutive_failures,
53
+ "total_scans": self.total_scans,
54
+ "stopped_reason": self.stopped_reason,
55
+ "scan_interval_seconds": SCAN_INTERVAL,
56
+ }
57
+
58
+ def record_success(self, found: int):
59
+ with self._lock:
60
+ self.consecutive_failures = 0
61
+ self.targets_found += found
62
+ self.total_scans += 1
63
+ self.last_scan = datetime.now(timezone.utc).isoformat()
64
+
65
+ def record_failure(self) -> int:
66
+ with self._lock:
67
+ self.consecutive_failures += 1
68
+ self.total_scans += 1
69
+ self.last_scan = datetime.now(timezone.utc).isoformat()
70
+ return self.consecutive_failures
71
+
72
+ _daemon_state = SocialDaemonState()
73
+
74
+ def scan_once() -> Dict[str, Any]:
75
+ """Execute a single social scan cycle and process results (LED-238)."""
76
+ from ai.social_target import scan_targets, process_targets
77
+
78
+ try:
79
+ # 1. DISCOVER: Scan all platforms
80
+ targets = scan_targets(platforms=["x", "reddit", "github", "hn", "devto"])
81
+ found = len(targets)
82
+
83
+ # 2. ORCHESTRATE: Process discovered targets (LED-238)
84
+ # draft_replies=True -> emits social_draft emails
85
+ # create_ledger=True -> creates strategic ledger items
86
+ processed = process_targets(targets, draft_replies=True, create_ledger=True)
87
+ OWNER_ACTION_SUMMARY.parent.mkdir(parents=True, exist_ok=True)
88
+ OWNER_ACTION_SUMMARY.write_text(json.dumps({
89
+ "timestamp": datetime.now(timezone.utc).isoformat(),
90
+ "targets_found": found,
91
+ "owner_actions": len(processed.get("owner_actions", [])),
92
+ "drafted": len(processed.get("drafted", [])),
93
+ "ledger_items": len(processed.get("ledger_items", [])),
94
+ "strategy_items": len(processed.get("strategy_items", [])),
95
+ }, indent=2) + "\n")
96
+
97
+ _daemon_state.record_success(found)
98
+ return {
99
+ "targets_found": found,
100
+ "processed": processed
101
+ }
102
+ except Exception as e:
103
+ failures = _daemon_state.record_failure()
104
+ logger.error("Social scan failed: %s", e)
105
+ if failures >= MAX_CONSECUTIVE_FAILURES:
106
+ reason = f"3 consecutive social scan failures. Last: {e}"
107
+ _daemon_state.stopped_reason = reason
108
+ _daemon_state.running = False
109
+ _daemon_state._stop_event.set()
110
+ return {"error": str(e), "consecutive_failures": failures}
111
+
112
+ def _daemon_loop() -> None:
113
+ """Main scanning loop."""
114
+ logger.info("Social daemon started. Scanning every %d seconds.", SCAN_INTERVAL)
115
+
116
+ while not _daemon_state._stop_event.is_set():
117
+ try:
118
+ result = scan_once()
119
+ if "error" in result:
120
+ logger.warning("Scan cycle error: %s", result["error"])
121
+ else:
122
+ logger.info("Scan complete: %d new targets", result.get("targets_found", 0))
123
+ except Exception as e:
124
+ logger.error("Unexpected error in social daemon loop: %s", e)
125
+ failures = _daemon_state.record_failure()
126
+ if failures >= MAX_CONSECUTIVE_FAILURES:
127
+ break
128
+
129
+ _daemon_state._stop_event.wait(timeout=SCAN_INTERVAL)
130
+
131
+ _daemon_state.running = False
132
+ logger.info("Social daemon stopped.")
133
+
134
+ def start_daemon() -> Dict[str, Any]:
135
+ """Start the social daemon in a background thread."""
136
+ if _daemon_state.running:
137
+ return {"status": "already_running", **_daemon_state.to_dict()}
138
+
139
+ _daemon_state.running = True
140
+ _daemon_state.stopped_reason = None
141
+ _daemon_state.consecutive_failures = 0
142
+ _daemon_state._stop_event.clear()
143
+
144
+ thread = threading.Thread(target=_daemon_loop, name="social-daemon", daemon=True)
145
+ _daemon_state._thread = thread
146
+ thread.start()
147
+
148
+ return {"status": "started", **_daemon_state.to_dict()}
149
+
150
+ def stop_daemon() -> Dict[str, Any]:
151
+ """Stop the social daemon."""
152
+ if not _daemon_state.running:
153
+ return {"status": "not_running", **_daemon_state.to_dict()}
154
+
155
+ _daemon_state._stop_event.set()
156
+ _daemon_state.stopped_reason = "manual_stop"
157
+ if _daemon_state._thread:
158
+ _daemon_state._thread.join(timeout=5)
159
+ _daemon_state.running = False
160
+
161
+ return {"status": "stopped", **_daemon_state.to_dict()}
162
+
163
+ def get_daemon_status() -> Dict[str, Any]:
164
+ """Get current daemon status."""
165
+ return _daemon_state.to_dict()
166
+
167
+ def main():
168
+ """Run as standalone process."""
169
+ import argparse
170
+ parser = argparse.ArgumentParser(description="Delimit social sensing daemon")
171
+ parser.add_argument("--interval", type=int, help="Scan interval in seconds")
172
+ parser.add_argument("--once", action="store_true", help="Run once and exit")
173
+ args = parser.parse_args()
174
+
175
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s")
176
+
177
+ if args.interval:
178
+ global SCAN_INTERVAL
179
+ SCAN_INTERVAL = args.interval
180
+
181
+ if args.once:
182
+ scan_once()
183
+ return
184
+
185
+ _daemon_state.running = True
186
+ _daemon_loop()
187
+
188
+ if __name__ == "__main__":
189
+ main()