CEPLDocs Ceplin

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.

CEPL
>>> "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 { }.

CEPL
>>> "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.

CEPL
>>> "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.

CEPL
>>> "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.

CEPL
>>> 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.

CEPL
>>> "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.

CEPL
>>> '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.

CEPL
>>> "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.

CEPL
>>> 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.

CEPL
>>> "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.

CEPL
>>> 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.

CEPL
>>> "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.

CEPL
>>> "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.

CEPL
>>> "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.

CEPL
>>> ["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.

CEPL
>>> ["aGk=", "aGVsbG8="][5]
[ER] [5] is past the end of a 2-item list (the first is [0])
CEPL
>>> ["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.

CEPL
>>> nosuchvar.decode(auto)
[ER] no variable "nosuchvar": quote it to use it as text, "nosuchvar" (variables: x)
CEPL
>>> 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).

CEPL
>>> "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].

CEPL
>>> "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.

CEPL
>>> "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.

CEPL
>>> "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.

CEPL
>>> "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.

CEPL
>>> "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.

CEPL
>>> "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].

CEPL
>>> "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.

CEPL
>>> "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.

CEPL
>>> "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).

CEPL
>>> "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*].

CEPL
>>> "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.

CEPL
>>> "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.

fromtohow
hostnamedomainthe same name
urldomainthe URL's host
samplehashthe sample's sha256
texturltext starting with http:// or https://
textcommandthe 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.

CEPL
>>> "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.

CEPL
>>> "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.

CEPL
>>> ["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.

CEPL
>>> "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.

CEPL
>>> "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.

CEPL
>>> "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()
CEPL
>>> "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)
CEPL
>>> "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.

CEPL
>>> "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.

CEPL
>>> ["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.

CEPL
>>> ["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.

CEPL
>>> ["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).

CEPL
>>> ["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.

CEPL
>>> ["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.

CEPL
>>> "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.

CEPL
>>> "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).

CEPL
>>> ["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.

CEPL
>>> ["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).

CEPL
>>> ["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).

CEPL
>>> ["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.

CEPL
>>> ["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.

CEPL
>>> ["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.

CEPL
>>> ["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.

CEPL
>>> ["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.

CEPL
>>> ["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.

CEPL
>>> ["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.

CEPL
>>> "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.

CEPL
>>> ["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".

CEPL
>>> ["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.

CEPL
>>> 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.

CEPL
>>> 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).

CEPL
>>> 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.

CEPL
>>> 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().

CEPL
>>> 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.

CEPL
>>> 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).

CEPL
>>> /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).

CEPL
>>> /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.

CEPL
>>> /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).

CEPL
>>> /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.

CEPL
>>> /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.

CEPL
>>> "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.

CEPL
>>> "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.

CEPL
>>> "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.

CEPL
>>> "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.

markermeaning
[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.

CEPL
>>> "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.

CEPL
>>> ["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().