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.
>>> "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.
>>> "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.
>>> "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.
>>> "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.
>>> "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:
>>> "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.
>>> "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.
>>> "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.
>>> "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.
>>> "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.
>>> "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.
>>> ["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.
>>> "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.
>>> ["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:
>>> "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.
>>> ["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.
>>> ["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.
>>> ["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.
>>> ["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.
>>> ["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:
>>> ["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.
>>> ["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.
>>> "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.
>>> "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.
>>> "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.
>>> 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:
>>> 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.
>>> 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.
>>> /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:
>>> /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.
host,ip,owner web-01,185.215.113.66,shop web-02,45.9.148.112,shop dns-01,8.8.8.8,infra
>>> "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.
>>> "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.
>>> 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.
>>> 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.