CEPLDocs Ceplin

Guide

Language tour

This page covers the whole language in order. Each example runs on Ceplin's sample data, and the examples run in one session, so a variable set in one section can be used in a later one. The reference has the exact rules.

#Artefacts

A line starts with an artefact: the value you are investigating. Write it in double or single quotes. Ceplin recognises its type from the value.

CEPL
>>> "185.215.113.66".abuseipdb()[score]
[OK] IP reputation [score] on 185.215.113.66 via AbuseIPDB
    100

>>> 'cdn-telemetry.xyz'.rdap()[registrar]
[OK] WHOIS / RDAP [registrar] on cdn-telemetry.xyz via RDAP
    NameSilo, LLC

The types you can write include ip, cidr, domain, url, email, asn, hash, command, filename and text. The types table lists them all with the verbs that take each one.

A defanged artefact is read as the value it stands for, and the output says so. The value stays marked as defanged, so it can be defanged again for a report.

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

An email, URL or domain has parts that need no lookup: [domain], [host], [subdomain], [tld], and [user] for an email.

CEPL
>>> "billing@mail.accounts-verify-paypa1.top"[domain]
[OK] [domain] on billing@mail.accounts-verify-paypa1.top
    accounts-verify-paypa1.top

Text that spans several lines, or contains quotes, goes between three backticks. The email header recipe uses this.

#Verbs

A verb is a lookup or a transformation, called with parentheses after a dot. Each verb lists the types it takes, and a line that passes it anything else is refused before any lookup is made.

CEPL
>>> "45.9.148.112".asn()
[OK] IP to ASN (Cymru) on 45.9.148.112 via team cymru
    asn:       AS202425
    prefix:    45.9.148.0/24
    as_name:   IPVOLUME, NL
    country:   NL
    registry:  ripencc
    allocated: 2019-06-28

>>> "aGk=".geo()
[ER] geo cannot accept text (accepts ip)

Some verbs take a mode in the parentheses. decode() needs one: decode(auto) finds the encoding itself, and decode(base64) names it.

CEPL
>>> "aHR0cDovL2Nkbi10ZWxlbWV0cnkueHl6L3dpbi91cGRhdGUucHMx".decode(auto)
[OK] Decode on aHR0cDovL2Nkbi10ZWxlbWV0cnkueHl6L3dpb...dGUucHMx
    http://cdn-telemetry.xyz/win/update.ps1
    text:       http://cdn-telemetry.xyz/win/update.ps1
    url:        http://cdn-telemetry.xyz/win/update.ps1
    method:     base64
    confidence: 100

>>> "http://cdn-telemetry.xyz/win/update.ps1".encode(defang)
[OK] Encode on http://cdn-telemetry.xyz/win/update.ps1
    hxxp[://]cdn-telemetry[.]xyz/win/update[.]ps1
    text:   hxxp[://]cdn-telemetry[.]xyz/win/update[.]ps1
    method: defang

Names are read in any case, and a misspelled verb is refused with the nearest one:

CEPL
>>> "185.215.113.66".abusipdb()
[ER] unknown method "abusipdb" (try: .abuseipdb())

#Fields

A lookup returns a report. [field] keeps one field of it, and [a, b] keeps several.

CEPL
>>> "secure-login-paypa1.com".rdap()[registrar, registered, age]
[OK] WHOIS / RDAP [registrar, registered, age] on secure-login-paypa1.com via RDAP
    registrar:  NameSilo, LLC
    registered: 2026-07-15T18:20:31.000Z
    age:        ● 113.7

A field that holds a list can be indexed from 0 with [n]. [start*] selects every field whose name starts with start.

CEPL
>>> "secure-login-paypa1.com".rdap()[nameservers][0]
[OK] WHOIS / RDAP [nameservers][0] on secure-login-paypa1.com via RDAP
    ns1.bullethost.net

>>> "secure-login-paypa1.com".rdap()[registrar*]
[OK] WHOIS / RDAP [registrar*] on secure-login-paypa1.com via RDAP
    registrar:             NameSilo, LLC
    registrar_abuse_email: abuse@namesilo.com

A field the report does not have is refused, with the fields it does have.

CEPL
>>> "185.215.113.66".abuseipdb()[scroe]
[ER] no field "scroe" available to extract (try: score)

#Chains

Each step takes what the step before it produced. A report passes on only a field you name, so to go from one lookup to another, select the field that the next verb takes.

CEPL
>>> "secure-login-paypa1.com".urlscan()[ip].asn()[as_name]
[OK] IP to ASN (Cymru) [as_name] on 185.215.113.66 via team cymru
    PROSPERO-AS, RU

>>> "secure-login-paypa1.com".urlscan().asn()
[ER] urlscan() gives a report, and asn() takes ip, asn: select the field to pass on, as [field].asn() (try: [asn].asn() or [ip].asn())

A long chain can continue on the next line. A line that starts with ., [ or { continues the line above.

CEPL
>>> "secure-login-paypa1.com".urlscan()
...   [ip]
...   .rdap()[net_name]
[OK] WHOIS / RDAP [net_name] on 185.215.113.66 via RDAP
    PROSPERO-NET

#Lists

A list of artefacts in square brackets runs every step on each value. Results come back in the order of the list.

CEPL
>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb()[score]
[OK] IP reputation [score] on 3 ips via AbuseIPDB
    ip              score
    ─────────────────────
    185.215.113.66  100
    45.9.148.112    100
    8.8.8.8         0

#Blocks

A block runs several checks on the same value. The checks go inside { }, separated by commas or new lines, and the answers come back as one row per value.

CEPL
>>> "185.215.113.66"{ abuseipdb()[score], geo()[country_name], asn()[as_name] }
[OK] Multi Report Expression on 185.215.113.66
    abuseipdb[score]:  100
    geo[country_name]: Netherlands
    asn[as_name]:      PROSPERO-AS, RU

A block can follow a list or a step. On several values the rows become a table.

CEPL
>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"]{ abuseipdb()[score], asn()[as_name] }
[OK] Multi Report Expression on 3 ips
    ip              score  as_name
    ──────────────────────────────────────
    185.215.113.66  100    PROSPERO-AS, RU
    45.9.148.112    100    IPVOLUME, NL
    8.8.8.8         0      GOOGLE, US

>>> "secure-login-paypa1.com".dns()[a]{
...   abuseipdb()[score]
...   rdap()[net_name]
... }
[OK] Multi Report Expression on 185.215.113.66
    abuseipdb[score]: 100
    rdap[net_name]:   PROSPERO-NET

Two lookups on the same value must be written as a block. Chaining them directly is refused, and the error shows the block to write:

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

#Filters

filter(condition) keeps the results that match. A condition compares a field with ==, !=, >, >=, <, <=, contains or ~. Text on the right is quoted.

CEPL
>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(score > 50)[isp]
[OK] IP reputation .filter(score > 50)[isp] on 2 ips via AbuseIPDB
    ip              isp
    ─────────────────────────────
    185.215.113.66  Prospero OOO
    45.9.148.112    IP Volume inc

>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(isp contains "google")[score]
[OK] IP reputation .filter(isp contains "google")[score] on 8.8.8.8 via AbuseIPDB
    0

Join comparisons with and or or. Mixing the two needs parentheses, and not negates a comparison or a group.

CEPL
>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"].abuseipdb().filter(score > 50 and not (country == "Netherlands"))[score, country]
[OK] IP reputation .filter(score > 50 and not (country == "Netherlands"))[score, country] on 185.215.113.66 via AbuseIPDB
    score:   ● 100
    country: Russian Federation

~ /pattern/ matches a regular expression. present tests that a field has a value, and in tests a list. Dates compare as dates, and now and today take an offset in hours, days or weeks.

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(country in ["Netherlands", "Germany"])[score]
[OK] IP reputation .filter(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"].abuseipdb().filter(last_reported > now - 365d)[last_reported]
[OK] IP reputation .filter(last_reported > now - 365d)[last_reported] on 2 ips via AbuseIPDB
    ip              last_reported
    ─────────────────────────────────────────
    185.215.113.66  2026-08-12T22:41:07+00:00
    45.9.148.112    2026-07-21T07:19:44+00:00

After a block, a filter can test the fields of any of its checks, so a row is kept or dropped on all of them together.

CEPL
>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"]{ abuseipdb()[score], asn()[country] }.filter(score > 50 and country == "RU")
[OK] Multi Report Expression on 185.215.113.66
    abuseipdb[score]: 100
    asn[country]:     RU
    filter(score > 50 and country == "RU") kept 1 of 3

#Sorting and counting

sort() orders results by one or more fields, ascending unless written desc. first(n), last(n) and limit(n) keep that many, uniq() drops repeats, and count() counts.

CEPL
>>> ["8.8.8.8", "185.215.113.66", "45.9.148.112"].abuseipdb().sort(score desc, total_reports desc)[score, total_reports]
[OK] [score, total_reports] on 3 ips
    ip              score  total_reports
    ────────────────────────────────────
    185.215.113.66  100    1148
    45.9.148.112    100    214
    8.8.8.8         0      0

>>> ["8.8.8.8", "185.215.113.66", "45.9.148.112"].abuseipdb().filter(score > 50).count()
[OK] count() on 2 ips
    2

After a block, [value] goes on with the subject of each row that was kept. This line keeps the high scoring addresses and then looks up their networks:

CEPL
>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8"]{ abuseipdb()[score] }.filter(score > 50)[value].rdap()[net_name]
[OK] WHOIS / RDAP [net_name] on 2 ips via RDAP
    ip              net_name
    ────────────────────────────
    185.215.113.66  PROSPERO-NET
    45.9.148.112    IPV-NET
    filter(score > 50) kept 2 of 3

#Grouping and summaries

group(field) makes one row per value of a field, with how many results have it and which ones they are. min(), max(), sum() and avg() reduce a field to one value.

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().sum(total_reports)
[OK] sum(total_reports) on 3 ips
    1362

#Searching text

find() searches text by shape (ip, domain, url, email, hash, cidr), by "text", or by /pattern/. After a search by shape, the next step works on the values found.

CEPL
>>> "Beacon to 185.215.113.66:443 then 45.9.148.112:8080, DNS via 8.8.8.8".find(ip).count()
[OK] count() on Beacon to 185.215.113.66:443 then 45.... 8.8.8.8
    3

>>> "Beacon to 185.215.113.66:443 then 45.9.148.112:8080, DNS via 8.8.8.8".find(ip).abuseipdb().filter(score > 50)[score]
[OK] IP reputation .filter(score > 50)[score] on 185.215.113.66 via AbuseIPDB
    100
[OK] IP reputation .filter(score > 50)[score] on 45.9.148.112 via AbuseIPDB
    100

#Decoding and encoding

decode() and encode() run locally and make no API calls. decode(auto) tries every decoder that fits, up to two deep, and keeps the most plausible result. The verbs page lists every mode.

CEPL
>>> "68747470733a2f2f7365637572652d6c6f67696e2d7061797061312e636f6d".decode(hex)
[OK] Decode on 68747470733a2f2f7365637572652d6c6f676...2e636f6d
    https://secure-login-paypa1.com
    text:   https://secure-login-paypa1.com
    url:    https://secure-login-paypa1.com
    method: hex

>>> "1784194262".decode(unix_time)
[OK] Decode on 1784194262
    2026-07-16T09:31:02.000Z (s)
    text:   2026-07-16T09:31:02.000Z (s)
    method: unix_time

>>> "secure-login-paypa1.com".encode(sha256)
[OK] Encode on secure-login-paypa1.com
    text:   af1c854dcbe17d49802807ec3ee8a2862014af924791ca03afc29c4220c095f1
    hash:   af1c854dcbe17d49802807ec3ee8a2862014af924791ca03afc29c4220c095f1
    method: sha256

#Comparing two values

Two values with == between them are compared, to spot a typosquat: whether they are equal, how many edits apart, each edit, the lookalike characters and the technique. Steps can follow. compare("value") does the same as a step, on each value of a list.

CEPL
>>> "secure-login-paypa1.com" == "secure-login-paypal.com"
[OK] Compare on secure-login-paypa1.com
    equal:       false
    lookalike:   true
    technique:   homoglyph
    distance:    1
    similarity:  96
    changes:     "1" for "l" at 19
    confusables: 1 for l
    other:       secure-login-paypal.com

>>> ["paypa1.com", "pyapal.com", "paypal.co"].compare("paypal.com")[technique, changes]
[OK] Compare [technique, changes] on 3 domains
    domain      technique      changes
    ───────────────────────────────────────────────────
    paypa1.com  homoglyph      "1" for "l" at 6
    pyapal.com  transposition  swapped "a" and "y" at 2
    paypal.co   tld swap       missing "m" at 10

#Variables

name = line keeps what the line produced, and the name can then start a line. output() is what the previous line produced.

CEPL
>>> suspects = ["185.215.113.66", "45.9.148.112", "8.8.8.8"]
[OK] suspects
    3 ip value(s)

>>> reps = suspects.abuseipdb()
[OK] IP reputation on 3 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

    45.9.148.112
      score          100
      total_reports  214
      domain         ipvolume.net
      isp            IP Volume inc
      usage_type     Data Center/Web Hosting/Transit
      country        Netherlands
      is_tor         false
      last_reported  2026-07-21T07:19:44+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
    3 row(s): ip, score, total_reports, isp, usage_type, country, is_tor, last_reported, domain, hostnames

>>> reps.filter(score > 50)[isp]
[OK] .filter(score > 50)[isp] on 2 ips
    ip              isp
    ─────────────────────────────
    185.215.113.66  Prospero OOO
    45.9.148.112    IP Volume inc

>>> threshold = 90
[OK] Recognised text
    90
[OK] threshold
    1 text value(s)

>>> reps.filter(score > threshold).count()
[OK] count() on 2 ips
    2

A variable that holds a block's table can be read by column, filtered and sorted again without new lookups:

CEPL
>>> t = suspects{ 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] t
    3 row(s): ip, score, country_name

>>> t.filter(score > 50).sort(score desc)[value]
[OK] [value] on 2 ips
    ips: 185.215.113.66 45.9.148.112
    filter(score > 50) kept 2 of 3

>>> t.cost()
[OK] t.cost() on 3 ips
    0 API call(s) - projected API calls (not executed): the rows are held, no lookup is made again

Lists combine in an assignment: a + b is every value of both, a & b the values in both, and a - b the values of a that are not in b.

CEPL
>>> known = ["8.8.8.8"]
[OK] known
    1 ip value(s)

>>> fresh = suspects - known
[OK] fresh
    2 ip value(s)

>>> fresh
[OK] fresh
    ip
    185.215.113.66
    45.9.148.112

#Saved verbs

/save keeps a chain as a verb of your own, for one type, or for several written with | between them (/save ip|domain|url.encode(defang) defang). .@name() runs it.

CEPL
>>> /save ip{ abuseipdb()[score], asn()[as_name], rdap()[net_name] } ipcheck
[OK] Saved @ipcheck
    accepts:    ip
    expression: ip{ abuseipdb()[score], asn()[as_name], rdap()[net_name] }
    returns:    ip
    cost:       3 API call(s)

>>> "45.9.148.112".@ipcheck()
[OK] Multi Report Expression on 45.9.148.112
    abuseipdb[score]: 100
    asn[as_name]:     IPVOLUME, NL
    rdap[net_name]:   IPV-NET

A saved verb can take parameters with defaults. The values are given by name when it is called:

CEPL
>>> /save ip.abuseipdb().filter(score > min_score)[score, isp] risky(min_score = 50)
[OK] Saved @risky
    accepts:    ip
    expression: ip.abuseipdb().filter(score > min_score)[score, isp]
    returns:    text
    cost:       1 API call(s)
    run as:     .@risky()

>>> suspects.@risky()
[OK] IP reputation .filter(score > 50)[score, isp] on 2 ips via AbuseIPDB
    ip              score  isp
    ────────────────────────────────────
    185.215.113.66  100    Prospero OOO
    45.9.148.112    100    IP Volume inc

>>> suspects.@risky(min_score = -1)
[OK] IP reputation .filter(score > -1)[score, isp] on 3 ips via AbuseIPDB
    ip              score  isp
    ────────────────────────────────────
    185.215.113.66  100    Prospero OOO
    45.9.148.112    100    IP Volume inc
    8.8.8.8         0      Google LLC

/list shows your saved verbs, and /edit, /remove and /recover manage them.

#Files

A CSV or TSV file is a head through import(). Its columns are its fields, and a header such as Dest IP becomes [dest_ip]. In ceplin the file is read from the folder ceplin was started in, or from the path you give.

hosts.csv
host,ip,owner
web-01,185.215.113.66,shop
web-02,45.9.148.112,shop
dns-01,8.8.8.8,infra
CEPL
>>> "hosts.csv".import()[ip].abuseipdb()[score]
[OK] IP reputation [score] on 3 ips via AbuseIPDB
    ip              score
    ─────────────────────
    185.215.113.66  100
    45.9.148.112    100
    8.8.8.8         0

>>> "hosts.csv".import().filter(owner == "shop").count()
[OK] count() on 2 rows
    2

export() writes results to a file. The file name's ending sets the format, .csv or .xlsx. With no name, ceplin writes a CSV into its folder.

CEPL
>>> "hosts.csv".import()[ip]{ abuseipdb()[score], asn()[as_name] }.export("host-reputation.xlsx")
[OK] Multi Report Expression on 3 ips
    ip              score  as_name
    ──────────────────────────────────────
    185.215.113.66  100    PROSPERO-AS, RU
    45.9.148.112    100    IPVOLUME, NL
    8.8.8.8         0      GOOGLE, US
[OK] Exported
    3 row(s) to host-reputation.xlsx

#Cost

.cost() prices a line without running it. A line that would make more API calls than your limit (10 by default) stops before its lookups and asks; Enter runs it and Esc cancels.

CEPL
>>> suspects{ abuseipdb(), geo(), asn(), rdap() }.cost()
[OK] { abuseipdb(), geo(), asn(), rdap() }.cost() on suspects
    12 API call(s) - projected API calls (not executed)

>>> ["185.215.113.66", "45.9.148.112", "8.8.8.8", "103.21.58.11"]{ abuseipdb(), geo(), asn() }
[??] Confirm
    This line will make 12 API calls (abuseipdb 4, asn 4, geo 4). Enter to continue, Esc to cancel.
Not run.

After a block, .filter(cost < N) runs it only when it costs less than N calls, and skips it otherwise. Cached lookups and addresses that are never sent cost nothing.

#Errors

Every line is checked before anything is looked up. A refusal says what was wrong and, where there is a right spelling, what to write.

CEPL
>>> cdn-telemetry.xyz.urlscan()[ip]
[ER] quote the artefact before its steps: write "cdn-telemetry.xyz".urlscan()[ip]

>>> "185.215.113.66".abuseipdb()[score].filter(score > "high")
[ER] "score" is a number; compare it to a number, e.g. score > 50

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

The reference has the full rules, including limits on line length, chain length and nesting, and on how much a line, a session and a file may hold.