Reference
Language reference
The definition of CEPL, version 1.0: its grammar, then every rule of the language, numbered. When Ceplin and this page disagree, Ceplin is wrong.
CEPL 1.0 is stable. A line that runs keeps its meaning in later versions, which may add to the language.
#The principle
0.1 Nothing is assumed. A line does what it says or is refused before any lookup is made. CEPL never picks, on the analyst's behalf, which value was meant, never runs a verb on something other than what the line names, and never answers an error with a different question. When a line is refused, the message says what to write instead.
>>> "198.51.100.0/24".geo() [ER] geo cannot accept cidr (accepts ip) >>> "hello%20world".decode() [ER] decode() needs a mode, e.g. .decode(auto); this looks like .decode(url)
0.2 Parentheses hold a condition or a mode, nothing else. decode(base64), identify(platform), filter(score > 50). A verb is never written inside another verb's parentheses: verbs are chained with . or grouped in a block { }.
>>> "185.215.113.66".abuseipdb(geo()) [ER] abuseipdb() takes no argument; select a field after the call: abuseipdb()[domain] >>> "185.215.113.66".{ abuseipdb()[score], geo()[country_name] } [OK] Multi Report Expression on 185.215.113.66 abuseipdb[score]: 100 geo[country_name]: Netherlands
#Grammar
The shape of everything below, in EBNF. Words in quotes are written as they are; newline is the end of a line.
program = statement { ( newline | ";" ) statement } .
statement = assignment | line | command .
assignment = name "=" ( line | list | combined ) .
combined = ( name | list ) { ( "+" | "&" | "-" ) ( name | list ) } .
command = "/" word { word } .
save = "/save" type { "|" type } chain name [ "(" [ param { "," param } ] ")" ] .
param = name [ "=" value ] .
call = "." "@" name "(" [ name "=" value { "," name "=" value } ] ")" .
line = head chain .
chain = { step } [ block [ tail ] [ "[" field { "," field } "]" chain | chain ] ] .
head = artefact | list | name | "output(" [ field ] ")" .
artefact = '"' text '"' | "'" text "'" | "```" text "```" | bare .
list = "[" artefact { "," artefact } "]" .
step = "." verb [ "(" [ mode | condition | number | pattern | keys | field ] ")" ] { select }
| call
| select .
select = "[" field { "," field } "]" | "[" number "]" .
block = [ "." ] "{" branch { ( "," | newline ) branch } "}" .
branch = verb [ "(" [ mode ] ")" ] { step } | "@" name "(" ")" | block .
tail = ".cost()"
| [ ".filter(cost <" number ")" ] [ ".filter(" condition ")" ] [ ".sort(" [ keys ] ")" ]
[ ( ".first(" | ".last(" ) [ number ] ")" | ".limit(" number ")" ]
[ ".count()" | ".export(" [ "format ==" format | quoted-name ] ")" ] .
keys = key { "," key } | "asc" | "desc" .
key = ( field | verb "." field ) [ "asc" | "desc" ] .
condition = term { "and" term } | term { "or" term } .
term = [ "not" ] ( "(" condition ")" | clause ) .
clause = field op value
| field ( "present" | "!present" | "not present" )
| field [ "not" ] "in" ( list | name ) .
op = "==" | "!=" | ">" | ">=" | "<" | "<=" | "~" | "contains" .
value = number | date | '"' text '"' | pattern | "true" | "false" | name | moment | address .
list = "[" value { "," value } "]" .
moment = ( "now" | "today" ) [ ( "+" | "-" ) number ( "h" | "d" | "w" ) ] .
pattern = "/" text "/" [ flags ] .After a block, a chain that goes on without [value] starts with a block (8.7); a combination uses one of +, & and - (12.4). A verb's parentheses may be left off only before a selection: .urlscan[ip]. Spaces between the parts of a step are layout: . dns () is .dns().
#1. Lines
1.1 A line is an artefact, then a chain of steps. The artefact is the thing being investigated; each step takes what the step before it produced.
>>> "aGk=".decode(auto) [OK] Decode on aGk= hi text: hi method: base64 confidence: 60 >>> "cdn-telemetry.xyz".urlscan()[ip].abuseipdb()[score] [OK] IP reputation [score] on 45.9.148.112 via AbuseIPDB 100
1.2 A chain may continue on the next line. A line that starts with ., [ or { continues the line above it.
>>> "cdn-telemetry.xyz".urlscan() ... [ip] ... .abuseipdb()[score] [OK] IP reputation [score] on 45.9.148.112 via AbuseIPDB 100
1.3 Statements. Several statements are written one per line or separated by ;. They run in order, and a program stops at its first failing statement.
>>> x = "aGk="; x.decode(auto) [OK] Recognised text aGk= [OK] x 1 text value(s) [OK] Decode on aGk= hi text: hi method: base64 confidence: 60
1.4 Comments. // starts a comment that runs to the end of the line, outside quotes. A defanged scheme such as hxxp[:]// is not a comment.
>>> "aGk=".decode(auto) // base64, probably [OK] Decode on aGk= hi text: hi method: base64 confidence: 60
#2. Artefacts
2.1 Quoting. An artefact is written in double or single quotes; curly quotes pasted from documents count as quotes. Text that spans lines, or holds quotes of its own, goes in a fence of three backticks.
>>> 'aGk='.decode(auto) [OK] Decode on aGk= hi text: hi method: base64 confidence: 60
2.1a Escapes. Inside double or single quotes, \" (\' in single quotes) is the quote and \\ is one backslash. Every other backslash is itself, so a Windows path is written as it is; only a backslash right before the closing quote, or a pair of them, must be doubled. A backtick string and a fence have no escapes.
>>> "a\"b".encode(hex) [OK] Encode on a"b 612262 text: 612262 method: hex >>> 'it\'s'.encode(hex) [OK] Encode on it's 69742773 text: 69742773 method: hex >>> "C:\Users\x".encode(hex) [OK] Encode on C:\Users\x 433a5c55736572735c78 text: 433a5c55736572735c78 method: hex >>> "C:\\".encode(hex) [OK] Encode on C:\ 433a5c text: 433a5c method: hex >>> 'a"b'.encode(hex) [OK] Encode on a"b 612262 text: 612262 method: hex
2.2 An artefact with steps after it is quoted. In 185.215.113.66.abuseipdb() the value's dots and the step's look alike, so a line with steps refuses an unquoted artefact and shows it quoted. An artefact on its own may be pasted bare: it is recognised and the menu opens, and choosing a step from the menu writes the quotes in. A bare word is a variable name, and one that names no variable is refused; a hash or an ASN is no word, whatever letter it starts with.
>>> 185.215.113.66 [OK] Recognised ip 185.215.113.66 >>> AS15169 [OK] Recognised asn AS15169 >>> cdn-telemetry.xyz.urlscan()[ip] [ER] quote the artefact before its steps: write "cdn-telemetry.xyz".urlscan()[ip] >>> C:\Users\x\.encode(hex) [ER] quote the artefact before its steps: write "C:\Users\x\\".encode(hex) >>> nope.decode(auto) [ER] no variable "nope": quote it to use it as text, "nope" (variables: x)
2.3 A quote that is never closed, and an empty artefact, are refused.
>>> "aGk=.decode(auto) [ER] the artefact has no closing quote: "aGk=.decode(auto) >>> "".decode(auto) [ER] the artefact is empty: put a value between the quotes
2.4 Every artefact has a type. The type is recognised from the artefact itself. The types are ip, cidr, asn, domain, hostname, url, email, email_header, hash, imphash, fuzzyhash, cert_serial, cert_fingerprint, malware_family, tag, yara_rule, filename, sample, command, text, and for values inside results org, country, score, age (in days), date, int, bool, http_status, image, file and row. A verb lists the types it takes, and every line is checked against them before anything is looked up. name.type says what a variable holds.
>>> x.type [OK] x.type [text]
2.5 Defanged artefacts are read as what they stand for. A defang is read back only where it can be one: hxxp, hxxps and fxp before ://, [:]// or [://]; [.], (.), {.}, [dot] and (dot); [:]; [@], (@), [at] and (at); and a whole name written with dot (evil dot com). The same letters inside a word or a command are left alone. The value is looked up as the real one and stays marked as defanged, so the output can be defanged again for sharing.
>>> "hxxp://cdn-telemetry[.]xyz/win/update.ps1".urlscan()[ip] [OK] URL scan [ip] on http://cdn-telemetry.xyz/win/update.ps1 via urlscan.io 45.9.148.112 Defanging undone: "hxxp://cdn-telemetry[.]xyz/win/update.ps1" was read as "http://cdn-telemetry.xyz/win/update.ps1". To work on it as written, turn on "Keep defanged artefacts as written" in Settings, Advanced >>> "cdn-telemetry dot xyz".rdap()[registrar] [OK] WHOIS / RDAP [registrar] on cdn-telemetry.xyz via RDAP NameSilo, LLC Defanging undone: "cdn-telemetry dot xyz" was read as "cdn-telemetry.xyz". To work on it as written, turn on "Keep defanged artefacts as written" in Settings, Advanced >>> "echo one dot two".encode(hex) [OK] Encode on echo one dot two 6563686f206f6e6520646f742074776f text: 6563686f206f6e6520646f742074776f method: hex
2.6 Parts of an artefact before any lookup. An email, URL or domain has parts that need no lookup: [domain] (the registrable domain), [host], [subdomain], [tld], and [user] for an email.
>>> "a@mail.corp.co.uk"[domain] [OK] [domain] on a@mail.corp.co.uk corp.co.uk
2.7 Documentation and private addresses are never sent anywhere. An address in a documentation range (RFC 5737) or a private, loopback or link-local range has no public reputation, so a lookup on it is skipped and says why. It costs no API call.
>>> "203.0.113.42".abuseipdb() [--] IP reputation on 203.0.113.42 via AbuseIPDB reserved/documentation range (203.0.113.0/24); no public reputation exists; not looked up, no API call made
#3. Heads
3.1 A list. [a, b, c] is a list of artefacts of one type, separated by commas.
>>> ["aGk=", "aGVsbG8="].decode(auto) [OK] Decode on 2 texts text text ─────────────── aGk= hi aGVsbG8= hello >>> ["aGk=" "aGVsbG8="].decode(auto) [ER] list values are separated by commas: "aGk=" "aGVsbG8="
3.2 An index picks one value of a list, counting from 0.
>>> ["aGk=", "aGVsbG8="][5] [ER] [5] is past the end of a 2-item list (the first is [0])
>>> ["aGk=", "aGVsbG8="][1].decode(auto) [OK] Decode on aGVsbG8= hello text: hello method: base64 confidence: 60
3.3 A variable. name = … stores what the right-hand side produced; the name then stands for it at the start of a line.
>>> nosuchvar.decode(auto) [ER] no variable "nosuchvar": quote it to use it as text, "nosuchvar" (variables: x)
>>> ips = ["185.215.113.66", "8.8.8.8"] [OK] ips 2 ip value(s) >>> ips.abuseipdb()[score] [OK] IP reputation [score] on 2 ips via AbuseIPDB ip score ───────────────────── 185.215.113.66 100 8.8.8.8 0
3.4 output(). output() is what the previous line produced; output(field) and output()[field] are a field of it. After a step with several results, output() is all of them, fields and all, and output()[1] is one of them; a variable is read the same way. After find() by shape, output() is the values found, as a variable set from the line holds them (12.1).
>>> "aGk=".decode(auto) [OK] Decode on aGk= hi text: hi method: base64 confidence: 60 >>> output()[text] [OK] Recognised text hi >>> ["185.215.113.66", "8.8.8.8"].abuseipdb() [OK] IP reputation on 2 ips via AbuseIPDB 185.215.113.66 score 100 total_reports 1148 isp Prospero OOO usage_type Data Center/Web Hosting/Transit country Russian Federation is_tor false last_reported 2026-08-12T22:41:07+00:00 8.8.8.8 score 0 total_reports 0 domain google.com hostnames dns.google isp Google LLC usage_type Content Delivery Network country United States of America is_tor false >>> output()[1][isp] [OK] [isp] on 8.8.8.8 Google LLC >>> "seen 185.215.113.66 and 8.8.8.8 today".find(ip) [OK] .find(ip) on seen 185.215.113.66 and 8.8.8.8 today 1 seen 185.215.113.66 and 8.8.8.8 today 1 seen 185.215.113.66 and 8.8.8.8 today >>> output()[1].geo()[country_name] [OK] IP geolocation [country_name] on 8.8.8.8 via ip2location.io United States of America
3.5 A file. An uploaded CSV or TSV is a head through import(): "suspects.csv".import()[ip]. The delimiter is read from the header row, and column names match in any case. A header is turned into a name of letters, digits and _: Source IP is [source_ip], src.ip is [src_ip]; [src.ip] is refused with the name to write. A row is all of its columns, so a lookup takes a column, not the row: .import().abuseipdb() is refused with the column to select.
3.6 A file without a header. When the first row is data (every cell of it in a column of addresses, domains, hashes, URLs or emails is one of those itself, as 192.0.2.1 above more addresses), the file has no header: that row is kept, and each column is named for what it holds, [ip], [ip_2], [domain], or [col_N] for text. Rows are then numbered from 1. A header of words, such as ip, hostname or src.ip, is read as a header, as before.
#4. Verbs
4.1 A verb is called with parentheses. .verb(). A subscript may stand for the parentheses: .urlscan[ip] is .urlscan()[ip].
>>> "cdn-telemetry.xyz".urlscan[ip] [OK] URL scan [ip] on cdn-telemetry.xyz via urlscan.io 45.9.148.112
4.2 A mode goes in the parentheses. decode(base64), encode(hex), decode(unix_time), identify(platform). A verb that needs a mode is refused without one. decode() is one: decode(auto) finds the encoding itself, and the refusal of a bare decode() names the mode the value looks like.
>>> "hi".encode(hex) [OK] Encode on hi 6869 text: 6869 method: hex >>> "hi".encode() [ER] encode() needs a mode, e.g. .encode(base64) (try: encode(base64) or encode(base64_utf16le) or encode(base64_offsets) or encode(base64_offsets_utf16le) or encode(hex)) >>> "hello%20world".decode(auto) [OK] Decode on hello%20world hello world text: hello world method: url confidence: 60 >>> "hello%20world".decode() [ER] decode() needs a mode, e.g. .decode(auto); this looks like .decode(url)
4.3 A verb takes the types it lists. Anything else is refused before a lookup, with the types the verb takes.
>>> "aGk=".geo() [ER] geo cannot accept text (accepts ip)
4.4 Names are read in any case and kept in lower case. Verbs, modes, field names, variable names, saved names and workspace commands: .DECODE(AUTO) is .decode(auto), X is x, /save … D64 saves @d64, /LIST is /list. What is between quotes keeps its case.
>>> "aGk=".DECODE(AUTO) [OK] Decode on aGk= hi text: hi method: base64 confidence: 60 >>> X = "aGk=" [OK] Recognised text aGk= [OK] x 1 text value(s) >>> x.decode(auto) [OK] Decode on aGk= hi text: hi method: base64 confidence: 60 >>> "Hi".encode(hex) [OK] Encode on Hi 4869 text: 4869 method: hex
4.5 An unknown verb is refused, with the nearest one.
>>> "aGk=".decod() [ER] unknown method "decod" (try: .decode())
4.6 One name per verb. Some verbs had other names (reputation() for abuseipdb(), geoip() for geo() and more). They are refused with the verb's name; Appendix A lists them. A verb named after a mode (decode_url(), decode_base64(), …) is gone too: decode(url) is the one name.
>>> "185.215.113.66".reputation() [ER] reputation() is called abuseipdb(): write .abuseipdb() >>> "aGk=".decode_base64() [ER] decode_base64() is decode(base64): write .decode(base64)
4.7 The verbs. Appendix A lists every verb: the types it takes, whether it makes API calls, its modes, its fields and its other names. It is generated from the code, and a test fails if the two differ.
#5. Selecting
5.1 [field] takes a field of a result; [a, b] takes several. [field] is how a field is selected. extract(field) is still accepted and means the same, but documentation and examples use [field].
>>> "185.215.113.66".abuseipdb()[score, isp] [OK] IP reputation [score, isp] on 185.215.113.66 via AbuseIPDB score: ● 100 isp: Prospero OOO >>> "cdn-telemetry.xyz".urlscan().extract(ip) [OK] URL scan [ip] on cdn-telemetry.xyz via urlscan.io 45.9.148.112
5.2 A field the result does not have is refused, with the ones it has.
>>> "185.215.113.66".abuseipdb()[scroe] [ER] no field "scroe" available to extract (try: score)
5.3 [n] picks the nth value of a list field, counting from 0.
>>> "cdn-telemetry.xyz".rdap()[nameservers][0] [OK] WHOIS / RDAP [nameservers][0] on cdn-telemetry.xyz via RDAP ns1.regway.com >>> "cdn-telemetry.xyz".rdap()[nameservers][-1] [ER] col 20: index cannot be negative; for the last value, write .last()
5.4 Field names are read in any case, as every name is (4.4).
>>> "cdn-telemetry.xyz".urlscan()[IP] [OK] URL scan [ip] on cdn-telemetry.xyz via urlscan.io 45.9.148.112
5.5 [start*] selects every field whose name starts with start. Only with the *: a name that is merely the start of field names is refused, and the error offers [start*].
>>> "cdn-telemetry.xyz".rdap()[registr*] [OK] WHOIS / RDAP [registr*] on cdn-telemetry.xyz via RDAP registrar: NameSilo, LLC registrar_abuse_email: abuse@namesilo.com >>> "cdn-telemetry.xyz".rdap()[registr] [ER] no field "registr" available to extract (try: [registr*] or registrar)
#6. Types between steps
6.1 A report passes on only a field written out. A lookup returns a report. The next verb gets a value only when the line names it with [field]. Two lookups on the same value are a block.
>>> "185.215.113.66".abuseipdb()[domain] [--] IP reputation [domain] on 185.215.113.66 via AbuseIPDB domain is empty for 185.215.113.66 in AbuseIPDB's record >>> "185.215.113.66".abuseipdb().geo() [ER] abuseipdb() gives a report, and geo() takes ip: to run both on the same ip, write .{ abuseipdb(), geo() } >>> "185.215.113.66".abuseipdb().urlscan() [ER] abuseipdb() gives a report, and urlscan() takes url, domain: select the field to pass on, as [field].urlscan() (try: [domain].urlscan()) >>> "185.215.113.66".{ abuseipdb()[isp], geo()[country_name] } [OK] Multi Report Expression on 185.215.113.66 abuseipdb[isp]: Prospero OOO geo[country_name]: Netherlands
6.2 Conversions between plain types. A value of one type reaches a verb that takes another only through these conversions; none is made from a report, and none chooses anything the line did not say.
| from | to | how |
|---|---|---|
| hostname | domain | the same name |
| url | domain | the URL's host |
| sample | hash | the sample's sha256 |
| text | url | text starting with http:// or https:// |
| text | command | the text as a command line |
A range is not an address and a domain is not a URL: a verb that takes only addresses refuses a range, rather than looking up one address of many.
>>> "198.51.100.0/24".geo() [ER] geo cannot accept cidr (accepts ip) >>> "198.51.100.0/24".rdap()[net_name] [--] WHOIS / RDAP on 198.51.100.0/24 via RDAP reserved/documentation range (198.51.100.0/24); no public reputation exists; not looked up, no API call made
#7. Several values
7.1 A verb or a block runs on every value it is given.
>>> "cdn-telemetry.xyz".rdap()[nameservers].resolve() [--] Domain IP resolve on ns1.regway.com Domain IP resolve has no record for ns1.regway.com [--] Domain IP resolve on ns2.regway.com Domain IP resolve has no record for ns2.regway.com >>> ["185.215.113.66", "8.8.8.8"]{ abuseipdb()[score], geo()[country_name] } [OK] Multi Report Expression on 2 ips ip score country_name ─────────────────────────────────────────────── 185.215.113.66 100 Netherlands 8.8.8.8 0 United States of America
7.2 each() is gone. Every form of it is refused, with what to write in its place.
>>> ["185.215.113.66", "8.8.8.8"].each(geo) [ER] each() is gone: verbs and blocks run on every value they are given. Write .geo() in place of .each(geo) >>> ["aGk=", "aGVsbG8="].each{ decode() } [ER] each() is gone: verbs and blocks run on every value they are given. Write { ... } in place of .each{ ... } >>> ["185.215.113.66", "8.8.8.8"].each(score > 5){ geo() } [ER] each() is gone: verbs and blocks run on every value they are given. Write .filter(score > 5){ ... } in place of .each(score > 5){ ... } >>> "cdn-telemetry.xyz".rdap()[nameservers].each().resolve() [ER] each() is gone: the next step runs on every value already, so leave out .each()
#8. Blocks
8.1 A block runs several branches on the same value. Branches are separated by commas or new lines; the answers line up as one row per value.
>>> "185.215.113.66"{ abuseipdb()[score], geo()[country_name] } [OK] Multi Report Expression on 185.215.113.66 abuseipdb[score]: 100 geo[country_name]: Netherlands >>> "185.215.113.66"{ ... abuseipdb()[score] ... geo()[country_name] ... } [OK] Multi Report Expression on 185.215.113.66 abuseipdb[score]: 100 geo[country_name]: Netherlands
8.2 A block may follow a step, a list or a filter.
>>> "cdn-telemetry.xyz".dns()[a]{ abuseipdb()[score], geo()[country_name] } [OK] Multi Report Expression on 45.9.148.112 abuseipdb[score]: 100 geo[country_name]: Netherlands >>> ["185.215.113.66", "8.8.8.8"].filter(value == "8.8.8.8").geo()[country_name] [OK] IP geolocation [country_name] on 8.8.8.8 via ip2location.io United States of America
8.3 After a block: .cost(), or in this order a cost limit, a filter on its results, sort(), one of first() / last() / limit(), and count() or an export. .filter(cost < N) is the cost limit, any other .filter() filters the results (8.5), and the steps after it work on the rows it kept (8.6). Each comes at most once, a step out of order is refused with the order to write, and .cost() stands alone. Nothing else may follow a block.
>>> "185.215.113.66"{ abuseipdb()[score], geo()[country_name] }.geo() [ER] col 60: only .cost(), .filter(...), .sort(...), .first(n), .last(n), .limit(n), .count() or .export(format == "csv") may follow a block; to go on with each row, [value].geo() or [field].geo()
>>> "185.215.113.66"{ abuseipdb(), geo() }.cost() [OK] { abuseipdb(), geo() }.cost() on 185.215.113.66 2 API call(s) - projected API calls (not executed)
>>> "185.215.113.66"{ abuseipdb()[score] }.export(csv).filter(score > 1) [ER] col 51: export() comes last after a block: write .filter(score > 1) before .export() >>> "185.215.113.66"{ abuseipdb()[score] }.filter(score > 1).filter(cost < 9) [ER] col 57: the cost limit comes first after a block: .filter(cost < 9) before the filter on the results >>> "185.215.113.66"{ abuseipdb()[score] }.filter(score > 1 and cost < 9) [ER] col 39: the cost limit is a filter of its own: .filter(cost < N).filter(...) >>> "185.215.113.66"{ abuseipdb()[score] }.sort(score).filter(score > 1) [ER] col 51: after a block the order is a cost limit, filter(), sort(), first() / last() / limit(), then count() or export(): write .filter(score > 1) before sort() >>> "185.215.113.66"{ abuseipdb()[score] }.count().export(csv) [ER] col 47: count() gives a number, and export() writes rows: a block takes one or the other >>> "185.215.113.66"{ abuseipdb()[score] }.cost().sort(score) [ER] col 58: cost() only reports the cost; it cannot be combined with filter(), sort() or export()
8.4 A branch that finds nothing says so. A branch whose find() matched nothing, or whose filter() kept nothing, shows (no match) or (none kept) rather than vanishing from the row.
>>> "185.215.113.66"{ abuseipdb().filter(score > 1000)[score], geo()[country_name] } [OK] Multi Report Expression on 185.215.113.66 abuseipdb().filter(score > 1000)[score]: (none kept) geo[country_name]: Netherlands
8.5 A filter after a block keeps the rows whose checks match. The condition names fields from any of the block's checks, so one row is kept or dropped on all of them together. A field only one check gives is written bare; one that several give is written with the check's verb, abuseipdb.country, and the bare name is refused with both spellings. value is the row's own subject. A check that gave several values matches when one of them does, and != when none is equal, as filter() reads a list. A check that failed or found nothing has no value, so a clause on it does not hold: the row is dropped, and the result says how many rows were kept and which check had nothing; present and !present (9.8) test that directly. A name on the right of a comparison is a field of any check too, so two checks' answers can be compared. The checks have already run, so the filter does not change the cost.
>>> ["185.215.113.66", "8.8.8.8"]{ abuseipdb()[score], geo()[country_name] }.filter(score > 50 and country_name == "Netherlands") [OK] Multi Report Expression on 185.215.113.66 abuseipdb[score]: 100 geo[country_name]: Netherlands filter(score > 50 and country_name == "Netherlands") kept 1 of 2 >>> ["185.215.113.66", "8.8.8.8"]{ abuseipdb()[score], geo()[country_name] }.filter(score > 100) [--] filter(score > 100) kept 0 of 2 >>> "185.215.113.66"{ abuseipdb()[country], asn()[country] }.filter(asn.country == "RU") [OK] Multi Report Expression on 185.215.113.66 abuseipdb[country]: Russian Federation asn[country]: RU filter(asn.country == "RU") kept 1 of 1 >>> "185.215.113.66"{ abuseipdb()[country], asn()[country] }.filter(country == "RU") [ER] filter() after the block: "country" is in abuseipdb() and asn(): write abuseipdb.country or asn.country >>> "185.215.113.66"{ abuseipdb()[score] }.filter(isp == "Prospero OOO") [ER] filter() after the block: no check in the block gives "isp"; they give score >>> ["192.0.2.1", "198.51.100.7"]{ abuseipdb()[score], geo()[country_name] }.filter(country_name == "Netherlands") [--] filter(country_name == "Netherlands") kept 0 of 2; 2 dropped because geo() had no result >>> "cdn-telemetry.xyz"{ dns()[a], rdap()[registrar] }.filter(a == "45.9.148.112") [OK] Multi Report Expression on cdn-telemetry.xyz dns[a]: [45.9.148.112] rdap[registrar]: NameSilo, LLC filter(a == "45.9.148.112") kept 1 of 1 >>> ["185.215.113.66", "45.9.148.112"]{ abuseipdb()[country], geo()[country_name] }.filter(country != country_name) [OK] Multi Report Expression on 185.215.113.66 abuseipdb[country]: Russian Federation geo[country_name]: Netherlands filter(country != country_name) kept 1 of 2 >>> ["185.215.113.66", "8.8.8.8"]{ abuseipdb()[score], asn()[asn] }.filter(asn present and score > 50) [OK] Multi Report Expression on 185.215.113.66 abuseipdb[score]: 100 asn[asn]: AS200593 filter(asn present and score > 50) kept 1 of 2
8.6 The rows a block kept can be sorted, cut and counted. sort() after a block orders its rows by the checks' fields, named as the filter names them (8.5): bare, or verb.field where two checks give the name; value is the row's own subject. first(n), last(n) and limit(n) then keep that many rows, and count() answers with how many there are. A check that gave several values sorts by its lowest, or by its highest with desc; a row whose check gave nothing goes last. An export writes the rows as they end up.
>>> ["8.8.8.8", "185.215.113.66"]{ abuseipdb()[score], geo()[country_name] }.sort(score desc) [OK] Multi Report Expression on 2 ips ip score country_name ─────────────────────────────────────────────── 185.215.113.66 100 Netherlands 8.8.8.8 0 United States of America >>> ["8.8.8.8", "185.215.113.66", "192.0.2.1"]{ abuseipdb()[score], geo()[country_name] }.filter(score >= 0).sort(score desc).first() [OK] Multi Report Expression on 185.215.113.66 abuseipdb[score]: 100 geo[country_name]: Netherlands filter(score >= 0) kept 2 of 3; 1 dropped because abuseipdb() had no result >>> ["8.8.8.8", "185.215.113.66", "192.0.2.1"]{ abuseipdb()[score], geo()[country_name] }.filter(score >= 0).count() [OK] { abuseipdb()[score], geo()[country_name] }.filter(score >= 0).count() on 2 ips 2 filter(score >= 0) kept 2 of 3; 1 dropped because abuseipdb() had no result >>> "185.215.113.66"{ abuseipdb()[country], asn()[country] }.sort(asn.country desc) [OK] Multi Report Expression on 185.215.113.66 abuseipdb[country]: Russian Federation asn[country]: RU >>> "185.215.113.66"{ abuseipdb()[country], asn()[country] }.sort(country) [ER] sort() after the block: "country" is in abuseipdb() and asn(): write abuseipdb.country or asn.country >>> "185.215.113.66"{ abuseipdb()[score] }.sort(isp) [ER] sort() after the block: no check in the block gives "isp"; they give score
8.7 [value] goes on with the rows' subjects; [score] with a check's field. After a block and the steps that follow it, [value] hands on the subject of each row it kept, and the line continues on them: a verb, a filter, another block; a filter after it can test what the subject carries itself (an address's scope). A block straight after is a block on them. [score] instead hands on a check's field from each row (named as the block's filter names it, 8.5), and [score, country_name] several, one result per row; a row whose checks gave none of them is left out. Nothing goes on after cost(), count() or an export, which give no rows. After a lookup in a chain, [value] is the same: what each report was fetched for, that is, the addresses a filter on their reports kept. A report's own field of that name ([ip] on abuseipdb()) does not exist, and says so.
>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"]{ abuseipdb()[score], geo()[country_name] }.filter(score > 50).sort(score desc).first(1)[value].asn()[asn] [OK] IP to ASN (Cymru) [asn] on 185.215.113.66 via team cymru AS200593 filter(score > 50) kept 2 of 3 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(score > 50)[value].asn()[asn] [OK] IP to ASN (Cymru) [asn] on 2 ips via team cymru ip asn ──────────────────────── 185.215.113.66 AS200593 45.9.148.112 AS202425 >>> ["185.215.113.66", "8.8.8.8"].abuseipdb()[ip] [ER] abuseipdb()'s reports have no field "ip": the ip each is about is [value] >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"]{ abuseipdb()[score] }.filter(score > 50){ asn()[asn], rdap()[net_name] } [OK] Multi Report Expression on 2 ips ip asn net_name ────────────────────────────────────── 185.215.113.66 AS200593 PROSPERO-NET 45.9.148.112 AS202425 IPV-NET filter(score > 50) kept 2 of 3 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"]{ abuseipdb()[score], geo()[country_name] }[score].max() [OK] max() on 3 scores 100 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"]{ abuseipdb()[score], geo()[country_name] }.filter(score > 50)[country_name].group() [OK] group() on 1 group value count ────────────────── Netherlands 2 filter(score > 50) kept 2 of 3 >>> ["185.215.113.66", "45.9.148.112", "10.0.0.1"]{ abuseipdb()[score] }[value].filter(scope == "private").count() [OK] count() on 10.0.0.1 1 >>> ["185.215.113.66", "8.8.8.8"]{ abuseipdb()[country], asn()[country] }[country] [ER] [country] after the block: "country" is in abuseipdb() and asn(): write abuseipdb.country or asn.country >>> ["185.215.113.66", "8.8.8.8"]{ abuseipdb()[score] }.count()[value] [ER] col 60: count() gives a number, not rows to go on with >>> ["185.215.113.66", "8.8.8.8"]{ abuseipdb()[score] }[0] [ER] col 52: after a block, [value] goes on with the subjects of its rows and [score] with a check's field from each, e.g. [value].rdap(); a block runs on the subjects
#9. Narrowing and counting
9.1 filter(condition) keeps the results that match. A comparison names a field and compares it with ==, !=, >, >=, <, <=, contains or ~. contains "text" and ~ "text" are the same: the text anywhere in the field, in any case. ~ /pattern/ is a regular expression, case sensitive unless written /pattern/i. Dates compare as dates (2026-08-01), ages are in days, and value is the value itself, for results with no fields. A list field matches when one of its values does, and != when none is equal. Several comparisons join with and or or (9.6); text on the right is quoted (9.7).
>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(isp contains "prospero").count() [OK] count() on 185.215.113.66 1 >>> ["185.215.113.66", "8.8.8.8"].abuseipdb().filter(score > 50 and last_reported > 2026-08-01).count() [OK] count() on 185.215.113.66 1 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(isp ~ /^Pros/).count() [OK] count() on 185.215.113.66 1 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(isp ~ /^pros/) [--] IP reputation .filter(isp ~ /^pros/) on 185.215.113.66 via AbuseIPDB no results carried forward >>> "seen 185.215.113.66 and 8.8.8.8".find(ip).filter(value == "8.8.8.8").count() [OK] count() on 8.8.8.8 1 >>> "cdn-telemetry.xyz".rdap().filter(age > 30)[registrar] [OK] WHOIS / RDAP .filter(age > 30)[registrar] on cdn-telemetry.xyz via RDAP NameSilo, LLC >>> "cdn-telemetry.xyz".rdap().filter(age < 30) [--] WHOIS / RDAP .filter(age < 30) on cdn-telemetry.xyz via RDAP no results carried forward
9.2 A comparison the results cannot make is refused. A field the results do not have, a number compared with a word, a date with a number, a condition with nothing after an operator.
>>> ["185.215.113.66", "8.8.8.8"].abuseipdb().filter(scroe > 50) [ER] filter(): no field "scroe" to compare here (try: score or total_reports or domain or hostnames or isp or usage_type or country or is_tor or last_reported) >>> ["185.215.113.66", "8.8.8.8"].abuseipdb().filter(score > "fifty") [ER] "score" is a number; compare it to a number, e.g. score > 50 >>> ["185.215.113.66", "8.8.8.8"].abuseipdb().filter(score > last_reported) [ER] "score" is a number and "last_reported" a date: they cannot be compared >>> ["185.215.113.66", "8.8.8.8"].abuseipdb().filter(score >) [ER] filter(): missing value after ">" >>> "aGk=".decode(auto).filter(score > 5) [ER] filter(): no field "score" to compare here (try: text or url or method or confidence)
9.3 find() searches text. By shape (domain, ip, email, url, hash, cidr), or by "text" or /pattern/. After a search by shape, the next step works on the values found. "text" is found in any case; /pattern/ is case sensitive unless written /pattern/i.
>>> "seen 185.215.113.66 and 8.8.8.8 today".find(ip).abuseipdb()[score] [OK] IP reputation [score] on 185.215.113.66 via AbuseIPDB 100 [OK] IP reputation [score] on 8.8.8.8 via AbuseIPDB 0 >>> "seen 185.215.113.66 and 8.8.8.8 today".find(ip).count() [OK] count() on seen 185.215.113.66 and 8.8.8.8 today 2 >>> "Hello World".find("hello") [OK] .find("hello") on Hello World 1 Hello World >>> "Hello World".find(/hello/) [--] .find(/hello/) on Hello World no results carried forward >>> "Hello World".find(/hello/i) [OK] .find(/hello/i) on Hello World 1 Hello World
9.4 first(n), last(n), limit(n), uniq() and count(). uniq() drops repeated values; uniq(country) keeps the first result for each country. count() takes nothing. first() and last() without a number take one, and the result says so: first(1). limit() needs its number, and first(0) is refused. After find() they count and take the values found, not the lines they were in, and so does [n]: find(ip)[1] is the second address found. The result shows the line each value was found in, with the value marked; the next step gets the value. A block after them runs on each value too, and costs its checks once per value. count() of nothing is 0: when a filter keeps nothing or find() finds nothing, count() after it answers 0 and says which step left nothing, as a block's count() says what its filter kept (8.6). A lookup that failed still stops the line with its reason.
>>> "seen 185.215.113.66 and 8.8.8.8 today".find(ip).first().abuseipdb()[score] [OK] IP reputation [score] on 185.215.113.66 via AbuseIPDB 100 >>> "seen 1.1.1.1 1.1.1.1 8.8.8.8".find(ip).uniq().count() [OK] count() on seen 1.1.1.1 1.1.1.1 8.8.8.8 2 >>> "seen 185.215.113.66 and 8.8.8.8 today".find(ip).first(1){ abuseipdb()[score], geo()[country_name] } [OK] Multi Report Expression on 185.215.113.66 abuseipdb[score]: 100 geo[country_name]: Netherlands >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().first() [OK] first(1) on 185.215.113.66 score: ● 100 total_reports: ● 1148 isp: Prospero OOO usage_type: Data Center/Web Hosting/Transit country: Russian Federation is_tor: ● false last_reported: 2026-08-12T22:41:07+00:00 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().limit() [ER] limit() takes a number, e.g. .limit(5) >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().first(0) [ER] first() needs a positive count, e.g. .first(5) >>> "seen 185.215.113.66 and 8.8.8.8 today".find(ip)[1].abuseipdb()[score] [OK] IP reputation [score] on 8.8.8.8 via AbuseIPDB 0 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().last().count() [OK] count() on 8.8.8.8 1 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().limit(2).count() [OK] count() on 2 ips 2 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().first(2)[score] [OK] [score] on 2 ips ip score ───────────────────── 185.215.113.66 100 45.9.148.112 100 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().uniq(usage_type).count() [OK] count() on 2 ips 2 >>> ["185.215.113.66", "45.9.148.112"].abuseipdb().filter(score > 1000).count() [OK] count() on 2 ips 0 filter(score > 1000) kept 0 of 2 >>> "Hello World".find(/hello/).count() [OK] count() on Hello World 0 find(/hello/) gave nothing >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().uniq(contry) [ER] uniq(): no field "contry" here (try: score or total_reports or domain or hostnames or isp or usage_type or country or is_tor or last_reported) >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().count(score) [ER] count() takes nothing: .count() is how many results there are
9.5 sort() puts results in order. sort(score desc, total_reports) orders by score, high to low, then by total_reports, low to high; asc may be written and is the default. sort() and sort(desc) order the values themselves. Numbers compare as numbers, addresses as addresses (192.0.2.9 before 192.0.2.10), dates as dates and text in any case; results equal on every key keep their order, and one without a key's value goes last. A list is sorted within itself, and after find() by shape the values found are sorted. A field the results do not have is refused, as in a filter (9.2).
>>> ["8.8.8.8", "185.215.113.66", "45.9.148.112"].sort() [OK] sort() on 3 ip values [8.8.8.8, 45.9.148.112, 185.215.113.66] >>> ["8.8.8.8", "185.215.113.66", "45.9.148.112"].sort(desc) [OK] sort(desc) on 3 ip values [185.215.113.66, 45.9.148.112, 8.8.8.8] >>> "cdn-telemetry.xyz".rdap()[nameservers].sort(desc) [OK] sort(desc) on cdn-telemetry.xyz [ns2.regway.com, ns1.regway.com] >>> ["8.8.8.8", "185.215.113.66", "45.9.148.112"].abuseipdb().sort(score desc, isp)[score, isp].first() [OK] first(1) on 45.9.148.112 score: ● 100 isp: IP Volume inc >>> ["8.8.8.8", "185.215.113.66"].abuseipdb().sort(scor) [ER] sort(): no field "scor" to sort by here; to sort the values themselves, .sort() or .sort(desc) (try: score or total_reports or domain or hostnames or isp or usage_type or country or is_tor or last_reported) >>> "cdn-telemetry.xyz".rdap()[nameservers].sort(score) [ER] sort(): no field "score" to sort by here; to sort the values themselves, .sort() or .sort(desc) (try: age or registered or expires or registrar or registrar_abuse_email or nameservers or status or dnssec or registrant_email or net_range or net_name or net_type or cidr or asn or org or country or abuse_email) >>> ["8.8.8.8", "185.215.113.66"].abuseipdb().sort(score up) [ER] col 42: sort() takes fields, each with asc or desc after it: sort(score desc, total_reports), not "score up"
9.6 Comparisons join with and or or; parentheses group them; not negates. Comparisons joined by one word need nothing more. and together with or must be grouped with parentheses, which say which goes first: the line is refused with both ways written out. not negates the comparison or the group right after it. && and || may be written for and and or.
>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(score > 50 or isp contains "google")[score] [OK] IP reputation .filter(score > 50 or isp contains "google")[score] on 3 ips via AbuseIPDB ip score ───────────────────── 185.215.113.66 100 45.9.148.112 100 8.8.8.8 0 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(isp contains "google" or (score > 50 and country == "Netherlands"))[score] [OK] IP reputation .filter(isp contains "google" or (score > 50 and country == "Netherlands"))[score] on 2 ips via AbuseIPDB ip score ─────────────────── 45.9.148.112 100 8.8.8.8 0 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(isp contains "google" or score > 50 and country == "Netherlands") [ER] filter(): and with or needs parentheses to say which goes first: write (isp contains "google" or score > 50) and country == "Netherlands", or isp contains "google" or (score > 50 and country == "Netherlands") >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(not (score > 50 or is_tor == true))[score] [OK] IP reputation .filter(not (score > 50 or is_tor == true))[score] on 8.8.8.8 via AbuseIPDB 0 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(not isp contains "google").count() [OK] count() on 2 ips 2 >>> ["185.215.113.66", "8.8.8.8"].abuseipdb().filter(!score > 50) [ER] filter(): write not before a comparison or a group, not '!': not (score > 50 or is_tor == true)
9.7 Text is quoted; a bare name is a field or a variable. On the right of a comparison, text is written in quotes: country == "Netherlands". A bare name is another field of the same result, as in score > total_reports, or a variable (12.1), as in score > limit. A name that is neither is refused with the quotes it needs, and one that is both is refused too. Numbers, dates, true, false, /patterns/ and addresses such as 192.0.2.1 need no quotes. null, none and the like are not values: the refusal says to test that the field is there (9.8). A variable holding several values is compared with in (9.9).
>>> ["185.215.113.66", "8.8.8.8"].abuseipdb().filter(country == Netherlands) [ER] filter(): Netherlands is not a field or a variable here; text is quoted: country == "Netherlands" >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(total_reports > score)[score] [OK] IP reputation .filter(total_reports > score)[score] on 2 ips via AbuseIPDB ip score ───────────────────── 185.215.113.66 100 45.9.148.112 100 >>> threshold = 50 [OK] Recognised text 50 [OK] threshold 1 text value(s) >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(score > threshold).count() [OK] count() on 2 ips 2 >>> ["185.215.113.66", "8.8.8.8"].abuseipdb().filter(domain != null) [ER] filter(): null is not a value: to keep results that have domain, write domain present
9.8 present tests that a field is there. domain present holds when the result has the field with a value; domain !present and domain not present when it has none. A field that is empty is not present, and a check in a block that failed or found nothing has no fields present (8.5).
>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(domain present)[domain] [OK] IP reputation .filter(domain present)[domain] on 2 ips via AbuseIPDB ip domain ────────────────────────── 45.9.148.112 ipvolume.net 8.8.8.8 google.com >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(domain !present).count() [OK] count() on 185.215.113.66 1 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(domain not present).count() [OK] count() on 185.215.113.66 1
9.9 in tests a list. country in ["Netherlands", "Germany"] holds when the field equals one of the values, in any case; not in when it equals none. The list is written in square brackets, its values quoted text or numbers, or is a variable holding several values.
>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(country in ["Netherlands", "Russian Federation"])[score] [OK] IP reputation .filter(country in ["Netherlands", "Russian Federation"])[score] on 2 ips via AbuseIPDB ip score ───────────────────── 185.215.113.66 100 45.9.148.112 100 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(country not in ["Netherlands", "Russian Federation"]).count() [OK] count() on 8.8.8.8 1 >>> watch = ["Netherlands", "Germany"] [OK] watch 2 text value(s) >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(country in watch).count() [OK] count() on 45.9.148.112 1 >>> ["185.215.113.66", "8.8.8.8"].abuseipdb().filter(country == watch) [ER] filter(): the variable watch holds 2 values: to match any of them, write country in watch >>> ["185.215.113.66", "8.8.8.8"].abuseipdb().filter(score in ["high"]) [ER] "score" is a number; "high" is not one
9.10 now and today are moments. A date can be compared with now or today (midnight, UTC), with an offset in hours, days or weeks (h, d, w, or the words, 7 days being 7d): last_reported > now - 7d, valid_to < today + 30d, seen > now - 12h, registered > now - 4w. A number of days (an age) is compared with a number instead.
>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(last_reported > 2026-01-01 and last_reported < now)[score] [OK] IP reputation .filter(last_reported > 2026-01-01 and last_reported < now)[score] on 2 ips via AbuseIPDB ip score ───────────────────── 185.215.113.66 100 45.9.148.112 100 >>> ["185.215.113.66", "8.8.8.8"].abuseipdb().filter(score > now - 7d) [ER] "score" is a number (e.g. days), not a date; compare it to a number, e.g. score < 7
9.11 group(field) makes one row per value. Each row holds the value, count (how many results have it) and items (which they are); group() groups the values themselves, and has no items. Rows come in the order their values first appear; the same value in any case is one row. A result whose field is a list counts in the row of each of its values, and results without the field are left out, which the result says. After group() the rows can be filtered, sorted, cut and counted by those fields, and shown as a table. After find() by shape, every time a value was found counts. Grouped by a field named count or items, as group() rows are, the value is the row's value, so count is still how many: group(country).group(count) is how many countries had each count.
>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().group(usage_type) [OK] group(usage_type) on 2 groups usage_type count items ──────────────────────────────────────────────────────────────────── Data Center/Web Hosting/Transit 2 185.215.113.66, 45.9.148.112 Content Delivery Network 1 8.8.8.8 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().group(usage_type).sort(count desc).first()[usage_type] [OK] [usage_type] on Data Center/Web Hosting/Transit Data Center/Web Hosting/Transit >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().group(country).count() [OK] count() on 3 groups 3 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().group(country).group(count) [OK] group(count) on 1 group value count items ─────────────────────────────────────────────────────────────────────── 1 3 Russian Federation, Netherlands, United States of America >>> "seen 192.0.2.1, 192.0.2.1 and 198.51.100.7".find(ip).group() [OK] group() on 2 groups value count ─────────────────── 192.0.2.1 2 198.51.100.7 1 >>> ["185.215.113.66", "8.8.8.8"].abuseipdb().group(contry) [ER] group(): no field "contry" here (try: score or total_reports or domain or hostnames or isp or usage_type or country or is_tor or last_reported)
9.12 min(), max(), sum() and avg() reduce a field to one value. max(score) is the highest score, min(last_reported) the earliest date, sum(total_reports) the total, avg(score) the average to two decimals. min() and max() take numbers or dates, sum() and avg() numbers; without a field they read the values themselves. After group() each is computed per group and added as a column named for it, max_score, which a filter or a sort can then name.
>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().max(score) [OK] max(score) on 3 ips 100 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().sum(total_reports) [OK] sum(total_reports) on 3 ips 1362 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().avg(score) [OK] avg(score) on 3 ips 66.67 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb()[score].min() [OK] min() on 3 scores 0 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().group(usage_type).avg(total_reports).sort(avg_total_reports desc) [OK] sort(avg_total_reports desc) on 2 groups usage_type count items avg_total_reports ─────────────────────────────────────────────────────────────────────────────────────── Data Center/Web Hosting/Transit 2 185.215.113.66, 45.9.148.112 681 Content Delivery Network 1 8.8.8.8 0 >>> ["185.215.113.66", "8.8.8.8"].abuseipdb().max(country) [ER] max() compares numbers or dates; "country" is text >>> ["185.215.113.66", "8.8.8.8"].abuseipdb().sum(last_reported) [ER] sum() adds numbers; "last_reported" is a date
#10. Cost
10.1 .cost() prices a line without running it.
>>> ["185.215.113.66", "8.8.8.8"].abuseipdb().cost() [OK] cost() on 2 ip values 2 API call(s) - projected, not executed >>> "aGk=".cost() [ER] cost() prices a chain: put it after the verbs, e.g. .abuseipdb().cost()
10.2 A line asks before going past the API-call limit. The limit is a setting (PREFERENCES, 10 to start with; 0 asks before every lookup). A line that would go past it stops before the step that would, and says how many calls it would make, by verb. Enter runs it; Esc cancels it. After Enter, no lookup the line already made is made again. A block is priced whole, every branch on every value. A program stops at the statement that goes over, and Enter runs the rest. Cached lookups and addresses that are never sent (2.7) cost nothing.
>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8", "1.1.1.1", "9.9.9.9", "185.215.113.67"]{ abuseipdb(), geo() } [??] Confirm This line will make 12 API calls (abuseipdb 6, geo 6). Enter to continue, Esc to cancel. Not run.
10.3 A block can refuse to run past a price. .filter(cost < N) after a block runs it only when it would make fewer than N calls. Otherwise it is skipped, marked [~~], and nothing is called.
>>> "185.215.113.66"{ abuseipdb()[score], geo()[country_name] }.filter(cost < 1) [~~] { abuseipdb()[score], geo()[country_name] }.filter(cost < 1) on 185.215.113.66 skipped: would cost 2 API call(s), exceeds limit 1
#11. Output to files
11.1 export() writes the results to a file in FILES. .export() is CSV; .export(format == "xlsx") is a workbook. A list field is a row per member; a variable of results is a row per result with every field.
>>> ["185.215.113.66", "8.8.8.8"].abuseipdb()[score].export() [OK] IP reputation [score] on 2 ips via AbuseIPDB ip score ───────────────────── 185.215.113.66 100 8.8.8.8 0 [OK] Exported 2 row(s) to ips-export-103628.csv >>> "aGk=".decode(auto).export(format == "pdf") [ER] col 20: export format must be "csv" or "xlsx" (got "pdf")
11.2 export("name.xlsx") says where. A file name in quotes names the file, its ending the format: .csv or .xlsx. In the browser it is the file's name in FILES, and a path is refused; in the terminal (ceplin) it is a path, from the folder ceplin runs in or absolute, and a plain .export() writes there under its usual name. A file head in the terminal is likewise a file on disk: "proxy.csv", "logs/proxy.csv", "/var/log/proxy.csv".
>>> ["185.215.113.66", "8.8.8.8"].abuseipdb()[score].export("scores.xlsx") [OK] IP reputation [score] on 2 ips via AbuseIPDB ip score ───────────────────── 185.215.113.66 100 8.8.8.8 0 [OK] Exported 2 row(s) to scores.xlsx >>> "aGk=".decode(auto).export("out.pdf") [ER] col 20: export writes .csv or .xlsx, not .pdf: export("out.csv") >>> "aGk=".decode(auto).export("report") [ER] col 20: export writes .csv or .xlsx: export("report.csv") or export("report.xlsx")
#12. Variables and programs
12.1 A variable keeps what its line produced, whole. Several results stay several results; find() by shape stores the values found; a block's table can be read by column. A field picked from several results is a table, a row per result beside what it is of, as the same pick on the line is; a lookup's own rows, abuse_reports(), are named as the line names them.
>>> reps = ["185.215.113.66", "8.8.8.8"].abuseipdb() [OK] IP reputation on 2 ips via AbuseIPDB 185.215.113.66 score 100 total_reports 1148 isp Prospero OOO usage_type Data Center/Web Hosting/Transit country Russian Federation is_tor false last_reported 2026-08-12T22:41:07+00:00 8.8.8.8 score 0 total_reports 0 domain google.com hostnames dns.google isp Google LLC usage_type Content Delivery Network country United States of America is_tor false [OK] reps 2 row(s): ip, score, total_reports, isp, usage_type, country, is_tor, last_reported, domain, hostnames >>> reps[score] [OK] [score] on 2 ips ip score ───────────────────── 185.215.113.66 100 8.8.8.8 0 >>> said = "185.215.113.66".abuse_reports() [OK] IP abuse reports on 185.215.113.66 via AbuseIPDB reported_at comment categories reporter_country reporter_id ──────────────────────────────────────────────────────────────────────────────────────── 2026-08-12 22:41:07 SSH brute force… Brute-Force, SSH Germany 40412 2026-08-12 19:03:55 Port scan on 22… Port Scan Netherlands 11873 2026-08-12 11:27:40 GET /wp-login.p… Web App Attack, … United States of… 72305 2026-08-11 23:58:12 Serving a loade… Exploited Host United Kingdom o… 5521 2026-08-11 08:14:02 - Brute-Force France 90117 [OK] said 5 row(s): ip, reported_at, comment, categories, reporter_country, reporter_id >>> said [OK] said on 5 abuse reports ip reported_at comment categories reporter_coun… reporter_id ──────────────────────────────────────────────────────────────────────────────────────── 185.215.113.66 2026-08-12 2… SSH brute fo… Brute-Force,… Germany 40412 185.215.113.66 2026-08-12 1… Port scan on… Port Scan Netherlands 11873 185.215.113.66 2026-08-12 1… GET /wp-logi… Web App Atta… United States… 72305 185.215.113.66 2026-08-11 2… Serving a lo… Exploited Ho… United Kingdo… 5521 185.215.113.66 2026-08-11 0… - Brute-Force France 90117 >>> said[comment] [OK] [comment] on 185.215.113.66 ip comment ─────────────────────────────────────────────────────────────────────────────────── 185.215.113.66 SSH brute force: 214 failed logins for root and admin in 10 minutes 185.215.113.66 Port scan on 22, 23, 2222, 8080 185.215.113.66 GET /wp-login.php and /xmlrpc.php, 90 requests 185.215.113.66 Serving a loader over HTTP, payload a.exe 185.215.113.66 (empty) >>> reps.filter(score > 50)[isp] [OK] .filter(score > 50)[isp] on 185.215.113.66 Prospero OOO >>> found = "mail from a@evil.example and b@evil.example".find(email) [OK] .find(email) on mail from a@evil.example and b@evil.example 1 mail from a@evil.example and b@evil.example 1 mail from a@evil.example and b@evil.example [OK] found 2 email value(s) >>> found[domain] [OK] [domain] on mail from a@evil.example and b@evil.example evil.example [OK] [domain] on mail from a@evil.example and b@evil.example evil.example
12.2 A variable holding a report passes on only a field written out, as 6.1.
>>> reps.geo() [ER] abuseipdb() gives a report, and geo() takes ip: to run both on the same ip, write .{ abuseipdb(), geo() }
12.3 A variable on its own shows what it holds; a block's table is read by column, or through the block's tail. r[column] is that column's values, and r.count() the number of rows. What may follow a block may follow the variable (filter(), sort(), first(n), last(n), limit(n), count(), cost(), then [value] or [field]) and works on the rows held, with no lookup made again (cost() is 0). A variable of one column is a list of its values, as before. r[score] reads as [score] after the block does: each row's score, and r[score].max() their largest. Alone, a block's table is shown as the block showed it (a table when tables are on, a block per row when they are off), headed by the variable's name. A filter or a sort that names the one column's field reads a one-column table this way too: r.filter(score > 50).
>>> table = ["185.215.113.66", "45.9.148.112", "8.8.8.8"]{ abuseipdb()[score], geo()[country_name] } [OK] Multi Report Expression on 3 ips ip score country_name ─────────────────────────────────────────────── 185.215.113.66 100 Netherlands 45.9.148.112 100 Netherlands 8.8.8.8 0 United States of America [OK] table 3 row(s): ip, score, country_name >>> table[score] [OK] [score] on 3 ips ip score ───────────────────── 185.215.113.66 100 45.9.148.112 100 8.8.8.8 0 >>> table.count() [OK] table.count() on 3 ips 3 >>> table.filter(score > 50 and country_name == "Netherlands").sort(score desc)[value] [OK] [value] on 2 ips ips: 185.215.113.66 45.9.148.112 filter(score > 50 and country_name == "Netherlands") kept 2 of 3 >>> table.cost() [OK] table.cost() on 3 ips 0 API call(s) - projected API calls (not executed): the rows are held, no lookup is made again >>> table.filter(nope > 1) [ER] filter() after the block: no check in the block gives "nope"; they give score, country_name
12.4 Lists combine in an assignment. a + b is every value of both, once; a & b the values of a that b also has; a - b those it does not. Each side is a variable or a [...] list, values are compared in any case, and the results keep the fields a gave them. One of +, & and - per assignment: combine two, then the next.
>>> seen = ["185.215.113.66", "45.9.148.112", "8.8.8.8"] [OK] seen 3 ip value(s) >>> known = ["8.8.8.8"] [OK] known 1 ip value(s) >>> fresh = seen - known [OK] fresh 2 ip value(s) >>> fresh.abuseipdb()[score] [OK] IP reputation [score] on 2 ips via AbuseIPDB ip score ───────────────────── 185.215.113.66 100 45.9.148.112 100 >>> both = seen & ["45.9.148.112", "192.0.2.1"] [OK] both 1 ip value(s) >>> both [OK] both ip 45.9.148.112 >>> mixed = seen + known - ["8.8.8.8"] [ER] mixed one of +, & or - per line: write the first two into a variable, then combine it with the next
Tables join on their subjects. When a side is a table (a block's rows, a file's rows, an earlier join), a + b is a row for every subject of either, with the columns of both, blank where one side has no row; a & b the subjects both have; a - b the rows of a whose subject b lacks, with a's columns. A file's rows join on their column holding the other side's subjects, or two files on a column both have by name. Where both have a column, a's value stands and b's fills only a blank. When b's subjects are of another kind than a's (domains against a table of ips), they are matched with a's column holding them: each row with the value takes b's columns, and a value no row holds adds no row. A file's rows are of their first column of artefacts. A side may be an expression. The result reads as a block's table: filter(), sort(), r[column] and export().
>>> geo_rows = ["185.215.113.66", "8.8.8.8"]{ geo()[country_name] } [OK] Multi Report Expression on 2 ips ip country_name ──────────────────────────────────────── 185.215.113.66 Netherlands 8.8.8.8 United States of America [OK] geo_rows 2 row(s): ip, country_name >>> scores = ["8.8.8.8", "45.9.148.112"]{ abuseipdb()[score] } [OK] Multi Report Expression on 2 ips ip score ─────────────────── 8.8.8.8 0 45.9.148.112 100 [OK] scores 2 row(s): ip, score >>> all = geo_rows + scores [OK] all 3 row(s): ip, country_name, score >>> both = geo_rows & scores [OK] both 1 row(s): ip, country_name, score
x += y, x -= y and x &= y are x = x + y and so on. x must already be a variable, and one list or table follows the operator: x -= a - b is refused, since it would read as x - a - b.
>>> geo_rows += geo_rows[ip]{ asn()[asn] } [OK] Multi Report Expression on 2 ips ip asn ──────────────────────── 185.215.113.66 AS200593 8.8.8.8 AS15169 [OK] geo_rows 2 row(s): ip, country_name, asn >>> geo_rows -= ["8.8.8.8"] [OK] geo_rows 1 row(s): ip, country_name, asn
#13. Saved verbs
13.1 /save chain name saves a chain; .@name() runs it. Inside a block it is written without the dot. A saved name is one word; /save over an existing name replaces it and says so. A chain written on a list saves as a verb on the list's type: /save ["192.0.2.1"].abuseipdb() rep saves ip.abuseipdb(); a list of two types is refused (13.5 saves a verb on several).
>>> /save text.decode(auto)[text] d64 [OK] Saved @d64 accepts: text expression: text.decode(auto)[text] returns: text cost: local only, no API calls >>> "aGk=".@d64() [OK] Decode [text] on aGk= hi >>> ["aGk=", "aGVsbG8="].@d64() [OK] Decode [text] on 2 texts text text ─────────────── aGk= hi aGVsbG8= hello
13.2 Saved verbs are managed with workspace commands. /list, /list name, /edit name, /remove name, /recover name within a day, /history, /help. Each has one name (as verbs do, 4.6) and is read in any case (4.4).
>>> /list d64 [OK] 1 saved expression(s) @d64: text.decode(auto)[text] local only, no API calls >>> /LIST D64 [OK] 1 saved expression(s) @d64: text.decode(auto)[text] local only, no API calls >>> /delete d64 [ER] /delete is called /remove: write /remove d64
13.3 A block can be saved. The type is followed by the block: /save ip{ ... } name. Running it puts the block back on the artefact.
>>> /save ip{ abuseipdb()[score], geo()[country_name] } health [OK] Saved @health accepts: ip expression: ip{ abuseipdb()[score], geo()[country_name] } returns: ip cost: 2 API call(s) >>> "185.215.113.66".@health() [OK] Multi Report Expression on 185.215.113.66 abuseipdb[score]: 100 geo[country_name]: Netherlands
13.4 A saved expression can take parameters. The names its conditions compare with are listed after its name: name(p = default, q). A call gives values by name, .@name(q = ...); one with a default may be left out, one without must be given. A value is what a condition takes (a number, quoted text, a date, now - 7d, true, false, or a [...] list for in) or the name of a variable, whose values are used. The call is written out with the values before the line is checked. A parameter must be named in a condition, and must not be a field of the results there. A condition of the saved chain does not compare with a variable of the session, which can change or be gone when the verb runs: the save is refused with the parameter to make of it, inplaces(places), given on the call as .@inplaces(places = places).
>>> /save ip.abuseipdb().filter(score > min_score and country in watch)[score] risky(min_score = 50, watch) [OK] Saved @risky accepts: ip expression: ip.abuseipdb().filter(score > min_score and country in watch)[score] returns: score cost: 1 API call(s) run as: .@risky(watch = ...) >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].@risky(watch = ["Netherlands", "Germany"]) [OK] IP reputation .filter(score > 50 and country in ["Netherlands", "Germany"])[score] on 45.9.148.112 via AbuseIPDB 100 >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].@risky(min_score = -1, watch = "United States of America") [OK] IP reputation .filter(score > -1 and country in ["United States of America"])[score] on 8.8.8.8 via AbuseIPDB 0 >>> places = ["Netherlands", "Germany"] [OK] places 2 text value(s) >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].@risky(watch = places) [OK] IP reputation .filter(score > 50 and country in ["Netherlands", "Germany"])[score] on 45.9.148.112 via AbuseIPDB 100 >>> ["185.215.113.66", "8.8.8.8"].@risky() [ER] @risky needs a value for watch: .@risky(watch = ...) >>> ["185.215.113.66", "8.8.8.8"].@risky(watch = ["NL"], limit = 3) [ER] @risky has no parameter limit; it takes risky(min_score = 50, watch) >>> /save ip.abuseipdb()[score] unused(min_score = 1) [ER] @unused would not run min_score is not in a condition of the expression: a parameter is a value a condition compares with, e.g. .filter(score > min_score) >>> /save ip.geo().filter(country_name in places)[country_name] inplaces [ER] @inplaces would not run a saved expression does not read the variable places, which can change or be gone when it runs: make it a parameter, /save ip.geo().filter(country_name in places)[country_name] inplaces(places), and give it on the call, .@inplaces(places = places)
13.5 A saved verb can run on several types. The types are written with | between them: /save ip|domain|url.encode(defang) defang, checked on each. Saving a name again with the same chain on another type adds that type; with a different chain it replaces the verb and says what it was.
>>> /save ip|domain|url.encode(defang) defang [OK] Saved @defang accepts: ip, domain and url expression: ip|domain|url.encode(defang) returns: text cost: local only, no API calls >>> "203.0.113.9".@defang() [OK] Encode on 203.0.113.9 203[.]0[.]113[.]9 text: 203[.]0[.]113[.]9 method: defang >>> "example.com".@defang() [OK] Encode on example.com example[.]com text: example[.]com method: defang >>> /save ip|hash.encode(defang) defang2 [ER] @defang2 would not run on hash: encode cannot accept hash (accepts text, command, url, domain, hostname, email, ip)
#14. Errors
14.1 A line is checked before anything is looked up. Type mistakes, unknown verbs and fields, and malformed lines are refused before any API call is made, including a mistake in a block's last branch.
>>> "185.215.113.66"{ abuseipdb()[score], geo()[nope] } [ER] branch 2: no field "nope" available to extract (try: asn)
14.2 A refusal says what to write. Where there is a right spelling, the error names it.
>>> "185.215.113.66".abusipdb() [ER] unknown method "abusipdb" (try: .abuseipdb()) >>> "185.215.113.66".abuseipdb()[scroe] [ER] no field "scroe" available to extract (try: score) >>> "185.215.113.66".abuseipdb().geo() [ER] abuseipdb() gives a report, and geo() takes ip: to run both on the same ip, write .{ abuseipdb(), geo() }
14.3 A failed lookup says why, and which step failed. not_found, a skipped documentation address, an upstream that is failing and a spent budget are told apart, and the step and provider named are the ones that failed. In a table, a lookup that failed stays a row, with or without a field picked, and its cells say why in short ("(no result)", "(failed)", "(skipped)"), with the full reason on hover. A field picked from a record that exists but does not hold it is "(empty)", and alone says the field is empty for that subject, not that the source has no record. Counts and aggregates run over the results only.
>>> "aGk=".decode(auto).find(/zzz/) [--] Decode .find(/zzz/) on hi no results carried forward >>> "185.215.113.66".abuseipdb()[domain] [--] IP reputation [domain] on 185.215.113.66 via AbuseIPDB domain is empty for 185.215.113.66 in AbuseIPDB's record >>> ["185.215.113.66", "8.8.8.8"].abuseipdb()[domain] [OK] IP reputation [domain] on 2 ips via AbuseIPDB ip domain ────────────────────────── 185.215.113.66 (empty) 8.8.8.8 google.com
#15. Limits
15.1 A line has limits, and going past one is refused. A line of at most 8 KB; a chain of at most 32 steps; a filter of at most 200 clauses; a program of at most 400 statements; blocks nested at most 12 deep. Lookups also stop at the API-call limit (10.2) and at each provider's own budget, which is reported as a skip, not an error.
What a line makes is bounded too, so one line cannot take the server down: a value is at most 4 MB (an encode() doubling a value twenty times is refused, with its size), one step's values at most 64 MB in all, and a block at most 50,000 runs, checks times values, refused before anything runs. A session holds at most 1,000 variables, with 25,000 values and 64 MB in them in all. A file to import is at most 2 MB, 5,000 rows and 64 columns, and FILES holds at most 20 files. The pane shows the first 1,000 results and the first 1,000 lines find() matched, with a note saying how many there were; the result itself, what count(), filter() and export() see, is whole.
>>> "aGk=".uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq().uniq() [ER] chain has more than 32 steps
#16. What a result looks like
16.1 Every result starts with a marker.
| marker | meaning |
|---|---|
[OK] | it ran and found something |
[--] | it ran and found nothing, or the step kept nothing |
[ER] | refused before running, or failed |
[~~] | skipped by a limit: a cost gate, a budget, a provider that is failing |
[??] | waiting for an answer: the API-call question |
The marker is followed by what ran, on what, and through which provider: IP reputation [score] on 185.215.113.66 via AbuseIPDB.
>>> "8.8.8.8".abuseipdb()[score] [OK] IP reputation [score] on 8.8.8.8 via AbuseIPDB 0 >>> "198.51.100.7".abuseipdb()[score] [--] IP reputation on 198.51.100.7 via AbuseIPDB reserved/documentation range (198.51.100.0/24); no public reputation exists; not looked up, no API call made >>> "185.215.113.66".abusipdb() [ER] unknown method "abusipdb" (try: .abuseipdb()) >>> "185.215.113.66"{ abuseipdb()[score], geo()[country_name] }.filter(cost < 1) [~~] { abuseipdb()[score], geo()[country_name] }.filter(cost < 1) on 185.215.113.66 skipped: would cost 2 API call(s), exceeds limit 1
16.2 Results come in the order the values were given. Lookups run in parallel, but a list's results, and a block's rows, are shown in the list's order.
>>> ["8.8.8.8", "185.215.113.66"]{ abuseipdb()[score], geo()[country_name] } [OK] Multi Report Expression on 2 ips ip score country_name ─────────────────────────────────────────────── 8.8.8.8 0 United States of America 185.215.113.66 100 Netherlands
#17. Same line, same steps
17.1 What "deterministic" means. A line always does the same steps on the same values, or is refused the same way. Lookups return live data, so the same line can find different data later: a score changes, a domain stops resolving. A lookup's answer is kept for an hour on the server and given again to the same lookup in that time, by anyone on the server, so a repeated line costs no API calls; such a line says how many of its lookups were answered that way. http() is never kept: it is what a host serves now. A line waiting at the API-call question (10.2) keeps what it has looked up, so that Enter does not repeat it.
#18. The editor
18.1 A .cepl file is a program. The EDITOR runs a file as a program (1.3): statement by statement, stopping at the first that fails. Workspace commands such as /save are refused there; they belong to the terminal. (Not covered by the examples, which run in the terminal.)
#Appendix A. The verbs
abuse_reports() takes ip. Calls: one API call per value, to AbuseIPDB. Fields: reported_at, comment, categories, reporter_country, reporter_id.
abuseipdb() takes ip. Calls: one API call per value, to AbuseIPDB. Fields: score, total_reports, domain, hostnames, isp, usage_type, country, is_tor, last_reported. Old names, refused with this one: reputation().
asn() takes ip, asn. Calls: one API call per value, to team cymru. Fields: asn, prefix, as_name, country, registry, allocated. Old names, refused with this one: cymru().
cert_lookup() takes cert_fingerprint, cert_serial, hash. Calls: one API call per value, to crt.sh. Fields: subject_cn, sans, issuer, serial, not_before, not_after, age, logged, entries, precert, source. Old names, refused with this one: cert_id().
compare() takes domain, hostname, url, email, text, ip, cidr, asn, hash, imphash, fuzzyhash, cert_serial, cert_fingerprint, filename, command, malware_family, tag. Calls: none: it runs locally. Fields: equal, lookalike, technique, distance, similarity, changes, confusables, other. Old names, refused with this one: lookalike().
ct_logs() takes domain, hostname. Calls: one API call per value, to certspotter. Fields: certificates, sans, issuers, first_seen, last_seen, newest_age, source. Old names, refused with this one: ct().
decode() takes text, command, url, domain, hostname, email. Calls: none: it runs locally. Modes (one is required): auto, base64, base64_utf16le, base32, hex, url, gzip, zlib, inflate, base58, base62, base45, base64url, base32hex, base85, binary, octal, decimal, hexdump, html_entity, quoted_printable, unicode_escape, unescape, punycode, jwt, utf16le, utf16be, rot13, rot47, atbash, reverse, a1z26, morse, braille, xor_brute, base92, netbios, citrix_ctx1, ip, strings, bacon, fang, strip_nulls, strip_whitespace, entropy, unix_time, filetime. Fields: text, url, method, confidence.
dns() takes domain, hostname. Calls: one API call per value, to resolver. Fields: a, aaaa, mx, ns, spf_includes, spf_ips, dmarc_policy, dmarc_rua, caa_issuers, soa_email, soa_serial, txt. Old names, refused with this one: records().
encode() takes text, command, url, domain, hostname, email, ip. Calls: none: it runs locally. Modes (one is required): base64, base64_utf16le, base64_offsets, base64_offsets_utf16le, hex, url, hexdump, defang, md5, sha1, sha256, sha512. Fields: text, hash, method.
geo() takes ip. Calls: one API call per value, to ip2location.io. Fields: country_code, country_name, region_name, city_name, latitude, longitude, zip_code, time_zone, asn, as, is_proxy. Old names, refused with this one: geoip(), locate().
http() takes url, domain, hostname, ip. Calls: one API call per value, to direct. Fields: status, final_url, redirect_chain, redirects, server, title, content_type, content_length, body_sha256, ip, cert_issuer, cert_sans, cert_serial, cert_age, forms, password_field, external_hosts, body. Old names, refused with this one: fetch().
identify() takes command. Calls: none: it runs locally. Modes: linux, macos, windows, powershell, netscaler, fortios, iosxe, junos. Fields: binary, platform, match, subcommand, summary, processes, explained, decoded, deobfuscated, endpoints, urls, flags, lolbas, gtfobins, sigma, sigma_possible, sigma_rules, level, techniques. Old names, refused with this one: identify_flags(), flags().
malwarebazaar() takes hash. Calls: one API call per value, to MalwareBazaar. Fields: sha256, md5, sha1, signature, imphash, ssdeep, tlsh, cert_serial, cert_subject, cscb_listed, tags, yara_rules, file_name, file_type, first_seen, last_seen, delivery_method.
man() takes command, filename. Calls: none: it runs locally. Fields: name, platform, synopsis, description, options, examples, see_also, source, page.
otx() takes ip, domain, hostname, url, hash. Calls: one API call per value, to AlienVault OTX. Fields: pulse_count, pulses, tags, malware_families, adversaries, references, country, asn, last_pulse, whitelisted. Old names, refused with this one: alienvault().
parse_header() takes email_header. Calls: none: it runs locally. Fields: from, return_path, to, subject, date, message_id, originating_ip, received_hops, auth_results.
rdap() takes domain, ip, cidr. Calls: one API call per value, to RDAP. Fields: age, registered, expires, registrar, registrar_abuse_email, nameservers, status, dnssec, registrant_email, net_range, net_name, net_type, cidr, asn, org, country, abuse_email. Old names, refused with this one: whois().
resolve() takes hostname, domain. Calls: none: it runs locally. Fields: ip. Old names, refused with this one: dns_resolve().
reverse_dns() takes ip. Calls: none: it runs locally. Fields: hostname. Old names, refused with this one: ptr().
routing() takes ip, cidr, asn. Calls: one API call per value, to ripestat. Fields: prefix, origin_asn, holder, announced, neighbours, related_prefixes, first_seen, last_seen. Old names, refused with this one: bgp().
samples() takes imphash, cert_serial, malware_family, tag, fuzzyhash. Calls: one API call per value, to MalwareBazaar. Fields: sha256, md5, sha1, signature, imphash, ssdeep, tlsh, cert_serial, cert_subject, cscb_listed, tags, yara_rules, file_name, file_type, first_seen, last_seen, delivery_method.
tls_cert() takes domain, hostname, url, ip. Calls: one API call per value, to direct. Fields: subject_cn, sans, issuer, serial, fingerprint_sha256, not_before, not_after, age, expires_in, trusted, self_signed, chain_length, key_algo, sig_algo, served_ip. Old names, refused with this one: cert().
urlscan() takes url, domain. Calls: one API call per value, to urlscan.io. Fields: final_url, ip, asn, domain, apex_domain, age, redirected, status, title, server, country, screenshot.
whatis() takes filename, command, hash. Calls: a lookup for some values. Modes: linux, macos, windows, powershell, netscaler, fortios, iosxe, junos. Fields: name, kind, platforms, description, summary, product, company, original_name, paths, signer, examples, lolbas, gtfobins, driver, rmm, techniques, sigma_rules, links, sources, match, version, signed, package, os, trust, known_malicious. Old names, refused with this one: hashlookup(), knownfile().