/ projects / bastion
BASTION
KQL investigation toolkit that ends the rebuild-from-scratch loop
Built into it
- KQL query builder with safety guards
- Compound detection-pattern engine
- Inline IP enrichment proxy

Problem
In a multi-tenant SOC with rotating coverage, every analyst rebuilds the same investigative KQL from scratch on every shift. There’s no shared query store and no agreed-on structure, so query quality varies by who’s on and how much time they have — and the alert context that should carry between shifts gets lost.
Approach
BASTION is a single-file HTML tool backed by a small FastAPI enrichment proxy. The query builder enforces a consistent shape — time filter first, entity filters before extend clauses, safe column references via column_ifexists — so a query someone wrote at 2 AM still reads cleanly the next morning. A compound detection engine maps analyst intent to table-specific queries, and the Security Knowledge Assistant adds offline MITRE technique lookups, a LOLBin library with flag parsing, and alert-type playbooks.
// Suspicious outbound from a workstation — generated by BASTION
DeviceNetworkEvents
| where Timestamp > ago(24h)
| where DeviceName == "DESKTOP-01"
| where RemoteIP !startswith "10." and RemoteIP !startswith "192.168."
| where RemotePort in (443, 80, 8080, 8443)
| project Timestamp, DeviceName, RemoteIP, RemotePort, InitiatingProcessFileName
The IP enrichment proxy checks Tor exit nodes, VPN/proxy hosting, and known government ranges and surfaces the result inline, so the analyst doesn’t lose their place to a separate lookup tab.
The thing BASTION actually fixes is the safety pass on every generated query. Off-the-cuff KQL works fine on a 50-host tenant — the same shape against a 5,000-host tenant times out, scans tables that don’t have the columns the analyst assumed, or returns a million-row result set. Compare:
Common shape of an off-the-cuff hunt query. Works on small tenants, breaks at scale.
SigninLogs
| where ResultType != 0
| where IPAddress == "10.4.7.18"
| where TargetUserName == "admin" Same intent, generated through BASTION. Time-bounded, schema-safe, paginated.
let lookback = 7d;
SigninLogs
| where TimeGenerated > ago(lookback)
| where ResultType != "0"
| where IPAddress == "10.4.7.18"
| where column_ifexists("UserPrincipalName", "")
endswith "@contoso.com"
| project TimeGenerated, UserPrincipalName, IPAddress,
ResultType, ConditionalAccessStatus
| sort by TimeGenerated desc
| limit 200 Outcome
A junior analyst working a beaconing alert reaches the same query shape a senior would write — on their first shift, not their tenth. Common investigative paths are one or two clicks from the alert, and the structural consistency makes the next shift’s handoff readable.
What’s next
A multi-tenant context-switching layer is the next thing I’m exploring. The open question is how much investigation-planner logic the single-file format can carry before maintenance starts to bite.
/ related · 03
Other projects
- 01
CARL
Offline SOC knowledge base that captures what lives in analysts' headsHTML JavaScript Python FastAPIActive - 02
KQL Sentinel Lab
Synthetic Sentinel environment for analysts to practice on real attack dataHTML JavaScript Python FastAPIActive - 03
ThreatWatch
Curated threat intel delivery from RSS feeds to Slack, automated dailyHTML JavaScript Python FastAPIActive