rivex-agent 0.6.0__py3-none-any.whl
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.
- agent_launcher.py +2971 -0
- core/__init__.py +1 -0
- core/config_manager.py +195 -0
- core/install_paths.py +152 -0
- core/logger.py +30 -0
- core/patch_engine.py +467 -0
- core/protection.py +295 -0
- core/remote_commands.py +112 -0
- core/scheduler.py +379 -0
- core/system_deps.py +620 -0
- core/system_install.py +782 -0
- core/updater.py +248 -0
- modules/__init__.py +1 -0
- modules/base_info.py +85 -0
- modules/filesystem.py +60 -0
- modules/fingerprint.py +26 -0
- modules/metrics.py +199 -0
- modules/network.py +237 -0
- modules/security.py +52 -0
- modules/services.py +111 -0
- modules/software.py +134 -0
- rivex_agent-0.6.0.dist-info/METADATA +140 -0
- rivex_agent-0.6.0.dist-info/RECORD +27 -0
- rivex_agent-0.6.0.dist-info/WHEEL +5 -0
- rivex_agent-0.6.0.dist-info/entry_points.txt +2 -0
- rivex_agent-0.6.0.dist-info/top_level.txt +4 -0
- rivex_agent.py +33 -0
agent_launcher.py
ADDED
|
@@ -0,0 +1,2971 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
from urllib.error import HTTPError, URLError
|
|
17
|
+
from urllib.parse import urljoin, urlparse
|
|
18
|
+
from urllib.request import Request, urlopen
|
|
19
|
+
|
|
20
|
+
AGENT_DIR = Path(__file__).resolve().parent
|
|
21
|
+
if str(AGENT_DIR) not in sys.path:
|
|
22
|
+
sys.path.insert(0, str(AGENT_DIR))
|
|
23
|
+
|
|
24
|
+
from core.config_manager import ConfigManager
|
|
25
|
+
from core.logger import get_log_path, get_logger
|
|
26
|
+
from core import install_paths as agent_paths
|
|
27
|
+
from core import patch_engine
|
|
28
|
+
from core import protection as agent_protection
|
|
29
|
+
from core import system_deps
|
|
30
|
+
from core.remote_commands import RemoteCommandClient
|
|
31
|
+
from core.scheduler import Scheduler
|
|
32
|
+
from core.updater import Updater
|
|
33
|
+
|
|
34
|
+
APP_NAME = "Rivex-Agent"
|
|
35
|
+
APP_VERSION = "0.6.0"
|
|
36
|
+
DEFAULT_REQUIREMENTS = ["psutil==5.9.8"]
|
|
37
|
+
DEFAULT_REQUIREMENTS_LINUX = ["psutil==5.9.8"]
|
|
38
|
+
DEFAULT_REQUIREMENTS_WINDOWS = ["psutil==5.9.8"]
|
|
39
|
+
REQUIREMENTS_FILE = "requirements.txt"
|
|
40
|
+
REQUIREMENTS_LINUX_FILE = "requirements-linux.txt"
|
|
41
|
+
REQUIREMENTS_WINDOWS_FILE = "requirements-windows.txt"
|
|
42
|
+
AGENT_PATHS = agent_paths.resolve_paths(AGENT_DIR)
|
|
43
|
+
AGENT_MODE = str(AGENT_PATHS.get("mode", "portable"))
|
|
44
|
+
RUNTIME_DIR = AGENT_PATHS["runtime_dir"]
|
|
45
|
+
RUNTIME_CONFIG_PATH = AGENT_PATHS["runtime_config_file"]
|
|
46
|
+
RUNTIME_SCAN_DIR = AGENT_PATHS["scan_dir"]
|
|
47
|
+
RUNTIME_UPDATE_DIR = AGENT_PATHS["update_dir"]
|
|
48
|
+
TEMPLATE_CONFIG_PATH = AGENT_PATHS["template_config_file"]
|
|
49
|
+
LOG_DIR_OVERRIDE = AGENT_PATHS["log_dir"]
|
|
50
|
+
LOG_FILE_OVERRIDE = AGENT_PATHS["log_file"]
|
|
51
|
+
RUNTIME_ALWAYS_STATE_KEYS = {
|
|
52
|
+
"agent_uuid",
|
|
53
|
+
"agent_token",
|
|
54
|
+
"machine_id",
|
|
55
|
+
"status",
|
|
56
|
+
"last_sync",
|
|
57
|
+
}
|
|
58
|
+
RUNTIME_SESSION_STATE_KEYS = {
|
|
59
|
+
"enrolled",
|
|
60
|
+
"server_url",
|
|
61
|
+
"comment",
|
|
62
|
+
"tags",
|
|
63
|
+
}
|
|
64
|
+
DEFAULT_REMOTE_ALLOWED_ACTIONS = {
|
|
65
|
+
"scan",
|
|
66
|
+
"sync",
|
|
67
|
+
"status",
|
|
68
|
+
"diag",
|
|
69
|
+
"score",
|
|
70
|
+
"update",
|
|
71
|
+
"reboot",
|
|
72
|
+
"patch",
|
|
73
|
+
"enable_auto_scan",
|
|
74
|
+
"disable_auto_scan",
|
|
75
|
+
"tag",
|
|
76
|
+
"check_protection",
|
|
77
|
+
"refresh_protection_baseline",
|
|
78
|
+
"check_system_deps",
|
|
79
|
+
"install_system_deps",
|
|
80
|
+
}
|
|
81
|
+
AGENT_API_DEFAULT_PATHS = {
|
|
82
|
+
"register_url": "/api/agent/register",
|
|
83
|
+
"sync_url": "/api/agent/sync",
|
|
84
|
+
"me_url": "/api/agent/me",
|
|
85
|
+
"commands_pull_url": "/api/agent/commands/pull",
|
|
86
|
+
"commands_result_url": "/api/agent/commands/result",
|
|
87
|
+
"alerts_url": "/api/agent/alerts",
|
|
88
|
+
}
|
|
89
|
+
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$")
|
|
90
|
+
FRONTEND_AGENT_VERSION_METADATA_PATH = AGENT_DIR.parent / "server" / "frontend" / "public" / "agent" / "version.json"
|
|
91
|
+
MANPAGE_BUNDLE_CANDIDATES = [
|
|
92
|
+
AGENT_DIR / "agent_launcher.py.1",
|
|
93
|
+
AGENT_DIR / "test" / "agent_launcher.py.1",
|
|
94
|
+
]
|
|
95
|
+
MANPAGE_TARGET_DIR_CANDIDATES = [
|
|
96
|
+
Path.home() / ".local" / "share" / "man" / "man1",
|
|
97
|
+
Path.home() / ".local" / "man" / "man1",
|
|
98
|
+
]
|
|
99
|
+
MANPAGE_TARGET_PAGE_NAMES = [
|
|
100
|
+
"agent_launcher.py.1",
|
|
101
|
+
"agent_launcher.1",
|
|
102
|
+
]
|
|
103
|
+
UPDATE_RESTART_GUARD_ENV = "RIVEX_AGENT_UPDATE_RESTARTED"
|
|
104
|
+
ELEVATION_REEXEC_GUARD_ENV = "RIVEX_AGENT_ELEVATED_REEXEC"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_valid_semver(value: str) -> bool:
|
|
108
|
+
return bool(SEMVER_RE.match(str(value or "").strip()))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse_semver(value: str) -> tuple[int, int, int]:
|
|
112
|
+
if not _is_valid_semver(value):
|
|
113
|
+
raise ValueError("Version must use semantic format x.y.z")
|
|
114
|
+
parts = [int(token) for token in value.strip().split(".")]
|
|
115
|
+
return parts[0], parts[1], parts[2]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _bump_semver(value: str, part: str) -> str:
|
|
119
|
+
major, minor, patch = _parse_semver(value)
|
|
120
|
+
bump_part = str(part or "patch").strip().lower()
|
|
121
|
+
if bump_part == "major":
|
|
122
|
+
major += 1
|
|
123
|
+
minor = 0
|
|
124
|
+
patch = 0
|
|
125
|
+
elif bump_part == "minor":
|
|
126
|
+
minor += 1
|
|
127
|
+
patch = 0
|
|
128
|
+
else:
|
|
129
|
+
patch += 1
|
|
130
|
+
return f"{major}.{minor}.{patch}"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _replace_version_in_text(text: str, pattern: re.Pattern[str], new_version: str) -> tuple[str, str]:
|
|
134
|
+
match = pattern.search(text)
|
|
135
|
+
if not match:
|
|
136
|
+
raise ValueError("Version token not found in target file")
|
|
137
|
+
old_version = match.group(2)
|
|
138
|
+
|
|
139
|
+
def _repl(found: re.Match[str]) -> str:
|
|
140
|
+
group4 = found.group(4) if found.lastindex and found.lastindex >= 4 else ""
|
|
141
|
+
return f"{found.group(1)}{new_version}{found.group(3)}{group4}"
|
|
142
|
+
|
|
143
|
+
updated = pattern.sub(_repl, text, count=1)
|
|
144
|
+
return old_version, updated
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _update_optional_frontend_agent_version(new_version: str) -> Dict[str, Any] | None:
|
|
148
|
+
if not FRONTEND_AGENT_VERSION_METADATA_PATH.exists():
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
content = FRONTEND_AGENT_VERSION_METADATA_PATH.read_text(encoding="utf-8")
|
|
153
|
+
payload = json.loads(content)
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
return {
|
|
156
|
+
"path": FRONTEND_AGENT_VERSION_METADATA_PATH.as_posix(),
|
|
157
|
+
"updated": False,
|
|
158
|
+
"reason": f"invalid_json: {exc}",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if not isinstance(payload, dict):
|
|
162
|
+
return {
|
|
163
|
+
"path": FRONTEND_AGENT_VERSION_METADATA_PATH.as_posix(),
|
|
164
|
+
"updated": False,
|
|
165
|
+
"reason": "invalid_json_object",
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
old_version = str(payload.get("version", "") or "").strip()
|
|
169
|
+
payload["version"] = new_version
|
|
170
|
+
FRONTEND_AGENT_VERSION_METADATA_PATH.write_text(
|
|
171
|
+
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
|
|
172
|
+
encoding="utf-8",
|
|
173
|
+
)
|
|
174
|
+
return {
|
|
175
|
+
"path": FRONTEND_AGENT_VERSION_METADATA_PATH.as_posix(),
|
|
176
|
+
"old_version": old_version,
|
|
177
|
+
"new_version": new_version,
|
|
178
|
+
"updated": old_version != new_version,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def run_version_bump(part: str = "patch", set_version: str = "") -> Dict[str, Any]:
|
|
183
|
+
bump_part = str(part or "patch").strip().lower()
|
|
184
|
+
if bump_part not in {"major", "minor", "patch"}:
|
|
185
|
+
raise ValueError("bump part must be one of: major, minor, patch")
|
|
186
|
+
|
|
187
|
+
explicit_version = str(set_version or "").strip()
|
|
188
|
+
if explicit_version and not _is_valid_semver(explicit_version):
|
|
189
|
+
raise ValueError("--set_version must follow semantic format x.y.z")
|
|
190
|
+
|
|
191
|
+
current_version = APP_VERSION
|
|
192
|
+
target_version = explicit_version or _bump_semver(current_version, bump_part)
|
|
193
|
+
|
|
194
|
+
targets = [
|
|
195
|
+
{
|
|
196
|
+
"path": AGENT_DIR / "agent_launcher.py",
|
|
197
|
+
"pattern": re.compile(r'^(APP_VERSION\s*=\s*")(\d+\.\d+\.\d+)(")(\s*)$', re.MULTILINE),
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"path": AGENT_DIR / "core" / "config_manager.py",
|
|
201
|
+
"pattern": re.compile(r'^(\s*"version"\s*:\s*")(\d+\.\d+\.\d+)(")(\s*,\s*)$', re.MULTILINE),
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
"path": AGENT_DIR / "config.json",
|
|
205
|
+
"pattern": re.compile(r'^(\s*"version"\s*:\s*")(\d+\.\d+\.\d+)(")(\s*,\s*)$', re.MULTILINE),
|
|
206
|
+
},
|
|
207
|
+
# pyproject.toml (PyPI package rivex-agent). Version TOML standard :
|
|
208
|
+
# version = "x.y.z"
|
|
209
|
+
{
|
|
210
|
+
"path": AGENT_DIR / "pyproject.toml",
|
|
211
|
+
"pattern": re.compile(r'^(version\s*=\s*")(\d+\.\d+\.\d+)(")(\s*)$', re.MULTILINE),
|
|
212
|
+
},
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
updated_files: List[Dict[str, Any]] = []
|
|
216
|
+
for target in targets:
|
|
217
|
+
path = target["path"]
|
|
218
|
+
if not path.exists():
|
|
219
|
+
raise FileNotFoundError(f"Version target file not found: {path}")
|
|
220
|
+
|
|
221
|
+
content = path.read_text(encoding="utf-8")
|
|
222
|
+
old_version, rewritten = _replace_version_in_text(content, target["pattern"], target_version)
|
|
223
|
+
changed = rewritten != content
|
|
224
|
+
if changed:
|
|
225
|
+
path.write_text(rewritten, encoding="utf-8")
|
|
226
|
+
|
|
227
|
+
updated_files.append(
|
|
228
|
+
{
|
|
229
|
+
"path": path.as_posix(),
|
|
230
|
+
"old_version": old_version,
|
|
231
|
+
"new_version": target_version,
|
|
232
|
+
"updated": changed,
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
metadata_update = _update_optional_frontend_agent_version(target_version)
|
|
237
|
+
if metadata_update is not None:
|
|
238
|
+
updated_files.append(metadata_update)
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
"bump_part": bump_part,
|
|
242
|
+
"from_version": current_version,
|
|
243
|
+
"to_version": target_version,
|
|
244
|
+
"set_version": explicit_version or None,
|
|
245
|
+
"updated_files": updated_files,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def utc_now_iso() -> str:
|
|
250
|
+
return datetime.now(timezone.utc).isoformat()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def ensure_runtime_storage() -> None:
|
|
254
|
+
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
RUNTIME_SCAN_DIR.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
RUNTIME_UPDATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
257
|
+
|
|
258
|
+
if RUNTIME_CONFIG_PATH.exists():
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
template_data: Dict[str, Any] | None = None
|
|
262
|
+
if TEMPLATE_CONFIG_PATH.exists():
|
|
263
|
+
try:
|
|
264
|
+
loaded = json.loads(TEMPLATE_CONFIG_PATH.read_text(encoding="utf-8"))
|
|
265
|
+
if isinstance(loaded, dict):
|
|
266
|
+
template_data = loaded
|
|
267
|
+
except Exception:
|
|
268
|
+
template_data = None
|
|
269
|
+
|
|
270
|
+
if template_data is not None:
|
|
271
|
+
RUNTIME_CONFIG_PATH.write_text(json.dumps(template_data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
ConfigManager(RUNTIME_CONFIG_PATH).ensure_exists()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _resolve_manpage_bundle_path() -> Optional[Path]:
|
|
278
|
+
for candidate in MANPAGE_BUNDLE_CANDIDATES:
|
|
279
|
+
if candidate.exists():
|
|
280
|
+
return candidate
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def ensure_linux_manpage(logger: Any) -> None:
|
|
285
|
+
if not sys.platform.startswith("linux"):
|
|
286
|
+
return
|
|
287
|
+
bundle_path = _resolve_manpage_bundle_path()
|
|
288
|
+
if bundle_path is None:
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
bundled = bundle_path.read_text(encoding="utf-8")
|
|
293
|
+
installed_targets: List[str] = []
|
|
294
|
+
for target_dir in MANPAGE_TARGET_DIR_CANDIDATES:
|
|
295
|
+
try:
|
|
296
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
297
|
+
except Exception:
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
for page_name in MANPAGE_TARGET_PAGE_NAMES:
|
|
301
|
+
target_path = target_dir / page_name
|
|
302
|
+
current = target_path.read_text(encoding="utf-8") if target_path.exists() else ""
|
|
303
|
+
if current != bundled:
|
|
304
|
+
target_path.write_text(bundled, encoding="utf-8")
|
|
305
|
+
installed_targets.append(target_path.as_posix())
|
|
306
|
+
|
|
307
|
+
if installed_targets:
|
|
308
|
+
try:
|
|
309
|
+
subprocess.run(
|
|
310
|
+
["mandb", "-q"],
|
|
311
|
+
capture_output=True,
|
|
312
|
+
text=True,
|
|
313
|
+
encoding="utf-8",
|
|
314
|
+
errors="replace",
|
|
315
|
+
check=False,
|
|
316
|
+
)
|
|
317
|
+
except Exception:
|
|
318
|
+
pass
|
|
319
|
+
else:
|
|
320
|
+
logger.warning("Unable to install local man page into user man paths")
|
|
321
|
+
except Exception as exc:
|
|
322
|
+
logger.warning("Unable to install local man page: %s", exc)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _read_template_config() -> Dict[str, Any]:
|
|
326
|
+
if not TEMPLATE_CONFIG_PATH.exists():
|
|
327
|
+
return {}
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
loaded = json.loads(TEMPLATE_CONFIG_PATH.read_text(encoding="utf-8"))
|
|
331
|
+
except Exception:
|
|
332
|
+
return {}
|
|
333
|
+
|
|
334
|
+
if not isinstance(loaded, dict):
|
|
335
|
+
return {}
|
|
336
|
+
return loaded
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def sync_runtime_config_from_template(
|
|
340
|
+
manager: ConfigManager,
|
|
341
|
+
runtime_config: Dict[str, Any],
|
|
342
|
+
preserve_runtime_session: bool = True,
|
|
343
|
+
) -> tuple[Dict[str, Any], bool]:
|
|
344
|
+
template_config = _read_template_config()
|
|
345
|
+
if not template_config:
|
|
346
|
+
return runtime_config, False
|
|
347
|
+
|
|
348
|
+
synced = json.loads(json.dumps(runtime_config))
|
|
349
|
+
preserved_keys = set(RUNTIME_ALWAYS_STATE_KEYS)
|
|
350
|
+
if preserve_runtime_session:
|
|
351
|
+
preserved_keys.update(RUNTIME_SESSION_STATE_KEYS)
|
|
352
|
+
|
|
353
|
+
for key, value in template_config.items():
|
|
354
|
+
if key in preserved_keys:
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
if key == "scheduler" and isinstance(value, dict):
|
|
358
|
+
runtime_scheduler = runtime_config.get("scheduler", {})
|
|
359
|
+
new_scheduler = dict(value)
|
|
360
|
+
if isinstance(runtime_scheduler, dict):
|
|
361
|
+
if "enabled" in runtime_scheduler:
|
|
362
|
+
new_scheduler["enabled"] = runtime_scheduler["enabled"]
|
|
363
|
+
for runtime_key, runtime_value in runtime_scheduler.items():
|
|
364
|
+
if runtime_key not in new_scheduler:
|
|
365
|
+
new_scheduler[runtime_key] = runtime_value
|
|
366
|
+
synced["scheduler"] = new_scheduler
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
if key == "agent_api" and isinstance(value, dict):
|
|
370
|
+
runtime_agent_api = runtime_config.get("agent_api", {})
|
|
371
|
+
new_agent_api = dict(value)
|
|
372
|
+
if preserve_runtime_session and isinstance(runtime_agent_api, dict):
|
|
373
|
+
for runtime_key in (
|
|
374
|
+
"register_url",
|
|
375
|
+
"sync_url",
|
|
376
|
+
"me_url",
|
|
377
|
+
"commands_pull_url",
|
|
378
|
+
"commands_result_url",
|
|
379
|
+
"alerts_url",
|
|
380
|
+
"timeout_seconds",
|
|
381
|
+
"enroll_key",
|
|
382
|
+
):
|
|
383
|
+
if runtime_key in runtime_agent_api and runtime_agent_api[runtime_key] not in (None, ""):
|
|
384
|
+
new_agent_api[runtime_key] = runtime_agent_api[runtime_key]
|
|
385
|
+
for runtime_key, runtime_value in runtime_agent_api.items():
|
|
386
|
+
if runtime_key not in new_agent_api:
|
|
387
|
+
new_agent_api[runtime_key] = runtime_value
|
|
388
|
+
synced["agent_api"] = new_agent_api
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
if key == "protection" and isinstance(value, dict):
|
|
392
|
+
runtime_protection = runtime_config.get("protection", {})
|
|
393
|
+
new_protection = dict(value)
|
|
394
|
+
if preserve_runtime_session and isinstance(runtime_protection, dict):
|
|
395
|
+
# Les reglages runtime (enabled / alert_url / ...) prennent
|
|
396
|
+
# toujours le pas sur le template pour que les decisions
|
|
397
|
+
# d exploitation ne soient pas ecrasees par un sync automatique.
|
|
398
|
+
for runtime_key, runtime_value in runtime_protection.items():
|
|
399
|
+
new_protection[runtime_key] = runtime_value
|
|
400
|
+
synced["protection"] = new_protection
|
|
401
|
+
continue
|
|
402
|
+
|
|
403
|
+
if key == "remote_commands" and isinstance(value, dict):
|
|
404
|
+
runtime_remote = runtime_config.get("remote_commands", {})
|
|
405
|
+
new_remote = dict(value)
|
|
406
|
+
if preserve_runtime_session and isinstance(runtime_remote, dict):
|
|
407
|
+
for runtime_key in (
|
|
408
|
+
"enabled",
|
|
409
|
+
"pull_url",
|
|
410
|
+
"result_url",
|
|
411
|
+
"auth_token",
|
|
412
|
+
"timeout_seconds",
|
|
413
|
+
"max_commands_per_poll",
|
|
414
|
+
"allowed_actions",
|
|
415
|
+
):
|
|
416
|
+
if runtime_key in runtime_remote:
|
|
417
|
+
new_remote[runtime_key] = runtime_remote[runtime_key]
|
|
418
|
+
|
|
419
|
+
runtime_allowed = runtime_remote.get("allowed_actions", [])
|
|
420
|
+
template_allowed = value.get("allowed_actions", [])
|
|
421
|
+
merged_allowed: List[str] = []
|
|
422
|
+
|
|
423
|
+
if isinstance(runtime_allowed, list):
|
|
424
|
+
for item in runtime_allowed:
|
|
425
|
+
action = str(item).strip().lower()
|
|
426
|
+
if action and action not in merged_allowed:
|
|
427
|
+
merged_allowed.append(action)
|
|
428
|
+
|
|
429
|
+
if isinstance(template_allowed, list):
|
|
430
|
+
for item in template_allowed:
|
|
431
|
+
action = str(item).strip().lower()
|
|
432
|
+
if action and action not in merged_allowed:
|
|
433
|
+
merged_allowed.append(action)
|
|
434
|
+
|
|
435
|
+
if merged_allowed:
|
|
436
|
+
new_remote["allowed_actions"] = merged_allowed
|
|
437
|
+
|
|
438
|
+
for runtime_key, runtime_value in runtime_remote.items():
|
|
439
|
+
if runtime_key not in new_remote:
|
|
440
|
+
new_remote[runtime_key] = runtime_value
|
|
441
|
+
synced["remote_commands"] = new_remote
|
|
442
|
+
continue
|
|
443
|
+
|
|
444
|
+
synced[key] = value
|
|
445
|
+
|
|
446
|
+
for key in preserved_keys:
|
|
447
|
+
if key in runtime_config:
|
|
448
|
+
synced[key] = runtime_config[key]
|
|
449
|
+
|
|
450
|
+
if synced != runtime_config:
|
|
451
|
+
manager.save(synced)
|
|
452
|
+
return synced, True
|
|
453
|
+
return runtime_config, False
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _write_requirements_file(path: Path, entries: List[str]) -> None:
|
|
457
|
+
path.write_text("\n".join(entries) + "\n", encoding="utf-8")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _platform_requirements_info(agent_dir: Path) -> tuple[Path, List[str]]:
|
|
461
|
+
system = platform.system().lower()
|
|
462
|
+
if system == "linux":
|
|
463
|
+
return agent_dir / REQUIREMENTS_LINUX_FILE, DEFAULT_REQUIREMENTS_LINUX
|
|
464
|
+
if system == "windows":
|
|
465
|
+
return agent_dir / REQUIREMENTS_WINDOWS_FILE, DEFAULT_REQUIREMENTS_WINDOWS
|
|
466
|
+
return agent_dir / REQUIREMENTS_FILE, DEFAULT_REQUIREMENTS
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def ensure_requirements_file(agent_dir: Path) -> Path:
|
|
470
|
+
fallback_requirements = agent_dir / REQUIREMENTS_FILE
|
|
471
|
+
if not fallback_requirements.exists():
|
|
472
|
+
_write_requirements_file(fallback_requirements, DEFAULT_REQUIREMENTS)
|
|
473
|
+
|
|
474
|
+
platform_requirements, defaults = _platform_requirements_info(agent_dir)
|
|
475
|
+
if not platform_requirements.exists():
|
|
476
|
+
_write_requirements_file(platform_requirements, defaults)
|
|
477
|
+
|
|
478
|
+
return platform_requirements if platform_requirements.exists() else fallback_requirements
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _normalize_requirement_name(spec: str) -> str:
|
|
482
|
+
name = re.split(r"[<>=!~\[;\s]", spec, maxsplit=1)[0].strip().lower()
|
|
483
|
+
return name
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _module_name_from_requirement(spec: str) -> str:
|
|
487
|
+
package_name = _normalize_requirement_name(spec)
|
|
488
|
+
module_name = package_name.replace("-", "_")
|
|
489
|
+
mapping = {
|
|
490
|
+
"pyyaml": "yaml",
|
|
491
|
+
}
|
|
492
|
+
return mapping.get(package_name, module_name)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _read_requirements(requirements_path: Path) -> List[str]:
|
|
496
|
+
if not requirements_path.exists():
|
|
497
|
+
return []
|
|
498
|
+
lines = requirements_path.read_text(encoding="utf-8").splitlines()
|
|
499
|
+
specs: List[str] = []
|
|
500
|
+
for line in lines:
|
|
501
|
+
item = line.strip()
|
|
502
|
+
if not item or item.startswith("#"):
|
|
503
|
+
continue
|
|
504
|
+
if item.startswith("-"):
|
|
505
|
+
continue
|
|
506
|
+
specs.append(item)
|
|
507
|
+
return specs
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _is_installed(spec: str) -> bool:
|
|
511
|
+
module_name = _module_name_from_requirement(spec)
|
|
512
|
+
return importlib.util.find_spec(module_name) is not None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def ensure_runtime_dependencies(agent_dir: Path, logger: Any, force_reinstall: bool = False) -> Dict[str, Any]:
|
|
516
|
+
requirements_path = ensure_requirements_file(agent_dir)
|
|
517
|
+
requirements = _read_requirements(requirements_path)
|
|
518
|
+
missing = [spec for spec in requirements if not _is_installed(spec)]
|
|
519
|
+
should_install = bool(force_reinstall or missing)
|
|
520
|
+
|
|
521
|
+
if not should_install:
|
|
522
|
+
return {
|
|
523
|
+
"requirements_file": str(requirements_path),
|
|
524
|
+
"installed": [],
|
|
525
|
+
"missing_before_install": [],
|
|
526
|
+
"attempted_install": False,
|
|
527
|
+
"install_reason": "already_satisfied",
|
|
528
|
+
"status": "ok",
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if force_reinstall:
|
|
532
|
+
logger.info("Installing dependencies from requirements.txt (forced refresh)")
|
|
533
|
+
else:
|
|
534
|
+
logger.info("Installing missing dependencies: %s", ", ".join(missing))
|
|
535
|
+
|
|
536
|
+
def _run_pip(extra_flags: List[str]) -> subprocess.CompletedProcess[str]:
|
|
537
|
+
return subprocess.run(
|
|
538
|
+
[sys.executable, "-m", "pip", "install", "--disable-pip-version-check", *extra_flags, "-r", str(requirements_path)],
|
|
539
|
+
capture_output=True,
|
|
540
|
+
text=True,
|
|
541
|
+
encoding="utf-8",
|
|
542
|
+
errors="replace",
|
|
543
|
+
check=False,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
process = _run_pip([])
|
|
547
|
+
|
|
548
|
+
# Sur Windows, si Python a ete installe pour tous les utilisateurs, pip
|
|
549
|
+
# refuse l installation globale sans elevation. On retente automatiquement
|
|
550
|
+
# avec `--user` pour eviter l echec silencieux du bootstrap dependances.
|
|
551
|
+
if process.returncode != 0:
|
|
552
|
+
error_text = (process.stderr or process.stdout or "").lower()
|
|
553
|
+
retryable_markers = (
|
|
554
|
+
"permission denied",
|
|
555
|
+
"access is denied",
|
|
556
|
+
"could not install",
|
|
557
|
+
"[winerror 5]",
|
|
558
|
+
"winerror 5",
|
|
559
|
+
"externally-managed-environment",
|
|
560
|
+
)
|
|
561
|
+
if any(marker in error_text for marker in retryable_markers):
|
|
562
|
+
logger.info("Retrying pip install with --user after privilege error")
|
|
563
|
+
retry = _run_pip(["--user"])
|
|
564
|
+
if retry.returncode == 0:
|
|
565
|
+
process = retry
|
|
566
|
+
else:
|
|
567
|
+
process = retry if retry.stderr else process
|
|
568
|
+
|
|
569
|
+
if process.returncode != 0:
|
|
570
|
+
logger.warning("Dependency installation failed: %s", process.stderr.strip())
|
|
571
|
+
return {
|
|
572
|
+
"requirements_file": str(requirements_path),
|
|
573
|
+
"installed": [],
|
|
574
|
+
"missing_before_install": missing,
|
|
575
|
+
"attempted_install": True,
|
|
576
|
+
"install_reason": "forced_refresh" if force_reinstall else "missing_dependencies",
|
|
577
|
+
"status": "failed",
|
|
578
|
+
"error": process.stderr.strip() or process.stdout.strip() or "Unknown pip error",
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
installed = [spec for spec in requirements if _is_installed(spec)]
|
|
582
|
+
return {
|
|
583
|
+
"requirements_file": str(requirements_path),
|
|
584
|
+
"installed": installed,
|
|
585
|
+
"missing_before_install": missing,
|
|
586
|
+
"attempted_install": True,
|
|
587
|
+
"install_reason": "forced_refresh" if force_reinstall else "missing_dependencies",
|
|
588
|
+
"status": "ok",
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _is_windows_admin() -> bool:
|
|
593
|
+
if platform.system().lower() != "windows":
|
|
594
|
+
return False
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
import ctypes
|
|
598
|
+
|
|
599
|
+
return bool(ctypes.windll.shell32.IsUserAnAdmin())
|
|
600
|
+
except Exception:
|
|
601
|
+
return False
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _is_process_elevated() -> bool:
|
|
605
|
+
system = platform.system().lower()
|
|
606
|
+
if system == "windows":
|
|
607
|
+
return _is_windows_admin()
|
|
608
|
+
if hasattr(os, "geteuid"):
|
|
609
|
+
return os.geteuid() == 0
|
|
610
|
+
return False
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _requested_privileged_actions(args: argparse.Namespace) -> List[str]:
|
|
614
|
+
"""Liste des actions CLI qui exigent obligatoirement root/admin.
|
|
615
|
+
|
|
616
|
+
Toute action presente ici declenche une relance automatique via sudo
|
|
617
|
+
(Linux) ou UAC (Windows) si le processus courant n est pas deja eleve.
|
|
618
|
+
"""
|
|
619
|
+
actions: List[str] = []
|
|
620
|
+
patch_value = str(getattr(args, "patch", "") or "").strip()
|
|
621
|
+
if patch_value:
|
|
622
|
+
# Execute des commandes systeme (apt/yum/pacman/dnf, ufw, systemctl).
|
|
623
|
+
actions.append("patch")
|
|
624
|
+
if bool(getattr(args, "pull_remote_commands", False)):
|
|
625
|
+
# Peut declencher des patches/installations a distance.
|
|
626
|
+
actions.append("pull_remote_commands")
|
|
627
|
+
if bool(getattr(args, "enable_auto_scan", False)):
|
|
628
|
+
# Ecrit dans crontab/systemd/schtasks niveau systeme.
|
|
629
|
+
actions.append("enable_auto_scan")
|
|
630
|
+
if bool(getattr(args, "disable_auto_scan", False)):
|
|
631
|
+
actions.append("disable_auto_scan")
|
|
632
|
+
if getattr(args, "set_scan_interval", None) is not None:
|
|
633
|
+
# Re-applique le scheduler OS si le scan automatique est actif.
|
|
634
|
+
actions.append("set_scan_interval")
|
|
635
|
+
if bool(getattr(args, "update", False)):
|
|
636
|
+
# Ecrit les fichiers de l agent (souvent root-owned en production).
|
|
637
|
+
actions.append("update")
|
|
638
|
+
if bool(getattr(args, "install_system_deps", False)):
|
|
639
|
+
actions.append("install_system_deps")
|
|
640
|
+
if bool(getattr(args, "install_system_deps_all", False)):
|
|
641
|
+
actions.append("install_system_deps_all")
|
|
642
|
+
if bool(getattr(args, "install", False)):
|
|
643
|
+
# Ecrit dans /opt/, /etc/, /usr/local/bin/, /etc/systemd/ (Linux) ou
|
|
644
|
+
# C:\Program Files\, HKLM PATH, services Windows.
|
|
645
|
+
actions.append("install")
|
|
646
|
+
if bool(getattr(args, "uninstall", False)):
|
|
647
|
+
# Arrete le service + retrait des fichiers installes (proteges).
|
|
648
|
+
actions.append("uninstall")
|
|
649
|
+
if bool(getattr(args, "service_run", False)):
|
|
650
|
+
# Point d entree du service: doit tourner avec les droits du demon
|
|
651
|
+
# (root sur Linux, LocalSystem sur Windows).
|
|
652
|
+
actions.append("service_run")
|
|
653
|
+
return actions
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _relaunch_with_elevation(raw_args: List[str]) -> Dict[str, Any]:
|
|
657
|
+
system = platform.system().lower()
|
|
658
|
+
script_path = str(Path(__file__).resolve())
|
|
659
|
+
command = [sys.executable, script_path, *raw_args]
|
|
660
|
+
|
|
661
|
+
if system == "linux":
|
|
662
|
+
sudo_executable = shutil.which("sudo")
|
|
663
|
+
if not sudo_executable:
|
|
664
|
+
raise RuntimeError("sudo is required to run this command as root on Linux")
|
|
665
|
+
|
|
666
|
+
env = os.environ.copy()
|
|
667
|
+
env[ELEVATION_REEXEC_GUARD_ENV] = "1"
|
|
668
|
+
elevated_command = [sudo_executable, "-E", *command]
|
|
669
|
+
result = subprocess.run(
|
|
670
|
+
elevated_command,
|
|
671
|
+
cwd=str(AGENT_DIR),
|
|
672
|
+
env=env,
|
|
673
|
+
check=False,
|
|
674
|
+
)
|
|
675
|
+
return {
|
|
676
|
+
"attempted": True,
|
|
677
|
+
"platform": "linux",
|
|
678
|
+
"command": elevated_command,
|
|
679
|
+
"exit_code": result.returncode,
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if system == "windows":
|
|
683
|
+
try:
|
|
684
|
+
import ctypes
|
|
685
|
+
except Exception as exc:
|
|
686
|
+
raise RuntimeError(f"Unable to request Windows elevation: {exc}") from exc
|
|
687
|
+
|
|
688
|
+
parameters = subprocess.list2cmdline([script_path, *raw_args])
|
|
689
|
+
result = ctypes.windll.shell32.ShellExecuteW(
|
|
690
|
+
None,
|
|
691
|
+
"runas",
|
|
692
|
+
sys.executable,
|
|
693
|
+
parameters,
|
|
694
|
+
str(AGENT_DIR),
|
|
695
|
+
1,
|
|
696
|
+
)
|
|
697
|
+
if int(result) <= 32:
|
|
698
|
+
raise RuntimeError(f"Windows elevation failed or was cancelled (code={int(result)})")
|
|
699
|
+
return {
|
|
700
|
+
"attempted": True,
|
|
701
|
+
"platform": "windows",
|
|
702
|
+
"command": command,
|
|
703
|
+
"spawned": True,
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
raise RuntimeError(f"Automatic privilege elevation is not supported on {system}")
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
710
|
+
parser = argparse.ArgumentParser(
|
|
711
|
+
prog="rivex-agent",
|
|
712
|
+
description="Rivex-Agent multiplatform collector launcher",
|
|
713
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
714
|
+
epilog=(
|
|
715
|
+
"Notes on argument relationships:\n"
|
|
716
|
+
" * Primary actions below are mutually exclusive (pick exactly one).\n"
|
|
717
|
+
" * Enrollment options (--url, --c, --enroll_key) require --enroll.\n"
|
|
718
|
+
" * --tag may be used alone (local tag update) or combined with --enroll.\n"
|
|
719
|
+
" * --output / -o is only valid with --scan.\n"
|
|
720
|
+
" * --enroll_key used alone implicitly triggers --enroll (URL/comment prompted).\n"
|
|
721
|
+
" * On Linux, a man page is installed at ~/.local/share/man/man1/ (use: man agent_launcher.py).\n"
|
|
722
|
+
"\n"
|
|
723
|
+
"Examples:\n"
|
|
724
|
+
" rivex-agent --enroll --url https://controller.local --enroll_key KEY --tag prod --c 'first enroll'\n"
|
|
725
|
+
" rivex-agent --scan -o scan.json\n"
|
|
726
|
+
" rivex-agent --tag urgent # update local tags without re-enrolling\n"
|
|
727
|
+
" rivex-agent --patch https://controller.local/api/patches/PTCH-2026-0001.json\n"
|
|
728
|
+
" rivex-agent --check_protection\n"
|
|
729
|
+
" rivex-agent --refresh_protection_baseline\n"
|
|
730
|
+
),
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
primary = parser.add_argument_group(
|
|
734
|
+
"Primary actions (mutually exclusive)",
|
|
735
|
+
"Exactly one of these actions drives the agent run. Without any primary action, --help is shown.",
|
|
736
|
+
)
|
|
737
|
+
primary_group = primary.add_mutually_exclusive_group(required=False)
|
|
738
|
+
primary_group.add_argument("-v", "--version", action="store_true", help="Show agent version")
|
|
739
|
+
primary_group.add_argument(
|
|
740
|
+
"--enroll",
|
|
741
|
+
action="store_true",
|
|
742
|
+
help="Enroll the agent (see 'Enrollment options' below for related flags)",
|
|
743
|
+
)
|
|
744
|
+
primary_group.add_argument("--unenroll", action="store_true", help="Unenroll the agent")
|
|
745
|
+
primary_group.add_argument("--show_config", action="store_true", help="Print local config JSON")
|
|
746
|
+
primary_group.add_argument("--update", action="store_true", help="Check, download and apply update")
|
|
747
|
+
primary_group.add_argument(
|
|
748
|
+
"--bump_version",
|
|
749
|
+
nargs="?",
|
|
750
|
+
const="patch",
|
|
751
|
+
choices=["major", "minor", "patch"],
|
|
752
|
+
metavar="PART",
|
|
753
|
+
help=argparse.SUPPRESS,
|
|
754
|
+
)
|
|
755
|
+
primary_group.add_argument("--sync", action="store_true", help="Force synchronization")
|
|
756
|
+
primary_group.add_argument("--status", action="store_true", help="Show agent status")
|
|
757
|
+
primary_group.add_argument(
|
|
758
|
+
"--diag",
|
|
759
|
+
"--diagnostic",
|
|
760
|
+
dest="diagnostic",
|
|
761
|
+
action="store_true",
|
|
762
|
+
help="Run diagnostics",
|
|
763
|
+
)
|
|
764
|
+
primary_group.add_argument("--show_log_path", action="store_true", help="Show log file path")
|
|
765
|
+
primary_group.add_argument(
|
|
766
|
+
"--scan",
|
|
767
|
+
action="store_true",
|
|
768
|
+
help="Run immediate scan (see 'Scan options' below for --output)",
|
|
769
|
+
)
|
|
770
|
+
primary_group.add_argument(
|
|
771
|
+
"--enable_auto_scan",
|
|
772
|
+
action="store_true",
|
|
773
|
+
help="Enable automatic scan using scheduler.interval_minutes from config",
|
|
774
|
+
)
|
|
775
|
+
primary_group.add_argument(
|
|
776
|
+
"--disable_auto_scan",
|
|
777
|
+
action="store_true",
|
|
778
|
+
help="Disable automatic scheduled scan",
|
|
779
|
+
)
|
|
780
|
+
primary_group.add_argument(
|
|
781
|
+
"--set_scan_interval",
|
|
782
|
+
type=int,
|
|
783
|
+
metavar="MINUTES",
|
|
784
|
+
help="Change the automatic scan interval (in minutes). Re-applies the OS-level scheduler if it is currently enabled.",
|
|
785
|
+
)
|
|
786
|
+
primary_group.add_argument(
|
|
787
|
+
"--patch",
|
|
788
|
+
nargs="?",
|
|
789
|
+
const="",
|
|
790
|
+
metavar="SOURCE",
|
|
791
|
+
help=(
|
|
792
|
+
"Apply a corrective patch JSON from a local file path or an http(s) URL. "
|
|
793
|
+
"Expected Phase 2 format: {\"vulnerabilities\": [{...}]} (update_package, "
|
|
794
|
+
"close_port, disable_service). Legacy config-merge JSON is still supported "
|
|
795
|
+
"for backward compatibility but deprecated."
|
|
796
|
+
),
|
|
797
|
+
)
|
|
798
|
+
primary_group.add_argument("--score", action="store_true", help="Compute health score")
|
|
799
|
+
primary_group.add_argument(
|
|
800
|
+
"--pull_remote_commands",
|
|
801
|
+
action="store_true",
|
|
802
|
+
help="Pull remote forced commands from server and execute allowed actions",
|
|
803
|
+
)
|
|
804
|
+
primary_group.add_argument(
|
|
805
|
+
"--check_protection",
|
|
806
|
+
action="store_true",
|
|
807
|
+
help="Verify agent files integrity against local baseline and alert the server if tampering is detected",
|
|
808
|
+
)
|
|
809
|
+
primary_group.add_argument(
|
|
810
|
+
"--refresh_protection_baseline",
|
|
811
|
+
action="store_true",
|
|
812
|
+
help="Rebuild the local integrity baseline of critical agent files (use after a legitimate update)",
|
|
813
|
+
)
|
|
814
|
+
primary_group.add_argument(
|
|
815
|
+
"--check_system_deps",
|
|
816
|
+
action="store_true",
|
|
817
|
+
help=(
|
|
818
|
+
"Audit OS-level tools required by the agent (cron, ufw, iptables, systemctl, "
|
|
819
|
+
"schtasks, netsh, sc, winget, choco, apt-get/dnf/yum). Read-only; reports what "
|
|
820
|
+
"is present and what is missing."
|
|
821
|
+
),
|
|
822
|
+
)
|
|
823
|
+
primary_group.add_argument(
|
|
824
|
+
"--install_system_deps",
|
|
825
|
+
action="store_true",
|
|
826
|
+
help=(
|
|
827
|
+
"Install missing OS-level tools required by the agent. On Linux: apt-get/dnf/yum. "
|
|
828
|
+
"On Windows: integrated tools cannot be installed automatically; the command prints "
|
|
829
|
+
"the exact command to run for winget/choco. Requires root/admin privileges."
|
|
830
|
+
),
|
|
831
|
+
)
|
|
832
|
+
primary_group.add_argument(
|
|
833
|
+
"--install_system_deps_all",
|
|
834
|
+
action="store_true",
|
|
835
|
+
help=argparse.SUPPRESS,
|
|
836
|
+
)
|
|
837
|
+
primary_group.add_argument(
|
|
838
|
+
"--install",
|
|
839
|
+
action="store_true",
|
|
840
|
+
help=(
|
|
841
|
+
"Install the agent system-wide (root/admin required). "
|
|
842
|
+
"Linux: /opt/rivex-agent/ + systemd service enabled at boot. "
|
|
843
|
+
"Windows: %%PROGRAMFILES%%/Rivex/Agent/ + NSSM service (AutoStart, LocalSystem). "
|
|
844
|
+
"After install, the 'rivex-agent' command is available to any user."
|
|
845
|
+
),
|
|
846
|
+
)
|
|
847
|
+
primary_group.add_argument(
|
|
848
|
+
"--uninstall",
|
|
849
|
+
action="store_true",
|
|
850
|
+
help=(
|
|
851
|
+
"Uninstall the agent from the system (root/admin required). "
|
|
852
|
+
"Stops the service, removes installed files, keeps config by default. "
|
|
853
|
+
"Use --purge to also remove /etc/rivex-agent/ (Linux) or %%PROGRAMDATA%%/Rivex/Agent/ (Windows)."
|
|
854
|
+
),
|
|
855
|
+
)
|
|
856
|
+
primary_group.add_argument(
|
|
857
|
+
"--install_status",
|
|
858
|
+
action="store_true",
|
|
859
|
+
help="Report the current installation status (read-only). JSON output.",
|
|
860
|
+
)
|
|
861
|
+
primary_group.add_argument(
|
|
862
|
+
"--service-run",
|
|
863
|
+
dest="service_run",
|
|
864
|
+
action="store_true",
|
|
865
|
+
help=argparse.SUPPRESS,
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
install_options = parser.add_argument_group(
|
|
869
|
+
"Install options (require --install or --uninstall)",
|
|
870
|
+
"Modifiers applied on top of --install / --uninstall.",
|
|
871
|
+
)
|
|
872
|
+
install_options.add_argument(
|
|
873
|
+
"--force",
|
|
874
|
+
action="store_true",
|
|
875
|
+
help="With --install: reinstall over an existing installation.",
|
|
876
|
+
)
|
|
877
|
+
install_options.add_argument(
|
|
878
|
+
"--purge",
|
|
879
|
+
action="store_true",
|
|
880
|
+
help="With --uninstall: also remove /etc/rivex-agent/ (Linux) or %%PROGRAMDATA%%/Rivex/Agent/ (Windows).",
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
enroll_options = parser.add_argument_group(
|
|
884
|
+
"Enrollment options (require --enroll, except --tag)",
|
|
885
|
+
"Modifiers applied on top of --enroll. --tag is the only one also usable alone to update local tags.",
|
|
886
|
+
)
|
|
887
|
+
enroll_options.add_argument(
|
|
888
|
+
"--url",
|
|
889
|
+
help="Server URL for agent registration. Requires --enroll.",
|
|
890
|
+
)
|
|
891
|
+
enroll_options.add_argument(
|
|
892
|
+
"--c",
|
|
893
|
+
dest="comment",
|
|
894
|
+
default="",
|
|
895
|
+
help="Enrollment comment attached to the machine. Requires --enroll.",
|
|
896
|
+
)
|
|
897
|
+
enroll_options.add_argument(
|
|
898
|
+
"--enroll_key",
|
|
899
|
+
default="",
|
|
900
|
+
help=(
|
|
901
|
+
"Enrollment key: either the server global key (AGENT_ENROLL_KEY) or the "
|
|
902
|
+
"per-machine key from the admin API/UI. Implicitly triggers --enroll when used alone. "
|
|
903
|
+
"If the agent is already enrolled locally, this option is required to re-enroll "
|
|
904
|
+
"(e.g. after revocation on the server); use the machine-specific key to avoid duplicates."
|
|
905
|
+
),
|
|
906
|
+
)
|
|
907
|
+
enroll_options.add_argument(
|
|
908
|
+
"--tag",
|
|
909
|
+
action="append",
|
|
910
|
+
default=[],
|
|
911
|
+
help=(
|
|
912
|
+
"Tag to attach to the agent. With --enroll: included in the enrollment payload. "
|
|
913
|
+
"Without any other primary action: updates local tags only."
|
|
914
|
+
),
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
scan_options = parser.add_argument_group(
|
|
918
|
+
"Scan options (require --scan)",
|
|
919
|
+
"Modifiers applied on top of --scan.",
|
|
920
|
+
)
|
|
921
|
+
scan_options.add_argument(
|
|
922
|
+
"-o",
|
|
923
|
+
"--output",
|
|
924
|
+
dest="output_file",
|
|
925
|
+
help="Write scan payload to this file path. Requires --scan.",
|
|
926
|
+
)
|
|
927
|
+
|
|
928
|
+
version_options = parser.add_argument_group(
|
|
929
|
+
"Version maintenance (internal)",
|
|
930
|
+
"Helpers used during release workflows. Normally not invoked by end users.",
|
|
931
|
+
)
|
|
932
|
+
version_options.add_argument("--set_version", default="", help=argparse.SUPPRESS)
|
|
933
|
+
|
|
934
|
+
return parser
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def validate_args(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
|
|
938
|
+
bump_version_value = getattr(args, "bump_version", None)
|
|
939
|
+
set_version_value = str(getattr(args, "set_version", "") or "")
|
|
940
|
+
|
|
941
|
+
primary_actions = [
|
|
942
|
+
args.version,
|
|
943
|
+
args.enroll,
|
|
944
|
+
args.unenroll,
|
|
945
|
+
args.show_config,
|
|
946
|
+
args.update,
|
|
947
|
+
bump_version_value is not None,
|
|
948
|
+
args.sync,
|
|
949
|
+
args.status,
|
|
950
|
+
args.diagnostic,
|
|
951
|
+
args.show_log_path,
|
|
952
|
+
args.scan,
|
|
953
|
+
args.enable_auto_scan,
|
|
954
|
+
args.disable_auto_scan,
|
|
955
|
+
args.set_scan_interval is not None,
|
|
956
|
+
args.patch is not None,
|
|
957
|
+
args.score,
|
|
958
|
+
args.pull_remote_commands,
|
|
959
|
+
args.check_protection,
|
|
960
|
+
args.refresh_protection_baseline,
|
|
961
|
+
args.check_system_deps,
|
|
962
|
+
args.install_system_deps,
|
|
963
|
+
args.install_system_deps_all,
|
|
964
|
+
getattr(args, "install", False),
|
|
965
|
+
getattr(args, "uninstall", False),
|
|
966
|
+
getattr(args, "install_status", False),
|
|
967
|
+
getattr(args, "service_run", False),
|
|
968
|
+
]
|
|
969
|
+
has_primary_action = any(primary_actions)
|
|
970
|
+
|
|
971
|
+
# Raccourci : `--enroll_key <clé>` (éventuellement combiné avec --url / --c /
|
|
972
|
+
# --tag) déclenche implicitement l'enrôlement. Evite à l'utilisateur de
|
|
973
|
+
# devoir saisir `--enroll --enroll_key <clé>`.
|
|
974
|
+
if not has_primary_action and args.enroll_key:
|
|
975
|
+
args.enroll = True
|
|
976
|
+
has_primary_action = True
|
|
977
|
+
|
|
978
|
+
if not has_primary_action and not args.tag:
|
|
979
|
+
parser.print_help()
|
|
980
|
+
raise SystemExit(0)
|
|
981
|
+
|
|
982
|
+
if args.tag and has_primary_action and not args.enroll:
|
|
983
|
+
parser.error("--tag can only be used alone or with --enroll")
|
|
984
|
+
|
|
985
|
+
if args.url and not args.enroll:
|
|
986
|
+
parser.error("--url can only be used with --enroll")
|
|
987
|
+
if args.comment and not args.enroll:
|
|
988
|
+
parser.error("--c can only be used with --enroll")
|
|
989
|
+
if set_version_value and bump_version_value is None:
|
|
990
|
+
parser.error("--set_version can only be used with --bump_version")
|
|
991
|
+
if set_version_value and not _is_valid_semver(set_version_value):
|
|
992
|
+
parser.error("--set_version must follow semantic format x.y.z")
|
|
993
|
+
if args.output_file and not args.scan:
|
|
994
|
+
parser.error("--output can only be used with --scan")
|
|
995
|
+
if args.set_scan_interval is not None and args.set_scan_interval <= 0:
|
|
996
|
+
parser.error("--set_scan_interval must be a positive integer (minutes)")
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _interactive_input(prompt: str, default: str = "") -> str:
|
|
1000
|
+
label = f"{prompt} [{default}]" if default else prompt
|
|
1001
|
+
try:
|
|
1002
|
+
value = input(f"{label}: ").strip()
|
|
1003
|
+
except EOFError:
|
|
1004
|
+
return default
|
|
1005
|
+
return value or default
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _prompt_enroll_values(args: argparse.Namespace) -> tuple[str, str, List[str]]:
|
|
1009
|
+
url_value = (args.url or "").strip()
|
|
1010
|
+
while True:
|
|
1011
|
+
if not url_value:
|
|
1012
|
+
url_value = _interactive_input("Server URL (http/https)")
|
|
1013
|
+
|
|
1014
|
+
parsed = urlparse(url_value)
|
|
1015
|
+
if parsed.scheme in ("http", "https") and parsed.netloc:
|
|
1016
|
+
break
|
|
1017
|
+
|
|
1018
|
+
print("Invalid URL. Example: https://controller.local")
|
|
1019
|
+
url_value = ""
|
|
1020
|
+
|
|
1021
|
+
comment_value = args.comment if args.comment else _interactive_input("Comment (optional)", "")
|
|
1022
|
+
|
|
1023
|
+
tags = [str(tag).strip() for tag in (args.tag or []) if str(tag).strip()]
|
|
1024
|
+
if not tags:
|
|
1025
|
+
tags_raw = _interactive_input("Tags (optional, comma-separated)", "")
|
|
1026
|
+
if tags_raw:
|
|
1027
|
+
tags = [item.strip() for item in tags_raw.split(",") if item.strip()]
|
|
1028
|
+
|
|
1029
|
+
unique_tags: List[str] = []
|
|
1030
|
+
for tag in tags:
|
|
1031
|
+
if tag not in unique_tags:
|
|
1032
|
+
unique_tags.append(tag)
|
|
1033
|
+
|
|
1034
|
+
return url_value, comment_value, unique_tags
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _safe_timeout(value: Any, fallback: int = 15) -> int:
|
|
1038
|
+
try:
|
|
1039
|
+
parsed = int(value)
|
|
1040
|
+
except (TypeError, ValueError):
|
|
1041
|
+
return fallback
|
|
1042
|
+
if parsed <= 0:
|
|
1043
|
+
return fallback
|
|
1044
|
+
return parsed
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _agent_api_config(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
1048
|
+
agent_api = config.get("agent_api", {})
|
|
1049
|
+
if not isinstance(agent_api, dict):
|
|
1050
|
+
agent_api = {}
|
|
1051
|
+
|
|
1052
|
+
merged: Dict[str, Any] = {
|
|
1053
|
+
"timeout_seconds": _safe_timeout(agent_api.get("timeout_seconds", 15), 15),
|
|
1054
|
+
"enroll_key": str(agent_api.get("enroll_key", "") or "").strip(),
|
|
1055
|
+
}
|
|
1056
|
+
for key, default_path in AGENT_API_DEFAULT_PATHS.items():
|
|
1057
|
+
value = str(agent_api.get(key, "") or "").strip()
|
|
1058
|
+
merged[key] = value
|
|
1059
|
+
merged[f"{key}_default_path"] = default_path
|
|
1060
|
+
return merged
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def _resolve_agent_api_url(config: Dict[str, Any], endpoint_key: str) -> str:
|
|
1064
|
+
agent_api = _agent_api_config(config)
|
|
1065
|
+
explicit = str(agent_api.get(endpoint_key, "") or "").strip()
|
|
1066
|
+
if explicit:
|
|
1067
|
+
return explicit
|
|
1068
|
+
|
|
1069
|
+
server_url = str(config.get("server_url", "") or "").strip()
|
|
1070
|
+
if not server_url:
|
|
1071
|
+
return ""
|
|
1072
|
+
|
|
1073
|
+
default_path = str(agent_api.get(f"{endpoint_key}_default_path", "") or "").strip()
|
|
1074
|
+
if not default_path:
|
|
1075
|
+
return ""
|
|
1076
|
+
|
|
1077
|
+
return urljoin(server_url.rstrip("/") + "/", default_path.lstrip("/"))
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
class AgentHttpError(RuntimeError):
|
|
1081
|
+
def __init__(self, status_code: int, url: str, message: str, response_body: str = ""):
|
|
1082
|
+
super().__init__(f"HTTP {status_code} for {url}: {message}")
|
|
1083
|
+
self.status_code = int(status_code)
|
|
1084
|
+
self.url = url
|
|
1085
|
+
self.response_body = response_body
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _request_json(
|
|
1089
|
+
url: str,
|
|
1090
|
+
method: str,
|
|
1091
|
+
payload: Dict[str, Any],
|
|
1092
|
+
timeout_seconds: int,
|
|
1093
|
+
extra_headers: Optional[Dict[str, str]] = None,
|
|
1094
|
+
) -> Dict[str, Any]:
|
|
1095
|
+
if not url:
|
|
1096
|
+
raise RuntimeError("Agent API URL is not configured")
|
|
1097
|
+
|
|
1098
|
+
headers = {
|
|
1099
|
+
"Content-Type": "application/json",
|
|
1100
|
+
"Accept": "application/json",
|
|
1101
|
+
"User-Agent": APP_NAME,
|
|
1102
|
+
}
|
|
1103
|
+
if extra_headers:
|
|
1104
|
+
headers.update(extra_headers)
|
|
1105
|
+
|
|
1106
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
1107
|
+
request = Request(url, data=body, headers=headers, method=method.upper())
|
|
1108
|
+
|
|
1109
|
+
try:
|
|
1110
|
+
with urlopen(request, timeout=timeout_seconds) as response:
|
|
1111
|
+
raw = response.read().decode("utf-8", errors="replace").strip()
|
|
1112
|
+
except HTTPError as exc:
|
|
1113
|
+
try:
|
|
1114
|
+
raw_error = exc.read().decode("utf-8", errors="replace").strip()
|
|
1115
|
+
except Exception:
|
|
1116
|
+
raw_error = ""
|
|
1117
|
+
message = raw_error or str(exc)
|
|
1118
|
+
raise AgentHttpError(int(exc.code), url, message, response_body=raw_error) from exc
|
|
1119
|
+
except URLError as exc:
|
|
1120
|
+
raise RuntimeError(f"Network error for {url}: {exc}") from exc
|
|
1121
|
+
|
|
1122
|
+
if not raw:
|
|
1123
|
+
return {}
|
|
1124
|
+
|
|
1125
|
+
try:
|
|
1126
|
+
parsed = json.loads(raw)
|
|
1127
|
+
except json.JSONDecodeError:
|
|
1128
|
+
return {"message": raw}
|
|
1129
|
+
if not isinstance(parsed, dict):
|
|
1130
|
+
return {"value": parsed}
|
|
1131
|
+
return parsed
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _build_enroll_payload(config: Dict[str, Any], comment: str, tags: List[str]) -> Dict[str, Any]:
|
|
1135
|
+
from modules.base_info import collect_base_info
|
|
1136
|
+
from modules.fingerprint import collect_fingerprint
|
|
1137
|
+
|
|
1138
|
+
base_info = collect_base_info()
|
|
1139
|
+
fingerprint = collect_fingerprint(config)
|
|
1140
|
+
|
|
1141
|
+
payload = {
|
|
1142
|
+
"agent": {
|
|
1143
|
+
"version": str(config.get("version", APP_VERSION)),
|
|
1144
|
+
},
|
|
1145
|
+
"base_info": {
|
|
1146
|
+
"hostname": base_info.get("hostname"),
|
|
1147
|
+
"ip_address": base_info.get("ip_address"),
|
|
1148
|
+
"os": base_info.get("os"),
|
|
1149
|
+
"machine_type": base_info.get("machine_type"),
|
|
1150
|
+
},
|
|
1151
|
+
"os": {
|
|
1152
|
+
"name": base_info.get("os_name"),
|
|
1153
|
+
"version": base_info.get("os_version"),
|
|
1154
|
+
},
|
|
1155
|
+
"fingerprint": {
|
|
1156
|
+
"fingerprint": fingerprint.get("fingerprint"),
|
|
1157
|
+
},
|
|
1158
|
+
"snapshot": {
|
|
1159
|
+
"comment": comment,
|
|
1160
|
+
"tags": tags,
|
|
1161
|
+
},
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
agent_uuid = str(config.get("agent_uuid", "") or "").strip()
|
|
1165
|
+
if agent_uuid:
|
|
1166
|
+
payload["fingerprint"]["agent_uuid"] = agent_uuid
|
|
1167
|
+
|
|
1168
|
+
return payload
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def enroll_with_server(
|
|
1172
|
+
config: Dict[str, Any],
|
|
1173
|
+
server_url: str,
|
|
1174
|
+
comment: str,
|
|
1175
|
+
tags: List[str],
|
|
1176
|
+
enroll_key_override: str,
|
|
1177
|
+
) -> Dict[str, Any]:
|
|
1178
|
+
config["server_url"] = server_url
|
|
1179
|
+
agent_api = _agent_api_config(config)
|
|
1180
|
+
register_url = _resolve_agent_api_url(config, "register_url")
|
|
1181
|
+
timeout_seconds = _safe_timeout(agent_api.get("timeout_seconds", 15), 15)
|
|
1182
|
+
|
|
1183
|
+
enroll_key = str(enroll_key_override or "").strip() or str(agent_api.get("enroll_key", "")).strip()
|
|
1184
|
+
headers: Dict[str, str] = {}
|
|
1185
|
+
if enroll_key:
|
|
1186
|
+
headers["X-Agent-Enroll-Key"] = enroll_key
|
|
1187
|
+
|
|
1188
|
+
payload = _build_enroll_payload(config, comment, tags)
|
|
1189
|
+
response = _request_json(
|
|
1190
|
+
url=register_url,
|
|
1191
|
+
method="POST",
|
|
1192
|
+
payload=payload,
|
|
1193
|
+
timeout_seconds=timeout_seconds,
|
|
1194
|
+
extra_headers=headers,
|
|
1195
|
+
)
|
|
1196
|
+
|
|
1197
|
+
agent_uuid = str(response.get("agent_uuid", "") or "").strip()
|
|
1198
|
+
agent_token = str(response.get("agent_token", "") or "").strip()
|
|
1199
|
+
machine_id = str(response.get("machine_id", "") or "").strip()
|
|
1200
|
+
if not (agent_uuid and agent_token and machine_id):
|
|
1201
|
+
raise RuntimeError("Register response missing agent_uuid, agent_token or machine_id")
|
|
1202
|
+
|
|
1203
|
+
endpoints = response.get("endpoints", {})
|
|
1204
|
+
if not isinstance(endpoints, dict):
|
|
1205
|
+
endpoints = {}
|
|
1206
|
+
|
|
1207
|
+
config["enrolled"] = True
|
|
1208
|
+
config["server_url"] = server_url
|
|
1209
|
+
config["comment"] = comment
|
|
1210
|
+
config["tags"] = tags
|
|
1211
|
+
config["agent_uuid"] = agent_uuid
|
|
1212
|
+
config["agent_token"] = agent_token
|
|
1213
|
+
config["machine_id"] = machine_id
|
|
1214
|
+
|
|
1215
|
+
remote_cfg = config.setdefault("remote_commands", {})
|
|
1216
|
+
if not isinstance(remote_cfg, dict):
|
|
1217
|
+
remote_cfg = {}
|
|
1218
|
+
config["remote_commands"] = remote_cfg
|
|
1219
|
+
remote_cfg["auth_token"] = agent_token
|
|
1220
|
+
|
|
1221
|
+
pull_url = str(endpoints.get("commands_pull", "") or "").strip()
|
|
1222
|
+
result_url = str(endpoints.get("commands_result", "") or "").strip()
|
|
1223
|
+
if pull_url:
|
|
1224
|
+
remote_cfg["pull_url"] = pull_url
|
|
1225
|
+
if result_url:
|
|
1226
|
+
remote_cfg["result_url"] = result_url
|
|
1227
|
+
|
|
1228
|
+
agent_api_cfg = config.setdefault("agent_api", {})
|
|
1229
|
+
if not isinstance(agent_api_cfg, dict):
|
|
1230
|
+
agent_api_cfg = {}
|
|
1231
|
+
config["agent_api"] = agent_api_cfg
|
|
1232
|
+
|
|
1233
|
+
sync_url = str(endpoints.get("sync", "") or "").strip()
|
|
1234
|
+
if sync_url:
|
|
1235
|
+
agent_api_cfg["sync_url"] = sync_url
|
|
1236
|
+
|
|
1237
|
+
if pull_url:
|
|
1238
|
+
agent_api_cfg["commands_pull_url"] = pull_url
|
|
1239
|
+
if result_url:
|
|
1240
|
+
agent_api_cfg["commands_result_url"] = result_url
|
|
1241
|
+
|
|
1242
|
+
return {
|
|
1243
|
+
"agent_uuid": agent_uuid,
|
|
1244
|
+
"agent_token": agent_token,
|
|
1245
|
+
"machine_id": machine_id,
|
|
1246
|
+
"register_url": register_url,
|
|
1247
|
+
"sync_url": sync_url or _resolve_agent_api_url(config, "sync_url"),
|
|
1248
|
+
"commands_pull_url": pull_url or _resolve_agent_api_url(config, "commands_pull_url"),
|
|
1249
|
+
"commands_result_url": result_url or _resolve_agent_api_url(config, "commands_result_url"),
|
|
1250
|
+
"raw": response,
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def _http_status_code_from_exception(exc: Exception) -> Optional[int]:
|
|
1255
|
+
if isinstance(exc, AgentHttpError):
|
|
1256
|
+
return int(exc.status_code)
|
|
1257
|
+
if isinstance(exc, HTTPError):
|
|
1258
|
+
try:
|
|
1259
|
+
return int(exc.code)
|
|
1260
|
+
except Exception:
|
|
1261
|
+
return None
|
|
1262
|
+
|
|
1263
|
+
text = str(exc).lower()
|
|
1264
|
+
match = re.search(r"http\s*(?:error\s*)?(\d{3})", text)
|
|
1265
|
+
if not match:
|
|
1266
|
+
return None
|
|
1267
|
+
|
|
1268
|
+
try:
|
|
1269
|
+
return int(match.group(1))
|
|
1270
|
+
except Exception:
|
|
1271
|
+
return None
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _is_invalid_agent_token_error(exc: Exception) -> bool:
|
|
1275
|
+
status_code = _http_status_code_from_exception(exc)
|
|
1276
|
+
if status_code != 401:
|
|
1277
|
+
return False
|
|
1278
|
+
|
|
1279
|
+
if isinstance(exc, AgentHttpError):
|
|
1280
|
+
body_text = str(exc.response_body or "").lower()
|
|
1281
|
+
if "token invalide" in body_text or "invalid token" in body_text:
|
|
1282
|
+
return True
|
|
1283
|
+
|
|
1284
|
+
text = str(exc).lower()
|
|
1285
|
+
if "token" in text and ("invalid" in text or "invalide" in text):
|
|
1286
|
+
return True
|
|
1287
|
+
|
|
1288
|
+
# Most 401 responses on agent endpoints are token-related.
|
|
1289
|
+
return True
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
_AGENT_STATE_REVOKED_MARKERS = (
|
|
1293
|
+
"revoked",
|
|
1294
|
+
"revoque",
|
|
1295
|
+
"revoqu",
|
|
1296
|
+
"disabled",
|
|
1297
|
+
"desactive",
|
|
1298
|
+
"desactiv",
|
|
1299
|
+
"agent inactive",
|
|
1300
|
+
"agent inactif",
|
|
1301
|
+
"deleted",
|
|
1302
|
+
"supprime",
|
|
1303
|
+
"supprim",
|
|
1304
|
+
"not found",
|
|
1305
|
+
"introuvable",
|
|
1306
|
+
"agent_not_found",
|
|
1307
|
+
"agent_revoked",
|
|
1308
|
+
"agent_disabled",
|
|
1309
|
+
"machine_not_found",
|
|
1310
|
+
"machine_deleted",
|
|
1311
|
+
"machine_revoked",
|
|
1312
|
+
)
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def _is_agent_state_revoked_error(exc: Exception) -> bool:
|
|
1316
|
+
"""Detecte qu un agent n est plus valide cote serveur (revoque/supprime/desactive)."""
|
|
1317
|
+
status_code = _http_status_code_from_exception(exc)
|
|
1318
|
+
if status_code not in (403, 404, 410):
|
|
1319
|
+
return False
|
|
1320
|
+
|
|
1321
|
+
body_text = ""
|
|
1322
|
+
if isinstance(exc, AgentHttpError):
|
|
1323
|
+
body_text = str(exc.response_body or "").lower()
|
|
1324
|
+
text = f"{body_text} {str(exc).lower()}"
|
|
1325
|
+
|
|
1326
|
+
if any(marker in text for marker in _AGENT_STATE_REVOKED_MARKERS):
|
|
1327
|
+
return True
|
|
1328
|
+
|
|
1329
|
+
# Un 410 Gone sur endpoint agent = ressource supprimee cote serveur,
|
|
1330
|
+
# on considere l agent comme revoque meme sans marqueur explicite.
|
|
1331
|
+
if status_code == 410:
|
|
1332
|
+
return True
|
|
1333
|
+
|
|
1334
|
+
# Un 404 sur /api/agent/sync ou /api/agent/commands/pull signifie que le
|
|
1335
|
+
# serveur ne reconnait plus cet agent_uuid / machine_id.
|
|
1336
|
+
if status_code == 404 and "agent" in text:
|
|
1337
|
+
return True
|
|
1338
|
+
|
|
1339
|
+
return False
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _agent_state_revoked_reason(exc: Exception) -> str:
|
|
1343
|
+
status_code = _http_status_code_from_exception(exc)
|
|
1344
|
+
if status_code == 404:
|
|
1345
|
+
return "agent_not_found"
|
|
1346
|
+
if status_code == 410:
|
|
1347
|
+
return "agent_deleted"
|
|
1348
|
+
body_text = ""
|
|
1349
|
+
if isinstance(exc, AgentHttpError):
|
|
1350
|
+
body_text = str(exc.response_body or "").lower()
|
|
1351
|
+
text = f"{body_text} {str(exc).lower()}"
|
|
1352
|
+
if "revok" in text:
|
|
1353
|
+
return "agent_revoked"
|
|
1354
|
+
if "delet" in text or "supprim" in text:
|
|
1355
|
+
return "agent_deleted"
|
|
1356
|
+
if "disabl" in text or "desactiv" in text or "inactif" in text or "inactive" in text:
|
|
1357
|
+
return "agent_disabled"
|
|
1358
|
+
return "agent_state_invalid"
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def _mark_agent_as_revoked(
|
|
1362
|
+
config: Dict[str, Any],
|
|
1363
|
+
manager: ConfigManager,
|
|
1364
|
+
logger: Any | None,
|
|
1365
|
+
reason: str,
|
|
1366
|
+
) -> Dict[str, Any]:
|
|
1367
|
+
"""Nettoie proprement l etat local quand le serveur a revoque/supprime l agent."""
|
|
1368
|
+
previous_uuid = str(config.get("agent_uuid", "") or "")
|
|
1369
|
+
previous_machine_id = str(config.get("machine_id", "") or "")
|
|
1370
|
+
previous_server = str(config.get("server_url", "") or "")
|
|
1371
|
+
|
|
1372
|
+
config["enrolled"] = False
|
|
1373
|
+
config["agent_uuid"] = ""
|
|
1374
|
+
config["agent_token"] = ""
|
|
1375
|
+
config["machine_id"] = ""
|
|
1376
|
+
config["status"] = "revoked"
|
|
1377
|
+
|
|
1378
|
+
remote_cfg = config.setdefault("remote_commands", {})
|
|
1379
|
+
if isinstance(remote_cfg, dict):
|
|
1380
|
+
remote_cfg["auth_token"] = ""
|
|
1381
|
+
|
|
1382
|
+
try:
|
|
1383
|
+
manager.save(config)
|
|
1384
|
+
except Exception:
|
|
1385
|
+
if logger is not None:
|
|
1386
|
+
logger.exception("Failed to persist revoked state locally")
|
|
1387
|
+
|
|
1388
|
+
if logger is not None:
|
|
1389
|
+
logger.warning(
|
|
1390
|
+
"Agent marked as revoked locally (reason=%s, previous_uuid=%s, machine_id=%s)",
|
|
1391
|
+
reason, previous_uuid or "<none>", previous_machine_id or "<none>",
|
|
1392
|
+
)
|
|
1393
|
+
|
|
1394
|
+
return {
|
|
1395
|
+
"revoked": True,
|
|
1396
|
+
"reason": reason,
|
|
1397
|
+
"previous_agent_uuid": previous_uuid,
|
|
1398
|
+
"previous_machine_id": previous_machine_id,
|
|
1399
|
+
"server_url": previous_server,
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def _current_config_tags(config: Dict[str, Any]) -> List[str]:
|
|
1404
|
+
raw_tags = config.get("tags", [])
|
|
1405
|
+
if not isinstance(raw_tags, list):
|
|
1406
|
+
return []
|
|
1407
|
+
|
|
1408
|
+
unique_tags: List[str] = []
|
|
1409
|
+
for item in raw_tags:
|
|
1410
|
+
tag = str(item).strip()
|
|
1411
|
+
if tag and tag not in unique_tags:
|
|
1412
|
+
unique_tags.append(tag)
|
|
1413
|
+
return unique_tags
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def _recover_agent_token(
|
|
1417
|
+
config: Dict[str, Any],
|
|
1418
|
+
manager: ConfigManager,
|
|
1419
|
+
logger: Any | None = None,
|
|
1420
|
+
) -> Dict[str, Any]:
|
|
1421
|
+
server_url = str(config.get("server_url", "") or "").strip()
|
|
1422
|
+
if not server_url:
|
|
1423
|
+
return {
|
|
1424
|
+
"attempted": False,
|
|
1425
|
+
"recovered": False,
|
|
1426
|
+
"reason": "missing_server_url",
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
try:
|
|
1430
|
+
registration = enroll_with_server(
|
|
1431
|
+
config,
|
|
1432
|
+
server_url,
|
|
1433
|
+
str(config.get("comment", "") or ""),
|
|
1434
|
+
_current_config_tags(config),
|
|
1435
|
+
enroll_key_override="",
|
|
1436
|
+
)
|
|
1437
|
+
manager.save(config)
|
|
1438
|
+
return {
|
|
1439
|
+
"attempted": True,
|
|
1440
|
+
"recovered": True,
|
|
1441
|
+
"agent_uuid": registration.get("agent_uuid"),
|
|
1442
|
+
"machine_id": registration.get("machine_id"),
|
|
1443
|
+
"sync_url": registration.get("sync_url"),
|
|
1444
|
+
}
|
|
1445
|
+
except Exception as exc:
|
|
1446
|
+
if logger is not None:
|
|
1447
|
+
logger.warning("Automatic token recovery failed: %s", exc)
|
|
1448
|
+
return {
|
|
1449
|
+
"attempted": True,
|
|
1450
|
+
"recovered": False,
|
|
1451
|
+
"reason": str(exc),
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def sync_to_server(
|
|
1456
|
+
config: Dict[str, Any],
|
|
1457
|
+
manager: ConfigManager,
|
|
1458
|
+
scan_payload: Optional[Dict[str, Any]] = None,
|
|
1459
|
+
) -> tuple[Dict[str, Any], Dict[str, Any], int]:
|
|
1460
|
+
agent_token = str(config.get("agent_token", "") or "").strip()
|
|
1461
|
+
if not bool(config.get("enrolled", False)) or not agent_token:
|
|
1462
|
+
config["last_sync"] = utc_now_iso()
|
|
1463
|
+
config["status"] = "running"
|
|
1464
|
+
manager.save(config)
|
|
1465
|
+
return config, {
|
|
1466
|
+
"synced": False,
|
|
1467
|
+
"mode": "local_only",
|
|
1468
|
+
"reason": "agent_not_enrolled_or_missing_token",
|
|
1469
|
+
"last_sync": config["last_sync"],
|
|
1470
|
+
}, 1
|
|
1471
|
+
|
|
1472
|
+
sync_url = _resolve_agent_api_url(config, "sync_url")
|
|
1473
|
+
timeout_seconds = _safe_timeout(_agent_api_config(config).get("timeout_seconds", 15), 15)
|
|
1474
|
+
|
|
1475
|
+
payload = scan_payload if isinstance(scan_payload, dict) else run_scan(config, manager)
|
|
1476
|
+
token_recovery_result: Optional[Dict[str, Any]] = None
|
|
1477
|
+
|
|
1478
|
+
def _sync_request(current_token: str) -> Dict[str, Any]:
|
|
1479
|
+
payload["agent_token"] = current_token
|
|
1480
|
+
payload.setdefault("agent", {})["agent_uuid"] = str(config.get("agent_uuid", "") or "")
|
|
1481
|
+
payload.setdefault("agent", {})["machine_id"] = str(config.get("machine_id", "") or "")
|
|
1482
|
+
return _request_json(
|
|
1483
|
+
url=sync_url,
|
|
1484
|
+
method="POST",
|
|
1485
|
+
payload=payload,
|
|
1486
|
+
timeout_seconds=timeout_seconds,
|
|
1487
|
+
extra_headers={"Authorization": f"Bearer {current_token}"},
|
|
1488
|
+
)
|
|
1489
|
+
|
|
1490
|
+
try:
|
|
1491
|
+
response = _sync_request(agent_token)
|
|
1492
|
+
except Exception as exc:
|
|
1493
|
+
if _is_agent_state_revoked_error(exc):
|
|
1494
|
+
reason = _agent_state_revoked_reason(exc)
|
|
1495
|
+
revocation = _mark_agent_as_revoked(config, manager, None, reason)
|
|
1496
|
+
return config, {
|
|
1497
|
+
"synced": False,
|
|
1498
|
+
"mode": "revoked",
|
|
1499
|
+
"reason": reason,
|
|
1500
|
+
"message": "Agent revoked or deleted server-side. Local state reset.",
|
|
1501
|
+
"revocation": revocation,
|
|
1502
|
+
"last_sync": config.get("last_sync"),
|
|
1503
|
+
}, 1
|
|
1504
|
+
|
|
1505
|
+
if not _is_invalid_agent_token_error(exc):
|
|
1506
|
+
raise
|
|
1507
|
+
|
|
1508
|
+
token_recovery_result = _recover_agent_token(config, manager)
|
|
1509
|
+
if not token_recovery_result.get("recovered"):
|
|
1510
|
+
# Si la re-inscription echoue avec un 403/404, on est dans le cas
|
|
1511
|
+
# d un agent desactive cote serveur : on nettoie l etat local
|
|
1512
|
+
# plutot que de laisser une erreur remonter sans action.
|
|
1513
|
+
recovery_reason = str(token_recovery_result.get("reason", "") or "")
|
|
1514
|
+
if any(marker in recovery_reason.lower() for marker in _AGENT_STATE_REVOKED_MARKERS):
|
|
1515
|
+
revocation = _mark_agent_as_revoked(
|
|
1516
|
+
config, manager, None, "agent_revoked_on_recovery"
|
|
1517
|
+
)
|
|
1518
|
+
return config, {
|
|
1519
|
+
"synced": False,
|
|
1520
|
+
"mode": "revoked",
|
|
1521
|
+
"reason": "agent_revoked_on_recovery",
|
|
1522
|
+
"message": "Agent token recovery rejected by server. Local state reset.",
|
|
1523
|
+
"revocation": revocation,
|
|
1524
|
+
"token_recovery": token_recovery_result,
|
|
1525
|
+
}, 1
|
|
1526
|
+
raise RuntimeError(
|
|
1527
|
+
f"{exc} | automatic token recovery failed: {token_recovery_result.get('reason', 'unknown')}"
|
|
1528
|
+
) from exc
|
|
1529
|
+
|
|
1530
|
+
refreshed_token = str(config.get("agent_token", "") or "").strip()
|
|
1531
|
+
if not refreshed_token:
|
|
1532
|
+
raise RuntimeError(f"{exc} | automatic token recovery failed: missing refreshed token") from exc
|
|
1533
|
+
|
|
1534
|
+
response = _sync_request(refreshed_token)
|
|
1535
|
+
|
|
1536
|
+
config["last_sync"] = utc_now_iso()
|
|
1537
|
+
config["status"] = "running"
|
|
1538
|
+
manager.save(config)
|
|
1539
|
+
|
|
1540
|
+
result = {
|
|
1541
|
+
"synced": True,
|
|
1542
|
+
"mode": "server",
|
|
1543
|
+
"sync_url": sync_url,
|
|
1544
|
+
"last_sync": config["last_sync"],
|
|
1545
|
+
"server_response": response,
|
|
1546
|
+
}
|
|
1547
|
+
if token_recovery_result is not None:
|
|
1548
|
+
result["token_recovery"] = token_recovery_result
|
|
1549
|
+
|
|
1550
|
+
return config, result, 0
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
def merge_patch(base: Dict[str, Any], patch: Dict[str, Any]) -> Dict[str, Any]:
|
|
1554
|
+
result = dict(base)
|
|
1555
|
+
for key, value in patch.items():
|
|
1556
|
+
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
|
1557
|
+
result[key] = merge_patch(result[key], value)
|
|
1558
|
+
else:
|
|
1559
|
+
result[key] = value
|
|
1560
|
+
return result
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def run_patch_action(
|
|
1564
|
+
source: str,
|
|
1565
|
+
config: Dict[str, Any],
|
|
1566
|
+
manager: ConfigManager,
|
|
1567
|
+
logger: Any,
|
|
1568
|
+
timeout_seconds: int = 15,
|
|
1569
|
+
) -> tuple[Dict[str, Any], Dict[str, Any], int]:
|
|
1570
|
+
"""Execute `--patch <source>` en supportant URL + nouveau format vuln.
|
|
1571
|
+
|
|
1572
|
+
- Si le document contient `vulnerabilities` / `patches` / `fixes` /
|
|
1573
|
+
`remediations`, on applique la remediation via `core.patch_engine`
|
|
1574
|
+
(action de Phase 2: mise a jour d'un paquet, fermeture d'un port,
|
|
1575
|
+
desactivation d'un service - PAS de modification de config.json).
|
|
1576
|
+
- Sinon, on retombe sur l'ancien comportement (merge dans config.json)
|
|
1577
|
+
pour ne pas casser la compatibilite des tests / scripts existants.
|
|
1578
|
+
"""
|
|
1579
|
+
document = patch_engine.load_patch_document(source, timeout=timeout_seconds)
|
|
1580
|
+
|
|
1581
|
+
if patch_engine.looks_like_vulnerability_patch(document):
|
|
1582
|
+
result = patch_engine.apply_vulnerability_patch(document)
|
|
1583
|
+
result.setdefault("source", source)
|
|
1584
|
+
failed_count = len(result.get("failed", []) or [])
|
|
1585
|
+
exit_code = 0 if failed_count == 0 else 1
|
|
1586
|
+
if logger is not None:
|
|
1587
|
+
logger.info(
|
|
1588
|
+
"Vulnerability patch applied (source=%s, applied=%d, failed=%d, skipped=%d)",
|
|
1589
|
+
source,
|
|
1590
|
+
len(result.get("applied", []) or []),
|
|
1591
|
+
failed_count,
|
|
1592
|
+
len(result.get("skipped", []) or []),
|
|
1593
|
+
)
|
|
1594
|
+
return config, result, exit_code
|
|
1595
|
+
|
|
1596
|
+
merged = merge_patch(config, document)
|
|
1597
|
+
manager.save(merged)
|
|
1598
|
+
if logger is not None:
|
|
1599
|
+
logger.info("Legacy config patch merged from %s", source)
|
|
1600
|
+
return merged, {
|
|
1601
|
+
"patch_applied": source,
|
|
1602
|
+
"mode": "config_merge",
|
|
1603
|
+
"deprecated": True,
|
|
1604
|
+
"message": (
|
|
1605
|
+
"Legacy config-merge patch applied. Use the 'vulnerabilities' format for "
|
|
1606
|
+
"Phase 2 remediation patches. Config merge will be removed in a future release."
|
|
1607
|
+
),
|
|
1608
|
+
"patched_keys": sorted(list(document.keys())),
|
|
1609
|
+
}, 0
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def _scheduler_from_config(config: Dict[str, Any]) -> Scheduler:
|
|
1613
|
+
scheduler_cfg = config.get("scheduler", {})
|
|
1614
|
+
interval_raw = scheduler_cfg.get("interval_minutes", 30)
|
|
1615
|
+
try:
|
|
1616
|
+
interval_minutes = int(interval_raw)
|
|
1617
|
+
except (TypeError, ValueError):
|
|
1618
|
+
raise ValueError("scheduler.interval_minutes must be an integer")
|
|
1619
|
+
|
|
1620
|
+
task_name = str(scheduler_cfg.get("task_name", "Rivex-Agent-AutoScan"))
|
|
1621
|
+
linux_cron_marker = str(scheduler_cfg.get("linux_cron_marker", "# RIVEX_AGENT_AUTO_SCAN"))
|
|
1622
|
+
scan_command_args = str(scheduler_cfg.get("scan_command_args", "--scan"))
|
|
1623
|
+
linux_backend = str(scheduler_cfg.get("linux_backend", "cron"))
|
|
1624
|
+
systemd_unit_prefix = str(scheduler_cfg.get("systemd_unit_prefix", "rivex-agent-autoscan"))
|
|
1625
|
+
|
|
1626
|
+
return Scheduler(
|
|
1627
|
+
python_executable=sys.executable,
|
|
1628
|
+
launcher_path=Path(__file__).resolve(),
|
|
1629
|
+
interval_minutes=interval_minutes,
|
|
1630
|
+
task_name=task_name,
|
|
1631
|
+
linux_cron_marker=linux_cron_marker,
|
|
1632
|
+
scan_command_args=scan_command_args,
|
|
1633
|
+
linux_backend=linux_backend,
|
|
1634
|
+
systemd_unit_prefix=systemd_unit_prefix,
|
|
1635
|
+
)
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def run_scan(config: Dict[str, Any], manager: ConfigManager) -> Dict[str, Any]:
|
|
1639
|
+
from modules.base_info import collect_base_info
|
|
1640
|
+
from modules.filesystem import collect_files
|
|
1641
|
+
from modules.fingerprint import collect_fingerprint
|
|
1642
|
+
from modules.metrics import collect_metrics
|
|
1643
|
+
from modules.network import collect_network
|
|
1644
|
+
from modules.security import collect_certificates
|
|
1645
|
+
from modules.services import collect_services
|
|
1646
|
+
from modules.software import collect_software
|
|
1647
|
+
|
|
1648
|
+
base_info = collect_base_info()
|
|
1649
|
+
network_data = collect_network()
|
|
1650
|
+
fingerprint = collect_fingerprint(config)
|
|
1651
|
+
|
|
1652
|
+
if config.get("agent_uuid") != fingerprint.get("agent_uuid"):
|
|
1653
|
+
config["agent_uuid"] = fingerprint["agent_uuid"]
|
|
1654
|
+
manager.save(config)
|
|
1655
|
+
|
|
1656
|
+
# Le snapshot envoyé au serveur ne doit pas réinjecter les tags : ils sont
|
|
1657
|
+
# gérés côté admin (UI / API) et seraient écrasés à chaque sync si on les
|
|
1658
|
+
# envoyait depuis l'agent. On les enlève donc explicitement de la copie.
|
|
1659
|
+
snapshot = {k: v for k, v in config.items() if k != "tags"}
|
|
1660
|
+
|
|
1661
|
+
payload = {
|
|
1662
|
+
"scan_id": str(uuid4()),
|
|
1663
|
+
"collected_at": utc_now_iso(),
|
|
1664
|
+
"agent": {
|
|
1665
|
+
"version": str(config.get("version", APP_VERSION)),
|
|
1666
|
+
"last_heartbeat": utc_now_iso(),
|
|
1667
|
+
},
|
|
1668
|
+
"base_info": {
|
|
1669
|
+
"hostname": base_info.get("hostname"),
|
|
1670
|
+
"ip_address": base_info.get("ip_address"),
|
|
1671
|
+
"os": base_info.get("os"),
|
|
1672
|
+
"machine_type": base_info.get("machine_type"),
|
|
1673
|
+
},
|
|
1674
|
+
"os": {
|
|
1675
|
+
"name": base_info.get("os_name"),
|
|
1676
|
+
"version": base_info.get("os_version"),
|
|
1677
|
+
},
|
|
1678
|
+
"metrics": collect_metrics(),
|
|
1679
|
+
"softwares": collect_software(),
|
|
1680
|
+
"files": collect_files(config.get("scan", {})),
|
|
1681
|
+
"ports": network_data.get("ports", []),
|
|
1682
|
+
"services": collect_services(),
|
|
1683
|
+
"certs": collect_certificates(),
|
|
1684
|
+
"fingerprint": fingerprint,
|
|
1685
|
+
"http_endpoints": network_data.get("http_endpoints", []),
|
|
1686
|
+
"snapshot": snapshot,
|
|
1687
|
+
}
|
|
1688
|
+
return payload
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
def compute_health_score(scan_payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
1692
|
+
score = 100
|
|
1693
|
+
reasons: List[str] = []
|
|
1694
|
+
|
|
1695
|
+
metrics = scan_payload.get("metrics", {})
|
|
1696
|
+
cpu_percent = metrics.get("cpu_percent")
|
|
1697
|
+
ram_percent = metrics.get("ram_percent")
|
|
1698
|
+
disk_percent = metrics.get("disk_percent")
|
|
1699
|
+
|
|
1700
|
+
if isinstance(cpu_percent, (int, float)) and cpu_percent > 85:
|
|
1701
|
+
score -= 20
|
|
1702
|
+
reasons.append("High CPU usage")
|
|
1703
|
+
if isinstance(ram_percent, (int, float)) and ram_percent > 85:
|
|
1704
|
+
score -= 20
|
|
1705
|
+
reasons.append("High RAM usage")
|
|
1706
|
+
if isinstance(disk_percent, (int, float)) and disk_percent > 90:
|
|
1707
|
+
score -= 20
|
|
1708
|
+
reasons.append("High disk usage")
|
|
1709
|
+
|
|
1710
|
+
open_ports = len(scan_payload.get("ports", []))
|
|
1711
|
+
if open_ports > 20:
|
|
1712
|
+
score -= min(20, (open_ports - 20) // 2)
|
|
1713
|
+
reasons.append("Large open port exposure")
|
|
1714
|
+
|
|
1715
|
+
cert_count = len(scan_payload.get("certs", []))
|
|
1716
|
+
if cert_count == 0:
|
|
1717
|
+
score -= 5
|
|
1718
|
+
reasons.append("No local TLS certificate detected")
|
|
1719
|
+
|
|
1720
|
+
score = max(0, min(100, score))
|
|
1721
|
+
return {
|
|
1722
|
+
"score": score,
|
|
1723
|
+
"grade": "A" if score >= 90 else "B" if score >= 75 else "C" if score >= 60 else "D",
|
|
1724
|
+
"reasons": reasons,
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def build_status_payload(config: Dict[str, Any], config_path: Path, startup_sync_changed: bool) -> Dict[str, Any]:
|
|
1729
|
+
return {
|
|
1730
|
+
"agent": APP_NAME,
|
|
1731
|
+
"version": str(config.get("version", APP_VERSION)),
|
|
1732
|
+
"enrolled": config.get("enrolled", False),
|
|
1733
|
+
"status": config.get("status", "stopped"),
|
|
1734
|
+
"last_sync": config.get("last_sync"),
|
|
1735
|
+
"config_synced_on_startup": startup_sync_changed,
|
|
1736
|
+
"scheduler": config.get("scheduler", {}),
|
|
1737
|
+
"remote_commands": config.get("remote_commands", {}),
|
|
1738
|
+
"log_path": LOG_FILE_OVERRIDE.as_posix(),
|
|
1739
|
+
"config_path": config_path.as_posix(),
|
|
1740
|
+
"install_mode": AGENT_MODE,
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
def build_diagnostic_payload(config: Dict[str, Any], config_path: Path) -> Dict[str, Any]:
|
|
1745
|
+
parsed = urlparse(config.get("server_url", "")) if config.get("server_url") else None
|
|
1746
|
+
system_deps_report = system_deps.check_dependencies()
|
|
1747
|
+
diagnostics = {
|
|
1748
|
+
"config_exists": config_path.exists(),
|
|
1749
|
+
"log_path": LOG_FILE_OVERRIDE.as_posix(),
|
|
1750
|
+
"config_path": config_path.as_posix(),
|
|
1751
|
+
"install_mode": AGENT_MODE,
|
|
1752
|
+
"enrolled": config.get("enrolled", False),
|
|
1753
|
+
"server_url_valid": bool(parsed and parsed.scheme and parsed.netloc),
|
|
1754
|
+
"last_sync": config.get("last_sync"),
|
|
1755
|
+
"system_deps": {
|
|
1756
|
+
"platform": system_deps_report.get("platform"),
|
|
1757
|
+
"groups": system_deps_report.get("groups"),
|
|
1758
|
+
"missing": system_deps_report.get("missing"),
|
|
1759
|
+
"missing_required_groups": system_deps_report.get("missing_required_groups"),
|
|
1760
|
+
},
|
|
1761
|
+
}
|
|
1762
|
+
config_ok = diagnostics["config_exists"] and (not diagnostics["enrolled"] or diagnostics["server_url_valid"])
|
|
1763
|
+
deps_ok = not diagnostics["system_deps"].get("missing_required_groups")
|
|
1764
|
+
diagnostics["ok"] = config_ok and deps_ok
|
|
1765
|
+
return diagnostics
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def run_update_check(config: Dict[str, Any], manager: ConfigManager, logger: Any) -> tuple[Dict[str, Any], Dict[str, Any], int]:
|
|
1769
|
+
config, update_sync_changed = sync_runtime_config_from_template(
|
|
1770
|
+
manager,
|
|
1771
|
+
config,
|
|
1772
|
+
preserve_runtime_session=True,
|
|
1773
|
+
)
|
|
1774
|
+
if str(config.get("version", "")).strip() != APP_VERSION:
|
|
1775
|
+
config["version"] = APP_VERSION
|
|
1776
|
+
manager.save(config)
|
|
1777
|
+
update_cfg = config.get("update", {})
|
|
1778
|
+
if not isinstance(update_cfg, dict):
|
|
1779
|
+
update_cfg = {}
|
|
1780
|
+
local_version = APP_VERSION
|
|
1781
|
+
configured_version_url = str(update_cfg.get("version_url", "") or "").strip()
|
|
1782
|
+
resolved_version_url = configured_version_url
|
|
1783
|
+
if not resolved_version_url:
|
|
1784
|
+
server_url = str(config.get("server_url", "") or "").strip()
|
|
1785
|
+
if server_url:
|
|
1786
|
+
resolved_version_url = urljoin(server_url.rstrip("/") + "/", "agent/version.json")
|
|
1787
|
+
updater = Updater(
|
|
1788
|
+
current_version=local_version,
|
|
1789
|
+
version_url=resolved_version_url,
|
|
1790
|
+
)
|
|
1791
|
+
update_code = 0
|
|
1792
|
+
try:
|
|
1793
|
+
result = updater.check_for_update()
|
|
1794
|
+
result["version_url"] = resolved_version_url or None
|
|
1795
|
+
result["version_url_source"] = "config" if configured_version_url else "server_url_fallback"
|
|
1796
|
+
result["config_sync"] = {
|
|
1797
|
+
"performed": True,
|
|
1798
|
+
"changed": update_sync_changed,
|
|
1799
|
+
}
|
|
1800
|
+
if result.get("update_available") and result.get("download_url"):
|
|
1801
|
+
prepared = updater.prepare_update(result["download_url"], RUNTIME_UPDATE_DIR)
|
|
1802
|
+
result["prepared_update"] = prepared
|
|
1803
|
+
package_path = str(prepared.get("staged_file") or prepared.get("downloaded_file") or "").strip()
|
|
1804
|
+
if not package_path:
|
|
1805
|
+
raise RuntimeError("Update package path is missing after download")
|
|
1806
|
+
cleanup_paths_cfg = update_cfg.get("cleanup_paths", [])
|
|
1807
|
+
cleanup_paths: List[str] = []
|
|
1808
|
+
if isinstance(cleanup_paths_cfg, list):
|
|
1809
|
+
cleanup_paths = [str(item).strip() for item in cleanup_paths_cfg if str(item).strip()]
|
|
1810
|
+
elif isinstance(cleanup_paths_cfg, str) and cleanup_paths_cfg.strip():
|
|
1811
|
+
cleanup_paths = [cleanup_paths_cfg.strip()]
|
|
1812
|
+
|
|
1813
|
+
applied = updater.apply_update_package(
|
|
1814
|
+
package_path,
|
|
1815
|
+
AGENT_DIR,
|
|
1816
|
+
cleanup_paths=cleanup_paths,
|
|
1817
|
+
)
|
|
1818
|
+
result["applied_update"] = applied
|
|
1819
|
+
remote_version = str(result.get("remote_version", "") or "").strip()
|
|
1820
|
+
if remote_version:
|
|
1821
|
+
config["version"] = remote_version
|
|
1822
|
+
manager.save(config)
|
|
1823
|
+
result["local_version_after_apply"] = remote_version
|
|
1824
|
+
|
|
1825
|
+
dependencies = ensure_runtime_dependencies(AGENT_DIR, logger, force_reinstall=True)
|
|
1826
|
+
result["dependencies"] = dependencies
|
|
1827
|
+
|
|
1828
|
+
if dependencies.get("status") == "ok":
|
|
1829
|
+
result["message"] = "Update downloaded, applied locally, and requirements were refreshed."
|
|
1830
|
+
else:
|
|
1831
|
+
update_code = 1
|
|
1832
|
+
result["message"] = "Update applied locally, but requirements installation failed."
|
|
1833
|
+
elif result.get("update_available"):
|
|
1834
|
+
result["message"] = "Update available but no download URL was provided"
|
|
1835
|
+
return config, result, update_code
|
|
1836
|
+
except Exception as exc:
|
|
1837
|
+
logger.exception("Update check failed")
|
|
1838
|
+
return config, {"error": str(exc)}, 1
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
def _detached_popen_kwargs() -> Dict[str, Any]:
|
|
1842
|
+
"""Kwargs Popen multiplateformes pour detacher un process de fa on compatible Linux/Windows.
|
|
1843
|
+
|
|
1844
|
+
L ancienne implementation utilisait `start_new_session=True` (POSIX uniquement),
|
|
1845
|
+
ce qui faisait lever une erreur sur Windows au moment d un auto-restart post-update
|
|
1846
|
+
et bloquait en pratique l auto-redemarrage apres `--update` sur les postes Windows.
|
|
1847
|
+
"""
|
|
1848
|
+
kwargs: Dict[str, Any] = {
|
|
1849
|
+
"stdin": subprocess.DEVNULL,
|
|
1850
|
+
"stdout": subprocess.DEVNULL,
|
|
1851
|
+
"stderr": subprocess.DEVNULL,
|
|
1852
|
+
}
|
|
1853
|
+
if os.name == "nt":
|
|
1854
|
+
detached_process = 0x00000008
|
|
1855
|
+
create_new_process_group = 0x00000200
|
|
1856
|
+
kwargs["creationflags"] = detached_process | create_new_process_group
|
|
1857
|
+
kwargs["close_fds"] = True
|
|
1858
|
+
else:
|
|
1859
|
+
kwargs["start_new_session"] = True
|
|
1860
|
+
return kwargs
|
|
1861
|
+
|
|
1862
|
+
|
|
1863
|
+
def _restart_after_update(logger: Any) -> Dict[str, Any]:
|
|
1864
|
+
if str(os.getenv(UPDATE_RESTART_GUARD_ENV, "")).strip() == "1":
|
|
1865
|
+
return {
|
|
1866
|
+
"attempted": False,
|
|
1867
|
+
"restarted": False,
|
|
1868
|
+
"reason": "already_restarted_once",
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
restart_args = [arg for arg in sys.argv[1:] if arg != "--update"]
|
|
1872
|
+
if not restart_args:
|
|
1873
|
+
restart_args = ["--status"]
|
|
1874
|
+
|
|
1875
|
+
command = [sys.executable, str(Path(__file__).resolve()), *restart_args]
|
|
1876
|
+
env = os.environ.copy()
|
|
1877
|
+
env[UPDATE_RESTART_GUARD_ENV] = "1"
|
|
1878
|
+
|
|
1879
|
+
popen_kwargs = _detached_popen_kwargs()
|
|
1880
|
+
popen_kwargs.update({
|
|
1881
|
+
"cwd": str(AGENT_DIR),
|
|
1882
|
+
"env": env,
|
|
1883
|
+
})
|
|
1884
|
+
|
|
1885
|
+
try:
|
|
1886
|
+
subprocess.Popen(command, **popen_kwargs)
|
|
1887
|
+
return {
|
|
1888
|
+
"attempted": True,
|
|
1889
|
+
"restarted": True,
|
|
1890
|
+
"command": command,
|
|
1891
|
+
"platform": platform.system().lower(),
|
|
1892
|
+
}
|
|
1893
|
+
except Exception as exc:
|
|
1894
|
+
logger.warning("Unable to auto-restart agent process after update: %s", exc)
|
|
1895
|
+
return {
|
|
1896
|
+
"attempted": True,
|
|
1897
|
+
"restarted": False,
|
|
1898
|
+
"error": str(exc),
|
|
1899
|
+
"command": command,
|
|
1900
|
+
"platform": platform.system().lower(),
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
def _safe_int(value: Any, fallback: int) -> int:
|
|
1905
|
+
try:
|
|
1906
|
+
parsed = int(value)
|
|
1907
|
+
except (TypeError, ValueError):
|
|
1908
|
+
return fallback
|
|
1909
|
+
if parsed <= 0:
|
|
1910
|
+
return fallback
|
|
1911
|
+
return parsed
|
|
1912
|
+
|
|
1913
|
+
|
|
1914
|
+
def _remote_allowed_actions(config: Dict[str, Any]) -> set[str]:
|
|
1915
|
+
remote_cfg = config.get("remote_commands", {})
|
|
1916
|
+
if not isinstance(remote_cfg, dict):
|
|
1917
|
+
return set(DEFAULT_REMOTE_ALLOWED_ACTIONS)
|
|
1918
|
+
|
|
1919
|
+
value = remote_cfg.get("allowed_actions", [])
|
|
1920
|
+
if not isinstance(value, list):
|
|
1921
|
+
return set(DEFAULT_REMOTE_ALLOWED_ACTIONS)
|
|
1922
|
+
|
|
1923
|
+
allowed: set[str] = set()
|
|
1924
|
+
for item in value:
|
|
1925
|
+
action = str(item).strip().lower()
|
|
1926
|
+
if action:
|
|
1927
|
+
allowed.add(action)
|
|
1928
|
+
|
|
1929
|
+
return allowed or set(DEFAULT_REMOTE_ALLOWED_ACTIONS)
|
|
1930
|
+
|
|
1931
|
+
|
|
1932
|
+
def _normalize_remote_action(command_payload: Dict[str, Any]) -> str:
|
|
1933
|
+
action_value = command_payload.get("action") or command_payload.get("type") or command_payload.get("command")
|
|
1934
|
+
return str(action_value or "").strip().lower()
|
|
1935
|
+
|
|
1936
|
+
|
|
1937
|
+
def _resolve_output_path(output_name: str | None, prefix: str = "scan") -> Path:
|
|
1938
|
+
if output_name:
|
|
1939
|
+
output_path = Path(output_name)
|
|
1940
|
+
if not output_path.is_absolute():
|
|
1941
|
+
output_path = Path.cwd() / output_path
|
|
1942
|
+
return output_path
|
|
1943
|
+
|
|
1944
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
1945
|
+
return RUNTIME_SCAN_DIR / f"{prefix}_{timestamp}.json"
|
|
1946
|
+
|
|
1947
|
+
|
|
1948
|
+
def _execute_reboot_command() -> Dict[str, Any]:
|
|
1949
|
+
system = platform.system().lower()
|
|
1950
|
+
if system == "windows":
|
|
1951
|
+
command = ["shutdown", "/r", "/t", "0", "/f"]
|
|
1952
|
+
elif system == "linux":
|
|
1953
|
+
command = ["systemctl", "reboot"]
|
|
1954
|
+
else:
|
|
1955
|
+
raise RuntimeError(f"Unsupported platform for reboot action: {system}")
|
|
1956
|
+
|
|
1957
|
+
process = subprocess.run(
|
|
1958
|
+
command,
|
|
1959
|
+
capture_output=True,
|
|
1960
|
+
text=True,
|
|
1961
|
+
encoding="utf-8",
|
|
1962
|
+
errors="replace",
|
|
1963
|
+
check=False,
|
|
1964
|
+
)
|
|
1965
|
+
if process.returncode != 0:
|
|
1966
|
+
stderr = (process.stderr or "").strip()
|
|
1967
|
+
stdout = (process.stdout or "").strip()
|
|
1968
|
+
message = stderr or stdout or "reboot command failed"
|
|
1969
|
+
raise RuntimeError(message)
|
|
1970
|
+
|
|
1971
|
+
return {
|
|
1972
|
+
"requested": True,
|
|
1973
|
+
"platform": system,
|
|
1974
|
+
"command": command,
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
def _protection_config(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
1979
|
+
protection_cfg = config.get("protection", {})
|
|
1980
|
+
if not isinstance(protection_cfg, dict):
|
|
1981
|
+
return {}
|
|
1982
|
+
return protection_cfg
|
|
1983
|
+
|
|
1984
|
+
|
|
1985
|
+
def _resolve_alert_url(config: Dict[str, Any]) -> str:
|
|
1986
|
+
protection_cfg = _protection_config(config)
|
|
1987
|
+
explicit = str(protection_cfg.get("alert_url", "") or "").strip()
|
|
1988
|
+
if explicit:
|
|
1989
|
+
return explicit
|
|
1990
|
+
# Fallback via agent_api.alerts_url (defini dans les endpoints serveur
|
|
1991
|
+
# par defaut) puis derivation depuis server_url.
|
|
1992
|
+
return _resolve_agent_api_url(config, "alerts_url")
|
|
1993
|
+
|
|
1994
|
+
|
|
1995
|
+
def run_protection_check(
|
|
1996
|
+
config: Dict[str, Any],
|
|
1997
|
+
logger: Any,
|
|
1998
|
+
force_alert: bool = False,
|
|
1999
|
+
) -> tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
|
|
2000
|
+
"""Execute un controle d integrite et remonte une alerte si necessaire.
|
|
2001
|
+
|
|
2002
|
+
- Initialise silencieusement le baseline au premier run.
|
|
2003
|
+
- Si un ecart est detecte, tente d envoyer une alerte au serveur.
|
|
2004
|
+
L echec d envoi ne fait pas echouer la commande.
|
|
2005
|
+
"""
|
|
2006
|
+
protection_cfg = _protection_config(config)
|
|
2007
|
+
report = agent_protection.detect_tampering(AGENT_DIR, RUNTIME_DIR, auto_initialize=True)
|
|
2008
|
+
|
|
2009
|
+
if logger is not None and report.get("tampered"):
|
|
2010
|
+
logger.warning(
|
|
2011
|
+
"Agent integrity check: tampered=True, tampered_files=%d, missing_files=%d",
|
|
2012
|
+
len(report.get("tampered_files", []) or []),
|
|
2013
|
+
len(report.get("missing_files", []) or []),
|
|
2014
|
+
)
|
|
2015
|
+
|
|
2016
|
+
if not (report.get("tampered") or force_alert):
|
|
2017
|
+
return report, None
|
|
2018
|
+
|
|
2019
|
+
if not bool(protection_cfg.get("enabled", True)):
|
|
2020
|
+
return report, {
|
|
2021
|
+
"sent": False,
|
|
2022
|
+
"reason": "protection_disabled_in_config",
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
alert_url = _resolve_alert_url(config)
|
|
2026
|
+
timeout_seconds = _safe_timeout(
|
|
2027
|
+
protection_cfg.get("alert_timeout_seconds", 10), 10
|
|
2028
|
+
)
|
|
2029
|
+
payload = agent_protection.build_tamper_alert_payload(
|
|
2030
|
+
agent_uuid=str(config.get("agent_uuid", "") or ""),
|
|
2031
|
+
machine_id=str(config.get("machine_id", "") or ""),
|
|
2032
|
+
agent_version=str(config.get("version", APP_VERSION)),
|
|
2033
|
+
tamper_report=report,
|
|
2034
|
+
)
|
|
2035
|
+
alert_result = agent_protection.send_tamper_alert(
|
|
2036
|
+
server_url=str(config.get("server_url", "") or ""),
|
|
2037
|
+
alert_url=alert_url,
|
|
2038
|
+
auth_token=str(config.get("agent_token", "") or ""),
|
|
2039
|
+
payload=payload,
|
|
2040
|
+
timeout_seconds=timeout_seconds,
|
|
2041
|
+
)
|
|
2042
|
+
if logger is not None:
|
|
2043
|
+
if alert_result.get("sent"):
|
|
2044
|
+
logger.info("Tamper alert sent successfully to %s", alert_result.get("url"))
|
|
2045
|
+
else:
|
|
2046
|
+
logger.warning(
|
|
2047
|
+
"Tamper alert not sent (reason=%s)",
|
|
2048
|
+
alert_result.get("reason") or alert_result.get("error"),
|
|
2049
|
+
)
|
|
2050
|
+
return report, alert_result
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
def execute_forced_action(
|
|
2054
|
+
action: str,
|
|
2055
|
+
params: Dict[str, Any],
|
|
2056
|
+
config: Dict[str, Any],
|
|
2057
|
+
manager: ConfigManager,
|
|
2058
|
+
logger: Any,
|
|
2059
|
+
config_path: Path,
|
|
2060
|
+
startup_sync_changed: bool,
|
|
2061
|
+
) -> tuple[Dict[str, Any], Dict[str, Any], bool]:
|
|
2062
|
+
if action == "scan":
|
|
2063
|
+
payload = run_scan(config, manager)
|
|
2064
|
+
output_name = str(params.get("output_file", "")).strip() if isinstance(params, dict) else ""
|
|
2065
|
+
output_path = _resolve_output_path(output_name, prefix="forced_scan")
|
|
2066
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2067
|
+
output_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
2068
|
+
config, sync_result, sync_code = sync_to_server(config, manager, scan_payload=payload)
|
|
2069
|
+
return config, {
|
|
2070
|
+
"scan_saved_to": output_path.as_posix(),
|
|
2071
|
+
"scan_sync": sync_result,
|
|
2072
|
+
}, sync_code == 0
|
|
2073
|
+
|
|
2074
|
+
if action == "sync":
|
|
2075
|
+
config, sync_result, sync_code = sync_to_server(config, manager)
|
|
2076
|
+
return config, sync_result, sync_code == 0
|
|
2077
|
+
|
|
2078
|
+
if action == "status":
|
|
2079
|
+
return config, build_status_payload(config, config_path, startup_sync_changed), True
|
|
2080
|
+
|
|
2081
|
+
if action in {"diag", "diagnostic"}:
|
|
2082
|
+
return config, build_diagnostic_payload(config, config_path), True
|
|
2083
|
+
|
|
2084
|
+
if action == "score":
|
|
2085
|
+
score_payload = compute_health_score(run_scan(config, manager))
|
|
2086
|
+
return config, score_payload, True
|
|
2087
|
+
|
|
2088
|
+
if action == "update":
|
|
2089
|
+
config, update_result, exit_code = run_update_check(config, manager, logger)
|
|
2090
|
+
return config, update_result, exit_code == 0
|
|
2091
|
+
|
|
2092
|
+
if action == "reboot":
|
|
2093
|
+
return config, _execute_reboot_command(), True
|
|
2094
|
+
|
|
2095
|
+
if action == "patch":
|
|
2096
|
+
# Le serveur peut envoyer soit un document JSON directement, soit une
|
|
2097
|
+
# URL (patch_url) pointant vers le document genere par l analyse de
|
|
2098
|
+
# vulnerabilites. Les deux voies sont supportees.
|
|
2099
|
+
patch_source: Any = None
|
|
2100
|
+
if isinstance(params, dict):
|
|
2101
|
+
patch_source = params.get("patch_url") or params.get("url") or params.get("source")
|
|
2102
|
+
if patch_source is None and "patch" in params:
|
|
2103
|
+
patch_source = params.get("patch")
|
|
2104
|
+
elif patch_source is None:
|
|
2105
|
+
patch_source = params
|
|
2106
|
+
|
|
2107
|
+
if isinstance(patch_source, str):
|
|
2108
|
+
try:
|
|
2109
|
+
timeout_seconds = _safe_timeout(
|
|
2110
|
+
_agent_api_config(config).get("timeout_seconds", 15), 15
|
|
2111
|
+
)
|
|
2112
|
+
config, patch_result, exit_code = run_patch_action(
|
|
2113
|
+
patch_source, config, manager, logger, timeout_seconds=timeout_seconds
|
|
2114
|
+
)
|
|
2115
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
2116
|
+
raise ValueError(f"Remote patch source error: {exc}") from exc
|
|
2117
|
+
return config, patch_result, exit_code == 0
|
|
2118
|
+
|
|
2119
|
+
if not isinstance(patch_source, dict):
|
|
2120
|
+
raise ValueError(
|
|
2121
|
+
"Remote patch action requires a JSON object, an URL string, or a local path"
|
|
2122
|
+
)
|
|
2123
|
+
|
|
2124
|
+
if patch_engine.looks_like_vulnerability_patch(patch_source):
|
|
2125
|
+
result = patch_engine.apply_vulnerability_patch(patch_source)
|
|
2126
|
+
return config, result, not result.get("failed")
|
|
2127
|
+
|
|
2128
|
+
config = merge_patch(config, patch_source)
|
|
2129
|
+
manager.save(config)
|
|
2130
|
+
return config, {
|
|
2131
|
+
"patch_applied": True,
|
|
2132
|
+
"mode": "config_merge",
|
|
2133
|
+
"deprecated": True,
|
|
2134
|
+
"patched_keys": sorted(list(patch_source.keys())),
|
|
2135
|
+
}, True
|
|
2136
|
+
|
|
2137
|
+
if action == "enable_auto_scan":
|
|
2138
|
+
scheduler = _scheduler_from_config(config)
|
|
2139
|
+
result = scheduler.enable_every_30_minutes()
|
|
2140
|
+
config.setdefault("scheduler", {})["enabled"] = True
|
|
2141
|
+
manager.save(config)
|
|
2142
|
+
return config, {"auto_scan": "enabled", **result}, True
|
|
2143
|
+
|
|
2144
|
+
if action == "disable_auto_scan":
|
|
2145
|
+
scheduler = _scheduler_from_config(config)
|
|
2146
|
+
result = scheduler.disable_every_30_minutes()
|
|
2147
|
+
config.setdefault("scheduler", {})["enabled"] = False
|
|
2148
|
+
manager.save(config)
|
|
2149
|
+
return config, {"auto_scan": "disabled", **result}, True
|
|
2150
|
+
|
|
2151
|
+
if action == "check_protection":
|
|
2152
|
+
report, alert_result = run_protection_check(config, logger)
|
|
2153
|
+
return config, {
|
|
2154
|
+
"protection": report,
|
|
2155
|
+
"alert": alert_result,
|
|
2156
|
+
}, not report.get("tampered")
|
|
2157
|
+
|
|
2158
|
+
if action == "refresh_protection_baseline":
|
|
2159
|
+
baseline = agent_protection.update_baseline(AGENT_DIR, RUNTIME_DIR)
|
|
2160
|
+
return config, {
|
|
2161
|
+
"baseline_refreshed": True,
|
|
2162
|
+
"generated_at": baseline.get("generated_at"),
|
|
2163
|
+
}, True
|
|
2164
|
+
|
|
2165
|
+
if action == "check_system_deps":
|
|
2166
|
+
report = system_deps.check_dependencies()
|
|
2167
|
+
ok = not report.get("missing_required_groups")
|
|
2168
|
+
return config, {"system_deps": report}, ok
|
|
2169
|
+
|
|
2170
|
+
if action == "install_system_deps":
|
|
2171
|
+
include_optional = bool(params.get("include_optional", False))
|
|
2172
|
+
outcome = system_deps.install_missing_dependencies(include_optional=include_optional)
|
|
2173
|
+
attempted = bool(outcome.get("attempted"))
|
|
2174
|
+
success = bool(outcome.get("success"))
|
|
2175
|
+
ok = (not attempted) or success
|
|
2176
|
+
return config, {"system_deps_install": outcome}, ok
|
|
2177
|
+
|
|
2178
|
+
if action == "tag":
|
|
2179
|
+
tags_value: Any = params.get("tags", params.get("tag", []))
|
|
2180
|
+
append_mode = bool(params.get("append", True))
|
|
2181
|
+
|
|
2182
|
+
incoming_tags: List[str] = []
|
|
2183
|
+
if isinstance(tags_value, list):
|
|
2184
|
+
incoming_tags = [str(item).strip() for item in tags_value if str(item).strip()]
|
|
2185
|
+
elif isinstance(tags_value, str):
|
|
2186
|
+
incoming_tags = [item.strip() for item in tags_value.split(",") if item.strip()]
|
|
2187
|
+
|
|
2188
|
+
if not incoming_tags:
|
|
2189
|
+
raise ValueError("Remote tag action requires 'tag' or 'tags' parameter")
|
|
2190
|
+
|
|
2191
|
+
config = manager.set_tags(incoming_tags, append=append_mode)
|
|
2192
|
+
return config, {"enrolled": config.get("enrolled", False), "tags": config.get("tags", [])}, True
|
|
2193
|
+
|
|
2194
|
+
raise ValueError(f"Unsupported forced action: {action}")
|
|
2195
|
+
|
|
2196
|
+
|
|
2197
|
+
def process_remote_forced_commands(
|
|
2198
|
+
config: Dict[str, Any],
|
|
2199
|
+
manager: ConfigManager,
|
|
2200
|
+
logger: Any,
|
|
2201
|
+
config_path: Path,
|
|
2202
|
+
startup_sync_changed: bool,
|
|
2203
|
+
) -> tuple[Dict[str, Any], Dict[str, Any], int]:
|
|
2204
|
+
remote_cfg = config.get("remote_commands", {})
|
|
2205
|
+
if not isinstance(remote_cfg, dict):
|
|
2206
|
+
remote_cfg = {}
|
|
2207
|
+
|
|
2208
|
+
if not bool(remote_cfg.get("enabled", False)):
|
|
2209
|
+
return (
|
|
2210
|
+
config,
|
|
2211
|
+
{
|
|
2212
|
+
"remote_commands": {
|
|
2213
|
+
"enabled": False,
|
|
2214
|
+
"message": "Remote commands are disabled in config.remote_commands.enabled",
|
|
2215
|
+
}
|
|
2216
|
+
},
|
|
2217
|
+
0,
|
|
2218
|
+
)
|
|
2219
|
+
|
|
2220
|
+
client = RemoteCommandClient(
|
|
2221
|
+
server_url=str(config.get("server_url", "")),
|
|
2222
|
+
pull_url=str(remote_cfg.get("pull_url", "")),
|
|
2223
|
+
result_url=str(remote_cfg.get("result_url", "")),
|
|
2224
|
+
auth_token=str(remote_cfg.get("auth_token", "") or config.get("agent_token", "")),
|
|
2225
|
+
timeout_seconds=_safe_int(remote_cfg.get("timeout_seconds", 10), 10),
|
|
2226
|
+
)
|
|
2227
|
+
|
|
2228
|
+
pull_payload = {
|
|
2229
|
+
"agent_uuid": config.get("agent_uuid", ""),
|
|
2230
|
+
"machine_id": config.get("machine_id", ""),
|
|
2231
|
+
"agent_name": config.get("agent_name", APP_NAME),
|
|
2232
|
+
"agent_version": str(config.get("version", APP_VERSION)),
|
|
2233
|
+
"enrolled": bool(config.get("enrolled", False)),
|
|
2234
|
+
"requested_at": utc_now_iso(),
|
|
2235
|
+
}
|
|
2236
|
+
token_recovery_result: Optional[Dict[str, Any]] = None
|
|
2237
|
+
|
|
2238
|
+
try:
|
|
2239
|
+
pull_response = client.pull_commands(pull_payload)
|
|
2240
|
+
except Exception as exc:
|
|
2241
|
+
if _is_agent_state_revoked_error(exc):
|
|
2242
|
+
reason = _agent_state_revoked_reason(exc)
|
|
2243
|
+
revocation = _mark_agent_as_revoked(config, manager, logger, reason)
|
|
2244
|
+
return config, {
|
|
2245
|
+
"remote_commands": {
|
|
2246
|
+
"enabled": True,
|
|
2247
|
+
"pulled": 0,
|
|
2248
|
+
"executed": 0,
|
|
2249
|
+
"failed": 0,
|
|
2250
|
+
"reason": reason,
|
|
2251
|
+
"message": "Agent revoked or deleted server-side. Local state reset.",
|
|
2252
|
+
"revocation": revocation,
|
|
2253
|
+
},
|
|
2254
|
+
}, 1
|
|
2255
|
+
|
|
2256
|
+
if _is_invalid_agent_token_error(exc):
|
|
2257
|
+
token_recovery_result = _recover_agent_token(config, manager, logger=logger)
|
|
2258
|
+
if token_recovery_result.get("recovered"):
|
|
2259
|
+
client = RemoteCommandClient(
|
|
2260
|
+
server_url=str(config.get("server_url", "")),
|
|
2261
|
+
pull_url=str(remote_cfg.get("pull_url", "")),
|
|
2262
|
+
result_url=str(remote_cfg.get("result_url", "")),
|
|
2263
|
+
auth_token=str(remote_cfg.get("auth_token", "") or config.get("agent_token", "")),
|
|
2264
|
+
timeout_seconds=_safe_int(remote_cfg.get("timeout_seconds", 10), 10),
|
|
2265
|
+
)
|
|
2266
|
+
pull_payload["agent_uuid"] = config.get("agent_uuid", "")
|
|
2267
|
+
pull_payload["machine_id"] = config.get("machine_id", "")
|
|
2268
|
+
pull_payload["agent_version"] = str(config.get("version", APP_VERSION))
|
|
2269
|
+
try:
|
|
2270
|
+
pull_response = client.pull_commands(pull_payload)
|
|
2271
|
+
except Exception as retry_exc:
|
|
2272
|
+
logger.exception("Remote command pull failed after token recovery")
|
|
2273
|
+
return config, {
|
|
2274
|
+
"error": str(retry_exc),
|
|
2275
|
+
"stage": "pull_commands",
|
|
2276
|
+
"token_recovery": token_recovery_result,
|
|
2277
|
+
}, 1
|
|
2278
|
+
else:
|
|
2279
|
+
logger.exception("Remote command pull failed and token recovery failed")
|
|
2280
|
+
return config, {
|
|
2281
|
+
"error": str(exc),
|
|
2282
|
+
"stage": "pull_commands",
|
|
2283
|
+
"token_recovery": token_recovery_result,
|
|
2284
|
+
}, 1
|
|
2285
|
+
else:
|
|
2286
|
+
logger.exception("Remote command pull failed")
|
|
2287
|
+
return config, {"error": str(exc), "stage": "pull_commands"}, 1
|
|
2288
|
+
|
|
2289
|
+
if not pull_response.get("ok"):
|
|
2290
|
+
return (
|
|
2291
|
+
config,
|
|
2292
|
+
{
|
|
2293
|
+
"remote_commands": {
|
|
2294
|
+
"enabled": True,
|
|
2295
|
+
"pulled": 0,
|
|
2296
|
+
"executed": 0,
|
|
2297
|
+
"failed": 0,
|
|
2298
|
+
"reason": pull_response.get("reason", "unknown"),
|
|
2299
|
+
}
|
|
2300
|
+
},
|
|
2301
|
+
0,
|
|
2302
|
+
)
|
|
2303
|
+
|
|
2304
|
+
raw_commands = pull_response.get("commands", [])
|
|
2305
|
+
if not isinstance(raw_commands, list):
|
|
2306
|
+
raw_commands = []
|
|
2307
|
+
|
|
2308
|
+
max_commands = _safe_int(remote_cfg.get("max_commands_per_poll", 10), 10)
|
|
2309
|
+
commands = raw_commands[:max_commands]
|
|
2310
|
+
allowed_actions = _remote_allowed_actions(config)
|
|
2311
|
+
|
|
2312
|
+
results: List[Dict[str, Any]] = []
|
|
2313
|
+
executed = 0
|
|
2314
|
+
failed = 0
|
|
2315
|
+
|
|
2316
|
+
for command in commands:
|
|
2317
|
+
if not isinstance(command, dict):
|
|
2318
|
+
continue
|
|
2319
|
+
|
|
2320
|
+
command_id = str(command.get("id") or command.get("command_id") or "")
|
|
2321
|
+
action = _normalize_remote_action(command)
|
|
2322
|
+
params = command.get("params", {})
|
|
2323
|
+
if not isinstance(params, dict):
|
|
2324
|
+
params = {}
|
|
2325
|
+
|
|
2326
|
+
if not action:
|
|
2327
|
+
results.append(
|
|
2328
|
+
{
|
|
2329
|
+
"id": command_id,
|
|
2330
|
+
"status": "skipped",
|
|
2331
|
+
"reason": "missing_action",
|
|
2332
|
+
}
|
|
2333
|
+
)
|
|
2334
|
+
continue
|
|
2335
|
+
|
|
2336
|
+
if action not in allowed_actions:
|
|
2337
|
+
results.append(
|
|
2338
|
+
{
|
|
2339
|
+
"id": command_id,
|
|
2340
|
+
"action": action,
|
|
2341
|
+
"status": "skipped",
|
|
2342
|
+
"reason": "action_not_allowed",
|
|
2343
|
+
}
|
|
2344
|
+
)
|
|
2345
|
+
continue
|
|
2346
|
+
|
|
2347
|
+
executed += 1
|
|
2348
|
+
try:
|
|
2349
|
+
config, action_result, ok = execute_forced_action(
|
|
2350
|
+
action,
|
|
2351
|
+
params,
|
|
2352
|
+
config,
|
|
2353
|
+
manager,
|
|
2354
|
+
logger,
|
|
2355
|
+
config_path,
|
|
2356
|
+
startup_sync_changed,
|
|
2357
|
+
)
|
|
2358
|
+
if not ok:
|
|
2359
|
+
failed += 1
|
|
2360
|
+
results.append(
|
|
2361
|
+
{
|
|
2362
|
+
"id": command_id,
|
|
2363
|
+
"action": action,
|
|
2364
|
+
"status": "ok" if ok else "error",
|
|
2365
|
+
"result": action_result,
|
|
2366
|
+
}
|
|
2367
|
+
)
|
|
2368
|
+
except Exception as exc:
|
|
2369
|
+
failed += 1
|
|
2370
|
+
logger.exception("Forced remote action failed: %s", action)
|
|
2371
|
+
results.append(
|
|
2372
|
+
{
|
|
2373
|
+
"id": command_id,
|
|
2374
|
+
"action": action,
|
|
2375
|
+
"status": "error",
|
|
2376
|
+
"error": str(exc),
|
|
2377
|
+
}
|
|
2378
|
+
)
|
|
2379
|
+
|
|
2380
|
+
ack_payload = {
|
|
2381
|
+
"agent_uuid": config.get("agent_uuid", ""),
|
|
2382
|
+
"machine_id": config.get("machine_id", ""),
|
|
2383
|
+
"submitted_at": utc_now_iso(),
|
|
2384
|
+
"results": results,
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
try:
|
|
2388
|
+
ack_result = client.push_results(ack_payload)
|
|
2389
|
+
except Exception as exc:
|
|
2390
|
+
logger.exception("Remote command result submission failed")
|
|
2391
|
+
ack_result = {
|
|
2392
|
+
"ok": False,
|
|
2393
|
+
"error": str(exc),
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
response = {
|
|
2397
|
+
"remote_commands": {
|
|
2398
|
+
"enabled": True,
|
|
2399
|
+
"pull_url": client.pull_url,
|
|
2400
|
+
"result_url": client.result_url,
|
|
2401
|
+
"pulled": len(raw_commands),
|
|
2402
|
+
"processed": len(commands),
|
|
2403
|
+
"executed": executed,
|
|
2404
|
+
"failed": failed,
|
|
2405
|
+
"allowed_actions": sorted(allowed_actions),
|
|
2406
|
+
"results": results,
|
|
2407
|
+
"ack": ack_result,
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
if token_recovery_result is not None:
|
|
2411
|
+
response["remote_commands"]["token_recovery"] = token_recovery_result
|
|
2412
|
+
|
|
2413
|
+
return config, response, 0 if failed == 0 else 1
|
|
2414
|
+
|
|
2415
|
+
|
|
2416
|
+
def run_service_loop(
|
|
2417
|
+
config: Dict[str, Any],
|
|
2418
|
+
manager: ConfigManager,
|
|
2419
|
+
logger: Any,
|
|
2420
|
+
config_path: Path,
|
|
2421
|
+
) -> int:
|
|
2422
|
+
"""Boucle principale du mode service (declenchee par --service-run).
|
|
2423
|
+
|
|
2424
|
+
Utilisee comme `ExecStart` de l unit systemd (Linux) et de l executable
|
|
2425
|
+
NSSM (Windows). La boucle :
|
|
2426
|
+
|
|
2427
|
+
1. Verifie que l agent est enrole; sinon log + attente (pas de crash).
|
|
2428
|
+
2. Alterne entre:
|
|
2429
|
+
- synchronisation periodique avec le serveur (`sync_to_server`)
|
|
2430
|
+
- pull des commandes forcees (`process_remote_forced_commands`)
|
|
2431
|
+
- scan complet si l intervalle configure est atteint
|
|
2432
|
+
3. Gere SIGTERM (Linux) / CTRL_BREAK (Windows) pour un arret propre.
|
|
2433
|
+
|
|
2434
|
+
Retourne 0 a la sortie propre, 1 si erreur bloquante (relance geree par
|
|
2435
|
+
systemd / NSSM via leur mecanisme `Restart=on-failure` / RestartCtrl).
|
|
2436
|
+
"""
|
|
2437
|
+
import signal
|
|
2438
|
+
import threading
|
|
2439
|
+
import time
|
|
2440
|
+
|
|
2441
|
+
stop_event = threading.Event()
|
|
2442
|
+
|
|
2443
|
+
def _signal_handler(signum: int, _frame: Any) -> None:
|
|
2444
|
+
logger.info("Service received signal %s, stopping gracefully", signum)
|
|
2445
|
+
stop_event.set()
|
|
2446
|
+
|
|
2447
|
+
# SIGTERM (tous OS) + SIGINT (Ctrl+C lors d une exec manuelle).
|
|
2448
|
+
try:
|
|
2449
|
+
signal.signal(signal.SIGTERM, _signal_handler)
|
|
2450
|
+
signal.signal(signal.SIGINT, _signal_handler)
|
|
2451
|
+
if platform.system().lower() == "windows" and hasattr(signal, "SIGBREAK"):
|
|
2452
|
+
signal.signal(signal.SIGBREAK, _signal_handler) # type: ignore[attr-defined]
|
|
2453
|
+
except Exception:
|
|
2454
|
+
logger.exception("Service signal handler setup failed; continuing")
|
|
2455
|
+
|
|
2456
|
+
scheduler_cfg = config.get("scheduler", {}) if isinstance(config, dict) else {}
|
|
2457
|
+
scan_interval_s = max(60, int(scheduler_cfg.get("interval_minutes", 60) or 60) * 60)
|
|
2458
|
+
sync_interval_s = max(30, int(scheduler_cfg.get("sync_interval_seconds", 120) or 120))
|
|
2459
|
+
|
|
2460
|
+
logger.info(
|
|
2461
|
+
"Service loop started (sync every %ss, full scan every %ss)",
|
|
2462
|
+
sync_interval_s, scan_interval_s,
|
|
2463
|
+
)
|
|
2464
|
+
|
|
2465
|
+
last_scan_ts = 0.0
|
|
2466
|
+
|
|
2467
|
+
while not stop_event.is_set():
|
|
2468
|
+
cycle_start = time.time()
|
|
2469
|
+
enrolled = bool(config.get("enrolled"))
|
|
2470
|
+
|
|
2471
|
+
try:
|
|
2472
|
+
if enrolled:
|
|
2473
|
+
now = time.time()
|
|
2474
|
+
should_scan = (now - last_scan_ts) >= scan_interval_s
|
|
2475
|
+
if should_scan:
|
|
2476
|
+
logger.info("Service loop: running scheduled scan")
|
|
2477
|
+
scan_payload = run_scan(config, manager)
|
|
2478
|
+
config, _, _ = sync_to_server(config, manager, scan_payload=scan_payload)
|
|
2479
|
+
last_scan_ts = now
|
|
2480
|
+
else:
|
|
2481
|
+
config, _, _ = sync_to_server(config, manager)
|
|
2482
|
+
|
|
2483
|
+
# Pull des commandes distantes (non bloquant; erreurs logguees).
|
|
2484
|
+
try:
|
|
2485
|
+
config, _, _ = process_remote_forced_commands(
|
|
2486
|
+
config, manager, logger, config_path,
|
|
2487
|
+
startup_sync_changed=False,
|
|
2488
|
+
)
|
|
2489
|
+
except Exception:
|
|
2490
|
+
logger.exception("Service loop: remote commands pull failed")
|
|
2491
|
+
else:
|
|
2492
|
+
logger.info("Service loop: agent not enrolled, waiting (use --enroll first)")
|
|
2493
|
+
except Exception:
|
|
2494
|
+
logger.exception("Service loop: cycle failed, will retry")
|
|
2495
|
+
|
|
2496
|
+
elapsed = time.time() - cycle_start
|
|
2497
|
+
sleep_for = max(5.0, sync_interval_s - elapsed)
|
|
2498
|
+
stop_event.wait(timeout=sleep_for)
|
|
2499
|
+
|
|
2500
|
+
logger.info("Service loop stopped cleanly")
|
|
2501
|
+
return 0
|
|
2502
|
+
|
|
2503
|
+
|
|
2504
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
2505
|
+
raw_args = list(argv) if argv is not None else sys.argv[1:]
|
|
2506
|
+
parser = build_parser()
|
|
2507
|
+
args = parser.parse_args(raw_args)
|
|
2508
|
+
validate_args(parser, args)
|
|
2509
|
+
|
|
2510
|
+
privileged_actions = _requested_privileged_actions(args)
|
|
2511
|
+
if privileged_actions and not _is_process_elevated():
|
|
2512
|
+
if str(os.getenv(ELEVATION_REEXEC_GUARD_ENV, "")).strip() == "1":
|
|
2513
|
+
print(
|
|
2514
|
+
json.dumps(
|
|
2515
|
+
{
|
|
2516
|
+
"error": "Command requires root/admin privileges",
|
|
2517
|
+
"actions": privileged_actions,
|
|
2518
|
+
"stage": "elevation",
|
|
2519
|
+
},
|
|
2520
|
+
indent=2,
|
|
2521
|
+
ensure_ascii=False,
|
|
2522
|
+
)
|
|
2523
|
+
)
|
|
2524
|
+
return 1
|
|
2525
|
+
|
|
2526
|
+
try:
|
|
2527
|
+
elevation_result = _relaunch_with_elevation(raw_args)
|
|
2528
|
+
except Exception as exc:
|
|
2529
|
+
print(
|
|
2530
|
+
json.dumps(
|
|
2531
|
+
{
|
|
2532
|
+
"error": str(exc),
|
|
2533
|
+
"actions": privileged_actions,
|
|
2534
|
+
"stage": "elevation",
|
|
2535
|
+
},
|
|
2536
|
+
indent=2,
|
|
2537
|
+
ensure_ascii=False,
|
|
2538
|
+
)
|
|
2539
|
+
)
|
|
2540
|
+
return 1
|
|
2541
|
+
|
|
2542
|
+
if elevation_result.get("platform") == "linux":
|
|
2543
|
+
return int(elevation_result.get("exit_code", 1))
|
|
2544
|
+
|
|
2545
|
+
print(
|
|
2546
|
+
json.dumps(
|
|
2547
|
+
{
|
|
2548
|
+
"message": "Elevation requested. Command relaunched with administrator privileges.",
|
|
2549
|
+
"actions": privileged_actions,
|
|
2550
|
+
},
|
|
2551
|
+
indent=2,
|
|
2552
|
+
)
|
|
2553
|
+
)
|
|
2554
|
+
return 0
|
|
2555
|
+
|
|
2556
|
+
if getattr(args, "bump_version", None) is not None:
|
|
2557
|
+
try:
|
|
2558
|
+
result = run_version_bump(
|
|
2559
|
+
part=str(getattr(args, "bump_version", "patch") or "patch"),
|
|
2560
|
+
set_version=str(getattr(args, "set_version", "") or ""),
|
|
2561
|
+
)
|
|
2562
|
+
except Exception as exc:
|
|
2563
|
+
print(json.dumps({"error": str(exc), "stage": "bump_version"}, indent=2, ensure_ascii=False))
|
|
2564
|
+
return 1
|
|
2565
|
+
|
|
2566
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
2567
|
+
return 0
|
|
2568
|
+
|
|
2569
|
+
ensure_runtime_storage()
|
|
2570
|
+
|
|
2571
|
+
config_path = RUNTIME_CONFIG_PATH
|
|
2572
|
+
first_install = not config_path.exists()
|
|
2573
|
+
manager = ConfigManager(config_path)
|
|
2574
|
+
config = manager.load()
|
|
2575
|
+
config, startup_sync_changed = sync_runtime_config_from_template(manager, config)
|
|
2576
|
+
|
|
2577
|
+
if str(config.get("version", "")).strip() != APP_VERSION:
|
|
2578
|
+
config["version"] = APP_VERSION
|
|
2579
|
+
manager.save(config)
|
|
2580
|
+
|
|
2581
|
+
logger = get_logger(APP_NAME, LOG_FILE_OVERRIDE)
|
|
2582
|
+
ensure_linux_manpage(logger)
|
|
2583
|
+
dependency_status = ensure_runtime_dependencies(AGENT_DIR, logger, force_reinstall=first_install)
|
|
2584
|
+
if dependency_status.get("status") != "ok":
|
|
2585
|
+
logger.warning("Runtime dependency bootstrap status: %s", dependency_status)
|
|
2586
|
+
|
|
2587
|
+
if args.version:
|
|
2588
|
+
print(str(config.get("version", APP_VERSION)))
|
|
2589
|
+
return 0
|
|
2590
|
+
|
|
2591
|
+
if args.enroll:
|
|
2592
|
+
# Un agent deja marque enrole localement ne peut pas refaire un enroll
|
|
2593
|
+
# « normal » (cle globale / fingerprint seul) : risque de doublon ou de
|
|
2594
|
+
# comportement ambigu. La reprise apres revocation / re-attachement explicite
|
|
2595
|
+
# passe par --enroll_key (cle machine ou cle globale fournie en CLI).
|
|
2596
|
+
enroll_key_cli = str(args.enroll_key or "").strip()
|
|
2597
|
+
if bool(config.get("enrolled", False)) and not enroll_key_cli:
|
|
2598
|
+
print(
|
|
2599
|
+
json.dumps(
|
|
2600
|
+
{
|
|
2601
|
+
"error": (
|
|
2602
|
+
"Agent deja enrole localement. Pour re-enroler (ex. apres revocation "
|
|
2603
|
+
"cote serveur), fournissez une cle sur la ligne de commande : "
|
|
2604
|
+
"`--enroll --enroll_key <CLE>` (cle machine recommandee, voir admin). "
|
|
2605
|
+
"Pour repartir de zero : `--unenroll` puis `--enroll`."
|
|
2606
|
+
),
|
|
2607
|
+
"stage": "enroll_guard",
|
|
2608
|
+
"enrolled": True,
|
|
2609
|
+
},
|
|
2610
|
+
indent=2,
|
|
2611
|
+
ensure_ascii=False,
|
|
2612
|
+
)
|
|
2613
|
+
)
|
|
2614
|
+
return 1
|
|
2615
|
+
|
|
2616
|
+
enroll_url, enroll_comment, enroll_tags = _prompt_enroll_values(args)
|
|
2617
|
+
try:
|
|
2618
|
+
registration = enroll_with_server(
|
|
2619
|
+
config,
|
|
2620
|
+
enroll_url,
|
|
2621
|
+
enroll_comment,
|
|
2622
|
+
enroll_tags,
|
|
2623
|
+
enroll_key_cli,
|
|
2624
|
+
)
|
|
2625
|
+
except Exception as exc:
|
|
2626
|
+
logger.exception("Agent enrollment via API failed")
|
|
2627
|
+
print(json.dumps({"error": str(exc), "stage": "agent_register"}, indent=2, ensure_ascii=False))
|
|
2628
|
+
return 1
|
|
2629
|
+
|
|
2630
|
+
manager.save(config)
|
|
2631
|
+
# Initialise le baseline d integrite au premier enrolement: la machine
|
|
2632
|
+
# devient "officiellement enrolee" et on fige le hash des fichiers
|
|
2633
|
+
# critiques pour pouvoir detecter une tentative de suppression future.
|
|
2634
|
+
baseline_info: Optional[Dict[str, Any]] = None
|
|
2635
|
+
if bool(_protection_config(config).get("enabled", True)):
|
|
2636
|
+
try:
|
|
2637
|
+
baseline = agent_protection.update_baseline(AGENT_DIR, RUNTIME_DIR)
|
|
2638
|
+
baseline_info = {
|
|
2639
|
+
"refreshed": True,
|
|
2640
|
+
"generated_at": baseline.get("generated_at"),
|
|
2641
|
+
}
|
|
2642
|
+
except Exception as exc:
|
|
2643
|
+
logger.warning("Failed to initialize integrity baseline at enroll: %s", exc)
|
|
2644
|
+
baseline_info = {"refreshed": False, "error": str(exc)}
|
|
2645
|
+
|
|
2646
|
+
logger.info("Agent enrolled to %s", enroll_url)
|
|
2647
|
+
response_payload = {
|
|
2648
|
+
"enrolled": True,
|
|
2649
|
+
"server_url": config.get("server_url"),
|
|
2650
|
+
"agent_uuid": registration.get("agent_uuid"),
|
|
2651
|
+
"machine_id": registration.get("machine_id"),
|
|
2652
|
+
"sync_url": registration.get("sync_url"),
|
|
2653
|
+
"commands_pull_url": registration.get("commands_pull_url"),
|
|
2654
|
+
"commands_result_url": registration.get("commands_result_url"),
|
|
2655
|
+
"tags": config.get("tags", []),
|
|
2656
|
+
}
|
|
2657
|
+
if baseline_info is not None:
|
|
2658
|
+
response_payload["protection_baseline"] = baseline_info
|
|
2659
|
+
print(json.dumps(response_payload, indent=2, ensure_ascii=False))
|
|
2660
|
+
return 0
|
|
2661
|
+
|
|
2662
|
+
if args.tag and not args.enroll:
|
|
2663
|
+
config = manager.set_tags(args.tag, append=True)
|
|
2664
|
+
logger.info("Agent tags updated")
|
|
2665
|
+
print(json.dumps({"enrolled": config.get("enrolled", False), "tags": config.get("tags", [])}, indent=2, ensure_ascii=False))
|
|
2666
|
+
return 0
|
|
2667
|
+
|
|
2668
|
+
if args.unenroll:
|
|
2669
|
+
config = manager.unenroll()
|
|
2670
|
+
logger.info("Agent unenrolled")
|
|
2671
|
+
print(json.dumps({"enrolled": config.get("enrolled", False)}, indent=2))
|
|
2672
|
+
return 0
|
|
2673
|
+
|
|
2674
|
+
if args.show_config:
|
|
2675
|
+
print(json.dumps(config, indent=2))
|
|
2676
|
+
return 0
|
|
2677
|
+
|
|
2678
|
+
if args.update:
|
|
2679
|
+
config, update_result, update_code = run_update_check(config, manager, logger)
|
|
2680
|
+
if update_code == 0 and isinstance(update_result, dict) and update_result.get("applied_update"):
|
|
2681
|
+
# Update legitime => on reconstruit le baseline d integrite pour
|
|
2682
|
+
# ne pas confondre la nouvelle version avec du tampering.
|
|
2683
|
+
if bool(_protection_config(config).get("baseline_auto_refresh_on_update", True)):
|
|
2684
|
+
try:
|
|
2685
|
+
baseline = agent_protection.update_baseline(AGENT_DIR, RUNTIME_DIR)
|
|
2686
|
+
update_result["protection_baseline"] = {
|
|
2687
|
+
"refreshed": True,
|
|
2688
|
+
"generated_at": baseline.get("generated_at"),
|
|
2689
|
+
}
|
|
2690
|
+
except Exception as exc:
|
|
2691
|
+
logger.warning("Failed to refresh integrity baseline after update: %s", exc)
|
|
2692
|
+
update_result["protection_baseline"] = {
|
|
2693
|
+
"refreshed": False,
|
|
2694
|
+
"error": str(exc),
|
|
2695
|
+
}
|
|
2696
|
+
update_result["process_restart"] = _restart_after_update(logger)
|
|
2697
|
+
print(json.dumps(update_result, indent=2))
|
|
2698
|
+
return update_code
|
|
2699
|
+
|
|
2700
|
+
if args.sync:
|
|
2701
|
+
try:
|
|
2702
|
+
config, sync_result, sync_code = sync_to_server(config, manager)
|
|
2703
|
+
except Exception as exc:
|
|
2704
|
+
logger.exception("Manual sync failed")
|
|
2705
|
+
print(json.dumps({"error": str(exc), "stage": "agent_sync"}, indent=2, ensure_ascii=False))
|
|
2706
|
+
return 1
|
|
2707
|
+
|
|
2708
|
+
logger.info("Manual sync requested")
|
|
2709
|
+
print(json.dumps(sync_result, indent=2, ensure_ascii=False))
|
|
2710
|
+
return sync_code
|
|
2711
|
+
|
|
2712
|
+
if args.pull_remote_commands:
|
|
2713
|
+
config, remote_result, remote_code = process_remote_forced_commands(
|
|
2714
|
+
config,
|
|
2715
|
+
manager,
|
|
2716
|
+
logger,
|
|
2717
|
+
config_path,
|
|
2718
|
+
startup_sync_changed,
|
|
2719
|
+
)
|
|
2720
|
+
print(json.dumps(remote_result, indent=2, ensure_ascii=False))
|
|
2721
|
+
return remote_code
|
|
2722
|
+
|
|
2723
|
+
if args.status:
|
|
2724
|
+
status_payload = build_status_payload(config, config_path, startup_sync_changed)
|
|
2725
|
+
print(json.dumps(status_payload, indent=2))
|
|
2726
|
+
return 0
|
|
2727
|
+
|
|
2728
|
+
if args.diagnostic:
|
|
2729
|
+
diagnostics = build_diagnostic_payload(config, config_path)
|
|
2730
|
+
print(json.dumps(diagnostics, indent=2))
|
|
2731
|
+
return 0
|
|
2732
|
+
|
|
2733
|
+
if args.show_log_path:
|
|
2734
|
+
print(LOG_FILE_OVERRIDE.as_posix())
|
|
2735
|
+
return 0
|
|
2736
|
+
|
|
2737
|
+
if args.enable_auto_scan:
|
|
2738
|
+
scheduler = _scheduler_from_config(config)
|
|
2739
|
+
try:
|
|
2740
|
+
result = scheduler.enable_every_30_minutes()
|
|
2741
|
+
except Exception as exc:
|
|
2742
|
+
logger.exception("Failed to enable automatic scan schedule")
|
|
2743
|
+
print(json.dumps({"error": str(exc)}, indent=2, ensure_ascii=False))
|
|
2744
|
+
return 1
|
|
2745
|
+
|
|
2746
|
+
config.setdefault("scheduler", {})["enabled"] = True
|
|
2747
|
+
manager.save(config)
|
|
2748
|
+
|
|
2749
|
+
logger.info("Automatic scan enabled every %s minutes", result.get("interval_minutes", "?"))
|
|
2750
|
+
print(json.dumps({"auto_scan": "enabled", **result}, indent=2, ensure_ascii=False))
|
|
2751
|
+
return 0
|
|
2752
|
+
|
|
2753
|
+
if args.disable_auto_scan:
|
|
2754
|
+
scheduler = _scheduler_from_config(config)
|
|
2755
|
+
try:
|
|
2756
|
+
result = scheduler.disable_every_30_minutes()
|
|
2757
|
+
except Exception as exc:
|
|
2758
|
+
logger.exception("Failed to disable automatic scan schedule")
|
|
2759
|
+
print(json.dumps({"error": str(exc)}, indent=2, ensure_ascii=False))
|
|
2760
|
+
return 1
|
|
2761
|
+
|
|
2762
|
+
config.setdefault("scheduler", {})["enabled"] = False
|
|
2763
|
+
manager.save(config)
|
|
2764
|
+
|
|
2765
|
+
logger.info("Automatic scan disabled")
|
|
2766
|
+
print(json.dumps({"auto_scan": "disabled", **result}, indent=2, ensure_ascii=False))
|
|
2767
|
+
return 0
|
|
2768
|
+
|
|
2769
|
+
if args.set_scan_interval is not None:
|
|
2770
|
+
new_interval = int(args.set_scan_interval)
|
|
2771
|
+
scheduler_cfg = config.setdefault("scheduler", {})
|
|
2772
|
+
previous_interval = int(scheduler_cfg.get("interval_minutes", 30) or 30)
|
|
2773
|
+
was_enabled = bool(scheduler_cfg.get("enabled", False))
|
|
2774
|
+
scheduler_cfg["interval_minutes"] = new_interval
|
|
2775
|
+
manager.save(config)
|
|
2776
|
+
|
|
2777
|
+
response: Dict[str, Any] = {
|
|
2778
|
+
"scheduler_interval": "updated",
|
|
2779
|
+
"previous_interval_minutes": previous_interval,
|
|
2780
|
+
"interval_minutes": new_interval,
|
|
2781
|
+
"reapplied": False,
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
# Re-applique la planification OS uniquement si elle etait active, afin
|
|
2785
|
+
# que le nouvel intervalle soit effectivement pris en compte par cron /
|
|
2786
|
+
# systemd / Windows Task Scheduler. Un simple --enable_auto_scan futur
|
|
2787
|
+
# reprendra aussi la nouvelle valeur.
|
|
2788
|
+
if was_enabled:
|
|
2789
|
+
try:
|
|
2790
|
+
scheduler = _scheduler_from_config(config)
|
|
2791
|
+
reapply_result = scheduler.enable_every_30_minutes()
|
|
2792
|
+
response["reapplied"] = True
|
|
2793
|
+
response.update(reapply_result)
|
|
2794
|
+
except Exception as exc:
|
|
2795
|
+
logger.exception("Failed to re-apply scheduler with new interval")
|
|
2796
|
+
response["reapplied"] = False
|
|
2797
|
+
response["reapply_error"] = str(exc)
|
|
2798
|
+
|
|
2799
|
+
logger.info(
|
|
2800
|
+
"Scan interval changed from %s to %s minutes (reapplied=%s)",
|
|
2801
|
+
previous_interval, new_interval, response["reapplied"],
|
|
2802
|
+
)
|
|
2803
|
+
print(json.dumps(response, indent=2, ensure_ascii=False))
|
|
2804
|
+
return 0
|
|
2805
|
+
|
|
2806
|
+
if args.check_protection:
|
|
2807
|
+
report, alert_result = run_protection_check(config, logger)
|
|
2808
|
+
response: Dict[str, Any] = {"protection": report}
|
|
2809
|
+
if alert_result is not None:
|
|
2810
|
+
response["alert"] = alert_result
|
|
2811
|
+
print(json.dumps(response, indent=2, ensure_ascii=False))
|
|
2812
|
+
return 0 if not report.get("tampered") else 1
|
|
2813
|
+
|
|
2814
|
+
if args.refresh_protection_baseline:
|
|
2815
|
+
baseline = agent_protection.update_baseline(AGENT_DIR, RUNTIME_DIR)
|
|
2816
|
+
logger.info("Agent integrity baseline refreshed")
|
|
2817
|
+
print(json.dumps({
|
|
2818
|
+
"baseline_refreshed": True,
|
|
2819
|
+
"generated_at": baseline.get("generated_at"),
|
|
2820
|
+
"files": list((baseline.get("files") or {}).keys()),
|
|
2821
|
+
}, indent=2, ensure_ascii=False))
|
|
2822
|
+
return 0
|
|
2823
|
+
|
|
2824
|
+
if args.check_system_deps:
|
|
2825
|
+
report = system_deps.check_dependencies()
|
|
2826
|
+
missing_groups = report.get("missing_required_groups") or []
|
|
2827
|
+
if missing_groups:
|
|
2828
|
+
logger.warning(
|
|
2829
|
+
"System dependency audit: missing required groups=%s",
|
|
2830
|
+
",".join(missing_groups),
|
|
2831
|
+
)
|
|
2832
|
+
else:
|
|
2833
|
+
logger.info("System dependency audit: all required groups satisfied")
|
|
2834
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
2835
|
+
return 0 if not missing_groups else 1
|
|
2836
|
+
|
|
2837
|
+
if args.install_system_deps or args.install_system_deps_all:
|
|
2838
|
+
include_optional = bool(args.install_system_deps_all)
|
|
2839
|
+
outcome = system_deps.install_missing_dependencies(
|
|
2840
|
+
include_optional=include_optional,
|
|
2841
|
+
)
|
|
2842
|
+
attempted = bool(outcome.get("attempted"))
|
|
2843
|
+
success = bool(outcome.get("success"))
|
|
2844
|
+
reason = outcome.get("reason") or "unknown"
|
|
2845
|
+
# Raisons "fatales" qui doivent faire echouer la CLI meme si aucune
|
|
2846
|
+
# commande n a pu etre tentee (permet a un operateur / la CI de
|
|
2847
|
+
# detecter l etat erratique au lieu de croire que tout va bien).
|
|
2848
|
+
fatal_reasons = {"elevation_required", "unsupported_package_manager"}
|
|
2849
|
+
if attempted:
|
|
2850
|
+
logger.info(
|
|
2851
|
+
"System deps install: platform=%s success=%s",
|
|
2852
|
+
outcome.get("platform"), success,
|
|
2853
|
+
)
|
|
2854
|
+
elif reason in fatal_reasons:
|
|
2855
|
+
logger.error(
|
|
2856
|
+
"System deps install blocked: reason=%s message=%s",
|
|
2857
|
+
reason, outcome.get("message", ""),
|
|
2858
|
+
)
|
|
2859
|
+
else:
|
|
2860
|
+
logger.info(
|
|
2861
|
+
"System deps install skipped: reason=%s", reason,
|
|
2862
|
+
)
|
|
2863
|
+
print(json.dumps(outcome, indent=2, ensure_ascii=False))
|
|
2864
|
+
if attempted:
|
|
2865
|
+
return 0 if success else 1
|
|
2866
|
+
return 1 if reason in fatal_reasons else 0
|
|
2867
|
+
|
|
2868
|
+
if getattr(args, "install_status", False):
|
|
2869
|
+
from core import system_install
|
|
2870
|
+
status = system_install.installation_status()
|
|
2871
|
+
print(json.dumps(status, indent=2, ensure_ascii=False))
|
|
2872
|
+
return 0
|
|
2873
|
+
|
|
2874
|
+
if getattr(args, "install", False):
|
|
2875
|
+
from core import system_install
|
|
2876
|
+
outcome = system_install.install_agent(force=bool(getattr(args, "force", False)))
|
|
2877
|
+
print(json.dumps(outcome, indent=2, ensure_ascii=False))
|
|
2878
|
+
if outcome.get("success"):
|
|
2879
|
+
# Apres une installation reussie, on rafraichit le baseline
|
|
2880
|
+
# d integrite pour refleter l emplacement installe. `update_baseline`
|
|
2881
|
+
# est le nom de la fonction exportee par core.protection.
|
|
2882
|
+
try:
|
|
2883
|
+
agent_protection.update_baseline(AGENT_DIR, RUNTIME_DIR)
|
|
2884
|
+
logger.info("Integrity baseline refreshed post-install")
|
|
2885
|
+
except Exception:
|
|
2886
|
+
logger.exception("Failed to refresh integrity baseline after install")
|
|
2887
|
+
return 0
|
|
2888
|
+
return 1
|
|
2889
|
+
|
|
2890
|
+
if getattr(args, "uninstall", False):
|
|
2891
|
+
from core import system_install
|
|
2892
|
+
outcome = system_install.uninstall_agent(purge=bool(getattr(args, "purge", False)))
|
|
2893
|
+
print(json.dumps(outcome, indent=2, ensure_ascii=False))
|
|
2894
|
+
return 0 if outcome.get("success") else 1
|
|
2895
|
+
|
|
2896
|
+
if getattr(args, "service_run", False):
|
|
2897
|
+
return run_service_loop(config, manager, logger, config_path)
|
|
2898
|
+
|
|
2899
|
+
if args.scan:
|
|
2900
|
+
# Controle d integrite passif avant le scan, pour detecter une tentative
|
|
2901
|
+
# de suppression/modification des fichiers critiques de l agent. En cas
|
|
2902
|
+
# de tampering, une alerte est envoyee au serveur sans bloquer le scan.
|
|
2903
|
+
try:
|
|
2904
|
+
run_protection_check(config, logger)
|
|
2905
|
+
except Exception:
|
|
2906
|
+
logger.exception("Integrity pre-check failed, continuing with scan")
|
|
2907
|
+
payload = run_scan(config, manager)
|
|
2908
|
+
if args.output_file:
|
|
2909
|
+
output_path = Path(args.output_file)
|
|
2910
|
+
if not output_path.is_absolute():
|
|
2911
|
+
output_path = Path.cwd() / output_path
|
|
2912
|
+
else:
|
|
2913
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
2914
|
+
output_path = RUNTIME_SCAN_DIR / f"scan_{timestamp}.json"
|
|
2915
|
+
|
|
2916
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2917
|
+
output_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
2918
|
+
scan_response: Dict[str, Any] = {
|
|
2919
|
+
"scan_saved_to": output_path.as_posix(),
|
|
2920
|
+
}
|
|
2921
|
+
try:
|
|
2922
|
+
config, sync_result, _sync_code = sync_to_server(config, manager, scan_payload=payload)
|
|
2923
|
+
synced = bool(sync_result.get("synced", False))
|
|
2924
|
+
scan_response["scan_sync"] = sync_result
|
|
2925
|
+
if synced:
|
|
2926
|
+
scan_response["message"] = "Scan termine et envoye au serveur"
|
|
2927
|
+
else:
|
|
2928
|
+
scan_response["message"] = "Scan termine, envoi serveur non effectue"
|
|
2929
|
+
except Exception as exc:
|
|
2930
|
+
logger.exception("Automatic sync after scan failed")
|
|
2931
|
+
scan_response["scan_sync"] = {"synced": False, "error": str(exc)}
|
|
2932
|
+
scan_response["message"] = "Scan termine, envoi serveur en echec"
|
|
2933
|
+
|
|
2934
|
+
print(json.dumps(scan_response, indent=2, ensure_ascii=False))
|
|
2935
|
+
return 0
|
|
2936
|
+
|
|
2937
|
+
if args.patch is not None:
|
|
2938
|
+
if args.patch == "":
|
|
2939
|
+
print(json.dumps({"message": "No patch file given. Nothing applied."}, indent=2))
|
|
2940
|
+
return 0
|
|
2941
|
+
|
|
2942
|
+
try:
|
|
2943
|
+
timeout_seconds = _safe_timeout(
|
|
2944
|
+
_agent_api_config(config).get("timeout_seconds", 15), 15
|
|
2945
|
+
)
|
|
2946
|
+
config, patch_result, patch_code = run_patch_action(
|
|
2947
|
+
args.patch, config, manager, logger, timeout_seconds=timeout_seconds
|
|
2948
|
+
)
|
|
2949
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
2950
|
+
print(json.dumps({"error": str(exc), "stage": "patch"}, indent=2, ensure_ascii=False))
|
|
2951
|
+
return 1
|
|
2952
|
+
except Exception as exc:
|
|
2953
|
+
logger.exception("Patch application failed")
|
|
2954
|
+
print(json.dumps({"error": str(exc), "stage": "patch"}, indent=2, ensure_ascii=False))
|
|
2955
|
+
return 1
|
|
2956
|
+
|
|
2957
|
+
print(json.dumps(patch_result, indent=2, ensure_ascii=False))
|
|
2958
|
+
return patch_code
|
|
2959
|
+
|
|
2960
|
+
if args.score:
|
|
2961
|
+
payload = run_scan(config, manager)
|
|
2962
|
+
score_payload = compute_health_score(payload)
|
|
2963
|
+
print(json.dumps(score_payload, indent=2))
|
|
2964
|
+
return 0
|
|
2965
|
+
|
|
2966
|
+
parser.print_help()
|
|
2967
|
+
return 0
|
|
2968
|
+
|
|
2969
|
+
|
|
2970
|
+
if __name__ == "__main__":
|
|
2971
|
+
raise SystemExit(main())
|