OQL primer for the model¶
soc-ai loads this file verbatim into the agent's system prompt at initialization. Keep the file precise. Keep it self-contained. Give many examples.
OQL is the query language of Security Onion. A query is a Lucene-style boolean filter expression. One or more pipe stages can follow the expression.
soc-ai parses OQL with a strict grammar. It validates every field name against a whitelist. The whitelist comes from the ECS, SO, Zeek and Suricata field references. soc-ai then translates the query to Elasticsearch DSL before execution. Raw OQL never reaches Elasticsearch.
soc-ai rejects a query with an unknown field, with an unsafe operator, or with a result
size above the caller's max_results. The error explains the cause, so you can correct
the query in the next turn.
Filter grammar¶
filter ::= or
or ::= and ("OR" and)*
and ::= not ("AND" not)*
not ::= "NOT" atom | atom
atom ::= "*" | "(" or ")" | term
term ::= field ":" value
value ::= bare | quoted | wildcard | range
range ::= "[" bound "TO" bound "]"
bound ::= bare | quoted | "*"
- bare values are
tcp,203.0.113.50,2026-05-07,0and8080. A bare value holds no spaces. - quoted values look like
"ET MALWARE Suspicious User-Agent". Quote a value if it holds spaces or special characters. - wildcard values look like
et?probeandPSEXESVC*.*matches any sequence.?matches a single character. A LEADING wildcard is REJECTED as too expensive.*fooand*foo*are leading wildcards. Anchor the prefix and writefoo*. There is no substring match. If you need one, pick a more specific field or an anchored prefix. - ranges look like
[1 TO 100]and[now-7d TO now].[* TO 1000]is open-low.[1024 TO *]is open-high.
Two rejections to avoid¶
- Parentheses group whole
field:valueexpressions, NOT values.source.ip:(203.0.113.10 OR 203.0.113.20)is INVALID and returns an LPAR parse error. Write(source.ip:203.0.113.10 OR source.ip:203.0.113.20). A range or two separate queries also work. - Never assume a dataset is empty from a different dataset. To check a protocol,
query ITS dataset:
event.dataset:zeek.ssh,event.dataset:zeek.kerberosorevent.dataset:zeek.smb_files. An emptyzeek.connslice says nothing about SSH.
Pipe stages¶
| Stage | Purpose | Example |
|---|---|---|
groupby F[, F2…] |
Bucket aggregation by one or more fields. It returns aggregations, and it returns no documents. | * \| groupby host.name |
sortby F [asc\|desc] |
Sort the hits by F. The default is asc. Use sortby count desc after a groupby to sort the buckets by document count. |
… \| sortby @timestamp desc |
head N / limit N |
Return at most N hits. After a groupby it returns the top N buckets. The caller's max_results caps the number. |
… \| head 10 |
count |
Return only the total hit count. It returns no documents. | event.module:zeek \| count |
A pipe stage may not be repeated. The written order of the stages does not change the
evaluation. groupby always applies before sortby and head.
Field naming¶
soc-ai accepts ECS-style dotted field names. The common namespaces are:
- Time:
@timestamp - Event metadata:
event.module,event.kind,event.severity,event.severity_label,event.dataset - Rules / detections:
rule.name,rule.uuid,rule.severity - Network 5-tuple:
source.ip,source.port,destination.ip,destination.port,network.transport - Network correlation:
network.community_id. This is the most useful pivot in SO. - Host:
host.name,host.ip - Identity:
user.name - Process:
process.entity_id,process.name,process.command_line - File:
file.name,file.hash.sha256,file.hash.md5 - Zeek logs:
zeek.conn.*,zeek.dns.query,zeek.http.uri,zeek.ssl.server_name,zeek.files.* - Suricata:
suricata.eve.alert.*
If you name a field outside the whitelist, the validator rejects the query. The error names the bad field. Try a more conventional name from the list above before you guess.
Worked examples¶
These examples cover the most common patterns in alert triage.
1. Find a specific alert by rule name¶
2. Pivot from an alert to the matching Zeek connection¶
The network.community_id is a hash of the 5-tuple. The value is the same on the alert,
on the conn log and on any associated http, dns or ssl record. This is the canonical
pivot.
3. All events on a host in the last hour¶
Combine the query with time_range_minutes=60 from the caller. Use a host name or an IP
address:
4. Top-10 destination IPs that triggered any alert¶
5. Suspicious outbound traffic on non-standard ports¶
6. Failed DNS lookups for a host¶
host.name:workstation-01 AND event.module:zeek AND zeek.dns.rcode_name:NXDOMAIN
| sortby @timestamp desc
7. Count of alerts grouped by severity in the last day¶
8. Find every event with a specific file hash¶
9. Multi-host beacon pattern¶
This query returns the top destinations that many hosts contact.
event.module:zeek AND zeek.conn.duration:[60 TO *]
| groupby destination.ip, host.name
| sortby count desc
| head 20
10. Total alert volume in the window¶
This query returns the count only. It returns no documents.
Lateral-movement & behavioral examples¶
These examples target the datasets that reveal east-west movement and RITA-style rollups. Use them ONLY IF the dataset appears in the auto-discovered inventory. An absent dataset means that the grid does not collect that log. That absence is itself a finding.
11. Kerberoasting¶
These are TGS requests with RC4. The attacker harvests tickets under a weak cipher.
12. PsExec service creation¶
This is classic remote-execution lateral movement.
13. Successful SSH logins¶
These logins completed.
14. RITA-style behavioral rollups¶
If the grid ships summary datasets, they pre-compute the beacon scores and the DNS-tunnel scores. One query then decides the question, with no reconstruction from the raw conn and dns logs:
15. Every host that contacted one attacker indicator¶
This query measures the cross-host fan-out. Start from an external attacker IP address.
Find the SET of internal hosts that contacted it. The fan-out itself is a finding. The
indicator below is the TEST-NET address 203.0.113.7:
16. Which datasets one host ships¶
Run this query before you report that a host has no telemetry. The host ships a
plane when a dataset of that plane has documents. A Linux host with Elastic Agent
and no Elastic Defend ships system.syslog, system.auth and
osquery_manager.result, and no endpoint.events.*. A dataset that a host does
not ship is a gap in that plane only. Name the plane: "this host ships no process
events".
Use the address when you do not know the name:
Common pitfalls (avoid these)¶
- Do not quote a bare number or an IP address.
source.port:"443"andsource.ip:"203.0.113.1"work. The unquoted forms behave the same way, and they are clearer. - Do not use OR, AND, NOT or TO as a field name. These words are reserved.
- Do not mix
headandcount.countreturns the total.headreturns documents. Pick one. - Do not request more than
max_resultsdocuments in ahead N. The validator rejects the query. To scan more, write agroupbyaggregation. - Do not access
_sourcedirectly. It is forbidden. Use named ECS fields. - Do not omit the time bounds of an unbounded query. The caller always supplies a
time_range_minuteswindow. Scope the query by an indicator as well: a host, a rule or an IP address.
When the validator rejects a query¶
The error message names the bad fragment. Read the field reference above again. Pick a
known field from the same area. Emit the query again. For example, the validator rejects
agent.hostname, and the correct field is host.name.