mobsec-modules-library 0.0.1__tar.gz → 0.0.2__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 (20) hide show
  1. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/PKG-INFO +4 -2
  2. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/pyproject.toml +5 -3
  3. mobsec_modules_library-0.0.2/src/mobsec_modules_library/external/__init__.py +3 -0
  4. mobsec_modules_library-0.0.2/src/mobsec_modules_library/external/external_module.py +246 -0
  5. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library.egg-info/PKG-INFO +4 -2
  6. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library.egg-info/SOURCES.txt +2 -0
  7. mobsec_modules_library-0.0.2/src/mobsec_modules_library.egg-info/requires.txt +5 -0
  8. mobsec_modules_library-0.0.1/src/mobsec_modules_library.egg-info/requires.txt +0 -3
  9. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/LICENSE +0 -0
  10. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/README.md +0 -0
  11. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/setup.cfg +0 -0
  12. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library/__init__.py +0 -0
  13. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library/dynamic/__init__.py +0 -0
  14. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library/dynamic/adb_utils.py +0 -0
  15. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library/dynamic/base_module_manager.py +0 -0
  16. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library/dynamic/base_server.py +0 -0
  17. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library/dynamic/base_websocket_manager.py +0 -0
  18. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library/static/static_module.py +0 -0
  19. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library.egg-info/dependency_links.txt +0 -0
  20. {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mobsec_modules_library
3
- Version: 0.0.1
3
+ Version: 0.0.2
4
4
  Summary: Library for writing MobSec Modules using Python
5
5
  Author-email: Stormtrooperroman <stormtrooperroman@gmail.com>
6
6
  License-Expression: MIT
@@ -11,9 +11,11 @@ Classifier: Operating System :: OS Independent
11
11
  Requires-Python: >=3.9
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: redis>=4.0.2
14
+ Requires-Dist: redis>=4.2.0
15
15
  Requires-Dist: fastapi>=0.68.0
16
16
  Requires-Dist: uvicorn>=0.15.0
17
+ Requires-Dist: httpx>=0.23.0
18
+ Requires-Dist: PyYAML>=6.0
17
19
  Dynamic: license-file
18
20
 
19
21
  # MobSec modules library
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mobsec_modules_library"
3
- version = "0.0.1"
3
+ version = "0.0.2"
4
4
  authors = [
5
5
  { name="Stormtrooperroman", email="stormtrooperroman@gmail.com" },
6
6
  ]
@@ -15,9 +15,11 @@ license = "MIT"
15
15
  license-files = ["LICEN[CS]E*"]
16
16
 
17
17
  dependencies = [
18
- "redis>=4.0.2",
18
+ "redis>=4.2.0",
19
19
  "fastapi>=0.68.0",
20
- "uvicorn>=0.15.0"
20
+ "uvicorn>=0.15.0",
21
+ "httpx>=0.23.0",
22
+ "PyYAML>=6.0",
21
23
 
22
24
  ]
23
25
 
@@ -0,0 +1,3 @@
1
+ from .external_module import ExternalModule, make_finding
2
+
3
+ __all__ = ["ExternalModule", "make_finding"]
@@ -0,0 +1,246 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import os
5
+ import re
6
+ import shutil
7
+ import tarfile
8
+ from abc import ABC, abstractmethod
9
+ from contextlib import asynccontextmanager
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ import httpx
14
+ import redis.asyncio as aioredis
15
+ import uvicorn
16
+ import yaml
17
+ from fastapi import FastAPI, HTTPException
18
+ from pydantic import BaseModel
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class OperationRequest(BaseModel):
24
+ task_id: str
25
+ chain_task_id: Optional[str] = None
26
+ data: Dict[str, Any] = {}
27
+ file_hash: Optional[str] = None
28
+
29
+
30
+ class OperationResponse(BaseModel):
31
+ status: str
32
+ data: Dict[str, Any]
33
+
34
+
35
+ def make_finding(
36
+ *,
37
+ rule_id: str,
38
+ name: str,
39
+ message: str,
40
+ file: str,
41
+ severity: str = "WARNING",
42
+ path: Optional[str] = None,
43
+ start_line: int = 0,
44
+ end_line: Optional[int] = None,
45
+ code: str = "",
46
+ **metadata: Any,
47
+ ) -> Dict[str, Any]:
48
+ return {
49
+ "rule_id": rule_id,
50
+ "name": name,
51
+ "message": message,
52
+ "severity": severity,
53
+ "location": {
54
+ "file": file,
55
+ "path": path or file,
56
+ "start_line": start_line,
57
+ "end_line": end_line if end_line is not None else start_line,
58
+ "code": code,
59
+ },
60
+ "metadata": metadata,
61
+ }
62
+
63
+
64
+ def _safe_extract(tar_path: Path, dest: Path) -> None:
65
+ dest = dest.resolve()
66
+ with tarfile.open(tar_path, "r:gz") as tar:
67
+ members = []
68
+ for m in tar.getmembers():
69
+ if not (dest / m.name).resolve().is_relative_to(dest):
70
+ raise ValueError(f"Unsafe path in archive: {m.name}")
71
+ if m.issym() or m.islnk() or m.isdev():
72
+ continue
73
+ members.append(m)
74
+ tar.extractall(dest, members=members)
75
+
76
+
77
+ class ExternalModule(ABC):
78
+ def __init__(
79
+ self,
80
+ config_path: str = "config.yaml",
81
+ ui_component: Optional[str] = None,
82
+ ):
83
+ with open(config_path) as f:
84
+ self.config: Dict[str, Any] = yaml.safe_load(f)
85
+
86
+ slug = re.sub(r"[^a-z0-9]+", "-", self.config["name"].lower()).strip("-")
87
+ self.module_id = os.getenv("MODULE_ID", slug)
88
+ self.base_url = os.environ["MODULE_BASE_URL"].rstrip("/")
89
+ self.api_url = os.getenv("MOBSEC_API_URL", "http://backend:8000/api/v1")
90
+ self.redis = aioredis.Redis.from_url(
91
+ os.getenv("REDIS_URL", "redis://redis:6379/0"), decode_responses=True
92
+ )
93
+ self.temp_dir = Path(os.getenv("MODULE_TEMP_DIR", f"/tmp/{self.module_id}"))
94
+ self.temp_dir.mkdir(parents=True, exist_ok=True)
95
+ self.ui_component = Path(ui_component) if ui_component else None
96
+
97
+ @abstractmethod
98
+ async def scan(self, work_dir: Path, task: Dict[str, Any]) -> List[Dict[str, Any]]:
99
+ pass
100
+
101
+ async def fetch_files(self, file_hash: str, work_dir: Path) -> None:
102
+ tar_path = work_dir / "files.tar.gz"
103
+ timeout = httpx.Timeout(30.0, read=300.0)
104
+ async with httpx.AsyncClient(timeout=timeout) as client:
105
+ async with client.stream(
106
+ "GET", f"{self.api_url}/artifacts/{file_hash}/archive"
107
+ ) as resp:
108
+ resp.raise_for_status()
109
+ with tar_path.open("wb") as f:
110
+ async for chunk in resp.aiter_bytes():
111
+ f.write(chunk)
112
+ try:
113
+ await asyncio.to_thread(_safe_extract, tar_path, work_dir)
114
+ finally:
115
+ tar_path.unlink(missing_ok=True)
116
+
117
+ def _registration_payload(self) -> Dict[str, Any]:
118
+ config = {
119
+ "name": self.config["name"],
120
+ "version": self.config["version"],
121
+ "description": self.config.get("description", ""),
122
+ "input_formats": self.config.get("input_formats", []),
123
+ "has_custom_ui": bool(self.ui_component and self.ui_component.exists()),
124
+ }
125
+ if config["has_custom_ui"]:
126
+ config["ui_component"] = {
127
+ "name": self.ui_component.stem,
128
+ "endpoint": f"{self.base_url}/ui-component",
129
+ }
130
+ return {
131
+ "module_id": self.module_id,
132
+ "base_url": self.base_url,
133
+ "config": config,
134
+ "healthcheck_url": f"{self.base_url}/health",
135
+ "status": "active",
136
+ }
137
+
138
+ async def register(self, retries: int = 10, delay: float = 5.0) -> bool:
139
+ payload = self._registration_payload()
140
+ for attempt in range(1, retries + 1):
141
+ try:
142
+ async with httpx.AsyncClient(timeout=30.0) as client:
143
+ resp = await client.post(
144
+ f"{self.api_url}/external-modules/register", json=payload
145
+ )
146
+ if resp.status_code == 200:
147
+ logger.info("Module %s registered", self.module_id)
148
+ return True
149
+ logger.error("Registration failed: %s %s", resp.status_code, resp.text)
150
+ except Exception as e:
151
+ logger.error("Registration error: %s", e)
152
+ if attempt < retries:
153
+ await asyncio.sleep(delay)
154
+ logger.error("Could not register %s after %d attempts", self.module_id, retries)
155
+ return False
156
+
157
+ async def _handle_task(self, task_id: str) -> None:
158
+ raw = await self.redis.get(f"task:{task_id}")
159
+ if not raw:
160
+ logger.error("Task data not found for %s", task_id)
161
+ return
162
+ task = json.loads(raw)
163
+ file_hash = task.get("file_hash")
164
+ if not file_hash:
165
+ logger.error("No file_hash in task %s", task_id)
166
+ return
167
+
168
+ work_dir = self.temp_dir / f"scan_{task_id}"
169
+ try:
170
+ work_dir.mkdir(parents=True, exist_ok=True)
171
+ await self.fetch_files(file_hash, work_dir)
172
+ findings = await self.scan(work_dir, task)
173
+ result = {"status": "success", "results": findings}
174
+ logger.info("Task %s: %d findings", task_id, len(findings))
175
+ except Exception as e:
176
+ logger.exception("Task %s failed", task_id)
177
+ result = {"status": "error", "error": str(e), "results": []}
178
+ finally:
179
+ shutil.rmtree(work_dir, ignore_errors=True)
180
+
181
+ await self.redis.set(f"result:{self.module_id}:{file_hash}", json.dumps(result))
182
+
183
+ async def _worker(self) -> None:
184
+ queue_key = f"module:{self.module_id}:queue"
185
+ logger.info("Worker started on %s", queue_key)
186
+ while True:
187
+ try:
188
+ task_id = await self.redis.lpop(queue_key)
189
+ if task_id:
190
+ await self._handle_task(task_id)
191
+ else:
192
+ await asyncio.sleep(1)
193
+ except asyncio.CancelledError:
194
+ raise
195
+ except Exception:
196
+ logger.exception("Worker loop error")
197
+ await asyncio.sleep(5)
198
+
199
+ def create_app(self) -> FastAPI:
200
+ @asynccontextmanager
201
+ async def lifespan(app: FastAPI):
202
+ tasks = [
203
+ asyncio.create_task(self._worker()),
204
+ asyncio.create_task(self.register()),
205
+ ]
206
+ yield
207
+ for t in tasks:
208
+ t.cancel()
209
+ await asyncio.gather(*tasks, return_exceptions=True)
210
+
211
+ app = FastAPI(title=self.config["name"], lifespan=lifespan)
212
+
213
+ @app.get("/health")
214
+ async def health():
215
+ return {"status": "healthy"}
216
+
217
+ @app.get("/ui-component")
218
+ async def ui_component():
219
+ if not self.ui_component or not self.ui_component.exists():
220
+ raise HTTPException(404, "UI component file not found")
221
+ return {
222
+ "component_name": self.ui_component.stem,
223
+ "component_content": self.ui_component.read_text(),
224
+ }
225
+
226
+ @app.post("/operations/process", response_model=OperationResponse)
227
+ async def process(request: OperationRequest):
228
+ if not request.file_hash:
229
+ raise HTTPException(400, "Missing file_hash parameter")
230
+ await self.redis.set(
231
+ f"task:{request.task_id}",
232
+ json.dumps({**request.data, "file_hash": request.file_hash}),
233
+ ex=3600,
234
+ )
235
+ await self.redis.rpush(f"module:{self.module_id}:queue", request.task_id)
236
+ return OperationResponse(
237
+ status="success",
238
+ data={"task_id": request.task_id, "message": "Task added to queue"},
239
+ )
240
+
241
+ return app
242
+
243
+ def run(self) -> None:
244
+ uvicorn.run(
245
+ self.create_app(), host="0.0.0.0", port=int(os.getenv("PORT", "8080"))
246
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mobsec_modules_library
3
- Version: 0.0.1
3
+ Version: 0.0.2
4
4
  Summary: Library for writing MobSec Modules using Python
5
5
  Author-email: Stormtrooperroman <stormtrooperroman@gmail.com>
6
6
  License-Expression: MIT
@@ -11,9 +11,11 @@ Classifier: Operating System :: OS Independent
11
11
  Requires-Python: >=3.9
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Requires-Dist: redis>=4.0.2
14
+ Requires-Dist: redis>=4.2.0
15
15
  Requires-Dist: fastapi>=0.68.0
16
16
  Requires-Dist: uvicorn>=0.15.0
17
+ Requires-Dist: httpx>=0.23.0
18
+ Requires-Dist: PyYAML>=6.0
17
19
  Dynamic: license-file
18
20
 
19
21
  # MobSec modules library
@@ -12,4 +12,6 @@ src/mobsec_modules_library/dynamic/adb_utils.py
12
12
  src/mobsec_modules_library/dynamic/base_module_manager.py
13
13
  src/mobsec_modules_library/dynamic/base_server.py
14
14
  src/mobsec_modules_library/dynamic/base_websocket_manager.py
15
+ src/mobsec_modules_library/external/__init__.py
16
+ src/mobsec_modules_library/external/external_module.py
15
17
  src/mobsec_modules_library/static/static_module.py
@@ -0,0 +1,5 @@
1
+ redis>=4.2.0
2
+ fastapi>=0.68.0
3
+ uvicorn>=0.15.0
4
+ httpx>=0.23.0
5
+ PyYAML>=6.0
@@ -1,3 +0,0 @@
1
- redis>=4.0.2
2
- fastapi>=0.68.0
3
- uvicorn>=0.15.0