Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "polyswarm_api"
version = "4.7.0"
version = "4.8.0"
description = "Client library to simplify interacting with the PolySwarm consumer API"
readme = "README.md"
requires-python = ">=3.10,<4"
Expand Down Expand Up @@ -55,7 +55,7 @@ package-dir = { "" = "src" }
where = ["src"]

[tool.bumpversion]
current_version = "4.7.0"
current_version = "4.8.0"
commit = true
tag = false
sign_tags = true
Expand Down
2 changes: 1 addition & 1 deletion specs/02-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ Holds `handle`, `artifact_name`, `artifact_type`, `sha256`, `sha1`, `md5`. Also
Several resources add domain-specific classmethods on top of the standard CRUD set:

- `IOC` — `iocs_by_hash`, `ioc_search`, `check_known_hosts`, `create_known_good`, `create_known_bad`, `update_known_good`, `delete_known_good`.
- `LiveYaraRuleset` / `HistoricalHunt` / `YaraRuleset` — standard CRUD plus list/delete-batch variants. `YaraRuleset` also parses the hunt-page tracking fields (`favorite`, `favorited_at`, `rule_count` — `None` means the server had no answer, distinct from 0 — `historical_hunt_count`, and the stored `new_results_count` with its staleness marker `new_results_counted_at`: the server refreshes the counter on a schedule and the marker says when, so `None` means "not yet refreshed / no live hunt", never 0), and `HistoricalHunt` the source-rule provenance (`rule_id`, `rule_modified`, and the tri-state `source_rule_changed` — `None` is "unknown", never "unchanged"). All additive `.get()` parses; an older server leaves them `None`.
- `LiveYaraRuleset` / `HistoricalHunt` / `YaraRuleset` — standard CRUD plus list/delete-batch variants. `YaraRuleset` also parses the hunt-page tracking fields (`favorite`, `favorited_at`, `rule_count` — `None` means the server had no answer, distinct from 0 — `historical_hunt_count`, and the stored `new_results_count` with its staleness marker `new_results_counted_at`: the server refreshes the counter on a schedule and the marker says when, so `None` means "not yet refreshed / no live hunt", never 0), and `HistoricalHunt` the source-rule provenance (`rule_id`, `rule_modified`, and the tri-state `source_rule_changed` — `None` is "unknown", never "unchanged") and its time window `window_days` (4.8.0: the int the hunt was created with, on the single GET and the list alike; `None` means no window was requested, so the hunt reaches the platform maximum). All additive `.get()` parses; an older server leaves them `None`.
- `YaraRulesetFavorite` — `RESOURCE_ENDPOINT = '/hunt/rule/favorite'`, **`RESOURCE_ID_KEYS = ['community']`**: a deliberate deviation from the default `['id']`, splitting the request the way the server reads it — the toggle reads BOTH `id` and `favorite` from the PUT **body** (the default would move `id` into the query string and the server would 400 with "A valid rule id must be provided"), while `community` rides the **query string** to match where the ruleset GET/list calls send it. (The server's community middleware accepts the value from either the query or the body — but never both at once — so the placement here is about consistency and the id-400, not about a silently-ignored body value.) `favorite` serialises as `1`/`0` (the `_params` bool→int coercion), which the server's boolean parser accepts. Response carries the star state plus the team's `favorites_used`/`favorites_limit`. Pinned by `test/hunt_tracking_builder_test.py` and both transports of `test/ruleset_favorite_respx_test.py`.
- `SandboxTask` — `create_file`, `update_file`, `latest`, `my_tasks` for the various sandbox-submission shapes. **No `upload_file` instance method** in 4.0.
- `Sample` — `create` with `endpoint_fmt={'sha256': sha256}` for the URL-parametrised path.
Expand Down
2 changes: 1 addition & 1 deletion specs/03-endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Internal-only CRUD for the `/known-good` binary resource (distinct from the IOC

| Method | Resource builder |
|---|---|
| `historical_create(rule=None, ruleset_name=None)` | `HistoricalHunt.create` |
| `historical_create(rule=None, ruleset_name=None, window_days=None)` | `HistoricalHunt.create` — `window_days` (4.8.0) is how many days back the hunt reaches; the server accepts 30, 60, 90 or 180 and answers anything else with a 400, which surfaces as the usual typed exception (the SDK does not validate it). It is an int and rides the JSON body. `None` leaves the key out, the server keeps its default and the hunt reaches the platform maximum, so a bare call sends exactly what it always did. |
| `historical_get(hunt=None)` | `HistoricalHunt.get` |
| `historical_update(hunt)` | `HistoricalHunt.update` |
| `historical_delete(hunt)` | `HistoricalHunt.delete` |
Expand Down
1 change: 1 addition & 0 deletions specs/04-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ How the test suite is organised. Three layers: pure unit tests (no HTTP at all
- `test/hunt_tracking_builder_test.py` — pure-unit request-shape and parse tests for the hunt-page tracking builders/resources.
- `test/refang_test.py` — pure-unit tests for `polyswarm_api.refang` (driven by the shared case table `test/fixtures/refang_cases.json`, kept byte-identical with the other clients that implement the same contract) and request-shape tests for every client method that refangs its IoC inputs, captured at the `_paginate` / `_single` boundary for both transports. Pure-unit because this is client-side input normalization: the server contract is unchanged, so there is no new endpoint behaviour for a cassette to pin.
- `test/ioc_search_test.py` — `search_by_ioc(with_artifacts=)`: pure-unit request shape (the int flag, the unchanged default and bare call), pass-through on both client methods, and a dual-transport (`ClientTestCase`) respx parse of an artifact row — a stand-in until the e2e stack serves the parameter (see `99-open-questions.md`).
- `test/historical_window_test.py` — `historical_create(window_days=)` and `HistoricalHunt.window_days`: pure-unit request shape (the int in the body, the key absent for `None`, no client-side validation) and parse (int, explicit null, missing key — on `HistoricalHunt` and `HistoricalHuntList`), plus dual-transport (`ClientTestCase`) respx cases for the body each client sends — respx because VCR does not match bodies. What the server returns is pinned live by `test_historical_window_days` / `test_async_historical_window_days` (a hunt with a window and one without, read back on create, get and list).
- `test/ruleset_favorite_respx_test.py` — dual-transport (`ClientTestCase`) respx suite for the favorite toggle: the `FAVORITE_LIMIT` refusal envelope and the query/body split.
- `test/sandbox_file_qrcode_respx_test.py` — dual-transport (`ClientTestCase`) respx suite pinning that a QR-code `sandbox_file` submission uploads the image's bytes (the S3 PUT body) under its unrefanged basename. Respx rather than e2e because the stack's sandbox providers cannot reasonably process a QR image.

Expand Down
2 changes: 1 addition & 1 deletion src/polyswarm_api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# https://www.python.org/dev/peps/pep-0008/#module-level-dunder-names
__version__ = '4.7.0'
__version__ = '4.8.0'
__release_url__ = 'https://api.github.com/repos/polyswarm/polyswarm-api/releases/latest'

from . import api
Expand Down
7 changes: 5 additions & 2 deletions src/polyswarm_api/aio/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -642,18 +642,21 @@ async def live_result(self, result_id):
"""
return await self._single(resources.LiveHuntResult.get(self, id=result_id))

async def historical_create(self, rule=None, ruleset_name=None):
async def historical_create(self, rule=None, ruleset_name=None, window_days=None):
"""
Run a new historical hunt.

:param rule: YaraRuleset object or string containing YARA rules to install
:param ruleset_name: Name of the ruleset.
:param window_days: Days back the hunt reaches: 30, 60, 90 or 180. Omit it for the
platform maximum. The server validates the value; the SDK does not.
:return: The created Hunt resource
"""
logger.info('Create historical hunt %s', rule)
rule, rule_id = self._parse_rule(rule)
return await self._single(resources.HistoricalHunt.create(self, yara=rule.yara if rule else None, rule_id=rule_id,
ruleset_name=ruleset_name, community=self.community))
ruleset_name=ruleset_name, window_days=window_days,
community=self.community))

async def historical_get(self, hunt=None):
"""
Expand Down
5 changes: 4 additions & 1 deletion src/polyswarm_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -735,12 +735,14 @@ def live_result(self, result_id):
"""
return self._single(resources.LiveHuntResult.get(self, id=result_id))

def historical_create(self, rule=None, ruleset_name=None):
def historical_create(self, rule=None, ruleset_name=None, window_days=None):
"""
Run a new historical hunt.

:param rule: YaraRuleset object or string containing YARA rules to install
:param ruleset_name: Name of the ruleset.
:param window_days: Days back the hunt reaches: 30, 60, 90 or 180. Omit it for the
platform maximum. The server validates the value; the SDK does not.
:return: The created Hunt resource
"""
logger.info("Create historical hunt %s", rule)
Expand All @@ -751,6 +753,7 @@ def historical_create(self, rule=None, ruleset_name=None):
yara=rule.yara if rule else None,
rule_id=rule_id,
ruleset_name=ruleset_name,
window_days=window_days,
community=self.community,
)
)
Expand Down
2 changes: 2 additions & 0 deletions src/polyswarm_api/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,8 @@ def __init__(self, content, api=None):
# unknown (no source rule, or nothing to compare against) — not
# "unchanged".
self.source_rule_changed = content.get('source_rule_changed')
# None: no window requested (platform maximum), or a server predating the field.
self.window_days = content.get('window_days')


class HistoricalHuntList(HistoricalHunt):
Expand Down
18 changes: 18 additions & 0 deletions test/async_client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,24 @@ async def test_async_list_historical(self, uid):
listed_ids = {h.id async for h in api.historical_list()}
assert set(historical_ids) <= listed_ids

@vcr.use_cassette()
async def test_async_historical_window_days(self, uid):
async with self._api() as api:
yara_content = uid_yara(uid)
windowed = await api.historical_create(yara_content, window_days=30)
unbounded = await api.historical_create(yara_content)
try:
# The server echoes the window on create, get and list; None means none requested.
assert (windowed.window_days, unbounded.window_days) == (30, None)
assert (await api.historical_get(windowed.id)).window_days == 30
assert (await api.historical_get(unbounded.id)).window_days is None
ids = {windowed.id, unbounded.id}
listed = {h.id: h.window_days async for h in api.historical_list() if h.id in ids}
assert listed == {windowed.id: 30, unbounded.id: None}
finally:
for hunt in (windowed, unbounded):
await api.historical_delete(hunt.id)

@vcr.use_cassette()
async def test_async_historical_results(self, uid):
async with self._api() as api:
Expand Down
18 changes: 18 additions & 0 deletions test/client_scan_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,24 @@ def test_list_historical(self):
listed_ids = {h.id for h in api.historical_list()}
assert set(historical_ids) <= listed_ids

@vcr.use_cassette()
def test_historical_window_days(self):
api = PolyswarmAPI(self.test_api_key, uri=f'http://ai:9696/{self.api_version}', community='gamma')
yara_content = uid_yara(self._testMethodName)
windowed = api.historical_create(yara_content, window_days=30)
unbounded = api.historical_create(yara_content)
try:
# The server echoes the window on create, get and list; None means none requested.
assert (windowed.window_days, unbounded.window_days) == (30, None)
assert api.historical_get(windowed.id).window_days == 30
assert api.historical_get(unbounded.id).window_days is None
ids = {windowed.id, unbounded.id}
listed = {h.id: h.window_days for h in api.historical_list() if h.id in ids}
assert listed == {windowed.id: 30, unbounded.id: None}
finally:
for hunt in (windowed, unbounded):
api.historical_delete(hunt.id)

@vcr.use_cassette()
def test_historical_results(self):
api = PolyswarmAPI(self.test_api_key, uri=f'http://ai:9696/{self.api_version}', community='gamma')
Expand Down
103 changes: 103 additions & 0 deletions test/historical_window_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""``window_days`` on historical hunts: request shape, pass-through on both clients, parse.

Builder and parse cases are pure-unit. The pass-through cases are respx because VCR does
not match bodies, so only a mocked boundary can see what the body carried. The server's
side is pinned live by ``test_historical_window_days`` and its async twin.
"""
import pytest

from polyswarm_api import resources
from test._client_harness import BASE_URL, COMMUNITY, ClientTestCase

_YARA = 'rule window_probe { condition: true }'
_HUNT_URL = f'{BASE_URL}/hunt/historical'


class _FakeApi:
uri = 'https://api.example.test'
community = 'gamma'


def _hunt(**extra):
"""A hunt row as the server returns it, without the optional fields."""
return {
'id': '7', 'created': '2026-10-01T12:00:00+00:00', 'status': 'PENDING',
'progress': 0.0, 'results_csv_uri': None, **extra}


def _create(**kwargs):
return resources.HistoricalHunt.create(_FakeApi(), community='gamma', **kwargs)


class TestHistoricalCreateBuilder:
def test_window_rides_the_body_as_an_int(self):
req = _create(yara=_YARA, window_days=30)
assert req.method == 'POST'
assert req.url == f'{_FakeApi.uri}/hunt/historical'
assert req.input_json == {'yara': _YARA, 'window_days': 30, 'community': 'gamma'}
assert req.params is None
# A JSON number, not the string "30".
assert type(req.input_json['window_days']) is int
assert req.result_parser is resources.HistoricalHunt

def test_no_window_leaves_the_key_out(self):
req = _create(yara=_YARA, window_days=None)
assert req.input_json == {'yara': _YARA, 'community': 'gamma'}

def test_none_and_omitted_build_the_same_request(self):
omitted, explicit = _create(yara=_YARA), _create(yara=_YARA, window_days=None)
assert (explicit.method, explicit.url, explicit.params, explicit.input_json) == (
omitted.method, omitted.url, omitted.params, omitted.input_json)

def test_window_stays_an_int_beside_a_stored_rule_id(self):
# core._params stringifies *_id keys only; the window is not one.
req = _create(rule_id=5, window_days=90)
assert req.input_json == {'rule_id': '5', 'window_days': 90, 'community': 'gamma'}

def test_the_sdk_does_not_validate_the_value(self):
# The server owns the enum and answers anything else with a 400.
assert _create(yara=_YARA, window_days=45).input_json['window_days'] == 45


class HistoricalCreateWindowTestCase(ClientTestCase):
"""What ``historical_create`` puts on the wire, one body over both clients."""

def setUp(self):
super().setUp()
self.mock.add('POST', _HUNT_URL, json={'status': 'OK', 'result': _hunt()})

def test_window_reaches_the_body_as_an_int(self):
self.api.historical_create(_YARA, window_days=30)
assert self.mock.last_request_body == {
'yara': _YARA, 'window_days': 30, 'community': COMMUNITY}

def test_window_composes_with_a_stored_rule(self):
self.api.historical_create(5, window_days=90)
assert self.mock.last_request_body == {
'rule_id': '5', 'window_days': 90, 'community': COMMUNITY}

def test_no_window_sends_no_key(self):
self.api.historical_create(_YARA)
assert self.mock.last_request_body == {'yara': _YARA, 'community': COMMUNITY}

def test_explicit_none_is_byte_identical_to_a_bare_call(self):
self.api.historical_create(_YARA)
self.api.historical_create(_YARA, window_days=None)
bare, explicit = self.mock.requests
assert explicit.content == bare.content


class TestHistoricalHuntWindowParse:
@pytest.mark.parametrize('cls', [resources.HistoricalHunt, resources.HistoricalHuntList])
def test_the_window_parses_as_the_int(self, cls):
assert cls(_hunt(window_days=30), api=None).window_days == 30

@pytest.mark.parametrize('cls', [resources.HistoricalHunt, resources.HistoricalHuntList])
def test_an_explicit_null_is_none(self, cls):
assert cls(_hunt(window_days=None), api=None).window_days is None

@pytest.mark.parametrize('cls', [resources.HistoricalHunt, resources.HistoricalHuntList])
def test_a_missing_key_is_none(self, cls):
# A server predating the field omits the key altogether.
assert 'window_days' not in _hunt()
assert cls(_hunt(), api=None).window_days is None
Loading
Loading