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
510 changes: 509 additions & 1 deletion app.py

Large diffs are not rendered by default.

36 changes: 33 additions & 3 deletions core/self_defence.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,42 @@
"HIGH",
"An enrolment token was presented after it had already been used. The "
"token is a one-time credential, so a second use means it leaked."),
"TLS_DOWNGRADE_REFUSED": (
"TOTP_CODE_REPLAYED": (
"HIGH",
"A second-factor code that had already been used was presented again. "
"The code is valid for its whole thirty-second step, so this is what "
"somebody watching one being typed would try."),
"RECOVERY_CODE_USED": (
"MEDIUM",
"An agent tried the plaintext ingest port after this deployment "
"required TLS."),
"Somebody signed in with a recovery code instead of an authenticator. "
"Usually an honest lost phone; the other reading is a stolen password "
"and a stolen list."),
"TOTP_DISABLE_REFUSED": (
"HIGH",
"A wrong password was given when turning off two-factor. A hijacked "
"session trying to remove the control that would have stopped it "
"looks exactly like this."),
}

# `TLS_DOWNGRADE_REFUSED` was defined here and is deliberately gone.
#
# It described an agent trying the plaintext ingest port after the deployment
# required TLS - and `INGEST_TLS_REQUIRED=1` does not open that listener at
# all, so the server never sees the connection. The kind could not be raised
# by design.
#
# Making it raisable would mean binding the port and refusing politely, and
# that is worse for exactly the agents it would report. A build older than the
# receipt frame marks its rows sent once `sendall` returns; a clean close
# after we read its name would let it do that and lose the batch. ECONNREFUSED
# raises inside `sendall`, so the rows stay put.
#
# The visibility is not lost, it is on the other side: a refused send becomes
# `record_send_failure` on the agent, and the telemetry health view reports
# the table as `send failing` with the connection error and counts it as
# broken. That is the same fact, reported by the half of the system that can
# report it without risking anybody's data.

#: Severity order, so a caller can compare without hardcoding a list.
SEVERITY_RANK = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}

Expand Down
127 changes: 125 additions & 2 deletions docs/production-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -804,10 +804,102 @@ Every route is deny-by-default. The exceptions are the login endpoint, the SPA
shell, static assets and the agent-facing endpoints, which authenticate with
`X-Agent-Key` or an enrolment token.

### 6.2 Hardening checklist
### 6.2 Two-factor authentication

Off per account until an operator turns it on, at **My Security** in the
sidebar. There is no permission gate on that page and there deliberately is
not one: two-factor protects the account, not the tenancy, so an operator
whose role can do nothing else still has to be able to secure their own login.

An account that has it on cannot be signed into with a password alone by
anybody, including an administrator. There is no override.

#### What it protects against

Nothing about the password changes. Guessing was already covered by
`core.login_guard`, which locks out per user and per address. What a second
factor adds is the case a lockout cannot touch: a password that is *correct*
and in the wrong hands - reused from a breached site, phished, or read out of
a browser. This console can isolate a host, run a command as SYSTEM on every
endpoint, and read every log the fleet has produced, and one string is enough
for all of it.

#### Enrolling

1. **My Security** -> *Turn on two-factor*.
2. Scan the QR with any authenticator, or type the key by hand - a QR is no
use on the machine you are already sitting at.
3. Enter a code to confirm. **Nothing changes about the login until this
step**: a secret written when the QR appeared, with no proof it was kept,
would lock out everybody who scanned it into an app they then deleted.
4. Save the recovery codes. They are shown exactly once.

#### Recovery codes

Ten, each good for one sign-in, stored as SHA-256 hashes. The server cannot
show them again - that is the property that makes storing them safe, and it is
also the thing to tell operators before they close the tab.

**If they are lost and the authenticator is gone, the account cannot sign in.**
There is no support path that recovers it, by design: any mechanism that let
an administrator bypass somebody's second factor would be a bypass an attacker
could use too. The way back is a `users` row edit against the database, which
is deliberately awkward:

```sql
-- Only from a shell on the database host, and audit-log the fact you did it.
DELETE FROM userdb.user_totp WHERE user_id = <id>;
DELETE FROM userdb.user_recovery_codes WHERE user_id = <id>;
```

Signing in with a recovery code raises `RECOVERY_CODE_USED` on the platform
events panel (see §6.4). It is usually an honest lost phone; the other reading
is a stolen password and a stolen list, and the two look identical from here.

#### Settings

There are none. The step is 30 seconds, codes are 6 digits, and the accepted
drift is one step either side - the interoperable values, and not worth being
unusual about. One step is 30 seconds of clock skew in each direction, which
covers a phone that has not synchronised recently; widening it would hand an
attacker more time to reuse a code they watched being typed.

A code works once. TOTP is a shared secret and a clock, so the same six digits
stay valid for their whole step, and without single use anyone who sees one
entered has the rest of that step to use it themselves. A second attempt
raises `TOTP_CODE_REPLAYED`.

#### What is stored

| | |
| :--- | :--- |
| `user_totp.secret` | The seed, **Fernet-encrypted** with the server key. A dump of `userdb` alone does not hand over everybody's second factor. |
| `user_recovery_codes.code_hash` | SHA-256. Never the code. |
| `totp_pending.token_hash` | SHA-256 of the half-authenticated token, like a session. |

The seed column is `VARCHAR(512)` for ciphertext, not for the ~32 characters a
base32 seed reads as - see §4.1 for what sizing a column to the human-readable
value cost this platform once already.

**Losing the Fernet key takes every enrolled second factor with it.** The
secret becomes undecryptable, the server reports the account as having none,
and each operator has to enrol again. That is the survivable failure; the
alternative - refusing the login outright - would lock the whole console out
of itself. Back the key up (§4.1).

#### LDAP accounts

Gated identically. A second factor that protected the local path and not the
directory path would protect nothing: an attacker holding a password picks the
path without it.

### 6.3 Hardening checklist

1. Change `admin / admin123` immediately under **Users & Roles**.
2. Create per-operator accounts; stop using the shared admin.
Have each one turn on two-factor at **My Security** (§6.2) - especially the
accounts that can reach SOAR, because those can run a command as SYSTEM on
every endpoint.
3. Map operators to roles:
- `auditor` — `read_telemetry` only
- `operator` — read + `manage_soar` (approve shadow, dispatch SOAR)
Expand All @@ -823,7 +915,38 @@ shell, static assets and the agent-facing endpoints, which authenticate with
call. It is a server-side request forgery primitive by nature; empty means
the endpoint is inert.

### 6.3 Audit retention
### 6.4 Attacks on the platform itself

`userdb.platform_events`, surfaced under **Activity Logs**. The server already
detected all of this and told nobody: a lockout went to a container log, a
rejected agent key went to a container log, a permission denial went to a
table nobody opens unless they are already investigating. The platform watched
every host in the fleet and was the one machine nobody watched.

| Kind | Severity | What it means |
| :--- | :--- | :--- |
| `FLEET_KEY_ON_CHANNEL` | CRITICAL | The fleet-wide secret was offered on the agent channel. That channel carries `/self_destruct`, so a leaked master key must not open one against every endpoint at once. |
| `LOGIN_LOCKOUT` | HIGH | Failed logins crossed the threshold. Somebody guessing, or an integration on a credential that changed. |
| `AGENT_KEY_REJECTED` | HIGH | A channel was opened with a key this server does not know. Any revoked agent does this; so does anyone who found a key that no longer works. |
| `ENROLMENT_TOKEN_REUSED` | HIGH | A one-time token presented twice. Usually an installer re-run; the other reading is that it leaked. |
| `TOTP_CODE_REPLAYED` | HIGH | A second-factor code already used was presented again. |
| `TOTP_DISABLE_REFUSED` | HIGH | A wrong password when turning off two-factor. A hijacked session trying to remove the control that would have stopped it looks exactly like this. |
| `PERMISSION_DENIED` | MEDIUM | An operator reached past their role. One is a mis-click; a run of them is somebody mapping what they can touch. |
| `RECOVERY_CODE_USED` | MEDIUM | Signed in with a recovery code rather than an authenticator. |

Repeats fold into one row whose count climbs, within a ten-minute window. A
brute-force that wrote a row per attempt would make the view meant to reveal
it into the thing that buries it.

The panel is shown even when empty, and says so. An operator who never sees it
cannot tell "nothing has attacked us" from "this platform does not watch
itself" - and on a security view, a quiet twenty-four hours is a finding.

Nothing here records a credential. These rows describe attempts *on* secrets,
and the obvious fields to include - the password tried, the key presented -
are exactly the ones that turn an audit trail into a second breach.

### 6.5 Audit retention

`login_logs` and `audit_logs` record every UI login attempt with source IP and
result, every SOAR dispatch, every shadow approve/reject with operator and
Expand Down
10 changes: 10 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"axios": "^1.13.6",
"fuse.js": "^7.1.0",
"lucide-react": "^0.577.0",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.13.1",
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import Playbooks from './pages/Playbooks';
import Databases from './pages/Databases';
import LoginLogs from './pages/LoginLogs';
import AuditLogs from './pages/AuditLogs';
import AccountSecurity from './pages/AccountSecurity';
import Automations from './pages/Automations';
import GlobalAlerts from './pages/GlobalAlerts';
import Agents from './pages/Agents';
Expand Down Expand Up @@ -148,6 +149,15 @@ const App: React.FC = () => {
<AuditLogs />
</ProtectedRoute>
} />

{/* No permission gate. Two-factor protects the account rather than
the tenancy, so an operator whose role can do nothing else still
has to be able to secure their own login. */}
<Route path="/account/security" element={
<ProtectedRoute>
<AccountSecurity />
</ProtectedRoute>
} />

<Route path="/ai-analysis" element={
<ProtectedRoute>
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { NavLink, Link } from 'react-router-dom';
import { Activity, BrainCircuit, ChevronRight, ClipboardList, Database, Download, Grid3x3, Key, LayoutDashboard, LogOut, Monitor, PlaySquare, Radar, Save, Search, Settings, ShieldAlert, Users, X, Zap } from 'lucide-react';
import { Activity, BrainCircuit, ChevronRight, ClipboardList, Database, Download, Grid3x3, Key, LayoutDashboard, LogOut, Monitor, PlaySquare, Radar, Save, Search, Settings, ShieldAlert, ShieldCheck, Users, X, Zap } from 'lucide-react';
import { authService } from '../services/api';

interface SidebarProps {
Expand Down Expand Up @@ -157,6 +157,9 @@ const Sidebar: React.FC<SidebarProps> = ({ isOpen }) => {
{authService.hasPermission('manage_db') && <SidebarLink to="/admin/databases" icon={<Database size={18} />} label="Databases" />}
{authService.hasPermission('read_telemetry') && <SidebarLink to="/admin/login-logs" icon={<Key size={18} />} label="Access Logs" />}
{authService.hasPermission('read_telemetry') && <SidebarLink to="/admin/audit" icon={<ClipboardList size={18} />} label="Activity Logs" />}
{/* Ungated on purpose - see the route. A link only some roles can
see would leave the rest unable to secure their own account. */}
<SidebarLink to="/account/security" icon={<ShieldCheck size={18} />} label="My Security" />
</div>
</nav>

Expand Down
Loading
Loading