clickdetect 1.2.0__tar.gz → 1.3.0__tar.gz

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 (30) hide show
  1. {clickdetect-1.2.0 → clickdetect-1.3.0}/PKG-INFO +1 -1
  2. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/__init__.py +2 -2
  3. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/datasource/__init__.py +2 -0
  4. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/datasource/base.py +8 -3
  5. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/datasource/loki.py +24 -29
  6. clickdetect-1.3.0/clickdetect/detector/datasource/victorialogs.py +101 -0
  7. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/detector.py +1 -1
  8. {clickdetect-1.2.0 → clickdetect-1.3.0}/pyproject.toml +1 -1
  9. {clickdetect-1.2.0 → clickdetect-1.3.0}/README.md +0 -0
  10. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/api/detector.py +0 -0
  11. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/api/health.py +0 -0
  12. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/api/rules.py +0 -0
  13. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/__init__.py +0 -0
  14. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/config.py +0 -0
  15. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/datasource/clickhouse.py +0 -0
  16. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/datasource/elasticsearch.py +0 -0
  17. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/datasource/postgresql.py +0 -0
  18. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/manager.py +0 -0
  19. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/rules.py +0 -0
  20. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/runner.py +0 -0
  21. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/utils.py +0 -0
  22. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/watcher.py +0 -0
  23. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/webhooks/__init__.py +0 -0
  24. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/webhooks/base.py +0 -0
  25. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/webhooks/email.py +0 -0
  26. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/webhooks/forgejo.py +0 -0
  27. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/webhooks/generic.py +0 -0
  28. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/webhooks/iris.py +0 -0
  29. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/webhooks/matrix.py +0 -0
  30. {clickdetect-1.2.0 → clickdetect-1.3.0}/clickdetect/detector/webhooks/teams.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: clickdetect
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Generic SIEM engine detector
5
5
  Author: Vinicius Morais
6
6
  Author-email: Vinicius Morais <me@souzo.me>
@@ -20,7 +20,7 @@ logger = getLogger(__name__)
20
20
 
21
21
  def print_webhooks():
22
22
  for w in webhooks:
23
- print(f" Webhook: {w._name()}")
23
+ print(f"Webhook: {w._name()}")
24
24
  for param in w._params():
25
25
  print(
26
26
  f"\tName: {param.name}({param.type.__name__}) {'Required' if param.required else 'Optional'}. {f'Help: {param.help}' if param.help else ''}", end=' '
@@ -34,7 +34,7 @@ def print_webhooks():
34
34
 
35
35
  def print_datasources():
36
36
  for ds in datasources:
37
- print(f" Webhook: {ds._name()}")
37
+ print(f"Datasources: {ds._name()}")
38
38
  for param in ds._params():
39
39
  print(
40
40
  f"\tName: {param.name}({param.type.__name__}) {'Required' if param.required else 'Optional'}. {f'Help: {param.help}' if param.help else ''}", end=' '
@@ -4,6 +4,7 @@ from .clickhouse import ClickhouseDataSource
4
4
  from .loki import LokiDataSource
5
5
  from .elasticsearch import ElasticsearchDataSource
6
6
  from .postgresql import PostgreSQLDataSource
7
+ from .victorialogs import VictoriaLogsDataSource
7
8
 
8
9
 
9
10
  datasources: List[Type[BaseDataSource]] = [
@@ -11,4 +12,5 @@ datasources: List[Type[BaseDataSource]] = [
11
12
  LokiDataSource,
12
13
  ElasticsearchDataSource,
13
14
  PostgreSQLDataSource,
15
+ VictoriaLogsDataSource
14
16
  ]
@@ -1,3 +1,4 @@
1
+ from ..rules import Rule
1
2
  from ..utils import Parameters
2
3
  from dataclasses import dataclass
3
4
  from typing import Any, Dict, List
@@ -19,6 +20,9 @@ class BaseDataSource:
19
20
  async def connect(self):
20
21
  raise NotImplementedError()
21
22
 
23
+ async def _query(self, data: str, rule: Rule) -> DataSourceQueryResult | None:
24
+ return await self.query(data)
25
+
22
26
  async def query(self, data: str) -> DataSourceQueryResult | None:
23
27
  raise NotImplementedError()
24
28
 
@@ -26,6 +30,10 @@ class BaseDataSource:
26
30
  def _name(cls) -> str:
27
31
  raise NotImplementedError()
28
32
 
33
+ @classmethod
34
+ def _params(cls) -> List[Parameters]:
35
+ raise NotImplementedError()
36
+
29
37
  async def _parse(self, data: Any):
30
38
  self.params = self._params()
31
39
 
@@ -54,6 +62,3 @@ class BaseDataSource:
54
62
  result[param.name] = getattr(self, attr, param.default)
55
63
  return result
56
64
 
57
- @classmethod
58
- def _params(cls) -> List[Parameters]:
59
- raise NotImplementedError()
@@ -1,25 +1,20 @@
1
1
  from typing import Any, List
2
2
  from logging import getLogger
3
3
  from .base import BaseDataSource, DataSourceQueryResult
4
- import aiohttp
5
-
6
4
  from ..utils import Parameters
5
+ import aiohttp
7
6
 
8
7
  logger = getLogger(__name__)
9
8
 
10
9
 
11
10
  class LokiDataSource(BaseDataSource):
12
- host: str
13
- port: int
14
- username: str | None = None
15
- password: str | None = None
16
- verify: bool = False
17
- org_id: str | None = None
11
+ url: str
12
+ username: str
13
+ password: str
14
+ verify: bool
15
+ org_id: str
18
16
  _session: aiohttp.ClientSession | None = None
19
-
20
- def _base_url(self) -> str:
21
- scheme = "https" if self.verify else "http"
22
- return f"{scheme}://{self.host}:{self.port}"
17
+ _base_url: str
23
18
 
24
19
  def _headers(self) -> dict:
25
20
  headers = {"Content-Type": "application/json"}
@@ -33,16 +28,17 @@ class LokiDataSource(BaseDataSource):
33
28
  return None
34
29
 
35
30
  async def connect(self):
31
+ logger.debug(f"Connecting to {self.url}")
32
+ self._base_url = self.url.rstrip("/")
36
33
  try:
37
- connector = aiohttp.TCPConnector(ssl=False)
34
+ connector = aiohttp.TCPConnector(verify_ssl=self.verify)
38
35
  self._session = aiohttp.ClientSession(
39
36
  connector=connector, auth=self._auth(), headers=self._headers()
40
37
  )
41
- async with self._session.get(f"{self._base_url()}/ready") as resp:
42
- if resp.status != 200:
43
- raise Exception(f"Loki not ready, status: {resp.status}")
38
+ async with self._session.get(f"{self._base_url}/ready") as resp:
39
+ resp.raise_for_status()
44
40
  except Exception as ex:
45
- logger.error(f"Failed to connect to Loki at {self.host}:{self.port} | {ex}")
41
+ logger.error(f"Datasource connect exception. url: {self.url} | {ex}")
46
42
  if self._session:
47
43
  await self._session.close()
48
44
  self._session = None
@@ -53,13 +49,12 @@ class LokiDataSource(BaseDataSource):
53
49
  if not self._session:
54
50
  return None
55
51
  try:
52
+ logger.debug(f"Sending query to {self.url}")
56
53
  params = {"query": data, "limit": 5000}
57
54
  async with self._session.get(
58
- f"{self._base_url()}/loki/api/v1/query_range", params=params
55
+ f"{self._base_url}/loki/api/v1/query_range", params=params
59
56
  ) as resp:
60
- if resp.status != 200:
61
- body = await resp.text()
62
- raise Exception(f"HTTP {resp.status}: {body}")
57
+ resp.raise_for_status()
63
58
  payload = await resp.json()
64
59
  return await self._parse_result(payload)
65
60
  except Exception as ex:
@@ -72,9 +67,9 @@ class LokiDataSource(BaseDataSource):
72
67
  async def _parse_result(self, payload: Any) -> DataSourceQueryResult:
73
68
  result_type = payload.get("data", {}).get("resultType", "streams")
74
69
  results = payload.get("data", {}).get("result", [])
70
+ rows = []
75
71
 
76
72
  if result_type == "matrix":
77
- rows = []
78
73
  total = 0
79
74
  for series in results:
80
75
  metric = series.get("metric", {})
@@ -85,7 +80,6 @@ class LokiDataSource(BaseDataSource):
85
80
  total += float(values[-1][1])
86
81
  return DataSourceQueryResult(int(total), rows, self._name())
87
82
 
88
- rows = []
89
83
  for stream in results:
90
84
  labels = stream.get("stream", {})
91
85
  for ts, line in stream.get("values", []):
@@ -99,10 +93,11 @@ class LokiDataSource(BaseDataSource):
99
93
  @classmethod
100
94
  def _params(cls) -> List[Parameters]:
101
95
  return [
102
- Parameters('host', str, True, 'Loki host'),
103
- Parameters('port', int, False, 'Loki port', 3100),
104
- Parameters('username', str, False, 'Username', is_sensive_field=True),
105
- Parameters('password', str, False, 'Password', is_sensive_field=True),
106
- Parameters('verify', bool, False, 'Verify SSL', False),
107
- Parameters('org_id', str, False, 'Loki org ID (X-Scope-OrgID)'),
96
+ Parameters("url", str, True, "Loki url: http://localhost:3100"),
97
+ Parameters("username", str, False, "Username", is_sensive_field=True),
98
+ Parameters("password", str, False, "Password", is_sensive_field=True),
99
+ Parameters("verify", bool, False, "Verify SSL", False),
100
+ Parameters(
101
+ "org_id", str, False, "Loki org ID (X-Scope-OrgID)", default="fake"
102
+ ),
108
103
  ]
@@ -0,0 +1,101 @@
1
+ from typing import List
2
+ from logging import getLogger
3
+ from .base import BaseDataSource, DataSourceQueryResult
4
+ from ..utils import Parameters
5
+
6
+ import aiohttp
7
+ import json
8
+
9
+ logger = getLogger(__name__)
10
+
11
+
12
+ class VictoriaLogsDataSource(BaseDataSource):
13
+ url: str
14
+ username: str
15
+ password: str
16
+ verify: bool
17
+ account_id: str
18
+ project_id: str
19
+ _session: aiohttp.ClientSession | None = None
20
+ _base_url: str
21
+
22
+ def _headers(self) -> dict:
23
+ headers = {"Content-Type": "application/json"}
24
+ if self.account_id:
25
+ headers["X-VictoriaMetrics-Account-Id"] = self.account_id
26
+ if self.project_id:
27
+ headers["X-VictoriaMetrics-Project-Id"] = self.project_id
28
+ return headers
29
+
30
+ def _auth(self) -> aiohttp.BasicAuth | None:
31
+ if self.username and self.password:
32
+ return aiohttp.BasicAuth(self.username, self.password)
33
+ return None
34
+
35
+ async def connect(self):
36
+ logger.debug(f"Connecting to {self.url}")
37
+ self._base_url = self.url.rstrip("/")
38
+ try:
39
+ connector = aiohttp.TCPConnector(ssl=False)
40
+ self._session = aiohttp.ClientSession(
41
+ connector=connector, auth=self._auth(), headers=self._headers()
42
+ )
43
+ async with self._session.get(f"{self._base_url}/health") as resp:
44
+ if resp.status != 200:
45
+ raise Exception(f"VictoriaLogs not ready, status: {resp.status}")
46
+ except Exception as ex:
47
+ logger.error(f"Failed to connect to VictoriaLogs at {self.url} | {ex}")
48
+ if self._session:
49
+ await self._session.close()
50
+ self._session = None
51
+
52
+ async def query(self, data: str) -> DataSourceQueryResult | None:
53
+ if not self._session:
54
+ await self.connect()
55
+ if not self._session:
56
+ return None
57
+ try:
58
+ logger.debug(f"Sending query to {self.url}")
59
+ params = {"query": data, "limit": 5000}
60
+ async with self._session.get(
61
+ f"{self._base_url}/select/logsql/query", params=params
62
+ ) as resp:
63
+ resp.raise_for_status()
64
+ body = await resp.text()
65
+ return self._parse_result(body)
66
+ except Exception as ex:
67
+ logger.error(f"Query failed, resetting session | {ex}")
68
+ if self._session:
69
+ await self._session.close()
70
+ self._session = None
71
+ return None
72
+
73
+ def _parse_result(self, body: str) -> DataSourceQueryResult:
74
+ rows = []
75
+ for line in body.splitlines():
76
+ line = line.strip()
77
+ if line:
78
+ rows.append(json.loads(line))
79
+ return DataSourceQueryResult(len(rows), rows, self._name())
80
+
81
+ @classmethod
82
+ def _name(cls) -> str:
83
+ logger.warning("This datasource integration hasn't been tested yet")
84
+ logger.warning("I only did the integration based on victorialogs api documentation")
85
+ logger.warning("Please open an issue if you can validate this datasource")
86
+ return "victorialogs"
87
+
88
+ @classmethod
89
+ def _params(cls) -> List[Parameters]:
90
+ return [
91
+ Parameters("url", str, True, "VictoriaLogs url"),
92
+ Parameters("username", str, False, "Username", is_sensive_field=True),
93
+ Parameters("password", str, False, "Password", is_sensive_field=True),
94
+ Parameters("verify", bool, False, "Verify SSL", False),
95
+ Parameters(
96
+ "account_id", str, False, "X-VictoriaMetrics-Account-Id header", "0"
97
+ ),
98
+ Parameters(
99
+ "project_id", str, False, "X-VictoriaMetrics-Project-Id header", "0"
100
+ ),
101
+ ]
@@ -120,7 +120,7 @@ class Detector:
120
120
  logger.debug(f"Rule id: {rule.id}")
121
121
  logger.debug(f"Rule name: {rule.name}")
122
122
  logger.debug(f"rule query:\n {query}\n")
123
- result = await self.datasource.query(query)
123
+ result = await self.datasource._query(query, rule)
124
124
 
125
125
  if result is None:
126
126
  logger.warning("Datasource unavailable, skipping rule evaluation")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "clickdetect"
3
- version = "1.2.0"
3
+ version = "1.3.0"
4
4
  description = "Generic SIEM engine detector"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
File without changes