Catalyst / admin/Syanpse-Vanguard 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Syanpse-Vanguard

public
Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
Syanpse-Vanguard / synapse-vanguard-v3 / sv / query / builder.py 2576 B · main
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
"""Structured search -> parameterized Query. Field names are whitelisted and all
values are bound parameters, so user input can never reach the SQL text. This is
where the future NL->SQL translator will emit its output too."""
from __future__ import annotations

from datetime import datetime
from typing import Any
from uuid import UUID

from pydantic import BaseModel, Field

from sv.storage.base import Query

# Only these columns may be filtered/sorted on. Anything else is rejected before
# a query is built — an allowlist, not an escaping scheme.
_FILTERABLE = {
    "source_type", "host", "host_ip", "event_id", "severity",
    "category", "action", "outcome", "user_name", "process_name",
    "mitre_technique",
}
_SORTABLE = {"ts", "ingested_ts", "severity", "event_id"}
_MAX_LIMIT = 10_000


class SearchRequest(BaseModel):
    start: datetime | None = None
    end: datetime | None = None
    equals: dict[str, Any] = Field(default_factory=dict)   # exact-match filters
    contains: str | None = None                            # substring on message
    sort_by: str = "ts"
    descending: bool = True
    limit: int = Field(default=200, ge=1, le=_MAX_LIMIT)
    offset: int = Field(default=0, ge=0)


def build_search(req: SearchRequest, tenant_id: UUID) -> Query:
    where: list[str] = ["tenant_id = ?"]
    params: list[Any] = [str(tenant_id)]   # tenant isolation is always enforced

    if req.start is not None:
        where.append("ts >= ?")
        params.append(_naive(req.start))
    if req.end is not None:
        where.append("ts <= ?")
        params.append(_naive(req.end))

    for field_name, value in req.equals.items():
        if field_name not in _FILTERABLE:
            raise ValueError(f"field not filterable: {field_name}")
        where.append(f"{field_name} = ?")
        params.append(value)

    if req.contains:
        where.append("message ILIKE ?")
        params.append(f"%{req.contains}%")

    sort_by = req.sort_by if req.sort_by in _SORTABLE else "ts"
    direction = "DESC" if req.descending else "ASC"

    text = (
        "SELECT ts, ingested_ts, source_type, host, host_ip, event_id, "
        "severity, category, action, outcome, user_name, process_name, "
        "mitre_technique, message "
        "FROM events "
        f"WHERE {' AND '.join(where)} "
        f"ORDER BY {sort_by} {direction} "
        "LIMIT ? OFFSET ?"
    )
    params.extend([req.limit, req.offset])
    return Query(text=text, params=params)


def _naive(dt: datetime) -> datetime:
    return dt.replace(tzinfo=None) if dt.tzinfo else dt