Skip to content

Sandbox

Introduction

The SandboxMgr class of the sandbox module wraps the Recorded Future Sandbox (Triage) API so you can detonate samples, retrieve analysis reports, and manage company-wide analysis profiles.

It covers:

  • Submitting samples for analysis — local files, URLs, sandbox-fetched URLs, and imports from public Triage
  • Searching and listing submitted samples
  • Fetching per-sample status, full records, summary reports, and full analysis reports (overview, static, behavioral)
  • Creating, listing, updating, and deleting analysis profiles
  • Deleting samples (Enterprise tier, org_admin only)

See the API Reference for internal details of the module.

Notes

  • The Sandbox uses a separate token from the rest of the platform — set RF_SANDBOX_TOKEN (not RF_TOKEN) in your environment, or pass it explicitly: SandboxMgr(api_token=...). Tokens are issued at /account under "API Access".
  • Pick the right region with sandbox_choice. The default is 'eu' (https://sandbox.recordedfuture.com/api/v0); other choices are 'usa', 'apj', 'public' (the community Triage at tria.ge — all submissions are visible to everyone), and 'private' (self-hosted deployments).
  • submit_sample takes one of four kinds, each with its own required parameter:
    • kind='file' requires file_path (a Path to a local file).
    • kind='url' requires url — the URL is opened in a browser inside the analysis VM.
    • kind='fetch' requires url — the sandbox downloads the file at that URL first, then detonates it.
    • kind='import' requires source_id — a public Triage sample id to copy in.
  • Submissions are asynchronous. The sample status transitions through pendingstatic_analysisscheduledrunningprocessingreported (terminal, success) or failed (terminal, error). To wait for results, poll fetch_sample(id).status until it reaches a terminal state (see example 4 below). Typical wall-clock for a URL submission is 1–3 minutes.
  • delete_sample, update_profile, and delete_profile are designed for best-effort cleanup:
    • update_profile and delete_profile are idempotent on 404 — a missing target returns updated=False / deleted=False instead of raising, so you can call them without first checking existence.
    • delete_sample is not idempotent: any non-2xx response (including "already deleted") raises SampleDeleteError. The Triage API maps all delete failures to 401, so the raw error message on the exception is your only signal for why.
  • update_profile is a full replace: name, tags and timeout are required; any optional field you omit is cleared on the server.
  • Three methods return full analysis reports — all require the sample to have reached reported status:
    • fetch_sample_overview_report — the richest result: combined verdict, extracted malware configs, per-target IOC lists (domains, IPs, URLs), and a per-task breakdown. Raises SampleReportNotAvailableError if the sample exists but hasn't finished analysis yet, or SampleReportNotFoundError if the sample id is unknown — both are subclasses of SampleOverviewError.
    • fetch_sample_static_report — the pre-detonation pass: file identification, static signatures, the files table (the submitted file plus any archive members), and malware configs recoverable without execution.
    • fetch_behavioral_reports — fetches every behavioral task's detonation report in one call: process tree, network flows, DNS/HTTP requests, and dumped artifacts. Returns a BehavioralReportsResult envelope: finished reports in reports, still-running task ids in not_ready (check result.complete and poll again later), and per-task fetch failures in failed — so one unfinished task doesn't hide the reports that are ready. Pass max_workers to fetch them concurrently when a sample has multiple behavioral tasks (e.g. multi-architecture Linux submissions).
  • Each endpoint family raises its own subclass of RecordedFutureErrorSampleSubmitError, SampleFetchError, SampleFileFetchError, SampleDeleteError, SampleSearchError, SamplesFetchError, SampleSummaryError, SampleStaticReportError, SampleOverviewError, SampleReportNotAvailableError, SampleReportNotFoundError, SampleBehavioralReportError, SampleProfileError, ProfileFetchError, ProfileNotFoundError, ProfileCreateError, ProfileUpdateError, ProfileDeleteError. See the errors API reference for the full list.
  • The SaaS sandbox is capped at 1,000 submissions per enterprise per day. Examples below all submit a benign URL — they count against that quota.

Examples

Warning

The following examples demonstrate how to use this module. Be sure to add appropriate error handling as needed; all possible errors for each method or function are listed in the API Reference page.

Additionally, you must configure the RF_SANDBOX_TOKEN environment variable before getting started. For instructions, see Learn.

1: Submit a URL for analysis

submit_sample returns immediately with a SearchResult carrying the new sample's id_ and initial status. The example below uses kind='url', which detonates the URL inside a browser VM. user_tags are free-form labels you can use later to find the submission.

from psengine.sandbox import SandboxMgr

mgr = SandboxMgr()

result = mgr.submit_sample(
    kind='url',
    url='https://example.com',
    user_tags=['psengine-docs', 'demo'],
)

print(f'Submitted: id={result.id_}')
print(f'kind={result.kind}, status={result.status}')

A typical run prints something like:

Submitted: id=260501-h4p7laawme, kind=url, status=pending

2: Search for samples by malware family

search_samples accepts the same field-prefixed query syntax as the Sandbox web UI (family:, tag:, sha256:, ip:, domain:, ...). Each filter kwarg (family, tag, botnet, ...) is composed with AND into the final query string. The example searches for up to five samples matching any of three malware families.

Tip

Pass any combination of filter kwargs — they're joined with AND. For free-form composition (OR/NOT), pass a raw query via the query= argument instead.

from psengine.sandbox import SandboxMgr

mgr = SandboxMgr()

results = mgr.search_samples(
    family=['emotet', 'cobaltstrike', 'asyncrat'],
    max_results=5,
)

for sample in results:
    ioc = sample.sha256 or sample.url
    print(f'{sample.id_:20s} {sample.status:12s}')
    print(f'  {sample.kind:6s} {ioc}')

3: List your own samples and inspect one in detail

fetch_samples lists the samples your account can see; subset='owned' (the default) is just your own submissions, 'org' is everything your company has access to, and 'public' only works on the public Triage cloud. After listing, pass an id_ to fetch_sample to retrieve the richer SampleOut record (the same shape but with the per-task analysis breakdown attached).

from psengine.sandbox import SandboxMgr

mgr = SandboxMgr()

samples = mgr.fetch_samples(subset='owned', max_results=5)
for s in samples:
    print(f'{s.id_:20s} {s.status:12s} {s.kind:6s}')
    print(f'  submitted={s.submitted.isoformat()}')

if samples:
    detail = mgr.fetch_sample(samples[0].id_)
    print(f'\nFirst sample tasks: {detail.tasks}')

4: Submit, wait for the report, and read the summary

Submissions are asynchronous, so a fresh id_ will return status='pending' for a while. The example polls fetch_sample every 10 seconds until the status reaches a terminal state (reported or failed), then calls fetch_sample_summary to retrieve the score and per-task breakdown.

Tip

A URL submission usually reaches reported within 1–3 minutes. The script caps the wait at 10 minutes via TIMEOUT_SEC — tune both to your needs.

import time

from psengine.sandbox import SandboxMgr

TERMINAL = {'reported', 'failed'}
POLL_INTERVAL_SEC = 10
TIMEOUT_SEC = 600

mgr = SandboxMgr()

submission = mgr.submit_sample(
    kind='url', url='https://example.com'
)
print(f'Submitted {submission.id_}')
print('Polling until terminal status...')

deadline = time.monotonic() + TIMEOUT_SEC
while time.monotonic() < deadline:
    sample = mgr.fetch_sample(submission.id_)
    print(f'  status={sample.status}')
    if sample.status in TERMINAL:
        break
    time.sleep(POLL_INTERVAL_SEC)
else:
    raise RuntimeError(
        f'{submission.id_} not terminal in {TIMEOUT_SEC}s'
    )

summary = mgr.fetch_sample_summary(submission.id_)
print(f'\nScore: {summary.score}')
print(f'Target: {summary.target}')
for task_key in summary.tasks:
    print(f'  task={task_key}')

The summary's score is a 1–10 verdict (10 = known bad, 1 = no malicious behaviour observed); the tasks dict carries per-task results keyed by static1, behavioral1, and so on.

5: Submit and immediately delete a sample

delete_sample removes the sample and all its analyses. This example submits a throwaway URL and deletes it in the same run, useful for housekeeping flows where the submission is only needed to drive a downstream action.

Note

delete_sample requires the org_admin role on Enterprise Sandbox. On the public Triage cloud, samples can't be deleted at all and the call will raise SampleDeleteError.

from psengine.sandbox import SandboxMgr

mgr = SandboxMgr()

submission = mgr.submit_sample(
    kind='url',
    url='https://example.com',
    user_tags=['psengine-docs', 'delete-me'],
)
print(f'Submitted {submission.id_}')
print(f'status={submission.status}')

result = mgr.delete_sample(submission.id_)
print(f'Delete result: deleted={result.deleted}')

6: Manage analysis profiles end-to-end

Profiles are company-wide analysis configurations (OS tags, network mode, timeout, browser, optional VPN region). This example walks the full CRUD cycle on a single profile: create_profilefetch_profiles (list) → fetch_profile (round-trip) → update_profile (full replace — every non-id field is required) → delete_profile.

Tip

update_profile and delete_profile are idempotent on 404, they return updated=False / deleted=False if the target is already gone. Check the returned .updated / .deleted flag rather than wrapping the call in try/except.

from psengine.sandbox import SandboxMgr

mgr = SandboxMgr()
mgr.delete_profile('psengine-docs-demo')
created = mgr.create_profile(
    name='psengine-docs-demo',
    tags=['os:windows10-2004-x64', 'locale:en-us'],
    timeout=120,
    network='internet',
    browser='chrome',
)
print(f'Created {created.id_}')
print(f'name={created.name}, timeout={created.timeout}s')

all_profiles = mgr.fetch_profiles()
print(f'\nCompany has {len(all_profiles)} profile(s):')
for p in all_profiles:
    print(f'  {p.id_:40s} {p.name}')

fetched = mgr.fetch_profile(created.id_)
opts = fetched.options
browser = opts.browser if opts else None
print('\nRound-trip:')
print(f'  tags={fetched.tags}')
print(f'  network={fetched.network}')
print(f'  browser={browser}')

update_result = mgr.update_profile(
    profile_id=created.id_,
    name='psengine-docs-demo',
    tags=['os:windows10-2004-x64', 'locale:en-us'],
    timeout=300,
    network='internet',
    browser='firefox',
)
print(f'\nUpdate result: updated={update_result.updated}')

delete_result = mgr.delete_profile(created.id_)
print(f'Delete result: deleted={delete_result.deleted}')

7: Fetch the overview report for a completed sample

fetch_sample_overview_report returns the richest single-call result: the overall verdict score, malware family tags, all recovered extracted configs (C2s, crypto keys, credentials), per-target IOC lists (domains, IPs, URLs), and a tasks map keyed by task id. It is only available once the sample has reached reported status.

Note

Two distinct 404 subclasses are raised: SampleReportNotAvailableError means the sample exists but analysis isn't finished yet (retry later); SampleReportNotFoundError means the sample id is unknown. Both are subclasses of SampleOverviewError.

Tip

Pass wait_until_ready=True to poll internally until the overview is available, instead of catching SampleReportNotAvailableError yourself. It retries every OVERVIEW_REPORT_WAIT_INTERVAL_SECONDS (20s) and raises SampleReportNotAvailableError once timeout seconds (default 1800) elapse without success. SampleReportNotFoundError (unknown sample id) is never retried.

from psengine.sandbox import SandboxMgr

SAMPLE_ID = '260501-h4p7laawme'

mgr = SandboxMgr()

# wait_until_ready=True polls internally until the
# overview is ready, or `timeout` seconds elapse.
report = mgr.fetch_sample_overview_report(
    SAMPLE_ID, wait_until_ready=True
)

print(f'Score:  {report.analysis.score}')
print(f'Family: {report.analysis.family}')
print(f'Tags:   {report.analysis.tags}')

for cfg in report.extracted:
    if cfg.config:
        fam = cfg.config.family
        c2 = cfg.config.c2
        print(f'  config: {fam}  c2={c2}')

for target in report.targets:
    print(f'\nTarget: {target.target}')
    print(f'  score={target.score}')
    if target.iocs:
        print(f'  domains={target.iocs.domains}')
        print(f'  ips={target.iocs.ips}')

for task_id, task in report.tasks.items():
    print(f'  {task_id}  {task.kind}  {task.status}')
    print(f'    score={task.score}')

8: Inspect the static analysis report

fetch_sample_static_report returns the pre-detonation pass: the analysis score, any static signatures, the files table (the submitted file plus every member unpacked from it — useful for archives), and any malware configs extractable without execution.

Tip

Pass wait_until_ready=True to poll internally until the report is available, instead of catching SampleReportNotAvailableError yourself. It retries every STATIC_REPORT_WAIT_INTERVAL_SECONDS (20s) and raises SampleReportNotAvailableError once timeout seconds (default 600) elapse without success. SampleReportNotFoundError (unknown sample id) is never retried.

from psengine.sandbox import SandboxMgr

SAMPLE_ID = '260501-h4p7laawme'

mgr = SandboxMgr()

# wait_until_ready=True polls internally until the
# report is ready, or `timeout` seconds elapse.
report = mgr.fetch_sample_static_report(
    SAMPLE_ID, wait_until_ready=True, timeout=300
)

print(f'Score: {report.analysis.score}')
print(f'Tags:  {report.analysis.tags}')

for f in report.files:
    print(f'  file: {f.filename}')
    print(f'    kind={f.kind}  size={f.filesize}')
    print(f'    sha256={f.sha256}')

for sig in report.signatures:
    print(f'  sig: {sig.name}  score={sig.score}')

for item in report.extracted:
    cfg = item.config
    if cfg and cfg.family:
        print(f'  config: {cfg.family}  c2={cfg.c2}')

9: Walk the behavioral detonation reports

fetch_behavioral_reports discovers the sample's behavioral tasks and fetches each task's full detonation report in one call, returning a BehavioralReportsResult: finished reports in reports, task ids still awaiting analysis in not_ready, and per-task fetch failures in failed (each with its status code and message). result.complete is True once nothing is pending — including when the sample has no behavioral tasks at all — so a poll loop can simply retry until it's set. Each BehavioralReport carries the run analysis verdict, the processes tree, network flows and DNS/HTTP requests, dumped artifacts, and any extracted configs. Non-behavioral tasks (static*, urlscan*) are skipped.

Tip

Pass max_workers to fetch per-task reports concurrently when a sample has several behavioral tasks (e.g. multi-architecture Linux submissions). For samples with a single behavioral task the default sequential mode is sufficient.

from psengine.sandbox import SandboxMgr

SAMPLE_ID = '260501-h4p7laawme'

mgr = SandboxMgr()
result = mgr.fetch_behavioral_reports(SAMPLE_ID)

for report in result.reports:
    print(f'Task: {report.task_id}')
    print(f'  score={report.analysis.score}')
    print(f'  platform={report.analysis.platform}')
    print(f'  tags={report.analysis.tags}')

    for proc in report.processes[:5]:
        print(f'  pid={proc.pid}  cmd={proc.cmd}')

    for flow in report.network.flows[:5]:
        print(f'  dst={flow.dst}  domain={flow.domain}')
        print(f'    proto={flow.proto}')

if not result.complete:
    print(f'Still running, retry later: {result.not_ready}')

for failure in result.failed:
    print(
        f'Fetch failed for {failure.task_id}: '
        f'{failure.status_code} {failure.message}'
    )

10: Wait for every behavioral task to finish

Pass wait_until_ready=True to fetch_behavioral_reports to poll every BEHAVIORAL_REPORT_WAIT_INTERVAL_SECONDS (20s) until result.complete is True or timeout seconds (default 1800) elapse.

Note

A timeout here never raises — check result.complete; not_ready still lists whatever hadn't resolved.

from psengine.sandbox import SandboxMgr

SAMPLE_ID = '260501-h4p7laawme'

mgr = SandboxMgr()

# wait_until_ready=True polls until every task resolves
# or `timeout` seconds elapse. Never raises on timeout --
# check `result.complete` instead.
result = mgr.fetch_behavioral_reports(
    SAMPLE_ID, max_workers=10, wait_until_ready=True
)

if result.complete:
    print('No tasks pending.')
else:
    print('Timed out, still pending:', result.not_ready)

for report in result.reports:
    print(f'Task: {report.task_id}')
    print(f'  score={report.analysis.score}')
    print(f'  platform={report.analysis.platform}')

for failure in result.failed:
    print(
        f'Fetch failed for {failure.task_id}: '
        f'{failure.status_code} {failure.message}'
    )