Repository navigation
Fields we said were encrypted were stored in the clear - #43
Merged
Merged
Conversation
Seven writers stored fields declared encrypted in the clear, and plaintext reads back identically to ciphertext so nothing could see it; the portscanner had been dead every cycle on a SQL string it built by hand.
d3vhex
added a commit
that referenced
this pull request
Sep 8, 2026
…ryption-fixes Fields we said were encrypted were stored in the clear
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
enc_db.ENCRYPT_FIELDS_MAPlistedfim_data.pathandhash_sha256asencrypted.
core.telemetry_crypto.ENCRYPTED_FIELDSlisted them as fields todecrypt. And
fim.py— which produces almost every fim_data row there is —called the plain
insert_record. On one host: 1,081 file paths storedunencrypted, locally and on the server, while both ends of the design said
otherwise.
Nothing could see it. The server's
decrypt_valuehands anything without theenc::prefix straight back, by design — that is what makes a mixed tablereadable — so plaintext and ciphertext render identically in the console.
The table looked perfect. The only way to notice was to ask what the stored
bytes actually begin with.
A test now closes the class, and found six more sites: five in
inventory.pywriting
hardware_inventory, one indisks.pywritingevents_alert, and afallback in
main.pythat wrote an alert in the clear ifinsert_record_encwere ever missing. That last one was dead code, and a fallback to storing a
secret in the clear is not a fallback — it is a bug waiting for its condition.
The port scanner had been dead every cycle
No port scan result was ever written. Because it is a table that produces rows
rarely, that was indistinguishable from a host with no open ports.
The cause matters more than the trigger: the query was assembled by
interpolation with a hand-rolled
normalize()that doubled single quotes —and
fetch_wherehas taken aparamstuple all along. nmap returns theproduct and version strings a service chose to advertise, so the input is
attacker-controlled by definition, and the answer to that is never a better
escaping function. A security product assembling SQL by concatenation is
running the pattern it exists to detect elsewhere.
Parameterised, and a scan over the whole agent confirms it was the only site
interpolating a value — the others interpolate a table name from our own
fixed list, which no driver lets you parameterise.
Nobody watched the platform
The server already detected attacks on itself and told no one. A brute-force
produced
login_logsrows and a lockout while the alert view showed nothing.A revoked agent key retrying printed to a container log, which nobody reads
until they already suspect something — the wrong order for the one signal
that says a key is being used after it was taken away.
core/self_defence.pyrecords these as events of the same shape as everythingelse, with three rules it exists to keep: recording never breaks the thing it
records (every writer swallows its own failure — a server that will not reject
a bad login because it could not journal the rejection has turned a monitoring
gap into an outage); a severity is a claim, so the conclusion is stored
rather than every attempt; and no field here ever holds a credential.
Repeats fold into one row whose count climbs. A brute-force writing a row per
attempt makes the view that is supposed to reveal the attack into the thing
that buries it.
The panel is shown even when empty, which is a correction of the first
version. Hiding it read well as an argument — a permanently empty panel is
furniture people learn to skip — and it recreated exactly the failure the rest
of this work removes: an operator who never sees the panel cannot tell
"nothing has attacked us" from "this platform does not watch itself".
How much of the history can this server actually read?
The answer was invisible. A key mismatch printed one line per field name, once
each per process, then returned
<decryption failed — key mismatch>pervalue — so a table where 86% of the messages were unreadable produced the same
output as one bad row. Measured: 173 of 200
siem_eventsmessages, 197 of 200events_alert, 172 of 200critical_filespaths.The Fernet key comes from the server's bootstrap and there is no rotation path,
so this is not fixable — which is exactly why the quantity has to be visible
rather than the fact. The console now reports the proportion.
And recovery stopped making it worse: the agent no longer re-offers rows it can
no longer decrypt itself. The first re-offer on a live host moved about two
thousand permanently unreadable rows onto the server, spending a bounded
recovery budget on data nobody can read.
The console was opening; nobody was waiting
The agent tries three strategies and only the last works when it runs as a
service. Reaching it takes ~15s. The server gave up at 15s and told the
operator the agent "would not open a console". It would; it was still trying.
Fixed on both sides, because raising the timeout alone would have covered the
symptom:
STREAM_OPEN_TIMEOUT_Sis now a named 45s constant with its reasoningattached, and the agent remembers which strategy worked. Whether a process
can host a pseudoconsole is a property of how it was started and cannot change
while it runs — paying 15s to rediscover that on every console open is 15s
spent proving something already known. A stale memory falls back to the full
chain rather than reporting a failure, so the cache cannot become an outage.
A guard naming a permission that does not exist
require_permission("read_audit")— invented for the platform-events view andnever seeded anywhere. It fails closed, which is the right direction, but
silently: the route is unreachable for every role including admin, and looks
like a bug in the feature rather than a typo in its guard. A test now checks
every guard against the permissions
db/init_userdb.sqlactually creates.A pooled connection returned on GC timing
awaitbinds tighter than a conditional expression, so the else branch wasnever awaited — and
_PooledConn.closeis a coroutine. Connections went backto the pool only when
__del__ran. Not a leak, but a pool that hands back itsslots on garbage-collector timing is a pool that runs out under exactly the
load that makes collection late.
close()is tried first, deliberately: on_PooledConnthat is the methodthat releases the slot, while
ensure_closedwould reach through__getattr__to the underlying connection and shut the socket without telling the pool —
turning the warning into the slot leak it was only pretending to be.
Closing the plaintext ingest path
INGEST_TLS_REQUIRED=1closes the listener inside the container while Dockergoes on publishing the host port, so a batch is still accepted by the proxy and
written into a socket nobody reads. The guide used to say "delete the line from
docker-compose.yaml", which is a bad thing to ask of a tracked file;
INGEST_PLAINTEXT_BINDbinds it to loopback instead, the same mechanismBIND_ADDRalready uses. Verified: 5001 refused from the LAN, telemetry stillflowing on 5011.
Tests
2,392 passing. New invariants: every write to a table with declared encrypted
fields goes through the encrypting insert; no agent module interpolates a value
into a SQL predicate; every
require_permissionnames a permission thatexists; a stream-open deadline must exceed the agent's own fallback chain; and
the two encryption maps must agree.
Several of these tests check that their own detector still matches — a scan
that has stopped recognising anything passes silently, which is the same class
of failure as everything above.