Skip to main content
Sandfish

Reference

Phishing analysis and API documentation

Use the Sandfish phishing analysis API to submit URLs and emails, inspect automatic interaction, retrieve evidence, and replay WACZ archives.

Use Sandfish with your AI agent

Give your agent https://sandfi.sh/SKILL.md, or download the file and add it to your LLM's context. It contains step-by-step instructions for answering questions, inspecting evidence, and building API integrations.

Use the Sandfish phishing sandbox to analyze suspicious URLs and emails. This guide covers automatic browser interaction, QR code analysis, WACZ archive replay, and evidence search. Use Evaluation and verdicts to interpret a result. Use the phishing analysis API guide for integration examples and the OpenAPI schema.

For tool selection, read the phishing tool comparison. It compares features, prices, access tiers, and evidence from real cases.

1. Start with the data model

Sandfish organizes a submission into records with different purposes.

RecordMeaning
Analysis groupOne submitted source, its visibility, and all related analyses.
AnalysisOne email parse, live web capture, or replay.
OperationDurable requested work, such as capture, reanalysis, or rescore.
EvaluationOne versioned rule result with completeness, score, and verdict.
SignalOne rule match linked to supporting evidence.
Phishing kitA derived group that shares implementation evidence.
ActorA derived group that reuses selected operator-controlled identifiers.

An analysis can have several evaluation generations. Its current evaluation supplies the displayed verdict. An analysis group can contain several related analyses, but it does not have a combined verdict.

2. Submission and processing

A browser or API client submits a source. The API validates access and quota, then creates the group, analysis, operation, and durable task records in PostgreSQL. A dispatcher publishes due work through RabbitMQ. Celery workers run the applicable engine, persist normalized facts, evaluate rules, and store the result.

Browser or API client
  -> API validation and access checks
  -> PostgreSQL operation and outbox records
  -> dispatcher -> RabbitMQ -> worker
  -> engine -> evidence -> rules -> evaluation
                       |
                       +-> object storage for source artifacts

An accepted submission is not a completed analysis. Clients must follow the operation or analysis status until work reaches a terminal state.

Guests can submit five combined URL and EML inputs per public IP per UTC day. One guest operation can run at a time. HTML and WACZ uploads require an account. Account submissions use a separate allowance. See API access for visibility and quota details.

3. Web analysis

URL and HTML inputs pass through an instrumented browser and a recording proxy. URL analysis opens live content. HTML analysis serves the uploaded file from a synthetic origin and can still load absolute resources from the live web. Both paths produce a standard WACZ archive.

A WACZ input uses pywb to replay stored responses. Replay does not execute the original program under identical conditions. Time, random values, browser state, missing archive entries, and unsupported protocols can change the observed path.

The web pipeline normalizes browser and proxy observations into evidence records. It can extract identifiers, JavaScript fingerprints, TLS facts, passive infrastructure metadata, technology observations, and delivered payloads. Stored facts support later rescoring without another browser run.

Automatic browser interaction during live capture can click controls, enter synthetic identity values, submit forms, and collect downloads. This can reveal later stages of a multi-step phishing page. Replay remains passive. The Interaction row in the analysis summary shows the stop reason and recorded actions. A completed click or submit action does not prove that a server received data. Use the request records to check that claim.

The summary distinguishes the Submitted URL, Effective URL, and Final URL. The effective URL identifies the representative page that supplies the summary evidence. The final URL identifies the last committed document. These can differ when a page redirects to another site after interaction.

4. Email analysis

EML processing stores the original message digest and parses bounded headers and MIME structure. It records the selected body, links, attachments, identifiers, warnings, and processing limits. Authentication processing keeps message claims separate from independently verified results.

Eligible HTML parts and selected links can create related web analyses. These members remain in the same analysis group, but each member receives its own evaluation.

Static email parsing does not open a browser. A related web analysis can contact live resources. Review the email authentication mode and its DNS evidence before comparing results. A sender's Authentication-Results header is a claim, not independent verification.

5. Evidence

Sandfish can record these web evidence types:

EvidenceExamples
NavigationMain-frame documents, redirect chains, triggers, and causes.
RequestURL, host, path, remote IP, digests, and archive locators.
Behavior eventInstrumented browser API calls and permission requests.
Page snapshotVisible text, forms, inputs, links, image, and canonical URL.
QR codeDecoded content, source screenshot, pixel bounds, and destination-analysis links.
CookieResponse declarations and browser observations with separate coverage.
Runtime window nameMain-frame window property names with snapshot provenance.
IdentifierA normalized value with occurrence and source provenance.
JavaScript functionBounded function fingerprints and resource attribution.
InfrastructureSelected endpoints, passive network facts, and technologies.
TLS certificateCertificate facts linked to observed requests.
PayloadA delivered file with type, size, acquisition method, and digests.

Available raw response content remains in the archive. Normalized facts support search, display, rule evaluation, and comparison. Each collection has limits. An absent record does not establish that the source contained no such evidence.

5.1 QR codes

Select QR codes in the analysis summary to inspect decoded content and source frames. Recognition reads captured screenshot pixels. It can miss small, obscured, damaged, or offscreen codes. Coverage distinguishes a completed scan, a limit, a failure, and an older unscanned snapshot.

Eligible HTTP(S) destinations from a live capture or archive refresh can create related analyses in the same group. Each destination has its own evaluation. Repeated destinations can link to an existing member. Expansion has per-analysis, group, and depth limits. Each entry states why a destination was skipped. Replay decodes QR codes but does not start new live captures.

5.2 Cookies and runtime window names

Open Network → Observed cookies for cookie evidence. A Set-Cookie response records a declaration, not browser acceptance. A browser observation records presence in the context-wide cookie jar. Its navigation identifies where collection occurred, not which page set the cookie.

Cookie records store the value's UTF-8 byte length and SHA-256, not its raw value. Raw HTTP cookie headers can still exist in the archive. Names preserve letter case. Review source-specific coverage before interpreting an empty list.

Open Behavior → Runtime window names for main-frame property names. This inventory reads names, not values or getters. It excludes module locals, lexical bindings, and child-frame globals. A name does not identify the script that created it.

5.3 Payloads

The Payloads tab lists files delivered to the browser. It shows the content-based file kind, declared filename and type, acquisition method, and digests. A delivered executable alone does not establish malicious intent.

Use a digest as a file identity only when digests_identify_file is true. A digest from truncated content identifies the recorded fragment. Sample downloads require an account and access to the analysis. They return an encrypted ZIP with the fixed password infected.

Sample bytes normally expire after 30 days. Read bytes_state and bytes_expire_at for the record's state and expiry. Metadata and digests can remain after expiry. A sample download returns HTTP 410 when its bytes expired and 404 when no bytes were stored.

6. Evaluation and verdicts

Read a result in this order:

  1. Check processing status.
  2. Check evidence completeness and recorded limits.
  3. Review the score and verdict.
  4. Follow each signal to its source evidence.
  5. Review related analyses in the same group.

Processing status answers whether work finished. Completeness states which expected evidence or processing was unavailable. A signal states which deterministic predicate matched. The score applies the recorded scoring policy. The verdict classifies that score and completeness.

The current score policy is v10. Risk severity weights are 0, 5, 15, 30, and 60 for informational, low, medium, high, and critical findings. The policy counts duplicate fingerprints once. It takes the highest weight within each correlated rule family and each remaining rule ID. It caps the risk total at 100.

Assurance findings can subtract credit from that total. Credit cannot exceed 20 points or half the risk total. A high or critical risk finding, or a risk total of at least 60, prevents credit. Read risk_score, assurance_credit, and the final score together.

VerdictCurrent policy
maliciousA critical risk finding or a final score of at least 60.
suspiciousA final score of at least 25, below the malicious threshold.
benignComplete evidence and a risk score of zero.
unknownAll other results, including partial low-risk results.

A score is not a probability. Partial evidence can still support a suspicious or malicious verdict. Earlier evaluations retain their recorded policy and result. Compare policy versions and completeness before comparing scores.

The Signals tab groups repeated observations for display. Expand a finding to inspect each occurrence. A row can name both the configured credential destination rule and the matching endpoint rule. Display grouping does not change stored signals or scoring.

A verdict is not a guarantee

A benign result describes the evidence available to one evaluation. Replay misses, blocked resources, fast navigation, or other capture limits can prevent Sandfish from observing relevant behavior.

7. Search and pivots

Simple search examines analysis metadata and current evidence. Advanced search combines typed conditions with AND and OR groups. Evidence pivots open an exact or partial search for the selected value across analyses that the current viewer can read.

The page selects Analyses by default. An empty search lists recent visible analyses. Select Advanced to add conditions, then select Search to apply them. The URL preserves the applied query for sharing, Reload, Back, and Forward.

  • Contains matches part of a value. Equals matches the complete value. Both normally ignore letter case. Exact Formspark form IDs preserve their case-sensitive identity.
  • Equals, case-sensitive is available for cookie names and JavaScript global variable names. It also preserves query whitespace.
  • JavaScript function search requires Equals and a complete 64-character SHA-256 fingerprint. Function names and partial fingerprints are not searched.
  • Separate AND conditions can match different evidence records in one analysis. In evidence results, conditions for that evidence type apply to the result record. A group result requires one member analysis to match the complete query.
  • Analyses supports Previous and Next pages. Other result types return bounded lists. At least marks a lower-bound count. With All types, sections can finish independently.

For example, combine Identifier value equals campaign-123 with Request host or domain equals collector.example. This finds analyses containing both observations. It does not prove that the identifier occurred in that request.

A pivot badge counts other matching analyses. The destination search includes all matches, including the source analysis. Shared links always use the viewer's access rights.

One shared value does not always establish a relationship. Public services, analytics identifiers, common JavaScript, and shared infrastructure can occur on unrelated sites.

8. Phishing kits and actors

Phishing kits and actors are separate derived records.

A phishing kit links substantial shared implementation evidence. This can include the same entry document, document structure, visible text, JavaScript, or reviewed named-kit signature. Shared JavaScript must cover most of the code in both analyses. A kit can contain deployments from different operators who used the same software.

A generated codename identifies a cluster. A reviewed kit name identifies a known implementation and can appear on an analysis before a cluster exists. A retired cluster preserves historical links. An older-policy record is historical and unverified.

An actor links selected high-value operator-controlled identifiers, such as a supported bot token or wallet address. It records identifier reuse, not identity. A shared panel or resold credential can also produce reuse.

A supported secret credential can link analyses on its own. Wallet addresses need additional shared values. Known public contract addresses, widely reused values, and large wallet lists are excluded from linkage. An extracted identifier is not automatically eligible to link an actor.

Both records show only analyses that the viewer can read. Neither record replaces the evaluation of an individual analysis.

Actor pages list Kits observed. Kit pages list Associated actors. A relationship requires at least one shared case in current active clusters. Select a codename to open the related record. Select its shared case count to inspect the supporting cases. Counts, dates, and component names cover only shared cases that you can read.

API clients can list these relationships with GET /api/actors/{slug}/phishing-kits and GET /api/phishing-kits/{slug}/actors. Use kit_slug on an actor's /analyses endpoint or actor_slug on a kit's /analyses endpoint to read the supporting cases. Historical relationships return HTTP 409 with code cluster_not_current.

Brands show identities that current evaluations report as impersonated. A shared brand is a search filter, not evidence of a common kit or operator. The Similar tab also distinguishes implementation similarity (kit) from operator-identifier reuse (operation).

9. API

Use https://sandfi.sh/api as the application API base URL. The OpenAPI schema contains the supported integration endpoints for the deployed frontend release. Generated clients use https://sandfi.sh as their server URL because endpoint paths already include /api. The schema includes anonymous and API-key access, file responses, and error definitions.

9.1 Authentication

API keys use the HTTP Bearer scheme. Create and manage keys from API access. Set the key in your shell before you run an authenticated request.

export SANDFISH_API_KEY="replace-with-your-api-key"
 
curl --fail-with-body \
  --header "Authorization: Bearer $SANDFISH_API_KEY" \
  "https://sandfi.sh/api/analyses?limit=10"

Public analysis groups and ordinary captured evidence can be read without authentication. Private records require their owner. An API key needs data:read for reads and analysis:submit for source submissions.

Collected Telegram and HTTP destination traffic, collection status, exports, and media require an administrator browser session. An ordinary account or API key does not grant this access, even for a public analysis. Ordinary evidence CSV exports follow analysis visibility.

Private submissions require a paid plan. If visibility is omitted, paid accounts default to private; free accounts and guests default to public. Set visibility explicitly when the choice matters. A public submission can expose its source content and captured artifacts.

Browser sessions and API keys share the account's submission allowance and read counters. Read limits use fixed UTC windows. Use plan limits for current allowances, GET /api/account/usage for account usage, and GET /api/guest/usage for guest usage. Submission and active-operation limits are separate from read limits. Respect Retry-After when a request returns HTTP 429.

9.2 Submit a source

InputMethod and pathBody
URLPOST /api/analysesJSON with url and optional visibility.
HTML or WACZPOST /api/analyses/uploadMultipart file and optional visibility.
EMLPOST /api/analysis-groups/emlMultipart file and optional visibility.

This example submits a benign demonstration URL:

curl --fail-with-body https://sandfi.sh/api/analyses \
  --header "Authorization: Bearer $SANDFISH_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{"url":"https://example.com/","visibility":"public"}'

Upload an HTML file with this command. For WACZ, replace the file path with a valid .wacz archive.

curl --fail-with-body https://sandfi.sh/api/analyses/upload \
  --header "Authorization: Bearer $SANDFISH_API_KEY" \
  --form '[email protected]' \
  --form 'visibility=public'

For EML, change the endpoint to /api/analysis-groups/eml and use [email protected]. Use visibility=private with a paid account for private files. Let curl set the multipart content type and boundary.

API-side file limits are 8 MiB for UTF-8 HTML, 25 MiB for EML, and 256 MiB for WACZ. Public-site proxies can impose lower request limits. WACZ uploads must contain a valid archive with a replayable entry page. File content selects HTML or WACZ processing; renaming a file does not convert it.

Accepted submissions return HTTP 202. URL, HTML, and WACZ responses contain id for the analysis, plus group_id, operation_id, and status. EML uses primary_analysis_id instead of id. Save the group ID to find related analyses. Acceptance does not establish a verdict.

9.3 Read results

Poll the operation identified by operation_id. Leave a delay between requests and respect the read limits.

OPERATION_ID="replace-with-operation-id"
 
curl --fail-with-body \
  --header "Authorization: Bearer $SANDFISH_API_KEY" \
  "https://sandfi.sh/api/operations/$OPERATION_ID"

Operation states are pending, running, succeeded, failed, and cancelled. Stop polling at the last three states. A parent operation can finish before related members finish. Check the group when you need all members.

Use id from a web submission or primary_analysis_id from an EML submission to read the analysis:

ANALYSIS_ID="replace-with-analysis-id"
 
curl --fail-with-body \
  --header "Authorization: Bearer $SANDFISH_API_KEY" \
  "https://sandfi.sh/api/analyses/$ANALYSIS_ID"

The top-level status describes analysis processing. Verdict fields are inside current_evaluation, which can be null before an evaluation exists. Read its status, completeness, completeness_reasons, risk_score, assurance_credit, score, and verdict. The analysis and operation use different status vocabularies.

Use GET /api/analyses/{analysis_id}/evaluations for evaluation history. A new rescore does not erase earlier completed results.

Request one evidence collection when you need its source records. For example, this command returns the first page of requests for the analysis.

curl --fail-with-body \
  --header "Authorization: Bearer $SANDFISH_API_KEY" \
  "https://sandfi.sh/api/analyses/$ANALYSIS_ID/requests?limit=50"

You can also open the same analysis in the browser at /analyses/{analysis-id}.

9.4 Endpoint groups

The API includes analyses, groups, operations, evaluations, requests, signals, rules, identifiers, behavior events, snapshots, cookies, QR codes, payloads, email evidence, infrastructure, TLS certificates, search, statistics, brands, kits, and actors.

Most paginated collections return items, total, limit, and offset. Increase offset by the returned page limit to read the next page. Stop when the page is empty or the next offset reaches total. Limits and response shapes vary by endpoint; check the schema. CSV exports are available for selected collections.

HTTP statusMeaning or action
400Invalid source content or request. Read the error message.
401Authentication is required or invalid.
403The account, key scope, or plan does not permit the action.
404The record does not exist or is not visible to the caller.
409The operation conflicts with current state or required source data is missing.
410Stored sample bytes have expired.
413The uploaded body exceeds its size limit.
422A request field or search expression failed validation.
429A submission, active-operation, or read limit was reached.
503A required service is temporarily unavailable.

API errors use an error object with code, message, details, and request_id. An edge or proxy rejection can return a different body, including HTML. Keep the request ID when reporting a failed API request. Check HTTP status and content type before reading a success response.

9.5 Search API

GET /api/search accepts either q or a JSON filters parameter. Text requires at least two non-space characters. To list records without text, send filters={"kind":"group","operator":"and","children":[]}. Search uses per_type_limit from 1 to 25, not the collection limit parameter. offset is supported only with type=analysis.

curl --fail-with-body --get https://sandfi.sh/api/search \
  --data-urlencode 'type=analysis' \
  --data-urlencode 'per_type_limit=10' \
  --data-urlencode 'include_total=false' \
  --data-urlencode 'filters={"kind":"group","operator":"and","children":[{"kind":"term","field":"request.host","operator":"equals","value":"example.com"}]}'

A group has kind, operator (and or or), and children. A condition has kind: "term", field, operator, and value. Nested groups must contain a condition. Queries allow 50 nodes, five nesting levels, and 512 characters per value.

Condition operators are contains, equals, and equals_case_sensitive. The last operator is restricted to cookie.name and page_snapshot.global_name. The schema lists every supported field.

include_total=false avoids exhaustive counts. A response with total_is_exact=false gives a lower bound. Each entry in types has its own flag. Omitting include_total requests exact totals and can take longer.

9.6 Follow-up operations

These actions require authentication and applicable ownership checks. A public read link does not grant permission to modify a record. Source availability and operation state can also prevent an action.

All paths below use POST /api/analyses/{analysis_id} followed by the suffix.

ActionSuffixResult
Rescore/rescoreApplies current rules to stored facts. No browser or source fetch.
Refresh archive/refresh-archiveUpdates archive extraction and stored-screenshot QR decoding without a browser.
Reanalyse/reanalyseReplays the stored archive in a browser as a new member.
Recapture/recaptureVisits the URL or captures the uploaded HTML again as a new member.
Reparse email/email/reparseRuns email parsing again.
Refresh email authentication/email/refresh-authenticationUpdates authentication processing with current DNS evidence.
Offline reauthenticate/email/offline-reauthenticateUses stored source and DNS evidence.

Choose recapture when live evidence is missing. Rescore cannot recover a page or request that was never recorded. Archive refresh can recover facts from stored artifacts, but cannot recreate missing live-browser observations.

10. Implementation

Sandfish separates the core analysis code, API, and user interface.

LayerImplementation
InterfaceNext.js, React, and a generated OpenAPI client.
APIFastAPI routes, Pydantic schemas, access checks, and service assembly.
CoreCapture, parsing, extraction, deterministic rules, scoring, and reusable services.
PersistencePostgreSQL for normalized facts and lifecycle records.
ArtifactsObject storage for source files, archives, screenshots, and retained samples.
Work executionDurable outbox records, RabbitMQ, Celery workers, and recovery state.
Web captureInstrumented browser, recording proxy, WACZ packaging, and pywb replay.

The core package does not depend on FastAPI. Rule evaluation does not access the database, browser, object storage, or network. This boundary keeps evaluation deterministic and makes stored facts suitable for rescoring.

11. Known limits

All collection is bounded. Pages can change after capture, detect automation, require unsupported interaction, or use protocols that ordinary replay does not reproduce. Fast transitions can prevent a snapshot. An uploaded HTML file does not retain the original origin, headers, cookies, or referrer.

Live capture has a specific behavior-event limit: the stealth browser isolates the API hooks from page code. These hooks do not observe page-code API calls in live capture. Network requests remain recorded. Script errors and the runtime window-name inventory use separate collection paths. An empty event list does not establish that a page did nothing.

Main-frame snapshots inventory embedded frames but do not collect their text, forms, or inputs. An empty form list beside an embedded-frame inventory can mean unmeasured content. Camera and location permissions are denied. Authentication gates and unsupported controls can prevent further interaction.

Replay misses identify requests without an archived response. Even a replay without misses can follow a different path from the original visit. Review replay warnings and interaction stop reasons with evidence completeness.

Trend values describe the corpus visible to the caller. They do not measure global phishing prevalence. Growth counts submissions, not unique threats. Cluster timelines count member analyses by observation day.

The hosting map counts current completed malicious web evaluations by observed document-server country. Partial evaluations can contribute. One analysis can appear in several countries. A location can identify a CDN or redirect server, not the origin host or operator.

External feeds select capture candidates. Provider verdicts and labels do not contribute to Sandfish scores. Each accepted capture receives its own evaluation.

Payload samples have a different lifetime from payload records. A record and its digests can remain after the stored bytes expire.

12. Glossary

Capture: One browser run against live or synthetic content.

Replay: A browser run that obtains available responses from a stored archive.

Rescore: A new evaluation from stored facts without a browser or source fetch.

Signal: A rule match with links to supporting source facts.

Completeness: A record of unavailable evidence or processing that affects interpretation.

Provenance: The source location and context for an extracted fact or finding.