Skip to content

Encrypt the telemetry path, and stop it discarding data in silence - #41

Merged
d3vhex merged 1 commit into
mainfrom
feat/tls-and-ingest-integrity
Sep 4, 2026
Merged

d3vhex merged 1 commit into
mainfrom
feat/tls-and-ingest-integrity

Conversation

@d3vhex

@d3vhex d3vhex commented Sep 4, 2026

Copy link
Copy Markdown
Owner

TLS was documented, not implemented

TLS_ENABLED, TLS_CERT and TLS_KEY appeared in .env.example, in the
production guide, and in the instructions certs/generate_certs.py prints
when it finishes. No line of code read any of them. Setting TLS_ENABLED=1
produced a console that came up cleanly on plain HTTP while its operator had
every reason to believe it was HTTPS — login form and session cookie in clear
text, with no symptom at all.

That is the worst shape a security control can have: not missing, but
documented. A missing feature gets noticed the first time someone looks for
it; a documented one gets trusted.

core/tls.py makes it real, on one rule: asking for TLS and not getting it
is a startup failure, never a fallback.
A certificate that cannot be read,
half a configuration, a key that does not match its cert — each refuses to
start and names the file.

SESSION_COOKIE_SECURE now follows TLS_ENABLED when unset. The two had to
agree and nothing made them, so turning on TLS and leaving the flag alone gave
an HTTPS console whose cookie was still allowed onto plain HTTP.

Telemetry moves to a second port (5011) inside TLS. Two ports rather than a
flag on one: the existing protocol opens with a 4-byte length so its first
byte is always 0x00, a ClientHello opens with 0x16, and telling them apart
means reading a byte that cannot be handed back to the TLS layer. The
plaintext listener stays open and names each agent still using it, once each;
INGEST_TLS_REQUIRED=1 closes it when the fleet has moved. Cutting straight
to TLS would blackhole every un-rebuilt agent, and a host that stopped
reporting looks exactly like a host with nothing to report.

The ingest protocol had no reply

The agent marked rows sent when sendall returned — which means the bytes
reached a socket buffer, and nothing more. A server that accepted a batch and
stored none of it was indistinguishable from one that stored all of it.

There is a receipt frame now, and three cases stay distinguishable: rows
stored (mark sent), an error (keep the rows, retry, say why), and no receipt
at all (an older server — mark sent, and record that it is belief). Collapsing
any two recreates the bug.

Four live data-loss bugs, found by making failure visible

The insertion failure had been printed behind if debug. Moving it out turned
up four separate paths that were dropping rows while every layer above
reported success:

  • docker_containers — the agent ships a row per container per cycle, so
    one batch carries the same container several times; UNIQUE(container_id)
    raised 1062 and the whole batch rolled back. Rows are inserted individually
    now (a batch is fifty independent facts, not one transaction) and snapshots
    take the last write.
  • registry_logs, process_events, security_audit — named in
    ALLOWED_TABLES and DEDUP_TABLES, collected by the agent, and absent from
    db/init.sql. 7,788 rows held on one endpoint against a table that did not
    exist.
  • siem_events — the console's Clear button truncated the rows and left
    every ingest fingerprint behind, so each future row was skipped as a
    duplicate of a row that no longer existed. One click permanently blinded the
    table.
  • critical_files — check_permissions writes a row only when a file's
    owner, group or mode changed. It is a delta stream, and it was classified as
    a server-side snapshot, so every batch deleted the twelve hundred rows it
    held and kept the handful that had changed.

A rejected row now releases its dedup fingerprint. Without that, the batch
commits, the fingerprint survives, and the row can never be stored even after
the reason it failed is fixed.

The channel could starve itself

GET /config/rules timed out at 5s against an agent that was connected,
healthy, and shipping telemetry. The channel multiplexes control traffic and
stream payloads down one websocket behind one send lock, a screen capture has
no natural end, and nothing told an orphaned pump to stop — so it competed for
that lock for the life of the connection on behalf of nobody. PING_INTERVAL_S
was also serving as the socket timeout, so one slow send could hold the lock
for thirty seconds against a five-second deadline.

Stream payloads are droppable and yield to a queued reply; control frames are
not. The socket deadline is its own constant. The server tells the agent when
a stream is gone, and the pump stops when its stream is closed under it.

The console has one visual language

Charts reached eleven pages — File Integrity, SOAR, Threat Intel, Assets,
Audit and Login logs, and Agent Detail's tabs. Every one derives from rows the
page already holds: a chart with its own request is a chart that can disagree
with the table beside it.

Two places deliberately have no chart, and say so in the code: OSV gives an
advisory id and no CVSS score, so there is no severity to plot; and
resource_usage is a snapshot table, so a CPU line would be drawn through a
single sample.

Tests

2,225 passing. New structural invariants: a table the agent deduplicates
locally can never be a server snapshot (this catches critical_files without
reading the collector), every table in ALLOWED_TABLES must have somewhere to
land, and clearing a deduplicated table must clear its fingerprints.

The nine skips in test_encrypted_column_widths are gone — they were skipping
because three tables had no schema at all, so the suite reported "nothing to
check here" about tables the agent was filling.

TLS_ENABLED was documented in three places and read by nothing; the ingest protocol had no reply frame, so four separate paths were dropping rows while every layer reported success.
Comment thread Sentora/main.py
if not INGEST_TLS:
return raw
context = ssl.create_default_context(cafile=INGEST_CA or None)
return context.wrap_socket(raw, server_hostname=SERVER_HOSTNAME or SERVER_IP)
return filters
.filter((f) => f.field && f.value)
.map((f) => {
const value = /[\s"]/.test(f.value) ? `"${f.value.replace(/"/g, '\\"')}"`
@d3vhex
d3vhex merged commit 6a1e49e into main Sep 4, 2026
10 of 11 checks passed
@d3vhex
d3vhex deleted the feat/tls-and-ingest-integrity branch September 7, 2026 18:16
d3vhex added a commit that referenced this pull request Sep 8, 2026
Encrypt the telemetry path, and stop it discarding data in silence
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants