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
  • ${CAPTURE_ID}: CloudShark capture ID.
    • Example: ea238bd44aaa
  • ${API_TOKEN}: API token with PCAP analysis enabled.
    • Example: 82f2230b777174f098623e4a5203e4ce

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 by limit and offset.

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.