@simbimbo/brainstem 0.0.3 → 0.0.5
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.
- package/CHANGELOG.md +26 -0
- package/README.md +26 -0
- package/brainstem/__init__.py +1 -1
- package/brainstem/adapters.py +120 -0
- package/brainstem/api.py +468 -57
- package/brainstem/config.py +136 -0
- package/brainstem/connectors/logicmonitor.py +57 -0
- package/brainstem/demo.py +16 -2
- package/brainstem/fingerprint.py +54 -0
- package/brainstem/ingest.py +440 -33
- package/brainstem/interesting.py +56 -1
- package/brainstem/listener.py +181 -0
- package/brainstem/models.py +1 -0
- package/brainstem/recurrence.py +63 -9
- package/brainstem/scoring.py +6 -4
- package/brainstem/source_drivers.py +179 -0
- package/brainstem/storage.py +389 -12
- package/docs/README.md +103 -0
- package/docs/api.md +260 -280
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/tests/test_adapters.py +95 -0
- package/tests/test_api.py +812 -0
- package/tests/test_canonicalization.py +8 -0
- package/tests/test_config.py +39 -0
- package/tests/test_file_ingest.py +77 -0
- package/tests/test_fingerprint.py +51 -1
- package/tests/test_interesting.py +10 -0
- package/tests/test_listener.py +253 -0
- package/tests/test_logicmonitor.py +54 -1
- package/tests/test_recurrence.py +16 -0
- package/tests/test_source_drivers.py +95 -0
- package/tests/test_storage.py +178 -1
package/brainstem/storage.py
CHANGED
|
@@ -2,18 +2,111 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import sqlite3
|
|
5
|
+
from datetime import datetime, timezone
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import Any, Iterable, List
|
|
7
8
|
|
|
8
9
|
from .models import Candidate, Event, RawInputEnvelope, Signature
|
|
10
|
+
from .config import resolve_default_db_path
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
def default_db_path() -> Path:
|
|
12
|
-
return Path(
|
|
14
|
+
return Path(resolve_default_db_path())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _snapshot_timestamp() -> tuple[str, str]:
|
|
18
|
+
now = datetime.now(timezone.utc)
|
|
19
|
+
created_at = now.isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
20
|
+
compact_timestamp = now.strftime("%Y%m%dT%H%M%S%fZ")
|
|
21
|
+
return created_at, compact_timestamp
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def create_sqlite_snapshot(db_path: str | None = None) -> dict[str, str | int]:
|
|
25
|
+
source_path = Path(db_path) if db_path else default_db_path()
|
|
26
|
+
if not source_path.exists():
|
|
27
|
+
raise FileNotFoundError(f"source database does not exist: {source_path}")
|
|
28
|
+
if not source_path.is_file():
|
|
29
|
+
raise ValueError(f"source path is not a file: {source_path}")
|
|
30
|
+
|
|
31
|
+
created_at, compact_timestamp = _snapshot_timestamp()
|
|
32
|
+
snapshot_name = f"{source_path.stem}.snapshot.{compact_timestamp}{source_path.suffix}"
|
|
33
|
+
snapshot_path = source_path.with_name(snapshot_name)
|
|
34
|
+
|
|
35
|
+
source_connection = sqlite3.connect(str(source_path))
|
|
36
|
+
snapshot_connection = sqlite3.connect(str(snapshot_path))
|
|
37
|
+
try:
|
|
38
|
+
source_connection.backup(snapshot_connection)
|
|
39
|
+
finally:
|
|
40
|
+
snapshot_connection.close()
|
|
41
|
+
source_connection.close()
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"source_path": str(source_path),
|
|
45
|
+
"snapshot_path": str(snapshot_path),
|
|
46
|
+
"size": snapshot_path.stat().st_size,
|
|
47
|
+
"created_at": created_at,
|
|
48
|
+
}
|
|
13
49
|
|
|
14
50
|
|
|
15
51
|
RAW_ENVELOPE_STATUSES = ("received", "canonicalized", "parse_failed", "unsupported")
|
|
16
52
|
RAW_ENVELOPE_FAILURE_STATUSES = ("parse_failed", "unsupported")
|
|
53
|
+
MAINTENANCE_TABLES = ("events", "raw_envelopes", "signatures", "candidates")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _coerce_raw_envelope_id(value: Any) -> int | None:
|
|
57
|
+
if isinstance(value, bool):
|
|
58
|
+
return None
|
|
59
|
+
if isinstance(value, int):
|
|
60
|
+
return value
|
|
61
|
+
if isinstance(value, str):
|
|
62
|
+
value = value.strip()
|
|
63
|
+
if not value.isdigit():
|
|
64
|
+
return None
|
|
65
|
+
return int(value)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _coerce_raw_envelope_id_list(raw_value: Any) -> List[int]:
|
|
70
|
+
if raw_value is None:
|
|
71
|
+
return []
|
|
72
|
+
if isinstance(raw_value, list):
|
|
73
|
+
ids = [_coerce_raw_envelope_id(item) for item in raw_value]
|
|
74
|
+
return [item for item in ids if item is not None]
|
|
75
|
+
if isinstance(raw_value, tuple):
|
|
76
|
+
ids = [_coerce_raw_envelope_id(item) for item in raw_value]
|
|
77
|
+
return [item for item in ids if item is not None]
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def extract_source_raw_envelope_ids(metadata_json: str | None) -> List[int]:
|
|
82
|
+
if not metadata_json:
|
|
83
|
+
return []
|
|
84
|
+
try:
|
|
85
|
+
metadata = json.loads(metadata_json)
|
|
86
|
+
except json.JSONDecodeError:
|
|
87
|
+
return []
|
|
88
|
+
if not isinstance(metadata, dict):
|
|
89
|
+
return []
|
|
90
|
+
return _coerce_raw_envelope_id_list(metadata.get("source_raw_envelope_ids"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def resolve_maintenance_tables(table_names: Iterable[str] | None = None) -> list[str]:
|
|
94
|
+
if table_names is None:
|
|
95
|
+
return list(MAINTENANCE_TABLES)
|
|
96
|
+
|
|
97
|
+
requested: list[str] = []
|
|
98
|
+
for table_name in table_names:
|
|
99
|
+
table_name = str(table_name).strip().lower()
|
|
100
|
+
if not table_name:
|
|
101
|
+
continue
|
|
102
|
+
if table_name == "all":
|
|
103
|
+
return list(MAINTENANCE_TABLES)
|
|
104
|
+
if table_name not in MAINTENANCE_TABLES:
|
|
105
|
+
raise ValueError(f"unsupported table for maintenance clear: {table_name}")
|
|
106
|
+
if table_name not in requested:
|
|
107
|
+
requested.append(table_name)
|
|
108
|
+
|
|
109
|
+
return requested or list(MAINTENANCE_TABLES)
|
|
17
110
|
|
|
18
111
|
|
|
19
112
|
def _validate_canonicalization_status(status: str) -> None:
|
|
@@ -101,6 +194,35 @@ def init_db(db_path: str | None = None) -> None:
|
|
|
101
194
|
conn.close()
|
|
102
195
|
|
|
103
196
|
|
|
197
|
+
def get_storage_counts(db_path: str | None = None) -> dict[str, int]:
|
|
198
|
+
init_db(db_path)
|
|
199
|
+
conn = connect(db_path)
|
|
200
|
+
try:
|
|
201
|
+
return {
|
|
202
|
+
table: _query_count(conn, f"SELECT COUNT(*) FROM {table}")
|
|
203
|
+
for table in MAINTENANCE_TABLES
|
|
204
|
+
}
|
|
205
|
+
finally:
|
|
206
|
+
conn.close()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def clear_storage_tables(db_path: str | None = None, *, tables: Iterable[str] | None = None) -> dict[str, int]:
|
|
210
|
+
resolved_tables = resolve_maintenance_tables(tables)
|
|
211
|
+
init_db(db_path)
|
|
212
|
+
conn = connect(db_path)
|
|
213
|
+
try:
|
|
214
|
+
counts: dict[str, int] = {}
|
|
215
|
+
for table in resolved_tables:
|
|
216
|
+
count = _query_count(conn, f"SELECT COUNT(*) FROM {table}")
|
|
217
|
+
conn.execute(f"DELETE FROM {table}")
|
|
218
|
+
conn.execute("DELETE FROM sqlite_sequence WHERE name = ?", (table,))
|
|
219
|
+
counts[table] = count
|
|
220
|
+
conn.commit()
|
|
221
|
+
return counts
|
|
222
|
+
finally:
|
|
223
|
+
conn.close()
|
|
224
|
+
|
|
225
|
+
|
|
104
226
|
def store_raw_envelopes(raw_envelopes: Iterable[RawInputEnvelope], db_path: str | None = None) -> List[int]:
|
|
105
227
|
conn = connect(db_path)
|
|
106
228
|
raw_ids: List[int] = []
|
|
@@ -176,17 +298,62 @@ def get_raw_envelope_by_id(raw_envelope_id: int, db_path: str | None = None) ->
|
|
|
176
298
|
conn.close()
|
|
177
299
|
|
|
178
300
|
|
|
301
|
+
def get_raw_envelopes_by_ids(
|
|
302
|
+
raw_envelope_ids: Iterable[int | str | object],
|
|
303
|
+
db_path: str | None = None,
|
|
304
|
+
) -> List[sqlite3.Row]:
|
|
305
|
+
ids = list(dict.fromkeys(_coerce_raw_envelope_id_list(raw_envelope_ids)))
|
|
306
|
+
if not ids:
|
|
307
|
+
return []
|
|
308
|
+
|
|
309
|
+
conn = connect(db_path)
|
|
310
|
+
try:
|
|
311
|
+
placeholders = ",".join(["?"] * len(ids))
|
|
312
|
+
return conn.execute(
|
|
313
|
+
f"SELECT * FROM raw_envelopes WHERE id IN ({placeholders})",
|
|
314
|
+
ids,
|
|
315
|
+
).fetchall()
|
|
316
|
+
finally:
|
|
317
|
+
conn.close()
|
|
318
|
+
|
|
319
|
+
|
|
179
320
|
def _recent_raw_envelopes_query(
|
|
180
321
|
canonicalization_status: str | None,
|
|
181
322
|
*,
|
|
182
323
|
failures_only: bool,
|
|
183
|
-
|
|
324
|
+
tenant_id: str | None = None,
|
|
325
|
+
source_type: str | None = None,
|
|
326
|
+
source_id: str | None = None,
|
|
327
|
+
source_path: str | None = None,
|
|
328
|
+
) -> tuple[str, tuple[str, ...]]:
|
|
329
|
+
where_clauses: list[str] = []
|
|
330
|
+
args: list[str] = []
|
|
331
|
+
|
|
184
332
|
if canonicalization_status is None and failures_only:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
333
|
+
where_clauses.append("canonicalization_status IN (?, ?)")
|
|
334
|
+
args.extend(RAW_ENVELOPE_FAILURE_STATUSES)
|
|
335
|
+
elif canonicalization_status is None and not failures_only:
|
|
336
|
+
pass
|
|
337
|
+
elif canonicalization_status is not None:
|
|
338
|
+
_validate_canonicalization_status(canonicalization_status)
|
|
339
|
+
where_clauses.append("canonicalization_status = ?")
|
|
340
|
+
args.append(canonicalization_status)
|
|
341
|
+
|
|
342
|
+
if tenant_id is not None:
|
|
343
|
+
where_clauses.append("tenant_id = ?")
|
|
344
|
+
args.append(tenant_id)
|
|
345
|
+
if source_type is not None:
|
|
346
|
+
where_clauses.append("source_type = ?")
|
|
347
|
+
args.append(source_type)
|
|
348
|
+
if source_id is not None:
|
|
349
|
+
where_clauses.append("source_id = ?")
|
|
350
|
+
args.append(source_id)
|
|
351
|
+
if source_path is not None:
|
|
352
|
+
where_clauses.append("source_path = ?")
|
|
353
|
+
args.append(source_path)
|
|
354
|
+
|
|
355
|
+
where_clause = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
|
356
|
+
return where_clause, tuple(args)
|
|
190
357
|
|
|
191
358
|
|
|
192
359
|
def list_recent_raw_envelopes(
|
|
@@ -195,10 +362,21 @@ def list_recent_raw_envelopes(
|
|
|
195
362
|
limit: int = 20,
|
|
196
363
|
*,
|
|
197
364
|
failures_only: bool = False,
|
|
365
|
+
tenant_id: str | None = None,
|
|
366
|
+
source_type: str | None = None,
|
|
367
|
+
source_id: str | None = None,
|
|
368
|
+
source_path: str | None = None,
|
|
198
369
|
) -> List[sqlite3.Row]:
|
|
199
370
|
conn = connect(db_path)
|
|
200
371
|
try:
|
|
201
|
-
where_clause, status_args
|
|
372
|
+
where_clause, status_args = _recent_raw_envelopes_query(
|
|
373
|
+
status,
|
|
374
|
+
failures_only=failures_only,
|
|
375
|
+
tenant_id=tenant_id,
|
|
376
|
+
source_type=source_type,
|
|
377
|
+
source_id=source_id,
|
|
378
|
+
source_path=source_path,
|
|
379
|
+
)
|
|
202
380
|
prefix = f"{where_clause} " if where_clause else ""
|
|
203
381
|
rows = conn.execute(
|
|
204
382
|
f"""
|
|
@@ -214,6 +392,51 @@ def list_recent_raw_envelopes(
|
|
|
214
392
|
conn.close()
|
|
215
393
|
|
|
216
394
|
|
|
395
|
+
def list_canonical_events(
|
|
396
|
+
db_path: str | None = None,
|
|
397
|
+
limit: int = 20,
|
|
398
|
+
*,
|
|
399
|
+
tenant_id: str | None = None,
|
|
400
|
+
source_type: str | None = None,
|
|
401
|
+
host: str | None = None,
|
|
402
|
+
service: str | None = None,
|
|
403
|
+
severity: str | None = None,
|
|
404
|
+
) -> List[sqlite3.Row]:
|
|
405
|
+
conn = connect(db_path)
|
|
406
|
+
try:
|
|
407
|
+
where_clauses = ["canonicalization_status = ?"]
|
|
408
|
+
args: List[str] = ["canonicalized"]
|
|
409
|
+
|
|
410
|
+
if tenant_id is not None:
|
|
411
|
+
where_clauses.append("tenant_id = ?")
|
|
412
|
+
args.append(tenant_id)
|
|
413
|
+
if source_type is not None:
|
|
414
|
+
where_clauses.append("source_type = ?")
|
|
415
|
+
args.append(source_type)
|
|
416
|
+
if host is not None:
|
|
417
|
+
where_clauses.append("host = ?")
|
|
418
|
+
args.append(host)
|
|
419
|
+
if service is not None:
|
|
420
|
+
where_clauses.append("service = ?")
|
|
421
|
+
args.append(service)
|
|
422
|
+
if severity is not None:
|
|
423
|
+
where_clauses.append("severity = ?")
|
|
424
|
+
args.append(severity)
|
|
425
|
+
|
|
426
|
+
where_clause = " WHERE " + " AND ".join(where_clauses)
|
|
427
|
+
return conn.execute(
|
|
428
|
+
f"""
|
|
429
|
+
SELECT * FROM raw_envelopes
|
|
430
|
+
{where_clause}
|
|
431
|
+
ORDER BY id DESC
|
|
432
|
+
LIMIT ?
|
|
433
|
+
""",
|
|
434
|
+
(*args, max(1, limit)),
|
|
435
|
+
).fetchall()
|
|
436
|
+
finally:
|
|
437
|
+
conn.close()
|
|
438
|
+
|
|
439
|
+
|
|
217
440
|
def list_recent_failed_raw_envelopes(
|
|
218
441
|
db_path: str | None = None,
|
|
219
442
|
*,
|
|
@@ -309,6 +532,72 @@ def get_source_dimension_summaries(
|
|
|
309
532
|
conn.close()
|
|
310
533
|
|
|
311
534
|
|
|
535
|
+
def get_source_status_summaries(
|
|
536
|
+
db_path: str | None = None,
|
|
537
|
+
*,
|
|
538
|
+
limit: int = 20,
|
|
539
|
+
tenant_id: str | None = None,
|
|
540
|
+
source_type: str | None = None,
|
|
541
|
+
source_id: str | None = None,
|
|
542
|
+
source_path: str | None = None,
|
|
543
|
+
) -> List[dict[str, Any]]:
|
|
544
|
+
init_db(db_path)
|
|
545
|
+
conn = connect(db_path)
|
|
546
|
+
try:
|
|
547
|
+
query = """
|
|
548
|
+
SELECT
|
|
549
|
+
tenant_id,
|
|
550
|
+
source_type,
|
|
551
|
+
source_id,
|
|
552
|
+
source_path,
|
|
553
|
+
COUNT(*) AS raw_count,
|
|
554
|
+
SUM(CASE WHEN canonicalization_status = 'canonicalized' THEN 1 ELSE 0 END) AS canonicalized_count,
|
|
555
|
+
SUM(CASE WHEN canonicalization_status = 'parse_failed' THEN 1 ELSE 0 END) AS parse_failed_count,
|
|
556
|
+
SUM(CASE WHEN canonicalization_status = 'unsupported' THEN 1 ELSE 0 END) AS unsupported_count,
|
|
557
|
+
MIN(timestamp) AS first_seen_at,
|
|
558
|
+
MAX(timestamp) AS last_seen_at
|
|
559
|
+
FROM raw_envelopes
|
|
560
|
+
WHERE 1 = 1
|
|
561
|
+
"""
|
|
562
|
+
args: list[Any] = []
|
|
563
|
+
if tenant_id is not None:
|
|
564
|
+
query += " AND tenant_id = ?"
|
|
565
|
+
args.append(tenant_id)
|
|
566
|
+
if source_type is not None:
|
|
567
|
+
query += " AND source_type = ?"
|
|
568
|
+
args.append(source_type)
|
|
569
|
+
if source_id is not None:
|
|
570
|
+
query += " AND source_id = ?"
|
|
571
|
+
args.append(source_id)
|
|
572
|
+
if source_path is not None:
|
|
573
|
+
query += " AND source_path = ?"
|
|
574
|
+
args.append(source_path)
|
|
575
|
+
|
|
576
|
+
query += """
|
|
577
|
+
GROUP BY tenant_id, source_type, source_id, source_path
|
|
578
|
+
ORDER BY last_seen_at DESC, raw_count DESC
|
|
579
|
+
LIMIT ?
|
|
580
|
+
"""
|
|
581
|
+
args.append(max(1, limit))
|
|
582
|
+
return [
|
|
583
|
+
{
|
|
584
|
+
"tenant_id": row["tenant_id"],
|
|
585
|
+
"source_type": row["source_type"] or "",
|
|
586
|
+
"source_id": row["source_id"] or "",
|
|
587
|
+
"source_path": row["source_path"] or "",
|
|
588
|
+
"raw_count": int(row["raw_count"]),
|
|
589
|
+
"canonicalized_count": int(row["canonicalized_count"] or 0),
|
|
590
|
+
"parse_failed_count": int(row["parse_failed_count"] or 0),
|
|
591
|
+
"unsupported_count": int(row["unsupported_count"] or 0),
|
|
592
|
+
"first_seen_at": row["first_seen_at"],
|
|
593
|
+
"last_seen_at": row["last_seen_at"],
|
|
594
|
+
}
|
|
595
|
+
for row in conn.execute(query, args).fetchall()
|
|
596
|
+
]
|
|
597
|
+
finally:
|
|
598
|
+
conn.close()
|
|
599
|
+
|
|
600
|
+
|
|
312
601
|
def _get_source_dimension_summaries_from_conn(
|
|
313
602
|
conn: sqlite3.Connection,
|
|
314
603
|
*,
|
|
@@ -358,6 +647,27 @@ def store_signatures(signatures: Iterable[Signature], db_path: str | None = None
|
|
|
358
647
|
count = 0
|
|
359
648
|
try:
|
|
360
649
|
for signature in signatures:
|
|
650
|
+
row = conn.execute(
|
|
651
|
+
"SELECT metadata_json FROM signatures WHERE signature_key = ?",
|
|
652
|
+
(signature.signature_key,),
|
|
653
|
+
).fetchone()
|
|
654
|
+
|
|
655
|
+
metadata = dict(signature.metadata)
|
|
656
|
+
raw_ids = _coerce_raw_envelope_id_list(metadata.get("source_raw_envelope_ids"))
|
|
657
|
+
if not raw_ids:
|
|
658
|
+
raw_id = _coerce_raw_envelope_id(metadata.get("source_raw_envelope_id"))
|
|
659
|
+
if raw_id is not None:
|
|
660
|
+
raw_ids = [raw_id]
|
|
661
|
+
metadata.pop("source_raw_envelope_id", None)
|
|
662
|
+
|
|
663
|
+
if row is not None:
|
|
664
|
+
existing_metadata = json.loads(row["metadata_json"] or "{}")
|
|
665
|
+
if not isinstance(existing_metadata, dict):
|
|
666
|
+
existing_metadata = {}
|
|
667
|
+
existing_raw_ids = _coerce_raw_envelope_id_list(existing_metadata.get("source_raw_envelope_ids"))
|
|
668
|
+
metadata = dict(existing_metadata) | dict(metadata)
|
|
669
|
+
metadata["source_raw_envelope_ids"] = sorted(set(existing_raw_ids + raw_ids))
|
|
670
|
+
|
|
361
671
|
conn.execute(
|
|
362
672
|
'''
|
|
363
673
|
INSERT INTO signatures (
|
|
@@ -373,7 +683,7 @@ def store_signatures(signatures: Iterable[Signature], db_path: str | None = None
|
|
|
373
683
|
signature.event_family,
|
|
374
684
|
signature.normalized_pattern,
|
|
375
685
|
signature.service,
|
|
376
|
-
json.dumps(
|
|
686
|
+
json.dumps(metadata, ensure_ascii=False),
|
|
377
687
|
),
|
|
378
688
|
)
|
|
379
689
|
count += 1
|
|
@@ -416,12 +726,79 @@ def store_candidates(candidates: Iterable[Candidate], db_path: str | None = None
|
|
|
416
726
|
conn.close()
|
|
417
727
|
|
|
418
728
|
|
|
419
|
-
def list_candidates(
|
|
729
|
+
def list_candidates(
|
|
730
|
+
db_path: str | None = None,
|
|
731
|
+
limit: int = 20,
|
|
732
|
+
*,
|
|
733
|
+
candidate_type: str | None = None,
|
|
734
|
+
decision_band: str | None = None,
|
|
735
|
+
min_score_total: float | None = None,
|
|
736
|
+
) -> List[sqlite3.Row]:
|
|
420
737
|
conn = connect(db_path)
|
|
421
738
|
try:
|
|
739
|
+
where_clauses: List[str] = []
|
|
740
|
+
args: List[Any] = []
|
|
741
|
+
|
|
742
|
+
if candidate_type is not None:
|
|
743
|
+
where_clauses.append("candidate_type = ?")
|
|
744
|
+
args.append(candidate_type)
|
|
745
|
+
if decision_band is not None:
|
|
746
|
+
where_clauses.append("decision_band = ?")
|
|
747
|
+
args.append(decision_band)
|
|
748
|
+
if min_score_total is not None:
|
|
749
|
+
where_clauses.append("score_total >= ?")
|
|
750
|
+
args.append(min_score_total)
|
|
751
|
+
|
|
752
|
+
where_clause = ""
|
|
753
|
+
if where_clauses:
|
|
754
|
+
where_clause = " WHERE " + " AND ".join(where_clauses)
|
|
755
|
+
|
|
422
756
|
rows = conn.execute(
|
|
423
|
-
'SELECT * FROM candidates ORDER BY score_total DESC, id DESC LIMIT ?',
|
|
424
|
-
(max(1, limit)
|
|
757
|
+
f'SELECT * FROM candidates{where_clause} ORDER BY score_total DESC, id DESC LIMIT ?',
|
|
758
|
+
(*args, max(1, limit)),
|
|
759
|
+
).fetchall()
|
|
760
|
+
return rows
|
|
761
|
+
finally:
|
|
762
|
+
conn.close()
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def list_signatures(
|
|
766
|
+
db_path: str | None = None,
|
|
767
|
+
limit: int = 20,
|
|
768
|
+
*,
|
|
769
|
+
event_family: str | None = None,
|
|
770
|
+
service: str | None = None,
|
|
771
|
+
min_occurrence_count: int | None = None,
|
|
772
|
+
) -> List[sqlite3.Row]:
|
|
773
|
+
conn = connect(db_path)
|
|
774
|
+
try:
|
|
775
|
+
where_clauses: List[str] = []
|
|
776
|
+
args: List[Any] = []
|
|
777
|
+
|
|
778
|
+
if event_family is not None:
|
|
779
|
+
where_clauses.append("event_family = ?")
|
|
780
|
+
args.append(event_family)
|
|
781
|
+
if service is not None:
|
|
782
|
+
where_clauses.append("service = ?")
|
|
783
|
+
args.append(service)
|
|
784
|
+
if min_occurrence_count is not None:
|
|
785
|
+
where_clauses.append("occurrence_count >= ?")
|
|
786
|
+
args.append(min_occurrence_count)
|
|
787
|
+
|
|
788
|
+
where_clause = ""
|
|
789
|
+
if where_clauses:
|
|
790
|
+
where_clause = " WHERE " + " AND ".join(where_clauses)
|
|
791
|
+
|
|
792
|
+
rows = conn.execute(
|
|
793
|
+
f"""
|
|
794
|
+
SELECT
|
|
795
|
+
id, signature_key, event_family, normalized_pattern, service,
|
|
796
|
+
metadata_json, occurrence_count
|
|
797
|
+
FROM signatures{where_clause}
|
|
798
|
+
ORDER BY occurrence_count DESC, id DESC
|
|
799
|
+
LIMIT ?
|
|
800
|
+
""",
|
|
801
|
+
(*args, max(1, limit)),
|
|
425
802
|
).fetchall()
|
|
426
803
|
return rows
|
|
427
804
|
finally:
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Runtime Examples
|
|
2
|
+
|
|
3
|
+
Use this compact surface for the implemented runtime API, listener, file-ingest, and LogicMonitor-shaped webhook path.
|
|
4
|
+
|
|
5
|
+
## 0) Shared runtime settings
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
export BRAINSTEM_API_TOKEN=my-local-token # optional: set only if you want auth required
|
|
9
|
+
export BRAINSTEM_DB_PATH=/tmp/brainstem.sqlite3
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`BRAINSTEM_API_TOKEN` is optional. If you do not set it, omit all `X-API-Token` headers in the API examples.
|
|
13
|
+
|
|
14
|
+
## 1) API entry point
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# Starts the runtime API
|
|
18
|
+
python -m uvicorn brainstem.api:app --host 127.0.0.1 --port 8000
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
curl -s http://127.0.0.1:8000/healthz
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 2) UDP listener entry point
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Prints canonicalized events for each received datagram
|
|
29
|
+
python -m brainstem.listener --tenant demo-tenant --host 127.0.0.1 --port 5514 --source-path /var/log/syslog
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
printf 'Mar 22 03:10:00 fw-01 charon: IPsec SA rekey succeeded\n' | nc -u 127.0.0.1 5514
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 3) API ingest (syslog payload style)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
curl -s -X POST http://127.0.0.1:8000/ingest/event \
|
|
40
|
+
-H "Content-Type: application/json" \
|
|
41
|
+
-H "X-API-Token: $BRAINSTEM_API_TOKEN" \
|
|
42
|
+
-d '{"tenant_id":"demo-tenant","source_type":"syslog","source_path":"/var/log/syslog","message_raw":"Mar 22 03:11:00 fw-01 charon: child SA rekey started"}'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 4) API ingest for file source events
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
curl -s -X POST http://127.0.0.1:8000/ingest/batch \
|
|
49
|
+
-H "Content-Type: application/json" \
|
|
50
|
+
-H "X-API-Token: $BRAINSTEM_API_TOKEN" \
|
|
51
|
+
-d '{"threshold":2,"db_path":"/tmp/brainstem.sqlite3","events":[{"tenant_id":"demo-tenant","source_type":"file","source_path":"/tmp/manual.log","message_raw":"vpn tunnel dropped and recovered"}]}'
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 5) LogicMonitor-shaped ingest (connector payloads)
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
curl -s -X POST http://127.0.0.1:8000/ingest/logicmonitor \
|
|
58
|
+
-H "Content-Type: application/json" \
|
|
59
|
+
-H "X-API-Token: $BRAINSTEM_API_TOKEN" \
|
|
60
|
+
-d '{"tenant_id":"demo-tenant","threshold":2,"source_path":"/logicmonitor/ingest","db_path":"/tmp/brainstem.sqlite3","events":[{"resource_id":123,"resource_name":"edge-fw-01","message_raw":"VPN tunnel dropped and recovered","severity":"warning","metadata":{"datasource":"IPSec Tunnel"}}]}'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 6) Runtime inspection endpoints (same db path)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
curl -s "http://127.0.0.1:8000/ingest/recent?db_path=/tmp/brainstem.sqlite3&limit=5" \
|
|
67
|
+
-H "X-API-Token: $BRAINSTEM_API_TOKEN"
|
|
68
|
+
curl -s "http://127.0.0.1:8000/candidates?db_path=/tmp/brainstem.sqlite3&limit=5" \
|
|
69
|
+
-H "X-API-Token: $BRAINSTEM_API_TOKEN"
|
|
70
|
+
curl -s "http://127.0.0.1:8000/signatures?db_path=/tmp/brainstem.sqlite3&limit=5" \
|
|
71
|
+
-H "X-API-Token: $BRAINSTEM_API_TOKEN"
|
|
72
|
+
curl -s "http://127.0.0.1:8000/raw_envelopes?db_path=/tmp/brainstem.sqlite3&limit=5" \
|
|
73
|
+
-H "X-API-Token: $BRAINSTEM_API_TOKEN"
|
|
74
|
+
curl -s "http://127.0.0.1:8000/stats?db_path=/tmp/brainstem.sqlite3" \
|
|
75
|
+
-H "X-API-Token: $BRAINSTEM_API_TOKEN"
|
|
76
|
+
curl -s "http://127.0.0.1:8000/failures?db_path=/tmp/brainstem.sqlite3&limit=5" \
|
|
77
|
+
-H "X-API-Token: $BRAINSTEM_API_TOKEN"
|
|
78
|
+
curl -s "http://127.0.0.1:8000/sources?db_path=/tmp/brainstem.sqlite3&limit=5" \
|
|
79
|
+
-H "X-API-Token: $BRAINSTEM_API_TOKEN"
|
|
80
|
+
curl -s "http://127.0.0.1:8000/sources/status?db_path=/tmp/brainstem.sqlite3&limit=5" \
|
|
81
|
+
-H "X-API-Token: $BRAINSTEM_API_TOKEN"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## 7) Direct file ingest helper path
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
python - <<'PY'
|
|
88
|
+
from brainstem.ingest import run_ingest_file
|
|
89
|
+
|
|
90
|
+
result = run_ingest_file(
|
|
91
|
+
"tests/fixtures/sample_syslog.log",
|
|
92
|
+
tenant_id="demo-tenant",
|
|
93
|
+
threshold=2,
|
|
94
|
+
db_path="/tmp/brainstem.sqlite3",
|
|
95
|
+
)
|
|
96
|
+
print({
|
|
97
|
+
"events": len(result.events),
|
|
98
|
+
"signatures": len(result.signatures),
|
|
99
|
+
"candidates": len(result.candidates),
|
|
100
|
+
"parse_failed": result.parse_failed,
|
|
101
|
+
})
|
|
102
|
+
PY
|
|
103
|
+
```
|