/ projects / bastion

BASTION

KQL investigation toolkit that ends the rebuild-from-scratch loop

HTML JavaScript Python FastAPI KQL Microsoft Sentinel
20+ KQL hunt templates 7 detection categories Single-file HTML deploy

Built into it

  • KQL query builder with safety guards
  • Compound detection-pattern engine
  • Inline IP enrichment proxy
BASTION

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:

// kql safety · before / after suspect IP hunt · contoso.com

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"
› Issues no time bound · wrong column · type mismatch

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
› Returns bounded result set · safe column refs

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.