Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ AGENT_SHARED_SECRET=
# un-migrated agent is refused rather than silently accepted.
# INGEST_TLS_PORT=5011
# INGEST_TLS_REQUIRED=0
#
# Closing the plaintext path takes both of these. INGEST_TLS_REQUIRED
# stops the listener inside the container; Docker goes on publishing
# the host port regardless, so a connection is still accepted by the
# proxy and the batch written into a socket nobody reads - which looks
# exactly like a server that stored it. Bind the published port to
# loopback to make the refusal real.
# INGEST_PLAINTEXT_BIND=127.0.0.1

# SESSION_COOKIE_SECURE follows TLS_ENABLED when it is not set. It used to
# default to 0 regardless, so turning on TLS and leaving this alone gave an
Expand Down
11 changes: 7 additions & 4 deletions Sentora/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,10 +627,13 @@ def send_alert(source, severity, message, metadata=None):
return
rec["dup_fp"] = fingerprint

if hasattr(enc_db, "insert_record_enc"):
enc_db.insert_record_enc("events_alert", rec)
else:
insert_record("events_alert", rec)
# No fallback to the plain insert. `events_alert` has `source` and
# `message` declared encrypted, so writing one unencrypted stores the
# alert text in the clear - and plaintext and ciphertext render
# identically downstream, so nothing would ever say it happened.
# `insert_record_enc` has always existed; the guard only described a
# way to fail quietly.
enc_db.insert_record_enc("events_alert", rec)
except Exception as e:
print(f"[Alert] Failed to send alert: {e}")

Expand Down
46 changes: 43 additions & 3 deletions Sentora/modules/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -1165,6 +1165,14 @@ def new_direct_session(argv: list[str] | None = None):
raise ConsoleUnavailable(" | ".join(failures) or describe_unavailable())


#: The console strategy that worked last time in this process, if any.
#:
#: Process-local and forgotten on restart, which is the right lifetime: it
#: records how *this* process was started, and a restarted agent may have been
#: started differently.
_WORKING_STRATEGY = None


def new_session(argv: list[str] | None = None) -> "PtySession | WinPtySession":
"""The right session for this host, or a reason there is none.

Expand All @@ -1180,6 +1188,32 @@ def new_session(argv: list[str] | None = None) -> "PtySession | WinPtySession":
if argv:
return WinPtySession(argv)

# What worked last time, first.
#
# This chain is not cheap when it fails. On a host where the agent runs as
# a service, the in-process pseudoconsole dies with STATUS_CONTROL_C_EXIT
# and the session helper then times out after twelve seconds - so roughly
# fifteen seconds elapse before the strategy that actually works is even
# tried. The server gives up on a console after fifteen. Measured on a
# live host: 17:25:03 the first attempt, 17:25:18 the fall through to
# pipes, and by then nobody was listening.
#
# Whether this process can host a pseudoconsole is a property of how it
# was started. It cannot change while it is running, so paying fifteen
# seconds to rediscover it on every console open is fifteen seconds spent
# proving something already known.
global _WORKING_STRATEGY
if _WORKING_STRATEGY is not None:
try:
return _WORKING_STRATEGY()
except ConsoleUnavailable as e:
# The remembered answer stopped being true - a helper that has
# since died, a session that logged out. Fall through and work it
# out again rather than reporting a failure that may be stale.
print(f"[console] the strategy that worked last time no longer "
f"does ({e}); trying the others", flush=True)
_WORKING_STRATEGY = None

# Try here first. It costs a few hundred milliseconds and it is the only
# path that works when the agent is not a service - a developer running
# it from a terminal, for instance.
Expand All @@ -1189,14 +1223,18 @@ def new_session(argv: list[str] | None = None) -> "PtySession | WinPtySession":
# fail, which is the path whose whole purpose is to explain itself.
direct_error = ""
try:
return new_direct_session()
session = new_direct_session()
_WORKING_STRATEGY = new_direct_session
return session
except ConsoleUnavailable as e:
direct_error = str(e)
print(f"[console] no console in this process ({direct_error}); "
f"trying the user's session", flush=True)

try:
return HelperSession()
session = HelperSession()
_WORKING_STRATEGY = HelperSession
return session
except ConsoleUnavailable as e:
helper_error = str(e)
print(f"[console] no console in the user's session either "
Expand All @@ -1205,7 +1243,9 @@ def new_session(argv: list[str] | None = None) -> "PtySession | WinPtySession":

# Last resort, and honest about it - see PipeSession.
try:
return PipeSession()
session = PipeSession()
_WORKING_STRATEGY = PipeSession
return session
except ConsoleUnavailable as pipe_error:
raise ConsoleUnavailable(
f"no console of any kind could be started. "
Expand Down
36 changes: 33 additions & 3 deletions Sentora/modules/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,11 +221,41 @@ def reoffer_recent(table: str, limit: int) -> int:
The resend is safe because the server deduplicates: rows it already holds
are recognised and skipped rather than stored twice.
"""
query = (f"UPDATE {table} SET sent = FALSE WHERE id IN "
f"(SELECT id FROM {table} ORDER BY id DESC LIMIT %s)")
from modules import enc_db

with get_conn() as conn:
with conn.cursor(cursor_factory=DictCursor) as cur:
cur.execute(f"SELECT * FROM {table} ORDER BY id DESC LIMIT %s",
(limit,))
candidates = cur.fetchall()

# Skip what this agent can no longer read itself.
#
# The Fernet key comes from the server's bootstrap and there is no
# rotation path, so an agent that has outlived one key holds rows it
# cannot decrypt - and neither can the server. Offering them again
# imports rows that render as `<decryption failed - key mismatch>` for
# ever, spending a bounded recovery budget on data nobody can read.
#
# Found by doing it: the first re-offer on a live host moved about two
# thousand such rows onto the server, and the readable/unreadable
# boundary in the result was exact.
keep = []
for row in candidates:
try:
if enc_db.is_readable(table, dict(row)):
keep.append(row["id"])
except Exception:
# No key configured yet, or a shape this does not understand.
# Offering it is the older behaviour and the safer default:
# the cost is an unreadable row, not a lost one.
keep.append(row["id"])

if not keep:
return 0
with conn.cursor() as cur:
cur.execute(query, (limit,))
cur.execute(f"UPDATE {table} SET sent = FALSE WHERE id = ANY(%s)",
(keep,))
changed = cur.rowcount
conn.commit()
return changed if changed and changed > 0 else 0
Expand Down
30 changes: 30 additions & 0 deletions Sentora/modules/enc_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,36 @@ def _dec_value(v: Any) -> Any:
return v


def is_readable(table: str, row: Dict[str, Any]) -> bool:
"""Whether this agent can still decrypt its own stored row.

It cannot, for anything encrypted before the Fernet key last changed. The
key comes from the server's bootstrap, and there is no rotation path - so
an agent that has outlived one key holds a local history it can no longer
read, and neither can the server.

That matters when rows are offered again after a server-side reset. Those
rows arrive, store, and render as `<decryption failed - key mismatch>`
for ever: a recovery that fills the console with markers and spends its
bounded budget on rows nobody can read. Measured on a live host, the
boundary was exact - the newest 28 rows readable, every older one not.

Rows with nothing encrypted are readable by definition, which is the
common case and costs one dictionary lookup.
"""
fields = ENCRYPT_FIELDS_MAP.get(table) or []
for field in fields:
value = row.get(field)
if not isinstance(value, str) or not value.startswith(ENC_PREFIX):
continue # plaintext, or absent
if _dec_value(value) is value:
# `_dec_value` hands the input straight back when the token will
# not open. Identity rather than equality: a value that decrypts
# to itself is vanishingly unlikely and would be harmless anyway.
return False
return True


def _encrypt_row(table: str, data: Dict[str, Any]) -> Dict[str, Any]:
return {k: (_enc_value(v) if _should_encrypt(table, k) else v) for k, v in data.items()}

Expand Down
17 changes: 15 additions & 2 deletions Sentora/modules/fim.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from watchdog.events import FileSystemEventHandler

from . import agent_paths
from .db import insert_record
from .enc_db import insert_record_enc

DEFAULT_MONITOR_PATHS = [
"/etc",
Expand Down Expand Up @@ -157,7 +157,20 @@ def process(self, path, status):
return

current_hash = calculate_sha256(path) if status != "deleted" else "DELETED"
insert_record("fim_data", {
# `insert_record_enc`, not `insert_record`.
#
# `enc_db.ENCRYPT_FIELDS_MAP` lists fim_data's `path` and
# `hash_sha256` as encrypted and the server lists them as fields to
# decrypt, and this writer - which produces almost every fim_data row
# there is - used the plain insert. So the file paths of every
# monitored host were stored in the clear, locally and on the server,
# while both ends of the design said otherwise.
#
# It was invisible because plaintext and ciphertext render identically:
# the server's `decrypt_value` hands anything without the `enc::`
# prefix straight back, so the console showed a perfect table. The only
# way to see it was to ask what the stored bytes actually start with.
insert_record_enc("fim_data", {
"path": path,
"hash_sha256": current_hash or "ERROR",
"status": status,
Expand Down
11 changes: 6 additions & 5 deletions Sentora/modules/inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import subprocess
from datetime import datetime
from .db import insert_record
from .enc_db import insert_record_enc

def get_cpu_info():
cpu_name = platform.processor()
Expand Down Expand Up @@ -193,26 +194,26 @@ def scan_inventory():
gpu = get_gpu_info()
mobo = get_mobo_info()

insert_record("hardware_inventory", {
insert_record_enc("hardware_inventory", {
"type": "cpu", "name": cpu, "vendor_id": "CPU", "product_id": "Model",
"serial_number": "N/A", "status": "active", "timestamp": timestamp, "sent": False
})
insert_record("hardware_inventory", {
insert_record_enc("hardware_inventory", {
"type": "ram", "name": ram, "vendor_id": "System", "product_id": "Memory",
"serial_number": "N/A", "status": "active", "timestamp": timestamp, "sent": False
})
insert_record("hardware_inventory", {
insert_record_enc("hardware_inventory", {
"type": "gpu", "name": gpu, "vendor_id": "GPU", "product_id": "Display",
"serial_number": "N/A", "status": "active", "timestamp": timestamp, "sent": False
})
insert_record("hardware_inventory", {
insert_record_enc("hardware_inventory", {
"type": "motherboard", "name": mobo, "vendor_id": "System", "product_id": "BaseBoard",
"serial_number": "N/A", "status": "active", "timestamp": timestamp, "sent": False
})

for part in psutil.disk_partitions():
try:
insert_record("hardware_inventory", {
insert_record_enc("hardware_inventory", {
"type": "disk", "name": part.device, "vendor_id": part.fstype,
"product_id": part.mountpoint, "serial_number": "N/A", "status": "active",
"timestamp": timestamp, "sent": False
Expand Down
36 changes: 21 additions & 15 deletions Sentora/modules/portscanner/portscanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,23 +382,29 @@
],
}

def normalize(value):
return value.replace("'", "''") if isinstance(value, str) else value

def is_duplicate(port, protocol, service, product, version):
protocol = normalize(protocol)
service = normalize(service)
product = normalize(product)
version = normalize(version)
"""Have we already recorded this exact service on this port?

Parameterised, and it was not. The condition used to be assembled by
interpolation with a hand-rolled `normalize()` that doubled single quotes,
and on this host it produced

[!] portscanner failed: SyntaxError: unterminated quoted string
at or near "'"

every cycle - so no port scan result was written at all. Which input broke
it hardly matters: nmap hands back product and version strings containing
whatever a service chose to advertise, an attacker-controlled banner among
them, and the answer to that is never a better escaping function.

condition = (
f"port = {port} AND "
f"protocol = '{protocol}' AND "
f"service = '{service}' AND "
f"product = '{product}' AND "
f"version = '{version}'"
)
return bool(fetch_where(TABLE, condition))
A security product that builds SQL by string concatenation is running the
pattern it exists to detect elsewhere. `fetch_where` has taken a `params`
tuple all along.
"""
condition = ("port = %s AND protocol = %s AND service = %s "
"AND product = %s AND version = %s")
params = (port, protocol, service, product, version)
return bool(fetch_where(TABLE, condition, params))


async def smart_recv(reader, size=8192, max_attempts=5):
Expand Down
6 changes: 5 additions & 1 deletion Sentora/modules/resource_checker/disks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import platform
from datetime import datetime
from modules.db import insert_record, fetch_where, update_record, delete_all
from modules.enc_db import insert_record_enc

def bytes_to_gb(byte_value):
return round(byte_value / (1024**3), 2)
Expand All @@ -19,7 +20,10 @@ def send_local_alert(severity, message, metadata=None):
}
if metadata:
rec["message"] += f" | {json.dumps(metadata)}"
insert_record("events_alert", rec)
# `source` and `message` are declared encrypted, so the plain
# insert stored this alert in the clear - and plaintext reads
# back identically to ciphertext, so nothing said so.
insert_record_enc("events_alert", rec)
except Exception as e:
print(f"[DiskMonitor] Failed to send alert: {e}")

Expand Down
Loading
Loading