Zeek
The Zeek API gives you the structured logs Zeek produces
from a capture file: one log per protocol or activity, such as conn.log for
connections, dns.log for lookups, http.log for requests, ssl.log for TLS
sessions, and files.log for files seen on the wire. You can list the logs,
download them, and run grouping queries against them, so questions like “who
talked to the most hosts” or “which connections ran over port 443 without TLS”
are a single request.
Zeek runs on a capture the first time any Zeek endpoint asks for it, and the logs are kept, so later requests are answered from the stored logs.
Authentication
All endpoints require an API token with the
PCAP analysis permission enabled, and the token’s user must be able to view
the capture. Pass the token in the Authorization header:
Authorization: <token>
The examples below use these values:
${CLOUDSHARK_URL}: CloudShark instance URL.- Example:
https://cloudshark.local
- Example:
${CAPTURE_ID}: CloudShark capture ID.- Example:
ea238bd44aaa
- Example:
${API_TOKEN}: API token with PCAP analysis enabled.- Example:
82f2230b777174f098623e4a5203e4ce
- Example:
Endpoints
List logs
GET /api/v2/zeek/:id/logs
Returns the logs Zeek produced for the capture, with their sizes in rows. The first request for a capture runs Zeek and can take a while on a large file.
Response: 200 OK
{
"last_run": "2026-11-03T20:29:51.566Z",
"logs": [
{ "name": "conn.log", "size": 346 },
{ "name": "dns.log", "size": 172 },
{ "name": "http.log", "size": 16 },
{ "name": "ssl.log", "size": 89 }
],
"total": 4
}
Get a log
GET /api/v2/zeek/:id/logs/:log
Returns one log as tab-separated values
(Content-Type: text/tab-separated-values). The first row names the fields and
the second gives their Zeek types. Add raw=true to get the log file
exactly as Zeek wrote it, including Zeek’s #fields and #types header lines,
which is what tools like zeek-cut expect.
| Parameter | Type | Description |
|---|---|---|
raw |
boolean | Return the original Zeek log file. Defaults to false. |
Query a log
POST /api/v2/zeek/:id/logs/:log/query
Content-Type: application/json
Runs a grouping query over one log and returns the result as tab-separated values. See Queries for the request body.
Response: 200 OK
# matched: 346 groups: 3 returned: 3 omitted: 0
id.orig_h _count _distinct:id.resp_h
10.10.1.128 344 71
0.0.0.0 1 1
255.255.255.255 1 1
Get filters for a connection
GET /api/v2/zeek/:id/connection-filters?uid=<uid>
Turns a Zeek connection uid, as found in conn.log and most other logs, into
a Wireshark display filter and a BPF capture filter that match that
connection’s packets. Use the display filter with the
analysis API or in the CloudShark viewer.
Response: 200 OK
{
"bpf": "(tcp and src host 10.10.1.128 and dst host 195.63.128.106 and src port 63725 and dst port 443) or (tcp and src host 195.63.128.106 and dst host 10.10.1.128 and src port 443 and dst port 63725)",
"display": "((ip.src eq 10.10.1.128 and tcp.srcport eq 63725) and (ip.dst eq 195.63.128.106 and tcp.dstport eq 443)) or ((ip.src eq 195.63.128.106 and tcp.srcport eq 443) and (ip.dst eq 10.10.1.128 and tcp.dstport eq 63725))"
}
Queries
Zeek logs are tab-separated text, and most guides to working with them reach
for a shell pipeline: zeek-cut to pick columns, grep or awk to filter
rows, then sort | uniq -c | sort -rn to count and rank. That works on the
machine where the logs live, but it means copying the logs out first, and every
step is one more tool to get right.
The query endpoint does the same job as one API call. You name the columns you want, the rows to keep, and how to count and order them, and CloudShark runs the whole pipeline next to the logs and returns only the answer. Filters are checked against the log’s own field types, so a mistake comes back as an error that says what would have worked, rather than as an empty or misleading result.
A query is a JSON object. Every property is optional.
| Field | Type | Description |
|---|---|---|
fields |
array of strings | The columns to return, in order. Each entry is a Zeek field name or a measure. |
filters |
array of objects | Conditions, all of which must hold. |
sort |
string | A column to order by. Must be one of the entries in fields. |
order |
string | asc (the default) or desc. |
limit |
integer | Maximum rows to return. Omit it to get as many as the server returns in one response. |
offset |
integer | Rows to skip before the returned rows, after sorting. |
Fields and measures
Every Zeek field you list is a group key: rows with the same values in all
of them are folded into one result row. There is no separate “group by”.
Listing ["id.orig_h"] returns each originating host once, and listing
["id.orig_h", "id.resp_h"] returns each distinct pair.
A measure starts with _ and is computed for each group:
| Measure | Result |
|---|---|
_count |
Rows in the group. |
_count:<field> |
Rows in the group where <field> is set. |
_sum:<field> |
Total of a numeric field. |
_avg:<field> |
Average of a numeric field. |
_min:<field>, _max:<field> |
Smallest and largest value of a numeric, time, or port field. |
_first:<field>, _last:<field> |
The field’s value in the group’s first and last row. |
_distinct:<field> |
Number of different values the field takes in the group. |
Use "*" to return every field of every row.
Filters
Each filter is an object with a field, an op, and usually a value:
{ "field": "id.resp_p", "op": "eq", "value": 443 }
A filter on a Zeek field selects rows before they are grouped. A filter on a
measure, written exactly as it appears in fields, selects groups afterwards:
{ "field": "_count", "op": "gt", "value": 10 }
| Op | Meaning | Applies to |
|---|---|---|
eq, ne |
Equal, not equal | Any single value |
gt, gte, lt, lte |
Greater or less than | Numbers, times, and ports |
in |
Equal to any value in an array | Any single value |
contains |
Substring match | Strings |
has |
The set or vector contains the value | Sets and vectors |
has_any |
The set or vector contains any value in an array | Sets and vectors |
set, unset |
The field has a value, or is empty (-). Takes no value. |
Any field |
Values are read using the log’s own field types, so 443 against a port is a
number and an ISO 8601 string against a time field is a point in time.
The result header
The first line of every result reports what the query found:
# matched: 346 groups: 72 returned: 3 omitted: 69
matched: rows that passed the row filters.groups: result rows after grouping and group filters.returned: rows in this response.omitted: groups left out bylimitandoffset.
Every query reads the whole log, so these counts are exact. matched: 0 means
nothing in the log matched, not that the search stopped early. When omitted
is large, a narrower query is usually more useful than paging.
Discovering a log’s fields
Send a query with no fields to get the log’s schema: every field and its
type, the measures you can use, how many rows the log has, and the time span it
covers. The schema comes back as a 400 response with the schema in its
message, since the query itself selects nothing:
curl --request POST \
--header "Authorization: ${API_TOKEN}" \
--silent \
--data '{}' \
--url "${CLOUDSHARK_URL}/api/v2/zeek/${CAPTURE_ID}/logs/files.log/query" \
| jq -r '.exceptions[0]'
no fields specified. files.log has 27 fields:
ts time fuid string
uid string id.orig_h addr
id.orig_p port id.resp_h addr
...
mime_type string filename string
...
measures: _count, _count:f, _sum:f, _min:f, _max:f, _avg:f, _first:f, _last:f, _distinct:f
file covers 2026-10-01T19:27:27Z to 2026-10-01T19:40:21Z, 24 rows
use "*" for all fields
A query that names a field the log does not have, or uses an operator on the
wrong type of field, is also answered with a 400 whose message says what
would have worked:
{
"exceptions": [
"\"contains\" doesn't apply to id.resp_p (type port)\n contains is substring matching, and applies to string fields; use has for container membership"
],
"status": 400
}
Examples
The examples use curl, and jq for
the JSON responses. Query results are tab-separated text, so they can go
straight into column -t, a spreadsheet, or a script.
Top talkers
Which hosts started the most connections, and how many different hosts did each one reach:
curl --request POST \
--header "Authorization: ${API_TOKEN}" \
--silent \
--data '{
"fields": ["id.orig_h", "_count", "_distinct:id.resp_h"],
"sort": "_count", "order": "desc", "limit": 5
}' \
--url "${CLOUDSHARK_URL}/api/v2/zeek/${CAPTURE_ID}/logs/conn.log/query"
Output
# matched: 346 groups: 3 returned: 3 omitted: 0
id.orig_h _count _distinct:id.resp_h
10.10.1.128 344 71
0.0.0.0 1 1
255.255.255.255 1 1
Services in use
Destination ports by number of connections and number of servers. A port with many connections to one server is usually infrastructure; one with a single connection to an unusual host is worth a look:
curl --request POST \
--header "Authorization: ${API_TOKEN}" \
--silent \
--data '{
"fields": ["id.resp_p", "proto", "_count", "_distinct:id.resp_h"],
"sort": "_count", "order": "desc", "limit": 8
}' \
--url "${CLOUDSHARK_URL}/api/v2/zeek/${CAPTURE_ID}/logs/conn.log/query"
Output
# matched: 346 groups: 19 returned: 8 omitted: 11
id.resp_p proto _count _distinct:id.resp_h
53 udp 112 1
443 tcp 90 57
389 tcp 28 1
88 tcp 27 1
137 udp 16 2
80 tcp 15 12
389 udp 13 1
138 udp 11 1
Largest downloads by destination
Total bytes received from each server, keeping only servers that sent more than 100 KB. The filter is on a measure, so it applies to the totals rather than to individual connections:
curl --request POST \
--header "Authorization: ${API_TOKEN}" \
--silent \
--data '{
"fields": ["id.resp_h", "_count", "_sum:resp_bytes", "_max:duration"],
"filters": [{ "field": "_sum:resp_bytes", "op": "gt", "value": 100000 }],
"sort": "_sum:resp_bytes", "order": "desc", "limit": 5
}' \
--url "${CLOUDSHARK_URL}/api/v2/zeek/${CAPTURE_ID}/logs/conn.log/query"
Output
# matched: 346 groups: 11 returned: 5 omitted: 6
id.resp_h _count _sum:resp_bytes _max:duration
195.63.128.106 1 13382927 2233.635764
57.150.53.97 1 11523921 6.743223
44.203.10.190 1 1742611 123.936403
107.175.82.242 1 1647168 2.141484
35.170.55.9 4 1173911 2.018062
Port 443 traffic that isn’t TLS
Connections to port 443 where Zeek did not recognize any protocol. Encrypted
web traffic shows up as ssl; something else on port 443 is worth explaining.
The unset operator matches fields Zeek left empty:
curl --request POST \
--header "Authorization: ${API_TOKEN}" \
--silent \
--data '{
"fields": ["id.resp_h", "_count", "_first:uid"],
"filters": [
{ "field": "id.resp_p", "op": "eq", "value": 443 },
{ "field": "service", "op": "unset" }
],
"sort": "_count", "order": "desc"
}' \
--url "${CLOUDSHARK_URL}/api/v2/zeek/${CAPTURE_ID}/logs/conn.log/query"
Output
# matched: 3 groups: 2 returned: 2 omitted: 0
id.resp_h _count _first:uid
23.201.96.192 2 CkCZbA27GUby96a0lh
195.63.128.106 1 Cftj9E3wHDZSI3zjWc
Long-lived connections
Connections that lasted more than a minute, longest first:
curl --request POST \
--header "Authorization: ${API_TOKEN}" \
--silent \
--data '{
"fields": ["uid", "id.orig_h", "id.resp_h", "id.resp_p", "service", "duration"],
"filters": [{ "field": "duration", "op": "gt", "value": 60 }],
"sort": "duration", "order": "desc", "limit": 5
}' \
--url "${CLOUDSHARK_URL}/api/v2/zeek/${CAPTURE_ID}/logs/conn.log/query"
Output
# matched: 63 groups: 63 returned: 5 omitted: 58
uid id.orig_h id.resp_h id.resp_p service duration
Cyj4rB4cUszfPCl6v4 10.10.1.128 10.10.1.10 445 gssapi,krb,smb 2412.591804
Cftj9E3wHDZSI3zjWc 10.10.1.128 195.63.128.106 443 - 2233.635764
CnaOKN21OCrQR2LBm2 10.10.1.128 104.208.203.90 443 ssl 1932.284765
CUrCli37yXYAXDny6b 10.10.1.128 104.208.203.90 443 ssl 1876.653911
C23ibH1Ff7fPDkvsD 10.10.1.128 10.10.1.10 445 dce_rpc,gssapi,krb,smb 613.066296
The same connection, Cftj9E3wHDZSI3zjWc, shows up here, in the non-TLS port
443 list, and at the top of the largest downloads: a 37-minute session that
received 13 MB over port 443 without speaking TLS.
DNS lookups
The most frequently resolved names, with the answers from the first lookup of
each. The in operator matches any of a list of values:
curl --request POST \
--header "Authorization: ${API_TOKEN}" \
--silent \
--data '{
"fields": ["query", "_count", "_first:answers"],
"filters": [{ "field": "qtype_name", "op": "in", "value": ["A", "AAAA"] }],
"sort": "_count", "order": "desc", "limit": 5
}' \
--url "${CLOUDSHARK_URL}/api/v2/zeek/${CAPTURE_ID}/logs/dns.log/query"
Output
# matched: 115 groups: 58 returned: 5 omitted: 53
query _count _first:answers
wpad.natureforce.com 20 -
settings-win.data.microsoft.com 9 atm-settingsfe-prod-geo2.trafficmanager.net,settings-prod-eus-1-tagged.eastus.cloudapp.azure.com,48.211.4.16
v10.events.data.microsoft.com 6 win-global-asimov-leafs-events-data.trafficmanager.net,onedscolprdgwc52.germanywestcentral.cloudapp.azure.com,20.52.64.206
v20.events.data.microsoft.com 5 win-global-asimov-leafs-events-data.trafficmanager.net,onedscolprdcus70.centralus.cloudapp.azure.com,4.150.223.113
ctldl.windowsupdate.com 4 ctldl.windowsupdate.com.delivery.microsoft.com,wu-b-net.trafficmanager.net,bg.microsoft.map.fastly.net,199.232.214.172,199.232.210.172
TLS server names
Which servers were reached over TLS, by the name the client asked for:
curl --request POST \
--header "Authorization: ${API_TOKEN}" \
--silent \
--data '{
"fields": ["server_name", "id.resp_h", "_count", "_first:version"],
"sort": "_count", "order": "desc", "limit": 5
}' \
--url "${CLOUDSHARK_URL}/api/v2/zeek/${CAPTURE_ID}/logs/ssl.log/query"
Output
# matched: 89 groups: 60 returned: 5 omitted: 55
server_name id.resp_h _count _first:version
fonts.gstatic.com 142.250.26.94 6 TLSv13
edge.microsoft.com 150.171.30.11 5 TLSv12
ecs.office.com 52.123.250.8 4 TLSv13
login.microsoftonline.com 20.190.157.14 3 TLSv13
ogads-pa.clients6.google.com 142.251.116.95 3 TLSv13
Command-line HTTP clients
Web requests made by curl rather than a browser. Scripts and malware often
fetch their next stage this way, and contains matches part of a string:
curl --request POST \
--header "Authorization: ${API_TOKEN}" \
--silent \
--data '{
"fields": ["uid", "host", "uri", "status_code"],
"filters": [{ "field": "user_agent", "op": "contains", "value": "curl" }]
}' \
--url "${CLOUDSHARK_URL}/api/v2/zeek/${CAPTURE_ID}/logs/http.log/query"
Output
# matched: 3 groups: 3 returned: 3 omitted: 0
uid host uri status_code
C2IkQn22p7iFr0LoD4 holtandberyl.com /prw 301
CDFUSj3EEz8oxoKuFb 107.175.82.242:9000 /wilow/runner_ilove.exe 200
CQwSIc2hLihwOn9S4b holtandberyl.com /crv 301
Executables on the wire
Windows executables Zeek saw being transferred, from files.log. The uid
ties each file back to the connection that carried it:
curl --request POST \
--header "Authorization: ${API_TOKEN}" \
--silent \
--data '{
"fields": ["uid", "mime_type", "total_bytes"],
"filters": [{ "field": "mime_type", "op": "eq", "value": "application/x-dosexec" }]
}' \
--url "${CLOUDSHARK_URL}/api/v2/zeek/${CAPTURE_ID}/logs/files.log/query"
Output
# matched: 1 groups: 1 returned: 1 omitted: 0
uid mime_type total_bytes
CDFUSj3EEz8oxoKuFb application/x-dosexec 1646592
From a Zeek connection to its packets
Every Zeek log row carries the uid of its connection. Turn a uid into a
display filter, then use that filter with the
analysis API to read the connection’s packets.
This takes the suspicious port 443 session found above from a log row to the
bytes on the wire:
FILTER=$(curl --request GET \
--header "Authorization: ${API_TOKEN}" \
--silent \
--url "${CLOUDSHARK_URL}/api/v2/zeek/${CAPTURE_ID}/connection-filters?uid=Cftj9E3wHDZSI3zjWc" \
| jq -r '.display')
echo "${FILTER}"
Output
((ip.src eq 10.10.1.128 and tcp.srcport eq 63725) and (ip.dst eq 195.63.128.106 and tcp.dstport eq 443)) or ((ip.src eq 195.63.128.106 and tcp.srcport eq 443) and (ip.dst eq 10.10.1.128 and tcp.dstport eq 63725))
From a Suricata alert to a Zeek connection
Zeek’s conn.log includes each connection’s
Community ID, the same flow
hash Suricata alerts carry. Use it to find the
connection behind an alert:
curl --request POST \
--header "Authorization: ${API_TOKEN}" \
--silent \
--data '{
"fields": ["uid", "id.orig_h", "id.resp_h", "id.resp_p", "service", "duration", "resp_bytes"],
"filters": [{ "field": "community_id", "op": "eq", "value": "1:c8V1cQkZy/GD6rcc/qORahY2s6Y=" }]
}' \
--url "${CLOUDSHARK_URL}/api/v2/zeek/${CAPTURE_ID}/logs/conn.log/query"
Output
# matched: 1 groups: 1 returned: 1 omitted: 0
uid id.orig_h id.resp_h id.resp_p service duration resp_bytes
CDFUSj3EEz8oxoKuFb 10.10.1.128 107.175.82.242 9000 http 2.141484 1647168
Errors
Errors are returned as JSON with the message in exceptions:
{ "exceptions": ["API Token is not allowed to execute analysis method."], "status": 403 }
| Status | Cause |
|---|---|
403 |
The token does not have the PCAP analysis permission. |
400 |
The capture does not exist or the token’s user cannot view it, the log does not exist, or the query was rejected. The message explains which. |
404 |
The path is not one of the endpoints above. |