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.
- {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/PKG-INFO +4 -2
- {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/pyproject.toml +5 -3
- mobsec_modules_library-0.0.2/src/mobsec_modules_library/external/__init__.py +3 -0
- mobsec_modules_library-0.0.2/src/mobsec_modules_library/external/external_module.py +246 -0
- {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library.egg-info/PKG-INFO +4 -2
- {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library.egg-info/SOURCES.txt +2 -0
- mobsec_modules_library-0.0.2/src/mobsec_modules_library.egg-info/requires.txt +5 -0
- mobsec_modules_library-0.0.1/src/mobsec_modules_library.egg-info/requires.txt +0 -3
- {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/LICENSE +0 -0
- {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/README.md +0 -0
- {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/setup.cfg +0 -0
- {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library/__init__.py +0 -0
- {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library/dynamic/__init__.py +0 -0
- {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library/dynamic/adb_utils.py +0 -0
- {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library/dynamic/base_module_manager.py +0 -0
- {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library/dynamic/base_server.py +0 -0
- {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library/dynamic/base_websocket_manager.py +0 -0
- {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library/static/static_module.py +0 -0
- {mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library.egg-info/dependency_links.txt +0 -0
- {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.
|
|
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
|
|
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.
|
|
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
|
|
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,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.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mobsec_modules_library-0.0.1 → mobsec_modules_library-0.0.2}/src/mobsec_modules_library/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|