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 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())