Repository navigation
Expand file tree
/
Copy pathapp.py
More file actions
11754 lines (10149 loc) · 474 KB
/
Copy pathapp.py
File metadata and controls
11754 lines (10149 loc) · 474 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from sanic import Request, Sanic
import bcrypt
import aiomysql
import inspect
import secrets
import os, pathlib
import stat
import time
from enum import Enum
import re
from sanic_cors import CORS
from sanic.response import json, file, HTTPResponse, raw
import multiprocessing
import json as pyjson
from mysql.connector.aio import connect
import mysql.connector
from datetime import datetime, timezone
from time import sleep
import core.opensearch as os_utils
import smtplib
from email.message import EmailMessage
from typing import List, Any
from pydantic import BaseModel, EmailStr, constr, Field, field_validator
from typing import Optional, List
class LoginRequest(BaseModel):
# The same pattern CreateUserRequest has always had. Login did not, which
# mattered because the LDAP path interpolates this straight into a search
# filter: `*)(uid=*` turns an authentication check into a wildcard match.
# A username that cannot be created is not a username that should be
# accepted at login either.
# Hyphen last, so it is a literal rather than a range and needs no escape.
username: constr(min_length=3, max_length=50, pattern=r'^[a-zA-Z0-9_.@-]+$')
password: constr(min_length=6)
class CreateUserRequest(BaseModel):
username: constr(min_length=3, max_length=50, pattern=r'^[a-zA-Z0-9_-]+$')
password: constr(min_length=8)
role: str = "user"
@field_validator('password')
@classmethod
def password_complexity(cls, v: str):
if not re.search(r"[A-Z]", v):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r"[0-9]", v):
raise ValueError('Password must contain at least one number')
return v
class ChangePasswordRequest(BaseModel):
username: str
current_password: str
new_password: constr(min_length=8)
@field_validator('new_password')
@classmethod
def password_complexity(cls, v: str):
if not re.search(r"[A-Z]", v):
raise ValueError('Password must contain at least one uppercase letter')
if not re.search(r"[0-9]", v):
raise ValueError('Password must contain at least one number')
return v
import asyncio
import hashlib
import requests
import textwrap
from sanic.exceptions import SanicException
import subprocess
from functools import wraps
from sanic.response import json as sanic_json
import ldap3
from sanic import response
from ldap3 import Server, Connection, ALL, Tls
from ldap3.utils.conv import escape_filter_chars
from ldap3.core.exceptions import LDAPSocketOpenError, LDAPBindError
import ssl
from cryptography.fernet import Fernet, InvalidToken
from dotenv import load_dotenv, set_key, dotenv_values
ENC_PREFIX = "enc::"
ENV_PATH = pathlib.Path(".env")
if ENV_PATH.exists():
load_dotenv(dotenv_path=ENV_PATH)
CORS_ORIGINS = [o.strip() for o in os.getenv("CORS_ORIGINS", "").split(",") if o.strip()]
# A credentialed request cannot use a wildcard origin: the browser rejects
# `Access-Control-Allow-Origin: *` outright whenever cookies are attached. In
# the normal deployment this app serves the SPA itself, so requests are
# same-origin and no CORS entry is needed at all — hence the empty default.
if "*" in CORS_ORIGINS:
print("[!] CORS_ORIGINS='*' is incompatible with cookie authentication and was ignored. "
"List explicit origins if the UI is served from a different host.")
CORS_ORIGINS = [o for o in CORS_ORIGINS if o != "*"]
DB_HOST = os.getenv("DB_HOST", "127.0.0.1")
DB_USER = os.getenv("DB_USER", "root")
DB_PASSWORD = os.getenv("DB_PASSWORD", "my-secret-pw")
DB_PORT = int(os.getenv("DB_PORT", "3306"))
multiprocessing.freeze_support()
app = Sanic("SIEMLoggerAPI")
app.config.RESPONSE_TIMEOUT = 600
app.config.REQUEST_TIMEOUT = 600
app.config.WORKER_ACK_TIMEOUT = 60.0
CORS(app, origins=CORS_ORIGINS, supports_credentials=True)
app.static("/assets", "./frontend/dist/assets", name="frontend_assets")
app.static("/vite.svg", "./frontend/dist/vite.svg", name="frontend_logo")
from ai.utils import load_ai_config, is_critical_log, save_ai_results
from security import session as session_store
from security import ssrf
from security import totp as totp_store
from security import webauthn as webauthn_store
from core import agent_link
from core import attack
from core import config_validation
from core import login_guard
from core import netloc
from core import self_defence
from core import threat_feeds
from core import tls as product_tls
from core import version as product_version
# Installer templating: pure string generation, extracted so app.py is not
# also 420 lines of PowerShell. See core/installers.py.
from core.installers import _render_linux_install, _render_windows_install
# Cookies are only marked Secure when the platform is actually served over
# TLS; a Secure cookie on a plain-http lab deployment is silently dropped by
# the browser and nobody can log in. Set SESSION_COOKIE_SECURE=1 in .env once
# you terminate TLS in front of the app.
#
# Unset now means "follow TLS_ENABLED" rather than "0". The pair had to agree
# and nothing made them: an operator who turned on TLS and left this alone got
# an HTTPS console whose session cookie was still allowed onto plain HTTP.
# An explicit value still wins, because TLS terminating at a proxy is
# invisible from in here and that deployment needs the flag by hand.
SESSION_COOKIE_SECURE = product_tls.cookie_should_be_secure()
# 'Lax' blocks the cookie on cross-site POST/XHR, which is the CSRF case that
# matters, while staying compatible with normal same-origin use. 'Strict' is
# available for deployments that want it; 'None' requires Secure and exists
# only for split-origin setups.
SESSION_COOKIE_SAMESITE = os.getenv("SESSION_COOKIE_SAMESITE", "Lax").strip().capitalize()
if SESSION_COOKIE_SAMESITE not in ("Lax", "Strict", "None"):
print(f"[!] Invalid SESSION_COOKIE_SAMESITE={SESSION_COOKIE_SAMESITE!r}; falling back to 'Lax'.")
SESSION_COOKIE_SAMESITE = "Lax"
AGENT_SHARED_SECRET = os.getenv("AGENT_SHARED_SECRET", "") or os.getenv("AGENT_SHARED_SECRET", "")
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://ollama:11434/api")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.2:3b")
if not AGENT_SHARED_SECRET:
AGENT_SHARED_SECRET = secrets.token_urlsafe(32)
print("[+] Generated ephemeral agent shared secret (set AGENT_SHARED_SECRET in .env to override).")
# Fields to attempt decryption on. Deliberately a SUPERSET of what the agent
# currently encrypts (see Sentora/modules/enc_db.ENCRYPT_FIELDS_MAP).
#
# That asymmetry is safe and intentional: decrypt_row_fields skips any value
# that does not carry the `enc::` prefix, so listing a plaintext column costs
# nothing. Trimming this list to match the agent exactly would be the unsafe
# direction — older agents encrypted a different set, and a field dropped from
# here would render as raw ciphertext for any row written back then.
#
# Add to this map when the agent starts encrypting something new. Do not
# remove from it.
ENCRYPTED_FIELDS_MAP = {
"siem_events": ["source", "timestamp", "message"],
"critical_files": ["path", "owner", "grp", "permissions", "last_opened"],
"packages": ["package", "version"],
"vulnerabilities_report": ["package_name", "package_version", "vulnerability_id", "summary", "details_url"],
"events_alert": ["source", "timestamp", "severity", "score", "categories", "message"],
"soar_actions": ["timestamp", "action", "target", "comment", "status"],
"fim_data": ["path", "hash_sha256"],
"registry_logs": ["hive", "key_path", "value_name", "value_data"],
"network_connections": ["process_name", "local_addr", "remote_addr"],
"process_events": ["name", "cmdline", "username"],
"hardware_inventory": ["name", "serial_number"],
"security_audit": ["finding", "details"]
}
# The five boot migrations moved to core/schema_init.py: they are DDL, they
# share one dependency, and app.py is not where a schema belongs.
from core import schema_init # noqa: E402
@app.before_server_start
async def setup_hub(app):
"""Create and migrate the schema the platform cannot run without.
`before_server_start`, not `main_process_start`. That listener only fires
when Sanic runs a multi-process manager, and `app.run(single_process=True)`
- which is what `WORKERS=1` selects, and what the compose file asks for -
has no such manager. So nothing here ran at all: no `users`, no
`sessions`, no enrolment tables.
The symptom was three layers away from the cause. Password checks go
through the synchronous connector and kept working, so `/login`
authenticated the operator, failed to write a session into a table that
did not exist, and the handler's own `except` read that as "not a local
user" and answered 401. A correct password, told it was wrong, because a
startup hook was registered for a process model this server does not use.
Every migration is idempotent and cheap on a database that is already
correct, so running it per worker rather than once is not worth a second
process model to arrange.
"""
schema_init.set_defaults(conn_factory=sync_mysql_conn,
alert_template=DEFAULT_ALERT_TEMPLATE)
await schema_init.run_all()
def is_soar_enabled() -> bool:
"""Community edition: SOAR is unconditionally enabled."""
return True
def _soar_block_response():
"""Legacy guard return — never fires in Community edition because
is_soar_enabled() always returns True. Kept so existing call sites that
`if not is_soar_enabled(): return _soar_block_response()` stay valid."""
return sanic_json(
{"status": "error", "message": "SOAR unavailable"},
status=503,
)
AUTOMATION_ALLOWED_ACTIONS = {
"block_ip",
"unblock_ip",
"disable_user",
"enable_user",
"kill_process",
"restart_service",
"lock_machine",
"quarantine_file",
"tail_log",
"run_cmd",
"flush_dns",
"disable_interface",
"logoff_user",
"clear_temp",
"dump_process",
"container_kill",
"container_stop",
"container_isolate",
"suspend_process",
"delete_registry_key",
"protect_shadows",
"start_vnc",
"stop_vnc"
}
AUTOMATION_ALLOWED_STATUSES = {"pending", "active", "paused", "completed", "failed"}
SOAR_TO_AUTO_STATUS = {
"success": "completed",
"permanent": "completed",
"resolved": "completed",
"resolve_failed": "failed",
"failed": "failed",
}
def _is_valid_ipv4(ip: str) -> bool:
parts = str(ip).split(".")
if len(parts) != 4: return False
try:
return all(0 <= int(p) <= 255 for p in parts)
except Exception:
return False
_USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,32}$")
def _is_valid_username(u: str) -> bool:
return bool(_USER_RE.match(str(u or "")))
def ensure_permissions(path: pathlib.Path) -> bool:
"""Restrict a secret file to its owner, and say so if that did not happen.
This swallowed the failure. When chmod does not take effect the file keeps
whatever the umask gave it - commonly 0644 in a container - so the secret
is readable by every process there and nothing anywhere says so. A
permission control that fails silently is not a control.
Verified rather than assumed: chmod can return without error and still not
produce the mode asked for, on Windows and on some mounted filesystems.
Returns True when the file ends up owner-only. Never raises: the caller
has already written the secret, and refusing to continue would leave it
written and unusable.
"""
try:
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
except OSError as e:
print(f"[!] Could not restrict permissions on {path}: {e}. "
f"The file may be readable by other users.", flush=True)
return False
# POSIX modes are advisory on Windows, where this is expected to be a
# no-op; there is no point reporting it on every start.
if os.name == "nt":
return True
try:
mode = stat.S_IMODE(os.stat(path).st_mode)
except OSError:
return False
if mode & (stat.S_IRWXG | stat.S_IRWXO):
print(f"[!] {path} is still mode {mode:o} after chmod — it is "
f"readable beyond its owner.", flush=True)
return False
return True
def _as_bool(val):
if isinstance(val, bool):
return val
if isinstance(val, (int, float)):
return val == 1
if isinstance(val, str):
return val.strip().lower() in {"1", "true", "yes", "y", "on", "active"}
return False
def load_or_create_fernet_from_env():
"""Resolve the server-side Fernet key, which protects at-rest columns.
Normally supplied as `FERNET_KEY` in the environment (compose passes it
through from .env). The fallback generates one and writes it back to .env
so it survives a restart — but the container runs as a non-root user and
/app is not writable, so that path only works for a bare-metal dev run.
Generating a key we cannot persist would be the worst outcome: every
restart would produce a different key and silently render previously
encrypted values unreadable. So a failed write is fatal and says exactly
what to do about it.
"""
key_b64 = os.getenv("FERNET_KEY")
if key_b64:
return Fernet(key_b64.encode())
vals = dotenv_values(dotenv_path=ENV_PATH) if ENV_PATH.exists() else {}
key_b64 = vals.get("FERNET_KEY")
if not key_b64:
key_b64 = Fernet.generate_key().decode()
try:
if not ENV_PATH.exists():
ENV_PATH.touch()
set_key(str(ENV_PATH), "FERNET_KEY", key_b64)
ensure_permissions(ENV_PATH)
print(f"[+] Generated FERNET_KEY and persisted it to {ENV_PATH.resolve()}")
except OSError as e:
raise SystemExit(
f"[FATAL] No FERNET_KEY set and it could not be persisted to "
f"{ENV_PATH} ({e}).\n"
f" Add a key to your .env — compose passes it through:\n"
f" FERNET_KEY={key_b64}\n"
f" Losing this key makes existing encrypted columns "
f"unreadable, so store it somewhere you can restore from."
) from e
return Fernet(key_b64.encode())
fernet = load_or_create_fernet_from_env()
import zipfile
import io
@app.get("/api/agent/ca")
async def download_ca(request):
"""The self-signed CA certificate, for agents that have to verify this
server.
Public, and safe to be. This serves `rootCA.crt` and never `rootCA.key`:
the certificate is the half of the pair whose entire purpose is to be
distributed, and publishing it grants nothing - it lets a client check a
signature it could not otherwise check.
Fetching it over the connection it is meant to secure is trust on first
use, and worth being honest about rather than dressing up. It is the same
trust the enrolment token and the agent binary already travel on in the
same installer run: anyone able to intercept this exchange can hand over a
binary of their choosing, and the CA is the smallest of those problems.
Deployments that cannot accept that should ship `rootCA.crt` out of band
and set `server_ca` by hand.
404 rather than an empty body when there is no CA, because a zero-byte
PEM written to disk fails later with a message about the certificate
format, at a point far from the cause.
"""
# Resolved through `core.tls`, not from a path written out again here. The
# directory moves with TLS_DIR, and a second copy of the path is how the
# route that hands out the CA comes to look somewhere the generator never
# wrote to.
ca = product_tls.ca_certificate()
if ca is None:
return json({
"error": "no_local_ca",
"message": "This server has no self-signed CA. That is expected "
"when TLS is off, or when TLS_CERT points at a "
"certificate from a real CA - in which case the agent "
"needs no extra trust and server_ca can stay unset.",
}, status=404)
return await response.file(
str(ca), mime_type="application/x-pem-file",
filename="rootCA.crt")
@app.get("/api/agent/download/<os_type>")
async def download_agent(request, os_type):
user_id = current_user_id(request)
provided_key = request.headers.get("X-Agent-Key") or request.args.get("key")
agent_key = request.headers.get("X-Agent-Key") or request.args.get("agent_key")
is_authed = False
if user_id and await user_has_permission(user_id, "manage_agent"):
is_authed = True
elif provided_key == AGENT_SHARED_SECRET:
is_authed = True
elif agent_key:
try:
with sync_mysql_conn("userdb") as conn:
cur = conn.cursor()
try:
cur.execute(
"SELECT 1 FROM agent_identities WHERE agent_key=%s AND revoked_at IS NULL",
(agent_key,)
)
if cur.fetchone():
is_authed = True
finally:
cur.close()
except Exception:
pass
if not is_authed:
return sanic_json({"status": "error", "message": "Unauthorized"}, status=403)
await audit_log(request, "DOWNLOAD_AGENT", os_type, f"Agent package requested for {os_type}")
server_ip = request.host.split(':')[0]
memory_file = io.BytesIO()
agent_root = pathlib.Path("Sentora")
if not agent_root.exists():
return sanic_json({"ok": False, "error": "Agent source not found"}, status=404)
exe_name = "main.exe" if os_type == "windows" else "main"
bin_path = agent_root / exe_name
if not bin_path.exists():
return sanic_json({
"ok": False,
"error": f"Agent binary '{exe_name}' is not built. "
f"Run Sentora/build_agent.{'ps1' if os_type=='windows' else 'sh'} "
f"to produce it before enrolling agents."
}, status=503)
files_to_zip = [
exe_name,
"docker-compose.yml",
"db/init.sql"
]
with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf:
for f_path in files_to_zip:
abs_path = agent_root / f_path
if abs_path.exists():
zf.write(abs_path, f_path)
if os_type == "windows":
setup_bat = f"""@echo off
setlocal
cd /d "%~dp0"
:: Check for Administrator privileges
openfiles >nul 2>&1
if %errorlevel% neq 0 (
echo [!] This script requires Administrator privileges.
echo [*] Attempting to elevate...
powershell.exe -Command "Start-Process '%~f0' -Verb RunAs"
exit /b
)
echo [*] Starting Sentora Agent Database (Docker)...
if exist "docker-compose.yml" (
docker compose up -d
)
echo [*] Creating Background Runner...
echo @echo off > run_agent.bat
echo cd /d "%%~dp0" >> run_agent.bat
:: Prevent Python from trying to format %%COMPUTERNAME%%, just let batch handle it
echo "main.exe" -a "Agent-%%COMPUTERNAME%%" -s "{server_ip}" >> run_agent.bat
echo [*] Installing Sentora Agent Background Task...
set TASK_NAME=SentoraAgent
set WATCH_NAME=SentoraAgentWatchdog
set BIN_PATH="%~dp0run_agent.bat"
:: Stop and delete old Scheduled Tasks if they exist
schtasks /end /tn "%TASK_NAME%" >nul 2>&1
schtasks /delete /tn "%TASK_NAME%" /f >nul 2>&1
schtasks /delete /tn "%WATCH_NAME%" /f >nul 2>&1
:: Run silently on boot as SYSTEM
schtasks /create /tn "%TASK_NAME%" /tr "%BIN_PATH%" /sc onstart /ru System /f
if %errorlevel% neq 0 (
echo [!] Failed to create background task.
) else (
echo [*] Starting the Agent now...
schtasks /run /tn "%TASK_NAME%"
)
:: Watchdog. schtasks cannot attach a repetition interval to an onstart
:: trigger, so this is a second task on a 15-minute schedule. main.exe takes
:: a single-instance lock before doing any work and exits 0 if another agent
:: already holds it -- so while the agent is healthy this is a no-op, and
:: when it has died it brings it back without waiting for a reboot.
schtasks /create /tn "%WATCH_NAME%" /tr "%BIN_PATH%" /sc minute /mo 15 /ru System /f >nul 2>&1
if %errorlevel% neq 0 (
echo [!] Watchdog task not created - the agent will still start at boot.
) else (
echo [*] Watchdog registered ^(checks every 15 minutes^).
)
echo [+] Setup Complete! The Agent is now running in the background.
pause
"""
zf.writestr("setup.bat", setup_bat)
else:
setup_sh = f"""#!/bin/bash
cd "$(dirname "$0")"
if [ "$EUID" -ne 0 ]; then
echo "[!] Please run as root (sudo ./setup.sh)"
exit 1
fi
echo "[*] Starting Sentora Agent Database (Docker)..."
if command -v docker >/dev/null 2>&1; then
docker compose up -d || docker-compose up -d
else
echo "[!] Docker is not installed. Database cannot be started."
fi
echo "[*] Installing Sentora Agent Service..."
chmod +x main
SERVICE_FILE="/etc/systemd/system/sentora-agent.service"
cat > $SERVICE_FILE << EOF
[Unit]
Description=Sentora Agent
After=network.target docker.service
[Service]
ExecStart=$(pwd)/main -a "Agent-$(hostname)" -s "{server_ip}"
WorkingDirectory=$(pwd)
Restart=always
User=root
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable sentora-agent
systemctl start sentora-agent
echo "[+] Setup Complete! The Agent is now running in the background."
"""
zf.writestr("setup.sh", setup_sh)
memory_file.seek(0)
return HTTPResponse(
body=memory_file.getvalue(),
content_type="application/zip",
headers={
"Content-Disposition": f"attachment; filename=Sentora-Agent-{os_type}.zip",
"Access-Control-Expose-Headers": "Content-Disposition"
}
)
ENROLLMENT_TOKEN_TTL_HOURS = int(os.getenv("ENROLLMENT_TOKEN_TTL_HOURS", "24"))
def _gen_token() -> str:
return secrets.token_hex(32)
def _quote_identifier(name: str) -> str:
"""Quote a table or database name for interpolation into SQL.
Identifiers cannot be parameterised - `DESCRIBE %s` is not valid SQL - so
they have to be interpolated, and `f"DESCRIBE `{table}`"` was doing it
without escaping. A name containing a backtick closes the quoting early:
``tbl`; DROP DATABASE x; -- `` becomes two statements.
MySQL escapes a backtick inside an identifier by doubling it. The name is
also length-limited and rejected outright if it contains a NUL or a
newline, neither of which appears in a real identifier and both of which
make log entries misleading.
These paths all require `manage_db`, so this is depth rather than the only
control.
"""
raw = str(name or "")
if not raw or len(raw) > 64 or any(bad in raw for bad in ("\x00", "\n", "\r")):
raise ValueError(f"not a usable SQL identifier: {raw[:40]!r}")
return "`" + raw.replace("`", "``") + "`"
def _client_ip(request) -> str:
"""The client's address, per core/login_guard.client_ip.
This returned X-Forwarded-For unconditionally. With no reverse proxy in
front the header is whatever the caller typed, so every address in the
audit trail was attacker-choosable - log injection into the SIEM's own
audit log - and any per-address limit built on it resets itself.
"""
return login_guard.client_ip(request)
async def _validate_agent_auth(request) -> str | None:
"""Return agent_name if X-Agent-Key matches a non-revoked identity, else None.
Also accepts the global AGENT_SHARED_SECRET for backward compat (returns '*')."""
key = request.headers.get("X-Agent-Key") or request.args.get("agent_key")
if not key:
legacy = request.headers.get("X-Agent-Key") or request.args.get("key")
if legacy and legacy == AGENT_SHARED_SECRET:
return "*"
return None
try:
with sync_mysql_conn("userdb") as conn:
cur = conn.cursor()
try:
cur.execute(
"SELECT agent_name FROM agent_identities WHERE agent_key=%s AND revoked_at IS NULL",
(key,)
)
row = cur.fetchone()
if row:
return row[0]
finally:
cur.close()
except Exception as e:
print(f"[Enroll] agent auth lookup error: {e}")
if key == AGENT_SHARED_SECRET:
return "*"
return None
async def _require_agent(request, agent: str):
"""Authorise an agent-facing call, returning an error response or None.
The automation-poll pair used to carry no authentication at all, and
`report_automation_result_by_id` took the agent name from the request
body. That gave anyone who could reach the API two primitives:
GET /<agent>/automations/pending
read the response actions queued for a host - an attacker sees
`ISOLATE_HOST` for their own machine before the agent does.
POST /<agent>/automations/report {"task_id": N, "status": "SUCCESS"}
walk task_id from 1 upward marking everything done. The real agent
then never sees those rows, because it polls WHERE status='pending'.
The action never runs and the console shows it green.
The second is worse than an authentication bypass. It is a way to switch
off every autonomous response the platform makes while it continues to
report that they succeeded.
Identity comes from the key, never from the URL or the body, and it has to
match the agent being acted on.
"""
identity = await _validate_agent_auth(request)
if identity is None:
return sanic_json({"status": "error", "message": "unauthorized"},
status=401)
# "*" is the legacy fleet-wide secret. Kept working, because agents
# enrolled before per-agent keys existed still use it, but it is not an
# identity - it cannot be used to speak for a specific host beyond what it
# already could.
if identity == "*":
return None
if _agent_name_forms(identity)[1] != _agent_name_forms(agent)[1]:
print(f"[!] agent key for {identity!r} used against {agent!r}", flush=True)
return sanic_json({"status": "error", "message": "unauthorized"},
status=401)
return None
@app.post("/api/agents/enroll")
async def enroll_agent(request):
"""Create a one-time enrollment token (authenticated, requires manage_agent)."""
user_id = current_user_id(request)
if not user_id or not await user_has_permission(user_id, "manage_agent"):
return sanic_json({"status": "error", "message": "Unauthorized"}, status=403)
body = request.json or {}
hostname_hint = (body.get("hostname_hint") or "").strip()[:255]
note = (body.get("note") or "").strip()[:500]
ttl_hours = int(body.get("ttl_hours") or ENROLLMENT_TOKEN_TTL_HOURS)
ttl_hours = max(1, min(ttl_hours, 24 * 30))
username = None
try:
with sync_mysql_conn("userdb") as conn:
cur = conn.cursor()
try:
cur.execute("SELECT username FROM users WHERE id=%s", (user_id,))
r = cur.fetchone()
if r:
username = r[0]
finally:
cur.close()
except Exception:
pass
token = _gen_token()
expires_at = datetime.now().replace(microsecond=0)
from datetime import timedelta as _td
expires_at = expires_at + _td(hours=ttl_hours)
try:
with sync_mysql_conn("userdb") as conn:
cur = conn.cursor()
try:
cur.execute(
"""INSERT INTO enrollment_tokens
(token, created_by_user_id, created_by_username, hostname_hint, note, expires_at)
VALUES (%s,%s,%s,%s,%s,%s)""",
(token, user_id, username, hostname_hint or None, note or None, expires_at)
)
conn.commit()
finally:
cur.close()
except Exception as e:
return sanic_json({"status": "error", "message": f"Failed to create token: {e}"}, status=500)
await audit_log(request, "ENROLL_TOKEN_CREATE", token[:8] + "...",
f"ttl={ttl_hours}h hostname_hint={hostname_hint or '-'}")
proto = "https" if request.scheme == "https" else "http"
host = request.host
base = f"{proto}://{host}"
# A command that cannot work is worse than no command.
#
# These were emitted unconditionally, and on a self-signed deployment
# neither one can fetch the script it is supposed to run:
#
# iwr : Temel alinan baglanti kapatildi: SSL/TLS guvenli kanali icin
# guven iliskisi kurulamadi.
#
# Nothing on the endpoint trusts this CA yet - the installer is what puts
# it there - so enrolment cannot begin. The console handed out a broken
# one-liner and said nothing about why.
#
# So when this server signs with its own CA, the snippet fetches the
# script with certificate validation suspended for that one request. That
# is the same trust on first use the enrolment token in this URL already
# depends on; the script it fetches installs the CA properly, and
# everything after that verifies.
#
# `curl.exe` on Windows, not Invoke-WebRequest with a validation callback.
# The obvious form,
#
# [Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}
#
# is a PowerShell script block, and .NET invokes it on an I/O thread with
# no runspace. The callback itself throws, and what reaches the operator
# is "an unexpected error occurred on a send" - which reads as a TLS
# failure and sends you looking at protocol versions and cipher suites.
# It cost an hour of exactly that. `curl.exe` ships with Windows 10 1803
# and later, runs out of process, and leaves no static state to restore.
self_signed = product_tls.ca_certificate() is not None
linux = f"curl -fsSL '{base}/api/agent/deploy/linux?token={token}' | sudo bash"
windows = f"iwr -useb '{base}/api/agent/deploy/windows?token={token}' | iex"
if self_signed and proto == "https":
linux = (f"curl -fsSLk '{base}/api/agent/deploy/linux?token={token}' "
f"| sudo bash")
windows = (
f"iex (curl.exe -fsSk "
f"'{base}/api/agent/deploy/windows?token={token}' | Out-String)"
)
return sanic_json({
"status": "success",
"token": token,
"expires_at": expires_at.strftime("%Y-%m-%d %H:%M:%S"),
"server_url": base,
# Whether the snippets carry a first-contact exception, so the UI can
# say why rather than leaving somebody to wonder what the extra line
# is doing.
"self_signed": self_signed and proto == "https",
"install": {
"linux": linux,
"windows": windows,
}
})
@app.get("/api/agents/enrollments")
async def list_enrollments(request):
"""List recent enrollment tokens (authed)."""
user_id = current_user_id(request)
if not user_id or not await user_has_permission(user_id, "manage_agent"):
return sanic_json({"status": "error", "message": "Unauthorized"}, status=403)
try:
with sync_mysql_conn("userdb") as conn:
cur = conn.cursor(dictionary=True)
try:
cur.execute(
"""SELECT id, token, created_by_username, hostname_hint, note,
created_at, expires_at, used_at, used_by_agent, used_from_ip
FROM enrollment_tokens
ORDER BY created_at DESC LIMIT 100"""
)
rows = cur.fetchall()
finally:
cur.close()
for r in rows:
for k, v in list(r.items()):
if isinstance(v, datetime):
r[k] = v.strftime("%Y-%m-%d %H:%M:%S")
if r.get("token"):
r["token_preview"] = r["token"][:8] + "…" + r["token"][-4:]
del r["token"]
return sanic_json({"status": "success", "enrollments": rows})
except Exception as e:
return sanic_json({"status": "error", "message": str(e)}, status=500)
@app.delete("/api/agents/enrollments/<token_id:int>")
async def revoke_enrollment(request, token_id):
user_id = current_user_id(request)
if not user_id or not await user_has_permission(user_id, "manage_agent"):
return sanic_json({"status": "error", "message": "Unauthorized"}, status=403)
try:
with sync_mysql_conn("userdb") as conn:
cur = conn.cursor()
try:
cur.execute("DELETE FROM enrollment_tokens WHERE id=%s AND used_at IS NULL", (token_id,))
conn.commit()
affected = cur.rowcount
finally:
cur.close()
if affected:
await audit_log(request, "ENROLL_TOKEN_REVOKE", str(token_id), "")
return sanic_json({"status": "success"})
return sanic_json({"status": "error", "message": "Token not found or already used"}, status=404)
except Exception as e:
return sanic_json({"status": "error", "message": str(e)}, status=500)
@app.post("/api/agents/register")
async def register_agent(request):
"""Exchange a one-time enrollment token for a per-agent identity.
Body: {token, agent_name?, hostname?, os_type?}. Returns {agent_name, agent_key, server_url}."""
body = request.json or {}
token = (body.get("token") or "").strip()
if not token or len(token) != 64:
return sanic_json({"status": "error", "message": "Invalid token format"}, status=400)
client_ip = _client_ip(request)
hostname = (body.get("hostname") or "").strip()[:255] or None
os_type = (body.get("os_type") or "").strip()[:32] or None
req_name = (body.get("agent_name") or "").strip()[:128] or None
# An upgrading agent sends the identity it already holds. Possession of
# the key is the proof, so this cannot be used to claim someone else's
# name; without a matching key the request falls through to a normal
# fresh enrolment below.
existing_key = (body.get("agent_key") or "").strip()[:128] or None
if req_name and not re.match(r"^[A-Za-z0-9_.-]{1,128}$", req_name):
return sanic_json({"status": "error", "message": "Invalid agent_name"}, status=400)
try:
with sync_mysql_conn("userdb") as conn:
cur = conn.cursor(dictionary=True)
try:
cur.execute(
"""SELECT id, expires_at, used_at FROM enrollment_tokens
WHERE token=%s FOR UPDATE""",
(token,)
)
row = cur.fetchone()
if not row:
return sanic_json({"status": "error", "message": "Token not recognized"}, status=404)
if row["used_at"] is not None:
# A one-time credential presented a second time. The
# ordinary cause is an installer re-run, and the one that
# matters is a token that leaked - they are the same
# request, so this is recorded rather than judged.
await _platform_event(
"ENROLMENT_TOKEN_REUSED", subject=token[:8] + "...",
source_ip=_client_ip(request),
detail=f"first used at {row['used_at']}")
return sanic_json({"status": "error", "message": "Token already used"}, status=409)
if row["expires_at"] and row["expires_at"] < datetime.now():
return sanic_json({"status": "error", "message": "Token expired"}, status=410)
# Re-enrolment of a known agent: the installer took the
# upgrade path and is closing out the token it was given.
# Without this the token stayed unused forever and the
# Deploy page reported "waiting" after a deployment that had
# in fact succeeded - the UI contradicting reality.
if req_name and existing_key:
cur.execute(
"SELECT agent_name FROM agent_identities "
"WHERE agent_name=%s AND agent_key=%s",
(req_name, existing_key),
)
if cur.fetchone():
cur.execute(
"""UPDATE enrollment_tokens
SET used_at=NOW(), used_by_agent=%s, used_from_ip=%s
WHERE id=%s""",
(req_name, client_ip, row["id"]),
)
conn.commit()
await audit_log(request, "AGENT_UPGRADE", req_name,
f"existing identity kept, from {client_ip}")
proto = "https" if request.scheme == "https" else "http"
return sanic_json({
"status": "success",
"agent_name": req_name,
"agent_key": existing_key,
"server_url": f"{proto}://{request.host}",
"server_ip": request.host.split(":")[0],
"upgraded": True,
})
# Key did not match. Not an error worth failing on - fall
# through and enrol as a new agent, which is what a
# machine restored from a stale backup should get.
if not req_name:
base = hostname or f"Agent-{token[:8]}"
req_name = re.sub(r"[^A-Za-z0-9_.-]", "-", base)[:128] or f"Agent-{token[:8]}"
final_name = req_name
suffix = 1
while True:
cur.execute("SELECT 1 FROM agent_identities WHERE agent_name=%s", (final_name,))
if not cur.fetchone():
break
suffix += 1
final_name = f"{req_name}-{suffix}"[:128]
if suffix > 500:
return sanic_json({"status": "error", "message": "Could not allocate agent name"}, status=500)
agent_key = _gen_token()
cur.execute(
"""INSERT INTO agent_identities
(agent_name, agent_key, os_type, hostname, enrolled_from_ip, enrolled_via_token)
VALUES (%s,%s,%s,%s,%s,%s)""",
(final_name, agent_key, os_type, hostname, client_ip, token)
)
cur.execute(
"""UPDATE enrollment_tokens
SET used_at=NOW(), used_by_agent=%s, used_from_ip=%s
WHERE id=%s""",
(final_name, client_ip, row["id"])
)
conn.commit()
finally:
cur.close()
except mysql.connector.IntegrityError as e:
return sanic_json({"status": "error", "message": f"Identity conflict: {e}"}, status=409)
except Exception as e:
return sanic_json({"status": "error", "message": str(e)}, status=500)
proto = "https" if request.scheme == "https" else "http"
host = request.host
server_url = f"{proto}://{host}"
server_ip = host.split(":")[0]
return sanic_json({
"status": "success",
"agent_name": final_name,
"agent_key": agent_key,
"server_url": server_url,
"server_ip": server_ip,
})
@app.get("/api/agents/bootstrap")
async def agent_bootstrap(request):
"""Hand the shared Fernet key to a token-enrolled agent.
Community edition: the key is read from the server's local file
(FERNET_KEY_PATH) — no remote auth round-trip. The agent
authenticates with the per-agent X-Agent-Key issued by
/api/agents/register.
"""
agent_key = (request.headers.get("X-Agent-Key") or "").strip()
if not agent_key or len(agent_key) != 64:
return sanic_json({"ok": False, "error": "missing X-Agent-Key"}, status=401)
try:
with sync_mysql_conn("userdb") as conn: