SIEM has been the backbone of security operations for 20 years. It was built for a different era, when log volumes were manageable, detection meant static rules, and “AI” wasn’t part of the conversation.
I’ve spent the last few years migrating security operations away from legacy SIEM toward a Databricks-based Security Data Lake. The difference isn’t just architectural. It changes what’s possible.
Static rules become ML models. Correlation across siloed sources becomes a single SQL query. Retention that used to cost a fortune becomes cheap Delta storage. And threat hunting, which in SIEM means fighting the query language, becomes asking a question in plain English.
There’s one more thing nobody talks about enough: in a legacy SIEM you write detection logic in whatever proprietary language the vendor decided to give you. You wait for the next release to get a feature you need. You celebrate when they ship a new dashboard as if it’s a gift.
On a Data Lake you write Python. You write Spark. You use any library that exists. You build exactly what your team needs, not what the vendor decided to package.
To make that concrete, I built a threat hunting app running natively on Databricks Apps.
Architecture #
Before any code, the decisions that matter at scale.
flowchart TB
subgraph Sources["Security Telemetry"]
EDR["EDR"]
NET["Network"]
WIN["Windows"]
DNS["DNS"]
DLP["DLP"]
PROXY["Proxy"]
end
subgraph Lakehouse["Databricks Lakehouse"]
Delta["Delta Tables\n(one per source)"]
UC["Unity Catalog\nRBAC + Row/Column Filters"]
WH["Serverless SQL\nWarehouse"]
end
subgraph App["Databricks App"]
UI["Streamlit UI"]
LLM["Foundation Model\n(natural language → SQL)"]
HB["Hunt Board\n(Delta-backed)"]
end
Sources --> Delta
UC --> Delta
WH --> Delta
UI --> WH
UI --> LLM
LLM --> WH
UI --> HB
style LLM fill:#6366f1,color:#fff
style Delta fill:#1b7c3d,color:#fff
style WH fill:#1b7c3d,color:#fff
Data layer: Delta tables as the single source of truth. All security telemetry lands in Delta. No copies, no separate indexes per tool. One table per source type, governed by Unity Catalog. Every query in the hunting app runs on the same data your detection pipelines and dashboards use. No sync lag, no version drift.
Access model: roles mapped to query scope. Unity Catalog row and column filters enforce what each role can see. An L1 analyst and a threat hunter query the same tables but see different data. The app doesn’t manage permissions, Delta does. If you manage access in the app layer, you’ll eventually have a gap.
Compute: serverless SQL warehouse. Hunting queries are unpredictable in size and frequency. Serverless scales to zero between sessions and handles concurrent hunters without sizing decisions.
AI layer: natural language to SQL. The hunter describes what they’re looking for, the model generates SQL. The generated SQL is always shown to the hunter before execution because transparency matters in security workflows. More on the implementation below.
Frontend: Streamlit on Databricks Apps. Three files: app.py, app.yaml, requirements.txt. No build step, no infrastructure. Config() without arguments handles auth automatically through the Apps OAuth layer. The app inherits workspace permissions, so there’s no separate access control to maintain.
Connection: zero credentials in code #
The entire auth story is four lines:
|
|
Config() without arguments picks up the app’s service principal through the Apps OAuth layer. No client IDs, no secrets in environment variables, no token management. The connection is cached per session via @st.cache_resource, so you get one persistent connection per user, not one per query.
Every SQL call goes through a single helper:
|
|
fetchall_arrow() instead of fetchall() because Arrow columnar format is significantly faster for large result sets. The connector deserializes directly into a Pandas DataFrame without row-by-row conversion.
Hypothesis to SQL: the AI layer #
This is the part that changes the workflow. A threat hunter shouldn’t need to remember column names or write JOINs from memory. They should describe what they’re looking for.
The key is grounding. An LLM without schema context will hallucinate table and column names. The fix is simple: inject the actual schema into the system prompt at query time via DESCRIBE TABLE.
|
|
A few things to note:
DESCRIBE TABLEruns live against Unity Catalog. If a column is added or renamed, the next hypothesis picks it up automatically. No hardcoded schemas to maintain.- The schema filter (
startswith("#")) strips Spark metadata rows from theDESCRIBEoutput. Those are partition info and table properties, not columns the LLM should reference. ChatMessagewith typed roles fromdatabricks.sdk.service.serving. The SDK’s native types, not raw dicts.max_tokens=600because SQL queries don’t need 2048 tokens. Keeping it tight reduces latency and prevents the model from generating explanatory text after the query.- The model endpoint (
databricks-meta-llama-3-3-70b-instruct) is a Foundation Model. No deployment needed, pay-per-token, available in every workspace.
The generated SQL is never executed blindly. It’s displayed in an editable text area. The hunter reviews it, optionally modifies, and clicks “Run query” only when they’re satisfied:
|
|
In security, auto-executing AI-generated queries against your full telemetry is not acceptable. The human stays in the loop.
When the LLM endpoint is unavailable (offline workspace, rate limit, testing), the app falls back to a demo query, a CTE-based baseline comparison that demonstrates the pattern without requiring a live model.
Three views #
Hunt Workspace #
The hunter types a hypothesis in plain English. The app translates it to SQL, runs it against Delta, returns results. One-click pivot from any result row to the full entity timeline.
The screenshot shows a real hypothesis: “Hosts that established outbound connections to external IPs between 02:00-05:00 UTC and had no prior history of external communication in the last 30 days.” The model generated a CTE-based query with a baseline comparison, exactly what a hunter would write manually, but in seconds instead of minutes.
Results include a risk column and a one-click “Timeline” pivot for each entity. 8 rows matched, execution took 2.3 seconds across ~847MB of data.
The pivot is simple session state manipulation. Clicking a host name writes it to st.session_state and triggers a rerun that opens the Entity Timeline pre-filled:
|
|
Results can be exported to CSV or saved directly to the Hunt Board with one click.
Entity Timeline #
Click any entity (host, user, IP, process) and get its complete event timeline across all sources in one view. Events grouped by day, filterable by source type. The kind of pivot that takes 20 minutes in a SIEM takes seconds here.
The timeline for fin-ws-0471 tells a clear story: normal VPN logon on March 8, then on March 10 an unusual-hour logon, a suspicious DNS resolution, an encoded PowerShell execution, and finally an outbound connection to 185.220.101.47:4444, a known Tor exit node. Six different data sources (network, EDR, Windows, DNS, DLP, proxy), one coherent view. This is what a Lakehouse makes trivial and a SIEM makes painful.
Events are rendered with source icons and risk-colored badges, grouped by day with collapsible sections:
|
|
The source filter uses st.pills for quick toggling between All, network, EDR, Windows, DNS, DLP, proxy. One click narrows the view to a single telemetry source.
All data sources live in the same Lakehouse. No cross-system API calls, no data stitching, no waiting for index rebuilds. One query, all sources, sub-second response.
Hunt Board #
Hypothesis → In Progress → Confirmed → Dismissed. Each hunt is persisted in a Delta table. Full history auditable and queryable, because in a regulated environment, “we investigated this” needs to be provable.
The board state is backed by Delta with MERGE INTO, so every status change is atomic and versioned:
|
|
Status transitions are two buttons per card: “Start” / “Confirm” to advance, “Dismiss” to close. Each transition calls save_hunt() with the new status and triggers st.rerun(). No separate state management. Delta is the state.
Because it’s Delta, you get time travel for free. “What was the state of all hunts on March 10?” is a single query:
|
|
Try doing that in Jira.
Demo mode #
The app is designed to work without a live Databricks environment. When WAREHOUSE_ID is not set, every component falls back gracefully:
- Hunt Workspace: the LLM fallback returns a realistic CTE-based demo query with synthetic results
- Entity Timeline: shows the
fin-ws-0471attack chain with hardcoded events across six source types - Hunt Board: loads sample hunts covering lateral movement, DNS tunneling, privilege escalation, and confirmed IOCs
This matters for two reasons: you can show the concept to stakeholders without provisioning a warehouse, and you can develop the UI locally without network access.
|
|
Deploy #
The whole thing is three files:
app.py # the entire application
app.yaml # Databricks Apps config
requirements.txt # databricks-sdk, databricks-sql-connector, streamlit, pandas
|
|
Upload to your Databricks workspace, create an App, point it at the folder, start. Config() handles auth. Unity Catalog handles access. The hunts table auto-creates on first use via CREATE TABLE IF NOT EXISTS ... USING DELTA.
What this is not #
This is a PoC, not a finished product. The hunt board doesn’t have drag-and-drop in the Streamlit version. The LLM prompt will produce wrong SQL on sufficiently unusual schemas. There’s no user identity beyond what Databricks Apps passes through.
The production version of this is a different conversation, one I’m not ready to have publicly yet.
What it does show: when you stop treating the Security Data Lake as a cheaper SIEM and start treating it as a different category of tool, the ceiling moves significantly.
Links:
- databricks-threathunt - full source code (Apache 2.0)
- Databricks Apps documentation
- Foundation Model APIs
- Unity Catalog row and column filters
- Delta Lake time travel
If you found this useful, let’s connect: linkedin.com/in/mariusz-derela-30649a69