Skip to content

Add security keys, give security_audit a collector, and finish the console - #49

Merged
d3vhex merged 1 commit into
mainfrom
feat/security-keys-posture-audit-and-console
Sep 14, 2026
Merged

d3vhex merged 1 commit into
mainfrom
feat/security-keys-posture-audit-and-console

Conversation

@d3vhex

@d3vhex d3vhex commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Security keys, a table that never had a collector, and the console around them

Verified against the running deployment rather than only in tests — which
turned out to matter, because four of the bugs below were found by clicking the
button, after the tests for them had passed.


1. Security keys (WebAuthn)

A one-time code typed into a convincing copy of this login page works on the
real one, and the operator has no way to tell. A key signs over the origin it
is actually talking to.

There is no configuration, and that was the design question. The
relying-party ID is derived from the request, so a laptop at
https://localhost:8000 and an estate at https://soc.example.com both work
without being told anything, and no setting can disagree with the URL people
type.

The cost is that some origins cannot host a key, and the console says which
rather than showing a button that fails:

Console opened at Security keys
https://soc.example.com available
http://localhost:8000 available — localhost is a secure context by definition
https://10.0.0.5:8000 unavailable. A relying-party ID must be a domain; browsers reject an address.
http://soc.example.com unavailable. Needs a secure context — set TLS_ENABLED=1.
any, webauthn package absent unavailable. The import is lazy, so the option is lost rather than the console.

One-time codes are unaffected in every row, and the message says so — an
operator who reads "unavailable" as "two-factor is off here" turns the whole
thing off.

Three decisions worth reviewing:

  • A credential records its RP ID. A key registered at localhost is one
    the browser will not offer at a hostname. Without the column the console
    lists a factor that cannot work and the prompt times out silently.
  • _has_security_key is deliberately not scoped to the RP ID. Whether a
    login needs a second factor is a property of the account; whether this
    browser can offer a key is a property of the origin. Conflating them would
    let somebody with a key registered at a hostname skip the second factor
    entirely by opening the console at an address — turning the origin binding,
    the whole point of WebAuthn, into a bypass.
  • The signature counter is checked. A key presenting a lower count than the
    last one seen is refused and raises WEBAUTHN_COUNTER_REGRESSED (CRITICAL).
    Zero means "not implemented" — true of most platform passkeys — and is
    accepted, because refusing it would switch the control off for the hardware
    people actually have.

Verified end to end on a real authenticator:

user_webauthn        rp_id=localhost  transports=hybrid,internal  sign_count=0
                     created 18:17:25   last_used 18:56:31
webauthn_challenges  0 rows            (single-use deletion works)
login_logs           admin  success  "security key"

base64url is not base64

frontend/src/lib/webauthn.ts has its own tests because this is where WebAuthn
fails intermittently. - and _ stand in for + and /, padding is dropped,
and atob accepts neither. The alphabets agree until a byte lands on one of
the four differing characters — so an implementation that forgets to translate
passes a demo, passes review, then throws InvalidCharacterError for one
operator in three.


2. Certificate names (TLS_SAN)

The shipped certificate carried DNS:localhost, DNS:localhost — the SAN list
was [cn, "localhost"] with no dedup and no way to add a name. A console at
https://10.0.0.5:8000 failed hostname verification in every browser, on top
of the untrusted-CA warning: two errors that look like one.

TLS_SAN takes hostnames and addresses; anything that parses as an address
becomes an IP SAN, because a bare address in the DNS list matches nothing.

Generation is idempotent, so editing .env on a deployment that already
has a certificate does nothing — correctly, since a new identity per restart
would break every agent that pinned the CA. It now says so at startup with the
command to fix it. That check silently vanished on its first draft
(except: return made "unreadable" and "names match" produce identical
output); it has its own test.

docs/production-deployment.md §3.8 is the six-step version, including that
--force issues a new CA, and what happens if you skip all of it: nothing
breaks, you just do not get a security key.


3. security_audit: the table nothing ever wrote to

It existed in both schemas, in ALLOWED_TABLES, in DEDUP_TABLES and in two
encrypted-field maps since the beginning. Three modules were documented as
producing it. None of them ever did. So it shipped empty every cycle and
the console showed it permanently as NOT COLLECTED — a sensor that reads as
broken rather than as never built.

Sentora/modules/security_audit.py is the collector: posture findings, read
only. Windows (registry, services, local accounts, firewall) and Linux
(sshd_config, sudoers, /etc/passwd, file modes).

Running it on a real host immediately produced three bugs worth keeping:

  • It accused the anti-malware service of being malware. WinDefend,
    WdNisSvc and MDCoreSvc came out as CRITICAL persistence, because
    \programdata\ was in the writable-directory list. That root is writable,
    which is what makes it the textbook example — but the vendor subdirectories
    under it are not, and Defender lives there. The worst false positive
    available, and one is enough for an operator to stop reading the category.
  • It crashed on a localised Windows. subprocess.run(text=True) decodes
    with the console code page — cp1254 here — and raised UnicodeDecodeError
    inside subprocess's own reader thread, where the caller cannot catch it.
  • It overstated. "Can authenticate with an empty password" for
    PasswordRequired=False, which means the account is permitted to have a
    blank password, not that it has one. Downgraded and reworded; a finding that
    overstates is one an operator learns to dismiss, along with the next.

The group-membership check queries by SID (S-1-5-32-544), not by name:
Administrators in English, Yöneticiler here, and matching the name is a
check that silently finds nothing on a localised install.

Deduplication is on (category, finding) and deliberately not on
details — details drift ("2 members: …" → "3 members: …") and a fingerprint
over them re-inserts the same finding whenever one changes. send_alert
learned the general version of this: a state-reporting detector without
deduplication produced the identical alert 288 times a day.

And then the chain still ended in a wall. The rows arrived, stored
correctly, decryptable — and there was no route and no UI. Added both:
GET /<agent>/security_audit and a Posture tab. Its empty state is worded
differently from the telemetry tabs on purpose: empty here is a real answer.

Verified live: 6 findings on this host, all independently confirmed, read back
through the real decrypting path.


4. Agent fixes

automations/pending had been 401ing for the life of every install.
AutomationsClient sent no headers at all. The 401 is correct — unauthenticated
the route let anyone read the response actions queued for a host and POST
{"task_id": N, "status": "SUCCESS"} against every id, so the actions never ran
while the console showed them green.

What was missed is that this is not dead code. call_agent_soar queues a
pending row precisely "so an agent that missed the push can still poll for it".
Broken, the fallback did not exist. It authenticates now, and a rejection is
said once rather than four times a second.

The vulnerability scan reported a clean result it could not have reached.
scanners/vuln.py mapped Windows to OSV's NuGet ecosystem, but the package
list is registry Uninstall display names — "Google Chrome" — and NuGet is
Newtonsoft.Json. The two name spaces do not overlap and OSV has no ecosystem
for programs installed on Windows, so the query was structurally incapable of
matching while reporting new_findings=0 every cycle. It is a named skip now,
surfaced through the skipped_reason the console already renders.


5. The console (C2 + responsive)

All eight remaining pages moved onto the shared kit: 460 inline style objects
→ 171
, and eight hand-rolled modal overlays → the kit's Modal (Escape
closes, backdrop closes, portals to document.body because position: fixed
resolves against the nearest containing block — AgentDetail had hit exactly
that).

The styling was the smaller half. Every one of the eight swallowed its load
errors.
An empty Identity & Access reads as "nobody has access". Blank
AdminConfig forms read as "nothing is configured" and those forms save.
Playbooks reported a failed run to the browser console only — on a page whose
own editor labels steps "cannot be undone". AIAnalysis had a hardcoded green
"RabbitMQ Worker Active" pill with no request behind it. Deployment lost
enrolment tokens silently, because navigator.clipboard does not exist outside
a secure context and the button still said "Copied".

Then responsiveness, which the kit's own docstring had warned about: six pages
wrote gridTemplateColumns: '300px 1fr' inline, where a breakpoint cannot go.
Added .split-grid. Three places decided layout from window.innerWidth read
during render — which never follows a resize, and looks responsive to anyone
testing by reloading at each size. Sidebar was right only by accident; it
takes isMobile as a prop now.


6. Documentation that described a different product

Sentora/docs/MODULES.md was unchecked and substantially wrong:

  • Three table names that do not exist: installed_software,
    resource_log, disk_info.
  • edr_enforcer's entry was wrong end to end — documented as a
    hash-baseline scan writing security_audit; it is the agent's main periodic
    collector and writes five other tables, four of them undocumented.
  • network_inventory and packages appeared nowhere.

tests/test_module_reference.py holds both directions now. While correcting
the paragraph about scrub_nuls, a real NUL byte got into the file from an
escape that collapsed one layer too far — enough for grep to call it binary
and go quiet. Fixed, with a test.


7. tsc --noEmit -p tsconfig.json checks nothing

frontend/tsconfig.json is a solution file: "files": [] and project
references. Pointing tsc -p at it type-checks the empty set and exits 0, for
any code. vite build does not close the gap either — rolldown strips types
without checking them.

Two unused imports left by a refactor passed both, and were caught by the
Docker image build (which runs npm run build → tsc -b) — after the
container had spent a day serving an old agent binary, because the failing
build never produced a new image and the failure was masked by a
docker ... | tail pipeline returning tail's exit code.

tests/test_frontend_typecheck.py makes the real gate structural.


8. Four bugs the tests could not have caught

Every one was found by using the feature, and in each case the tests written
for it passed:

Bug Why the tests missed it
security_audit had no route or UI The tests checked the collector, not the chain
register/begin returned 500 json in app.py is Sanic's response helper, not the module. Valid Python, fails at request time only
login/2fa/webauthn/* returned 401 Added to the test's session-only list, not to the server's public-handler list
The first fix for that went to the wrong list _PUBLIC_EXACT_PATHS is a path-shaped mirror, consulted only as a backstop; authenticate matches handler names

New invariants for each: no module attribute may be read off a shadowed name
in app.py; any route that authenticates with a pending token must be in
_PUBLIC_HANDLERS; and the mirror must not drift from it. Each detector was
run against the shipped state to confirm it fires.


9. CodeQL alerts

Eight open on main. Five were real, two were true-but-misread, and one is the
design.

The search query builder was not faithful (js/incomplete-sanitization).
The label misleads here: the query box beside the builder is editable by
design, so an operator can type raw Lucene whenever they like and there is no
boundary being crossed. What was broken is that clicking a filter produced a
query meaning something else. It escaped the quote and not the backslash, so
every Windows path ran off the end of its own phrase; and it only quoted when
the value held whitespace or a quote, so a)OR(b went in bare and changed the
structure of the query rather than the term. Moved to
frontend/src/lib/luceneQuery.ts with tests — backslash escaped first, because
doing it second escapes the backslash that pass just added.

Two TLS floors stated rather than inherited. create_default_context has
set TLS 1.2 as the minimum since Python 3.10, so Sentora/main.py changes
nothing today — which is why it is worth writing down, since the guarantee
otherwise depends on which Python the agent was frozen against, and the
connection it protects is every byte of telemetry the endpoint produces. The
probe in link.py is different: nothing is sent over it and verification is
already off, so the floor is not protecting the handshake. It bounds what the
message is allowed to claim — a server that only speaks TLS 1.0 should not
produce "this is serving TLS, switch to https://", because switching would be
to something worse than what the agent has now.

TRUSTED_PROXIES is not a secret, but that branch runs precisely when
somebody has put something in it that is not an address, and the commonest way
that happens is a value pasted into the wrong variable. It logs the entry's
position now instead of its content, which is what an operator needs to find it
anyway.

The agent banner carried the MAC address. OS_INFO is
platform.platform() plus |HOST= and |MAC=; the server needs all of it,
because that is how a reinstalled agent is recognised as the same machine. The
log does not. A hostname is unremarkable in a file that lives on that host; a
MAC is a stable hardware identifier that follows the machine across networks
and reinstalls, sitting in a plain-text file that support bundles and
screenshots routinely include.

The rotation script printed the new database password on its failure path.
The recovery has to exist — the account has already been altered at that point,
so a password nobody has is a database nobody can reach. It does not have to
put the secret in terminal scrollback, a CI job log, or the screenshot somebody
takes of the failure to ask about it. It writes .db_password_rescue beside
.env, owner-readable only, and prints the path. Printing survives for the one
case left: the rescue file cannot be written.

One is dismissed rather than fixed. .env holds DB_PASSWORD in clear
text because that is how every service in the stack is configured, and there is
no version of this product where it does not. The addressable half is done —
.env is restricted to its owner after writing. The rest is a true finding,
accepted.


Testing

2711 passed, 5 skipped (Python) · 53 pass, 0 fail (frontend) ·
npm run build and docker compose build both exit 0, unmasked.

Verified against the running deployment: the security-key ceremony completed on
real hardware, security_audit findings reached the server and read back
decrypted, automations/pending answers 200, and the agent log carries no NUL
errors.

…nsole

Derive the WebAuthn relying party from the request so there is nothing to configure, write the posture collector the schema had carried since the beginning with no writer, add TLS_SAN and report a certificate that predates the configured names, authenticate the automations poll so the missed-push fallback exists, stop reporting a Windows vulnerability scan that could never match as clean, make the search query builder faithful to the filter that was clicked, keep the MAC address and the rotated database password out of logs, move the last pages onto the shared kit with real loading and failure states, and correct the agent module reference.
Comment thread Sentora/main.py
# Once, here, rather than on every table send. This is the banner that
# used to be repeated thousands of times a day in agent.log.
print(f"[*] Host: {OS_INFO}")
print(f"[*] Host: {_os_info_for_log()}")
Comment thread Sentora/main.py
f"({'server' if (AUTOMATIONS_MODE == 'server' or (AUTOMATIONS_MODE == 'auto' and AUTOMATIONS_API_URL)) else 'db'})")
print(f"[*] Public IP (auto-detected): {get_public_ip()}")
print(f"[*] OS Info: {OS_INFO}")
print(f"[*] OS Info: {_os_info_for_log()}")
# alone can read gives the same recovery with none of that.
rescue = ENV_PATH.with_name(".db_password_rescue")
try:
rescue.write_text(f"DB_PASSWORD={new_password}\n", encoding="utf-8")
f"and the rescue file could not be written ({write_error}).")
print(f" {e}")
print("\n This is the only copy. Save it now:")
print(f" DB_PASSWORD={new_password}") # nosec - see above
@d3vhex
d3vhex merged commit 142334f into main Sep 14, 2026
10 of 11 checks passed
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