Investigate with Pursuit
Elastio Pursuit investigates copies of your systems off-host for evidence of compromise, such as persistence mechanisms, without touching the running machine. Use the API to check whether any host is under suspicion, read the persistence reports, start a Pursuit investigation, and give Pursuit context about an asset.
Before you begin
Section titled “Before you begin”- A personal access token with access to Pursuit and Hunt findings. Starting an investigation needs write access. Pursuit exceptions are admin-only, for reads as well as writes. See Authentication.
- Pursuit runs on Windows only: on AWS, and on Azure VMs and managed disks for tenants with Pursuit on Azure enabled. The
pursuit_supportedfield onGET /gateway/api/v2/assets/{assetId}tells you whether an asset qualifies.
Check the estate
Section titled “Check the estate”The summary rolls up every host’s latest Pursuit report in a time window. By default that’s the last 30 days, and the window can’t exceed 180 days. state is SUSPECTED when at least one host’s latest report has a low, medium, high or critical suspicion level, and CLEAR otherwise. all_clear only means no host currently has such a report. It doesn’t mean the estate is proven clean.
curl "https://acme.app.elastio.com/gateway/api/v1/pursuit/summary" \ -H "Authorization: Bearer $ELASTIO_TOKEN"import os, requests
BASE = "https://acme.app.elastio.com"HEADERS = {"Authorization": f"Bearer {os.environ['ELASTIO_TOKEN']}"}
resp = requests.get(f"{BASE}/gateway/api/v1/pursuit/summary", headers=HEADERS, timeout=30)resp.raise_for_status()summary = resp.json()print(summary["state"], summary["affected_host_count"], summary["standalone_asset_ids"])$Base = "https://acme.app.elastio.com"$Headers = @{ Authorization = "Bearer $env:ELASTIO_TOKEN" }Invoke-RestMethod -Uri "$Base/gateway/api/v1/pursuit/summary" -Headers $Headers{ "state": "SUSPECTED", "all_clear": false, "affected_host_count": 1, "standalone_asset_ids": ["6f1c2a9e-3b4d-4e8f-9a7b-1c2d3e4f5a6b"], "incident_count": 0, "confirmed_lateral_incident_count": 0, "primary_incident_id": null, "active_incident_id": null, "lateral_asset_ids": [], "suspicion_asset_ids": []}Lateral movement and incidents aren’t modeled yet. The incident counts are always 0, the incident IDs are always null, lateral_asset_ids and suspicion_asset_ids are always empty, and state is never CONFIRMED_LATERAL. The affected hosts are in standalone_asset_ids.
For a time-ordered view, GET /gateway/api/v1/pursuit/timeline (Pursuit timeline) returns one item per host whose latest report in the window is medium, high or critical, with severity, time_utc, finding_count and a one-sentence summary.
Read persistence reports
Section titled “Read persistence reports”List reports in a window (default 30 days, max 180), one per Pursuit investigation. Page with page_size (default 50, max 500) and page_token.
curl -G "https://acme.app.elastio.com/gateway/api/v1/pursuit/reports" \ -H "Authorization: Bearer $ELASTIO_TOKEN" \ --data-urlencode "start_date=2026-09-12T00:00:00Z" \ --data-urlencode "page_size=100"params = {"start_date": "2026-09-12T00:00:00Z", "page_size": 100}reports = []while True: resp = requests.get(f"{BASE}/gateway/api/v1/pursuit/reports", headers=HEADERS, params=params, timeout=30) resp.raise_for_status() body = resp.json() reports.extend(body["reports"]) token = body["pagination"].get("next_page_token") if not token: break params = {"page_token": token}
for r in reports: if r["suspicion_level"] not in ("none", "low"): print(r["report_id"], r["asset_ref"]["name"], r["suspicion_level"], r["finding_count"])$Reports = Invoke-RestMethod -Uri "$Base/gateway/api/v1/pursuit/reports?start_date=2026-09-12T00:00:00Z&page_size=100" -Headers $Headers$Reports.reports | Where-Object { $_.suspicion_level -notin @("none", "low") } | Select-Object report_id, @{ n = "host"; e = { $_.asset_ref.name } }, suspicion_level, finding_count{ "reports": [ { "report_id": "b7c6d5e4-f3a2-4b1c-9d0e-8f7a6b5c4d3e", "asset_ref": { "id": "6f1c2a9e-3b4d-4e8f-9a7b-1c2d3e4f5a6b", "kind": "ec2_instance", "name": "fileserver-02" }, "generated_utc": "2026-10-11T04:12:00Z", "highest_suspicion_anchor_utc": "2026-10-10T23:47:31Z", "ingested_utc": "2026-10-11T04:13:05Z", "suspicion_level": "high", "summary": "A scheduled task added after the last clean copy launches an unsigned binary from a user-writable path.", "finding_count": 3, "image_id": null } ], "pagination": { "next_page_token": "", "prev_page_token": "", "total_count": 1 }}The page token is meant for follow-up requests, which is why the Python loop sends only page_token after the first page.
Open one report with GET /gateway/api/v1/pursuit/reports/{report_id}. It returns the engine’s report document as-is, with sections such as report_metadata, overall_assessment, findings, evidence and attack_timeline_reconstruction, plus text_report, the readable .txt analysis. A section the engine didn’t produce is left out. The spec only types text_report. Treat the other sections as engine-defined JSON.
curl "https://acme.app.elastio.com/gateway/api/v1/pursuit/reports/b7c6d5e4-f3a2-4b1c-9d0e-8f7a6b5c4d3e" \ -H "Authorization: Bearer $ELASTIO_TOKEN"report_id = "b7c6d5e4-f3a2-4b1c-9d0e-8f7a6b5c4d3e"resp = requests.get(f"{BASE}/gateway/api/v1/pursuit/reports/{report_id}", headers=HEADERS, timeout=30)resp.raise_for_status()print(resp.json().get("text_report", ""))$ReportId = "b7c6d5e4-f3a2-4b1c-9d0e-8f7a6b5c4d3e"(Invoke-RestMethod -Uri "$Base/gateway/api/v1/pursuit/reports/$ReportId" -Headers $Headers).text_report{ "report_metadata": { "…": "…" }, "overall_assessment": { "…": "…" }, "findings": [ "…" ], "text_report": "…"}Run Pursuit on an asset
Section titled “Run Pursuit on an asset”Start a Pursuit investigation through the same endpoint that starts a hunt (Schedule hunts and investigations): set pursuit: true in scan_type, or name a playbook in pursuit_playbook. Give exactly one of built_in (for example persistence) or custom.
curl -X POST "https://acme.app.elastio.com/gateway/api/v1/scans/schedule" \ -H "Authorization: Bearer $ELASTIO_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "scans": [ { "type": "ec2", "aws_instance_id": "i-0abc123def4567890", "aws_account_id": "123456789012", "aws_region": "us-east-1", "scan_type": { "pursuit_playbook": { "built_in": "persistence" } } } ] }'body = { "scans": [ { "type": "ec2", "aws_instance_id": "i-0abc123def4567890", "aws_account_id": "123456789012", "aws_region": "us-east-1", "scan_type": {"pursuit_playbook": {"built_in": "persistence"}}, } ]}resp = requests.post(f"{BASE}/gateway/api/v1/scans/schedule", headers=HEADERS, json=body, timeout=30)resp.raise_for_status()print(resp.json()["results"])$Body = @{ scans = @( @{ type = "ec2" aws_instance_id = "i-0abc123def4567890" aws_account_id = "123456789012" aws_region = "us-east-1" scan_type = @{ pursuit_playbook = @{ built_in = "persistence" } } } )} | ConvertTo-Json -Depth 6(Invoke-RestMethod -Method Post -Uri "$Base/gateway/api/v1/scans/schedule" ` -Headers $Headers -ContentType "application/json" -Body $Body).results{ "results": [ { "result": "job_id", "job_id": "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d", "resource": "aws_ec2" } ] }To poll the job, see Run a hunt. When it finishes, read the results in one of three ways:
GET /gateway/api/v2/scanjobs/{scanJobId}/threats/pursuit/findings(List Pursuit findings from an investigation): findings from that job.GET /gateway/api/v2/assets/{assetId}/threats/pursuit/findings: the latest findings for the asset. For a VM, this includes its attached volumes and disks.GET /gateway/api/v1/assets/{asset_id}/pursuit-scans, thenGET /gateway/api/v1/pursuit-scans/{pursuit_scan_id}: the investigation history, then one investigation with its playbook output (data.playbooks[], includingtextand runstatistics).
/gateway/api/v2/assets/{assetId}/threats/pursuit/findingsList the latest Pursuit findings for an assetcurl "https://acme.app.elastio.com/gateway/api/v2/assets/6f1c2a9e-3b4d-4e8f-9a7b-1c2d3e4f5a6b/threats/pursuit/findings?page_size=50" \ -H "Authorization: Bearer $ELASTIO_TOKEN"asset_id = "6f1c2a9e-3b4d-4e8f-9a7b-1c2d3e4f5a6b"resp = requests.get( f"{BASE}/gateway/api/v2/assets/{asset_id}/threats/pursuit/findings", headers=HEADERS, params={"page_size": 50}, timeout=30,)resp.raise_for_status()for f in resp.json()["data"]: print(f["severity"], f["confidence"], f["category"], f["title"])$AssetId = "6f1c2a9e-3b4d-4e8f-9a7b-1c2d3e4f5a6b"(Invoke-RestMethod -Uri "$Base/gateway/api/v2/assets/$AssetId/threats/pursuit/findings?page_size=50" -Headers $Headers).data | Select-Object severity, confidence, category, title{ "total": 1, "data": [ { "id": "c0d1e2f3-a4b5-4c6d-8e7f-9a0b1c2d3e4f", "asset_id": "6f1c2a9e-3b4d-4e8f-9a7b-1c2d3e4f5a6b", "pursuit_scan_id": "b7c6d5e4-f3a2-4b1c-9d0e-8f7a6b5c4d3e", "title": "Scheduled task launches binary from user-writable path", "category": "…", "severity": "high", "confidence": "medium", "details": "…", "persistence_type": "…", "lol_bin": null, "credential_assessment": null, "evidence": [], "extra": {}, "scanned_at": "2026-10-11T04:12:00Z" } ], "pagination": { "next_page_token": "", "prev_page_token": "" }}These endpoints only return findings from reports whose overall suspicion level is low or higher.
Give Pursuit context with an exception
Section titled “Give Pursuit context with an exception”A Pursuit exception is one free-text instruction (up to 4096 characters) that Pursuit reads the next time it analyzes the asset. For example, it can explain that a tool is expected on that host. It is not a suppression. Each asset has at most one exception. Writing again edits it, or reinstates it after it has been cleared, and every change is audit-logged.
These endpoints are turned on per tenant. Where they aren’t enabled, they return 404.
/gateway/api/v1/assets/{asset_id}/pursuit-exceptionCreate, edit or reinstate an asset's Pursuit exceptioncurl -X PUT "https://acme.app.elastio.com/gateway/api/v1/assets/6f1c2a9e-3b4d-4e8f-9a7b-1c2d3e4f5a6b/pursuit-exception" \ -H "Authorization: Bearer $ELASTIO_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "instruction": "PsExec under C:\\Tools is installed by IT for patching and is expected on this host." }'resp = requests.put( f"{BASE}/gateway/api/v1/assets/{asset_id}/pursuit-exception", headers=HEADERS, json={"instruction": r"PsExec under C:\Tools is installed by IT for patching and is expected on this host."}, timeout=30,)resp.raise_for_status()print(resp.json()["status"], resp.json()["updated_at"])$Body = @{ instruction = 'PsExec under C:\Tools is installed by IT for patching and is expected on this host.' } | ConvertTo-JsonInvoke-RestMethod -Method Put -Uri "$Base/gateway/api/v1/assets/$AssetId/pursuit-exception" ` -Headers $Headers -ContentType "application/json" -Body $Body{ "asset_id": "6f1c2a9e-3b4d-4e8f-9a7b-1c2d3e4f5a6b", "instruction": "PsExec under C:\\Tools is installed by IT for patching and is expected on this host.", "status": "active", "created_by": "…", "created_at": "2026-10-12T09:40:00Z", "updated_by": "…", "updated_at": "2026-10-12T09:40:00Z", "cleared_by": null, "cleared_at": null}GET on the same path returns the active exception, or JSON null if there is none. DELETE clears it; clearing is idempotent and recorded in the history. GET …/pursuit-exception/history lists every change. To find assets that have an exception, use GET /gateway/api/v2/assets?has_pursuit_exception=true.
All endpoints
Section titled “All endpoints”Pursuit
8 operations in the reference →- GET
/gateway/api/v1/assets/{asset_id}/pursuit-exceptionGet an asset's active Pursuit exception - PUT
/gateway/api/v1/assets/{asset_id}/pursuit-exceptionCreate, edit or reinstate an asset's Pursuit exception - DELETE
/gateway/api/v1/assets/{asset_id}/pursuit-exceptionClear an asset's Pursuit exception - GET
/gateway/api/v1/assets/{asset_id}/pursuit-exception/historyGet an asset's Pursuit exception change history - GET
/gateway/api/v1/pursuit/summaryGet the estate Pursuit summary - GET
/gateway/api/v1/pursuit/timelinePursuit timeline - GET
/gateway/api/v1/pursuit/reportsList Pursuit persistence reports - GET
/gateway/api/v1/pursuit/reports/{report_id}Get one Pursuit persistence report