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
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,4 @@ SECURITY.md
.idea/
.vscode/
*.exe
.db_password_rescue
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,8 @@ certs/*.pem
certs/*.csr
evals/cache/
backups/

# Written by scripts/rotate_db_password.py when the rotation lands on the
# account but cannot finish. It holds the new password in clear text and is
# meant to be read once and deleted.
.db_password_rescue
27 changes: 25 additions & 2 deletions Sentora/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,22 @@
if _MAC_ADDRESS:
OS_INFO = f"{OS_INFO}|MAC={_MAC_ADDRESS}"

def _os_info_for_log() -> str:
"""`OS_INFO` without the MAC address.

The full string is `platform.platform()` with `|HOST=` and `|MAC=`
appended, and the server needs all of it - that is how a reinstalled agent
is recognised as the same machine. The log does not.

The hostname is unremarkable in a file that lives on that host. The MAC is
the part with a life outside it: 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.
"""
return OS_INFO.split("|MAC=")[0]



TABLES = [
'critical_files',
'portscan_result',
Expand Down Expand Up @@ -687,6 +703,13 @@
if not INGEST_TLS:
return raw
context = ssl.create_default_context(cafile=INGEST_CA or None)
# Stated rather than inherited. `create_default_context` has set this
# floor since Python 3.10, so on the interpreter this agent is frozen
# against it changes nothing today - and that is exactly why it is
# worth writing down: the guarantee currently depends on which Python
# the build machine had, and the connection it protects is every byte
# of telemetry this endpoint produces.
context.minimum_version = ssl.TLSVersion.TLSv1_2
return context.wrap_socket(raw, server_hostname=SERVER_HOSTNAME or SERVER_IP)
except Exception:
raw.close()
Expand Down Expand Up @@ -2546,7 +2569,7 @@

# 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()}")

args = _parse_args()

Expand Down Expand Up @@ -2620,7 +2643,7 @@
print(f"[*] Automations Mode: {AUTOMATIONS_MODE} "
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()}")
print("[*] Starting agent...")

time.sleep(2)
Expand Down
8 changes: 8 additions & 0 deletions Sentora/modules/link.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,14 @@ def _explain_once_if_the_server_speaks_tls(self) -> None:
context = _ssl.SSLContext(_ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = _ssl.CERT_NONE
# Not to protect this handshake - nothing is sent over it, and
# verification is off two lines above. It bounds what the
# message below 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. The probe answering "no" there is the
# right answer.
context.minimum_version = _ssl.TLSVersion.TLSv1_2
with context.wrap_socket(raw, server_hostname=host):
pass
except Exception:
Expand Down
15 changes: 13 additions & 2 deletions core/login_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,26 @@


def _trusted_networks():
"""Parse TRUSTED_PROXIES, naming the position of anything that fails.

The position, not the value. `TRUSTED_PROXIES` is a list of proxy
addresses and is not a secret - but this branch runs precisely when
somebody has put something in it that is *not* an address, and the most
common way that happens is a value pasted into the wrong variable. Echoing
it into the container log turns a typo into a disclosure, and the entry
number is what an operator needs to find it anyway.
"""
nets = []
for raw in TRUSTED_PROXIES.split(","):
for position, raw in enumerate(TRUSTED_PROXIES.split(","), start=1):
raw = raw.strip()
if not raw:
continue
try:
nets.append(ipaddress.ip_network(raw, strict=False))
except ValueError:
print(f"[!] TRUSTED_PROXIES: {raw!r} is not an address or CIDR, ignored",
print(f"[!] TRUSTED_PROXIES: entry {position} is not an address or "
f"CIDR and is ignored ({len(raw)} characters). Nothing "
f"behind it is treated as a trusted proxy.",
flush=True)
return nets

Expand Down
103 changes: 103 additions & 0 deletions frontend/src/lib/luceneQuery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* The builder has to produce a query that means what was clicked.
*
* CodeQL called the old version incomplete sanitisation, and the label is
* misleading here: the query box next to 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 actually broken is fidelity — two shapes of value
* produced a query that searched for something else, or for nothing.
*
* Both are in here as their own cases, because they are the ones that were
* wrong rather than the ones that are easy to write.
*
* Run:
* cd frontend
* npm test
*/

import { test, describe } from 'node:test';
import assert from 'node:assert/strict';

import { quoteValue, toQuery } from './luceneQuery.ts';

describe('quoteValue', () => {
test('a plain token is left alone', () => {
// Quoting everything would work, and would make `foo*` stop being a
// wildcard for somebody who typed one into the value box.
assert.equal(quoteValue('CRITICAL'), 'CRITICAL');
assert.equal(quoteValue('agent-01'), 'agent-01');
assert.equal(quoteValue('user@example.com'), 'user@example.com');
assert.equal(quoteValue('4.18.25080.5'), '4.18.25080.5');
});

test('a trailing backslash does not escape the closing quote', () => {
// The bug. `a\` became `"a\"` — the quote escaped, the phrase running on
// into whatever came after it in the query.
assert.equal(quoteValue('a\\'), '"a\\\\"');
});

test('a Windows path survives intact', () => {
// The everyday version of the same thing: every path in this product's
// telemetry has backslashes in it.
assert.equal(quoteValue('C:\\Users\\jdoe'), '"C:\\\\Users\\\\jdoe"');
});

test('backslashes are escaped before quotes, not after', () => {
// Escaping the quote first turns `"` into `\"`, and the backslash pass
// then escapes the backslash that pass just added — producing `\\"`,
// which is an escaped backslash followed by a live quote.
assert.equal(quoteValue('say "hi"'), '"say \\"hi\\""');
assert.equal(quoteValue('a\\"b'), '"a\\\\\\"b"');
});

test('a value with no whitespace is still quoted when it needs it', () => {
// The old rule only quoted on whitespace or a quote, so this went in raw
// and changed the structure of the query rather than the term.
assert.equal(quoteValue('a)OR(b'), '"a)OR(b"');
assert.equal(quoteValue('x:y'), '"x:y"');
assert.equal(quoteValue('a&&b'), '"a&&b"');
});

test('whitespace still forces quoting', () => {
assert.equal(quoteValue('two words'), '"two words"');
});
});

describe('toQuery', () => {
test('an empty filter set is an empty query', () => {
assert.equal(toQuery([]), '');
});

test('filters with no field or no value are dropped', () => {
assert.equal(toQuery([
{ field: '', op: ':', value: 'x' },
{ field: 'severity', op: ':', value: '' },
]), '');
});

test('negation is spelled NOT field:value', () => {
assert.equal(
toQuery([{ field: 'severity', op: '!=', value: 'INFO' }]),
'NOT severity:INFO');
});

test('several filters join with AND', () => {
assert.equal(
toQuery([
{ field: 'severity', op: ':', value: 'CRITICAL' },
{ field: 'agent', op: '!=', value: 'WIN-01' },
]),
'severity:CRITICAL AND NOT agent:WIN-01');
});

test('a path value produces a query that still parses', () => {
const query = toQuery([
{ field: 'message', op: ':', value: 'C:\\Windows\\Temp\\svc.exe' },
]);
assert.equal(query, 'message:"C:\\\\Windows\\\\Temp\\\\svc.exe"');
// The quotes that remain are the pair that delimits the phrase: every
// other one would have to be escaped, and there are none here.
const unescaped = query.replace(/\\\\/g, '').replace(/\\"/g, '');
assert.equal((unescaped.match(/"/g) || []).length, 2);
});
});
53 changes: 53 additions & 0 deletions frontend/src/lib/luceneQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Turning a clicked filter into the query the server actually runs.
*
* The query box on the Search page is editable on purpose — somebody who knows
* Lucene should never be fighting the builder — so this is not a sanitiser and
* there is no privilege to escalate: the operator can type whatever they like
* into the box beside it, and the server runs it either way.
*
* What it has to be is *faithful*. Clicking `message = C:\Users\x` has to
* produce a query that searches for `C:\Users\x`, and the previous version did
* not:
*
* value.replace(/"/g, '\\"') // escapes the quote, not the backslash
*
* so `a\` became `"a\"` — the closing quote escaped, the string running on into
* whatever followed. And the unquoted branch was worse, because it only quoted
* when the value contained whitespace or a quote: `a)OR(b` has neither, went in
* raw, and changed the shape of the query.
*
* The rule here is the other way round. A value that is anything other than a
* plain token gets quoted, and inside the quotes the two characters that mean
* something to Lucene — the backslash and the quote — are escaped, backslash
* first. Escaping the quote first would then escape the backslash it just
* added, which is the classic way to get this exactly wrong.
*/

export type Filter = { field: string; op: ':' | '!='; value: string };

/** Characters a value may contain and still go in bare.
*
* Deliberately a small allow-list rather than a list of things to escape: the
* Lucene metacharacter set is `+ - && || ! ( ) { } [ ] ^ " ~ * ? : \ /` plus
* whitespace, and enumerating it is how one gets missed. Anything outside
* word characters, dot, at-sign and hyphen is quoted.
*/
const PLAIN_TOKEN = /^[\w.@-]+$/;

export function quoteValue(value: string): string {
if (PLAIN_TOKEN.test(value)) return value;
// Backslash first. The other order escapes the backslashes this line adds.
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `"${escaped}"`;
}

export function toQuery(filters: Filter[]): string {
return filters
.filter((f) => f.field && f.value)
.map((f) => {
const value = quoteValue(f.value);
return f.op === ':' ? `${f.field}:${value}` : `NOT ${f.field}:${value}`;
})
.join(' AND ');
}
21 changes: 9 additions & 12 deletions frontend/src/pages/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Search as SearchIcon, Plus, X, ChevronLeft, ChevronRight } from 'lucide-react';
import api from '../services/api';
import { toQuery, type Filter } from '../lib/luceneQuery';
import {
PageHeader, Card, Badge, DataTable, Row, Cell,
EmptyState, ErrorState, LoadingState, Tone,
} from '../components/ui';

type Tab = 'logs' | 'events';

type Filter = { field: string; op: ':' | '!='; value: string };

const RANGES = [
{ label: '1h', from: 'now-1h' },
Expand All @@ -41,17 +41,14 @@ const SEVERITY_TONE: Record<string, Tone> = {
low: 'low', info: 'info',
};

/** Filters -> the Lucene the server runs. The one place the two agree. */
function toQuery(filters: Filter[]): string {
return filters
.filter((f) => f.field && f.value)
.map((f) => {
const value = /[\s"]/.test(f.value) ? `"${f.value.replace(/"/g, '\\"')}"`
: f.value;
return f.op === ':' ? `${f.field}:${value}` : `NOT ${f.field}:${value}`;
})
.join(' AND ');
}
/* `toQuery` now lives in `lib/luceneQuery.ts`, with tests.
*
* It was wrong in two ways that only show up on real values: it escaped the
* quote but not the backslash, so a 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. Both are fidelity bugs — the box beside the builder is editable
* by design, so there was never a boundary here to cross. */

export default function Search() {
const [tab, setTab] = useState<Tab>('logs');
Expand Down
Loading
Loading