From cdb2a77e38df29241b58c9303bad7002ffd7e3ce Mon Sep 17 00:00:00 2001 From: Kyle Buchmiller Date: Fri, 21 Aug 2026 14:22:23 -0700 Subject: [PATCH 01/24] feat(hunts): expose matched strings on hunt-result resources A hunt result says which rule matched and its tags, never why. The server now returns the yara strings behind a hit; parse them onto LiveHuntResult and HistoricalHuntResult, and so onto their list subclasses. Read with .get() rather than a subscript. The key is additive, so a server predating it omits it entirely and a subscript would raise on every result. Three states reach callers and they are not interchangeable: None (not reported -- an older server, removed evidence, or a list endpoint, which omits it rather than fetch a blob per row), [] (the rule matched with no byte evidence to show), and a populated list (the evidence, as a lower bound rather than a match count). --- src/polyswarm_api/resources.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/polyswarm_api/resources.py b/src/polyswarm_api/resources.py index fa5f1c1d..25775cdf 100644 --- a/src/polyswarm_api/resources.py +++ b/src/polyswarm_api/resources.py @@ -766,6 +766,13 @@ def __init__(self, content, api=None): self.sha1 = content.get('sha1') self.rule_name = content['rule_name'] self.tags = content['tags'] + # `.get()`, not a subscript: the key is additive, so a server predating it omits + # it entirely and a subscript would raise on every result. Three distinct states + # reach here -- None (not reported: an older server, or a list endpoint, which + # omits the evidence rather than fetch a blob per row), [] (the rule matched but + # has no byte evidence to show), [...] (the evidence, as a lower bound). See + # specs/05-downstream-contract.md. + self.matched_strings = content.get('matched_strings') self.polyscore = content['polyscore'] self.malware_family = content['malware_family'] self.detections = content['detections'] @@ -814,6 +821,13 @@ def __init__(self, content, api=None): self.created = core.parse_isoformat(content['created']) self.rule_name = content['rule_name'] self.tags = content['tags'] + # `.get()`, not a subscript: the key is additive, so a server predating it omits + # it entirely and a subscript would raise on every result. Three distinct states + # reach here -- None (not reported: an older server, or a list endpoint, which + # omits the evidence rather than fetch a blob per row), [] (the rule matched but + # has no byte evidence to show), [...] (the evidence, as a lower bound). See + # specs/05-downstream-contract.md. + self.matched_strings = content.get('matched_strings') self.polyscore = content['polyscore'] self.malware_family = content['malware_family'] self.detections = content['detections'] From 08a31f4250bc3be76426ae1f6f698dc6b16b286b Mon Sep 17 00:00:00 2001 From: Kyle Buchmiller Date: Fri, 21 Aug 2026 14:22:24 -0700 Subject: [PATCH 02/24] test(hunts): pin the three matched-strings states Pure-unit: the resources parse a dict, so no HTTP and no stack is involved. Covers all four affected classes -- the list subclasses inherit __init__ and must behave identically -- and asserts the states stay distinguishable: an absent key and an explicit null both read as None, while an empty list stays an empty list rather than collapsing into it. Entries pass through verbatim, since only yara knows whether `data` is a hex dump or escaped text. --- test/hunt_matched_strings_test.py | 88 +++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 test/hunt_matched_strings_test.py diff --git a/test/hunt_matched_strings_test.py b/test/hunt_matched_strings_test.py new file mode 100644 index 00000000..c062ac52 --- /dev/null +++ b/test/hunt_matched_strings_test.py @@ -0,0 +1,88 @@ +"""Tests for the `matched_strings` attribute on hunt-result resources. + +Pure-unit: the resources parse a dict, so no HTTP and no stack are involved. The +point of these is the THREE-state contract (absent / empty / populated) documented +in specs/05-downstream-contract.md — the states are not interchangeable and a +consumer that collapses them loses the distinction between "we don't know" and +"the rule matched with no byte evidence". +""" + +import pytest + +from polyswarm_api.resources import ( + HistoricalHuntResult, + HistoricalHuntResultList, + LiveHuntResult, + LiveHuntResultList, +) + +_COMMON = { + "id": 1, + "instance_id": 2, + "created": "2022-05-26T19:41:33.797898", + "sha256": "f" * 64, + "rule_name": "dos_stub_message", + "tags": "{pe,stub}", + "polyscore": 0.5, + "malware_family": None, + "detections": {"malicious": 1, "total": 1}, +} + +_STRINGS = [ + {"offset": 78, "identifier": "$stub", "length": 14, + "data": "54 68 69 73 20 70 72 6F 67 72 61 6D 20 63", "truncated": False}, + {"offset": 0, "identifier": "$mz", "length": 512, + "data": "4D 5A 90 00 ...", "truncated": True}, +] + +# Both concrete classes plus their list-endpoint subclasses, which inherit __init__ +# and must therefore behave identically. +ALL_CLASSES = [ + LiveHuntResult, LiveHuntResultList, + HistoricalHuntResult, HistoricalHuntResultList, +] + + +def _content(cls, **extra): + content = dict(_COMMON, **extra) + if issubclass(cls, LiveHuntResult): + content["livescan_id"] = 3 + else: + content["historicalscan_id"] = 3 + return content + + +@pytest.mark.parametrize("cls", ALL_CLASSES) +def test_absent_key_parses_as_none(cls): + """A server predating the field omits the key; a subscript would raise here.""" + assert cls(_content(cls)).matched_strings is None + + +@pytest.mark.parametrize("cls", ALL_CLASSES) +def test_explicit_null_parses_as_none(cls): + """List endpoints send the key with a null value rather than omitting it.""" + assert cls(_content(cls, matched_strings=None)).matched_strings is None + + +@pytest.mark.parametrize("cls", ALL_CLASSES) +def test_empty_list_is_preserved_and_is_not_none(cls): + """`[]` means "matched, no byte evidence" — distinct from "not reported".""" + result = cls(_content(cls, matched_strings=[])) + assert result.matched_strings == [] + assert result.matched_strings is not None + + +@pytest.mark.parametrize("cls", ALL_CLASSES) +def test_populated_list_is_passed_through_verbatim(cls): + """The SDK does not reshape entries — `data` in particular stays as yara rendered it.""" + result = cls(_content(cls, matched_strings=_STRINGS)) + assert result.matched_strings == _STRINGS + assert result.matched_strings[0]["identifier"] == "$stub" + assert result.matched_strings[1]["truncated"] is True + + +@pytest.mark.parametrize("cls", ALL_CLASSES) +def test_raw_json_still_carries_the_key(cls): + """`.json` is part of the contract, so JSON-mode consumers see it without SDK work.""" + result = cls(_content(cls, matched_strings=_STRINGS)) + assert result.json["matched_strings"] == _STRINGS From 021d356d980be68398deaaee60f1253b03d91de5 Mon Sep 17 00:00:00 2001 From: Kyle Buchmiller Date: Fri, 21 Aug 2026 14:22:25 -0700 Subject: [PATCH 03/24] docs(specs): document matched_strings as a three-state attribute Records what each state means, the per-entry dict shape, and the two properties consumers get wrong: a populated list is a lower bound rather than a match count, and `truncated` means "there was more than this" rather than an exact size. Also states that the evidence rides on the detail routes only -- the paginated feed methods will always yield None -- so a caller looking for strings knows to fetch a single result. --- specs/05-downstream-contract.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/specs/05-downstream-contract.md b/specs/05-downstream-contract.md index 2b40d018..0cdbd477 100644 --- a/specs/05-downstream-contract.md +++ b/specs/05-downstream-contract.md @@ -270,6 +270,35 @@ What is **not** part of the contract: - The exact server JSON shape — that lives in the artifact-index repo's contract. - The order of fields in the JSON. +### `matched_strings` on hunt results — a three-state attribute + +`LiveHuntResult.matched_strings` / `HistoricalHuntResult.matched_strings` (and therefore +their `…List` subclasses) carry the yara strings behind a hunt hit. It is read with +`.get()` rather than a subscript, deliberately: the key is **additive**, so a server +older than it omits the key entirely and a subscript would raise on every result. + +Three values are possible and consumers **must not** collapse them: + +| Value | Meaning | +|---|---| +| `None` | Not reported. Either the server predates the field, the stored evidence was deleted, or this came from a **list** endpoint — those omit it rather than fetch a blob per row. "We don't know", *not* "there was nothing". | +| `[]` | The rule matched and there is no byte evidence to show — a rule with no strings section, one whose matching strings are all `private`, or one that matched on absence (`not $a`, `none of them`). | +| `[…]` | The evidence. A **lower bound**, not a match count: fast-scan reports only the first offset per string, `any of them` prints only the strings that hit, and `private` strings never appear. | + +Each entry is a dict: + +```python +{'offset': 78, 'identifier': '$stub', 'length': 14, 'data': '54 68 69 …', 'truncated': False} +``` + +- `data` is kept **exactly as yara rendered it** — a hex string comes back as byte pairs, a text string as ASCII with `\xNN` escapes. Only yara knows which applies, so it is not decoded back to bytes. +- `length` is the **stored** length, capped server-side. Past the cap the true length is unrecoverable. +- `truncated` means "there was more than this". It over-reports at exactly the cap, because nothing in the output distinguishes a match that ended there from one that was cut. + +**Evidence lives on the detail routes only.** `live_feed()` and `historical_results()` +page over list endpoints and will always yield `None` here; fetch a single result +(`live_result(id)` / `historical_result(id)`) to get the strings. + ## Pagination Generator endpoints return an iterable: From b29bc3d4f36d69cf64c82e025eaec8ba95ea7043 Mon Sep 17 00:00:00 2001 From: Kyle Buchmiller Date: Tue, 25 Aug 2026 12:17:27 -0700 Subject: [PATCH 04/24] docs(hunts): tighten the matched_strings comment Comment text only -- the parsed AST is identical before and after. Both copies edited identically, since the two resource classes carry the same note. Keeps what the comment exists for: why .get() and not a subscript, and that the three states are distinct. --- src/polyswarm_api/resources.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/polyswarm_api/resources.py b/src/polyswarm_api/resources.py index 25775cdf..da970a4c 100644 --- a/src/polyswarm_api/resources.py +++ b/src/polyswarm_api/resources.py @@ -767,10 +767,9 @@ def __init__(self, content, api=None): self.rule_name = content['rule_name'] self.tags = content['tags'] # `.get()`, not a subscript: the key is additive, so a server predating it omits - # it entirely and a subscript would raise on every result. Three distinct states - # reach here -- None (not reported: an older server, or a list endpoint, which - # omits the evidence rather than fetch a blob per row), [] (the rule matched but - # has no byte evidence to show), [...] (the evidence, as a lower bound). See + # it and a subscript would raise on every result. Three distinct states -- None + # (not reported: an older server, or a list endpoint), [] (matched, no byte + # evidence), [...] (the evidence, a lower bound). See # specs/05-downstream-contract.md. self.matched_strings = content.get('matched_strings') self.polyscore = content['polyscore'] @@ -822,10 +821,9 @@ def __init__(self, content, api=None): self.rule_name = content['rule_name'] self.tags = content['tags'] # `.get()`, not a subscript: the key is additive, so a server predating it omits - # it entirely and a subscript would raise on every result. Three distinct states - # reach here -- None (not reported: an older server, or a list endpoint, which - # omits the evidence rather than fetch a blob per row), [] (the rule matched but - # has no byte evidence to show), [...] (the evidence, as a lower bound). See + # it and a subscript would raise on every result. Three distinct states -- None + # (not reported: an older server, or a list endpoint), [] (matched, no byte + # evidence), [...] (the evidence, a lower bound). See # specs/05-downstream-contract.md. self.matched_strings = content.get('matched_strings') self.polyscore = content['polyscore'] From 74d0da9586510ba4ad157a7db868a199c1bd50f6 Mon Sep 17 00:00:00 2001 From: Kyle Buchmiller Date: Tue, 25 Aug 2026 15:54:19 -0700 Subject: [PATCH 05/24] test: pin matched_strings against the live server, not just dict.get The suite was pure-unit only, so nothing asserted that the detail route actually emits the key or that the list route omits it. Those tests exercise dict.get and would pass identically if the server never grew the field and the attribute were a permanent None -- which is the gap the e2e-first invariant exists to close: a fabricated response asserts what we think the server returns, a cassette asserts what it actually returned. test_live and test_async_live already poll the detail route on a rule that matched their own artifact, so the assertions cost two lines each and pin the list-vs-detail split that was previously stated only in prose. Cassettes re-recorded delete-driven against a live stack running the matching server and analyzer branches. Both now carry real evidence -- the per-test rule keys on the test's uid, so the recorded hit is `$u` at offset 69 with the uid's own length -- and null on the list rows. Verified the assertions are load-bearing: against the previous cassettes they fail with "detail route should carry the yara evidence / assert None". --- test/async_client_test.py | 7 + test/client_scan_test.py | 7 + test/vcr/test_async_live.vcr | 1030 +++++----------------------------- test/vcr/test_live.vcr | 1030 +++++----------------------------- 4 files changed, 288 insertions(+), 1786 deletions(-) diff --git a/test/async_client_test.py b/test/async_client_test.py index b633553e..14264b65 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -728,6 +728,13 @@ async def test_async_live(self, uid): result = await api.live_result(result_id) assert result.download_url + # The list/detail split, pinned against the real server rather than prose. + # The pure-unit tests exercise dict.get and would pass identically if the + # server never grew the field; only a cassette shows what it actually sent. + assert result.matched_strings, 'detail route should carry the yara evidence' + assert my_results[0].matched_strings is None, \ + 'list rows omit the evidence -- it is a per-row blob fetch' + await api.live_feed_delete([result_id]) with pytest.raises(exceptions.NotFoundException): await api.live_result(result_id) diff --git a/test/client_scan_test.py b/test/client_scan_test.py index 443f4a56..671b6786 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -501,6 +501,13 @@ def test_live(self): result = api.live_result(result_id) assert result.download_url + # The list/detail split, pinned against the real server rather than prose. + # The pure-unit tests exercise dict.get and would pass identically if the + # server never grew the field; only a cassette shows what it actually sent. + assert result.matched_strings, 'detail route should carry the yara evidence' + assert my_results[0].matched_strings is None, \ + 'list rows omit the evidence -- it is a per-row blob fetch' + api.live_feed_delete([result_id]) with pytest.raises(exceptions.NotFoundException): api.live_result(result_id) diff --git a/test/vcr/test_async_live.vcr b/test/vcr/test_async_live.vcr index 9becae4c..0f742c2b 100644 --- a/test/vcr/test_async_live.vcr +++ b/test/vcr/test_async_live.vcr @@ -18,12 +18,12 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: POST uri: http://ai:9696/v3/hunt/rule response: body: - string: '{"result":{"created":"2026-06-03T22:37:20.835178+00:00","deleted":false,"description":null,"id":"21319123963942093","livescan_created":null,"livescan_id":null,"modified":"2026-06-03T22:37:20.835178+00:00","name":"sdk-test_async_live","yara":"rule + string: '{"result":{"created":"2026-08-25T22:53:17.732230+00:00","deleted":false,"description":null,"id":"62754506679930173","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:53:17.732230+00:00","name":"sdk-test_async_live","yara":"rule sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} ' @@ -39,7 +39,7 @@ interactions: Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:37:20 GMT + - Tue, 25 Aug 2026 22:53:17 GMT Server: - gunicorn X-Billing-ID: @@ -48,7 +48,7 @@ interactions: code: 200 message: OK - request: - body: '{"rule_id":"21319123963942093"}' + body: '{"rule_id":"62754506679930173"}' headers: accept: - '*/*' @@ -65,12 +65,12 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: POST uri: http://ai:9696/v3/hunt/rule/live response: body: - string: '{"result":{"created":"2026-06-03T22:37:20.835178+00:00","deleted":false,"description":null,"id":"21319123963942093","livescan_created":"2026-06-03T22:37:20.890721+00:00","livescan_id":null,"modified":"2026-06-03T22:37:20.835178+00:00","name":"sdk-test_async_live","yara":"rule + string: '{"result":{"created":"2026-08-25T22:53:17.732230+00:00","deleted":false,"description":null,"id":"62754506679930173","livescan_created":"2026-08-25T22:53:17.826217+00:00","livescan_id":88995738053778536,"modified":"2026-08-25T22:53:17.824324+00:00","name":"sdk-test_async_live","yara":"rule sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} ' @@ -82,11 +82,11 @@ interactions: Connection: - keep-alive Content-Length: - - '366' + - '379' Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:37:20 GMT + - Tue, 25 Aug 2026 22:53:17 GMT Server: - gunicorn X-Billing-ID: @@ -108,12 +108,12 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/rule?id=21319123963942093&community=gamma + uri: http://ai:9696/v3/hunt/rule?id=62754506679930173&community=gamma response: body: - string: '{"result":{"created":"2026-06-03T22:37:20.835178+00:00","deleted":false,"description":null,"id":"21319123963942093","livescan_created":"2026-06-03T22:37:20.890721+00:00","livescan_id":88206418106491329,"modified":"2026-06-03T22:37:20.863779+00:00","name":"sdk-test_async_live","yara":"rule + string: '{"result":{"created":"2026-08-25T22:53:17.732230+00:00","deleted":false,"description":null,"id":"62754506679930173","livescan_created":"2026-08-25T22:53:17.826217+00:00","livescan_id":88995738053778536,"modified":"2026-08-25T22:53:17.824324+00:00","name":"sdk-test_async_live","yara":"rule sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} ' @@ -129,7 +129,7 @@ interactions: Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:37:21 GMT + - Tue, 25 Aug 2026 22:53:18 GMT Server: - gunicorn X-Billing-ID: @@ -155,12 +155,12 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: POST uri: http://ai:9696/v3/instance response: body: - string: '{"result":{"artifact_id":"94943400355321313","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-06-03T22:37:21.946455+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-06-03T22:37:21.946455+00:00","id":"94943400355321313","last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/94943400355321313","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/7e/34/70/7e34708c-caf6-4dd7-b5d2-9e1029e0dafc?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260603%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260603T223721Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=ef5a83090f1169483ad45b80d4b6bf9bf2a46ca939f4070b11ccc19254e68a8e","votes":[],"window_closed":false},"status":"OK"} + string: '{"result":{"artifact_id":"47587156523150020","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-08-25T22:53:18.933938+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-08-25T22:53:18.933938+00:00","id":"47587156523150020","known_good":null,"last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/47587156523150020","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"state":"CREATED","type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/97/f8/13/97f8139b-5b64-42f8-ac4e-2cd965f3f74c?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225318Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=79e8ef3ab561d8c40c9613747e83915d7b352c943fcd9a869d10d6f1cb801c65","votes":[],"window_closed":false},"status":"OK"} ' headers: @@ -171,11 +171,11 @@ interactions: Connection: - keep-alive Content-Length: - - '1008' + - '1044' Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:37:21 GMT + - Tue, 25 Aug 2026 22:53:18 GMT Server: - gunicorn X-Billing-ID: @@ -199,9 +199,9 @@ interactions: host: - minio:9000 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: PUT - uri: http://minio:9000/artifact-index/instances/7e/34/70/7e34708c-caf6-4dd7-b5d2-9e1029e0dafc?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260603%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260603T223721Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=ef5a83090f1169483ad45b80d4b6bf9bf2a46ca939f4070b11ccc19254e68a8e + uri: http://minio:9000/artifact-index/instances/97/f8/13/97f8139b-5b64-42f8-ac4e-2cd965f3f74c?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225318Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=79e8ef3ab561d8c40c9613747e83915d7b352c943fcd9a869d10d6f1cb801c65 response: body: string: '' @@ -211,7 +211,7 @@ interactions: Content-Length: - '0' Date: - - Wed, 03 Jun 2026 22:37:21 GMT + - Tue, 25 Aug 2026 22:53:18 GMT ETag: - '"6ac59cd96a9a3ee9d52f9ebc3ec22f70"' Server: @@ -224,17 +224,17 @@ interactions: X-Amz-Id-2: - dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8 X-Amz-Request-Id: - - 18B5B32EF4EBFFF4 + - 18CF2E38E284BA55 X-Content-Type-Options: - nosniff X-Ratelimit-Limit: - - '13624' + - '5063' X-Ratelimit-Remaining: - - '13624' + - '5063' X-Xss-Protection: - 1; mode=block x-amz-expiration: - - expiry-date="Fri, 05 Jun 2026 00:00:00 GMT", rule-id="expiration-artifact-index_0-instances" + - expiry-date="Thu, 27 Aug 2026 00:00:00 GMT", rule-id="expiration-artifact-index_0-instances" status: code: 200 message: OK @@ -256,12 +256,12 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: PUT - uri: http://ai:9696/v3/instance?id=94943400355321313 + uri: http://ai:9696/v3/instance?id=47587156523150020 response: body: - string: '{"result":{"artifact_id":"94943400355321313","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-06-03T22:37:21.946455+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-06-03T22:37:21.946455+00:00","id":"94943400355321313","last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/94943400355321313","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/7e/34/70/7e34708c-caf6-4dd7-b5d2-9e1029e0dafc?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260603%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260603T223721Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=ef5a83090f1169483ad45b80d4b6bf9bf2a46ca939f4070b11ccc19254e68a8e","votes":[],"window_closed":false},"status":"OK"} + string: '{"result":{"artifact_id":"47587156523150020","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-08-25T22:53:18.933938+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-08-25T22:53:18.933938+00:00","id":"47587156523150020","known_good":null,"last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/47587156523150020","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"state":"CREATED","type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/97/f8/13/97f8139b-5b64-42f8-ac4e-2cd965f3f74c?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225318Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=79e8ef3ab561d8c40c9613747e83915d7b352c943fcd9a869d10d6f1cb801c65","votes":[],"window_closed":false},"status":"OK"} ' headers: @@ -272,11 +272,11 @@ interactions: Connection: - keep-alive Content-Length: - - '1008' + - '1044' Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:37:21 GMT + - Tue, 25 Aug 2026 22:53:18 GMT Server: - gunicorn X-Billing-ID: @@ -298,151 +298,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:22 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:24 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:25 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:26 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -458,7 +314,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:37:27 GMT + - Tue, 25 Aug 2026 22:53:20 GMT Server: - gunicorn status: @@ -478,7 +334,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -494,7 +350,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:37:28 GMT + - Tue, 25 Aug 2026 22:53:21 GMT Server: - gunicorn status: @@ -514,7 +370,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -530,7 +386,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:37:29 GMT + - Tue, 25 Aug 2026 22:53:22 GMT Server: - gunicorn status: @@ -550,7 +406,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -566,7 +422,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:37:30 GMT + - Tue, 25 Aug 2026 22:53:23 GMT Server: - gunicorn status: @@ -586,7 +442,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -602,7 +458,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:37:31 GMT + - Tue, 25 Aug 2026 22:53:24 GMT Server: - gunicorn status: @@ -622,7 +478,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -638,7 +494,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:37:32 GMT + - Tue, 25 Aug 2026 22:53:25 GMT Server: - gunicorn status: @@ -658,7 +514,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -674,7 +530,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:37:33 GMT + - Tue, 25 Aug 2026 22:53:26 GMT Server: - gunicorn status: @@ -694,7 +550,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -710,7 +566,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:37:34 GMT + - Tue, 25 Aug 2026 22:53:27 GMT Server: - gunicorn status: @@ -730,12 +586,14 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: body: - string: '' + string: '{"has_more":false,"limit":50,"result":[{"community":"gamma","created":"2026-08-25T22:53:27.621267+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-08-25T22:53:18.933938+00:00","id":"37373253354683797","instance_id":"47587156523150020","livescan_id":"88995738053778536","malware_family":"EICAR","matched_strings":null,"md5":"6ac59cd96a9a3ee9d52f9ebc3ec22f70","polyscore":null,"rule_name":"sdk_test_async_live","sha1":"fade22953ce5af251e2dbd7491124c818bf35e5e","sha256":"ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5","tags":"{}","yara":null}],"status":"OK"} + + ' headers: Access-Control-Allow-Origin: - '*' @@ -743,15 +601,19 @@ interactions: - Authorization Connection: - keep-alive + Content-Length: + - '623' Content-Type: - - text/html; charset=utf-8 + - application/json Date: - - Wed, 03 Jun 2026 22:37:35 GMT + - Tue, 25 Aug 2026 22:53:28 GMT Server: - gunicorn + X-Billing-ID: + - '111' status: - code: 204 - message: NO CONTENT + code: 200 + message: OK - request: body: '' headers: @@ -766,12 +628,15 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma + uri: http://ai:9696/v3/hunt/live?id=37373253354683797 response: body: - string: '' + string: '{"result":{"community":"gamma","created":"2026-08-25T22:53:27.621267+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":"http://minio:9000/public-cache/ff/2b/25/ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5fade22953ce5af251e2dbd7491124c818bf35e5e6ac59cd96a9a3ee9d52f9ebc3ec22f70?response-content-disposition=attachment%3Bfilename%3Dinfected&response-content-type=application%2Foctet-stream&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225328Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=5d07ef302bc210af42bdc544364bfb23b47516ac4a61221a6d8ca76a06100ec5","first_seen":"2026-08-25T22:53:18.933938+00:00","id":"37373253354683797","instance_id":"47587156523150020","livescan_id":"88995738053778536","malware_family":"EICAR","matched_strings":[{"data":"test_async_live","identifier":"$u","length":15,"offset":69,"truncated":false}],"md5":"6ac59cd96a9a3ee9d52f9ebc3ec22f70","polyscore":null,"rule_name":"sdk_test_async_live","sha1":"fade22953ce5af251e2dbd7491124c818bf35e5e","sha256":"ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5","tags":"{}","yara":"rule + sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} + + ' headers: Access-Control-Allow-Origin: - '*' @@ -779,17 +644,21 @@ interactions: - Authorization Connection: - keep-alive + Content-Length: + - '1303' Content-Type: - - text/html; charset=utf-8 + - application/json Date: - - Wed, 03 Jun 2026 22:37:36 GMT + - Tue, 25 Aug 2026 22:53:28 GMT Server: - gunicorn + X-Billing-ID: + - '111' status: - code: 204 - message: NO CONTENT + code: 200 + message: OK - request: - body: '' + body: '{"result_ids":["37373253354683797"]}' headers: accept: - '*/*' @@ -799,15 +668,21 @@ interactions: - '11111111111111111111111111111111' connection: - keep-alive + content-length: + - '36' + content-type: + - application/json host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) + method: DELETE + uri: http://ai:9696/v3/hunt/live/list response: body: - string: '' + string: '{"has_more":true,"limit":50,"result":[{"community":"gamma","created":"2026-08-25T22:53:27.621267+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-08-25T22:53:18.933938+00:00","id":"37373253354683797","instance_id":"47587156523150020","livescan_id":"88995738053778536","malware_family":"EICAR","matched_strings":null,"md5":"6ac59cd96a9a3ee9d52f9ebc3ec22f70","polyscore":null,"rule_name":"sdk_test_async_live","sha1":"fade22953ce5af251e2dbd7491124c818bf35e5e","sha256":"ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5","tags":"{}","yara":null}],"status":"OK"} + + ' headers: Access-Control-Allow-Origin: - '*' @@ -815,15 +690,19 @@ interactions: - Authorization Connection: - keep-alive + Content-Length: + - '622' Content-Type: - - text/html; charset=utf-8 + - application/json Date: - - Wed, 03 Jun 2026 22:37:37 GMT + - Tue, 25 Aug 2026 22:53:28 GMT Server: - gunicorn + X-Billing-ID: + - '111' status: - code: 204 - message: NO CONTENT + code: 200 + message: OK - request: body: '' headers: @@ -838,12 +717,15 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma + uri: http://ai:9696/v3/hunt/live?id=37373253354683797 response: body: - string: '' + string: '{"errors":null,"result":"Could not find requested live hunt result: + 37373253354683797.","status":"error"} + + ' headers: Access-Control-Allow-Origin: - '*' @@ -851,17 +733,19 @@ interactions: - Authorization Connection: - keep-alive + Content-Length: + - '106' Content-Type: - - text/html; charset=utf-8 + - application/json Date: - - Wed, 03 Jun 2026 22:37:38 GMT + - Tue, 25 Aug 2026 22:53:28 GMT Server: - gunicorn status: - code: 204 - message: NO CONTENT + code: 404 + message: NOT FOUND - request: - body: '' + body: '{"rule_id":"62754506679930173"}' headers: accept: - '*/*' @@ -871,15 +755,22 @@ interactions: - '11111111111111111111111111111111' connection: - keep-alive + content-length: + - '31' + content-type: + - application/json host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) + method: DELETE + uri: http://ai:9696/v3/hunt/rule/live response: body: - string: '' + string: '{"result":{"created":"2026-08-25T22:53:17.732230+00:00","deleted":false,"description":null,"id":"62754506679930173","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:53:28.453628+00:00","name":"sdk-test_async_live","yara":"rule + sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} + + ' headers: Access-Control-Allow-Origin: - '*' @@ -887,15 +778,19 @@ interactions: - Authorization Connection: - keep-alive + Content-Length: + - '336' Content-Type: - - text/html; charset=utf-8 + - application/json Date: - - Wed, 03 Jun 2026 22:37:39 GMT + - Tue, 25 Aug 2026 22:53:28 GMT Server: - gunicorn + X-Billing-ID: + - '111' status: - code: 204 - message: NO CONTENT + code: 200 + message: OK - request: body: '' headers: @@ -910,666 +805,15 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma + uri: http://ai:9696/v3/hunt/rule?id=62754506679930173&community=gamma response: body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:40 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:41 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:42 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:43 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:44 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:45 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:46 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:47 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:48 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:49 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:50 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:37:51 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '{"has_more":false,"limit":50,"result":[{"community":"gamma","created":"2026-06-03T22:37:52.083918+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-06-03T22:37:21.946455+00:00","id":"74599466085661100","instance_id":"94943400355321313","livescan_id":"88206418106491329","malware_family":"EICAR","md5":"6ac59cd96a9a3ee9d52f9ebc3ec22f70","polyscore":null,"rule_name":"sdk_test_async_live","sha1":"fade22953ce5af251e2dbd7491124c818bf35e5e","sha256":"ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5","tags":"{}","yara":null}],"status":"OK"} - - ' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Length: - - '600' - Content-Type: - - application/json - Date: - - Wed, 03 Jun 2026 22:37:52 GMT - Server: - - gunicorn - X-Billing-ID: - - '111' - status: - code: 200 - message: OK -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live?id=74599466085661100 - response: - body: - string: '{"result":{"community":"gamma","created":"2026-06-03T22:37:52.083918+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":"http://minio:9000/public-cache/ff/2b/25/ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5fade22953ce5af251e2dbd7491124c818bf35e5e6ac59cd96a9a3ee9d52f9ebc3ec22f70?response-content-disposition=attachment%3Bfilename%3Dinfected&response-content-type=application%2Foctet-stream&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260603%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260603T223752Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=d5b9d22359ba7a730490da90c750dafa5f483a11e6d0631784f1badf5ef753ac","first_seen":"2026-06-03T22:37:21.946455+00:00","id":"74599466085661100","instance_id":"94943400355321313","livescan_id":"88206418106491329","malware_family":"EICAR","md5":"6ac59cd96a9a3ee9d52f9ebc3ec22f70","polyscore":null,"rule_name":"sdk_test_async_live","sha1":"fade22953ce5af251e2dbd7491124c818bf35e5e","sha256":"ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5","tags":"{}","yara":"rule - sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} - - ' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Length: - - '1196' - Content-Type: - - application/json - Date: - - Wed, 03 Jun 2026 22:37:52 GMT - Server: - - gunicorn - X-Billing-ID: - - '111' - status: - code: 200 - message: OK -- request: - body: '{"result_ids":["74599466085661100"]}' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - content-length: - - '36' - content-type: - - application/json - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: DELETE - uri: http://ai:9696/v3/hunt/live/list - response: - body: - string: '{"has_more":true,"limit":50,"result":[{"community":"gamma","created":"2026-06-03T22:37:52.083918+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-06-03T22:37:21.946455+00:00","id":"74599466085661100","instance_id":"94943400355321313","livescan_id":"88206418106491329","malware_family":"EICAR","md5":"6ac59cd96a9a3ee9d52f9ebc3ec22f70","polyscore":null,"rule_name":"sdk_test_async_live","sha1":"fade22953ce5af251e2dbd7491124c818bf35e5e","sha256":"ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5","tags":"{}","yara":null}],"status":"OK"} - - ' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Length: - - '599' - Content-Type: - - application/json - Date: - - Wed, 03 Jun 2026 22:37:52 GMT - Server: - - gunicorn - X-Billing-ID: - - '111' - status: - code: 200 - message: OK -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live?id=74599466085661100 - response: - body: - string: '{"errors":null,"result":"Could not find requested live hunt result: - 74599466085661100.","status":"error"} - - ' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Length: - - '106' - Content-Type: - - application/json - Date: - - Wed, 03 Jun 2026 22:37:52 GMT - Server: - - gunicorn - status: - code: 404 - message: NOT FOUND -- request: - body: '{"rule_id":"21319123963942093"}' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - content-length: - - '31' - content-type: - - application/json - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: DELETE - uri: http://ai:9696/v3/hunt/rule/live - response: - body: - string: '{"result":{"created":"2026-06-03T22:37:20.835178+00:00","deleted":false,"description":null,"id":"21319123963942093","livescan_created":"2026-06-03T22:37:20.890721+00:00","livescan_id":88206418106491329,"modified":"2026-06-03T22:37:20.863779+00:00","name":"sdk-test_async_live","yara":"rule - sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} - - ' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Length: - - '379' - Content-Type: - - application/json - Date: - - Wed, 03 Jun 2026 22:37:52 GMT - Server: - - gunicorn - X-Billing-ID: - - '111' - status: - code: 200 - message: OK -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/rule?id=21319123963942093&community=gamma - response: - body: - string: '{"result":{"created":"2026-06-03T22:37:20.835178+00:00","deleted":false,"description":null,"id":"21319123963942093","livescan_created":"2026-06-03T22:37:20.890721+00:00","livescan_id":null,"modified":"2026-06-03T22:37:52.675202+00:00","name":"sdk-test_async_live","yara":"rule - sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} - - ' + string: '{"result":{"created":"2026-08-25T22:53:17.732230+00:00","deleted":false,"description":null,"id":"62754506679930173","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:53:28.453628+00:00","name":"sdk-test_async_live","yara":"rule + sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} + + ' headers: Access-Control-Allow-Origin: - '*' @@ -1578,11 +822,11 @@ interactions: Connection: - keep-alive Content-Length: - - '366' + - '336' Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:37:52 GMT + - Tue, 25 Aug 2026 22:53:28 GMT Server: - gunicorn X-Billing-ID: @@ -1591,7 +835,7 @@ interactions: code: 200 message: OK - request: - body: '{"rule_id":"21319123963942093"}' + body: '{"rule_id":"62754506679930173"}' headers: accept: - '*/*' @@ -1608,7 +852,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: DELETE uri: http://ai:9696/v3/hunt/rule/live response: @@ -1628,7 +872,7 @@ interactions: Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:37:52 GMT + - Tue, 25 Aug 2026 22:53:28 GMT Server: - gunicorn status: @@ -1652,12 +896,12 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: DELETE - uri: http://ai:9696/v3/hunt/rule?id=21319123963942093 + uri: http://ai:9696/v3/hunt/rule?id=62754506679930173 response: body: - string: '{"result":{"created":"2026-06-03T22:37:20.835178+00:00","deleted":true,"description":null,"id":"21319123963942093","livescan_created":"2026-06-03T22:37:20.890721+00:00","livescan_id":null,"modified":"2026-06-03T22:37:52.675202+00:00","name":"sdk-test_async_live","yara":"rule + string: '{"result":{"created":"2026-08-25T22:53:17.732230+00:00","deleted":true,"description":null,"id":"62754506679930173","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:53:28.638516+00:00","name":"sdk-test_async_live","yara":"rule sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} ' @@ -1669,11 +913,11 @@ interactions: Connection: - keep-alive Content-Length: - - '365' + - '335' Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:37:52 GMT + - Tue, 25 Aug 2026 22:53:28 GMT Server: - gunicorn X-Billing-ID: diff --git a/test/vcr/test_live.vcr b/test/vcr/test_live.vcr index 8d4df416..6e950c1a 100644 --- a/test/vcr/test_live.vcr +++ b/test/vcr/test_live.vcr @@ -18,12 +18,12 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: POST uri: http://ai:9696/v3/hunt/rule response: body: - string: '{"result":{"created":"2026-06-03T22:35:46.641576+00:00","deleted":false,"description":null,"id":"63962466091392300","livescan_created":null,"livescan_id":null,"modified":"2026-06-03T22:35:46.641576+00:00","name":"sdk-test_live","yara":"rule + string: '{"result":{"created":"2026-08-25T22:52:32.002247+00:00","deleted":false,"description":null,"id":"14328545122436991","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:52:32.002247+00:00","name":"sdk-test_live","yara":"rule sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} ' @@ -39,7 +39,7 @@ interactions: Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:35:46 GMT + - Tue, 25 Aug 2026 22:52:32 GMT Server: - gunicorn X-Billing-ID: @@ -48,7 +48,7 @@ interactions: code: 200 message: OK - request: - body: '{"rule_id":"63962466091392300"}' + body: '{"rule_id":"14328545122436991"}' headers: accept: - '*/*' @@ -65,12 +65,12 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: POST uri: http://ai:9696/v3/hunt/rule/live response: body: - string: '{"result":{"created":"2026-06-03T22:35:46.641576+00:00","deleted":false,"description":null,"id":"63962466091392300","livescan_created":"2026-06-03T22:35:46.690992+00:00","livescan_id":null,"modified":"2026-06-03T22:35:46.641576+00:00","name":"sdk-test_live","yara":"rule + string: '{"result":{"created":"2026-08-25T22:52:32.002247+00:00","deleted":false,"description":null,"id":"14328545122436991","livescan_created":"2026-08-25T22:52:32.132148+00:00","livescan_id":35147411870661732,"modified":"2026-08-25T22:52:32.105062+00:00","name":"sdk-test_live","yara":"rule sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} ' @@ -82,11 +82,11 @@ interactions: Connection: - keep-alive Content-Length: - - '348' + - '361' Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:35:46 GMT + - Tue, 25 Aug 2026 22:52:32 GMT Server: - gunicorn X-Billing-ID: @@ -108,12 +108,12 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/rule?id=63962466091392300&community=gamma + uri: http://ai:9696/v3/hunt/rule?id=14328545122436991&community=gamma response: body: - string: '{"result":{"created":"2026-06-03T22:35:46.641576+00:00","deleted":false,"description":null,"id":"63962466091392300","livescan_created":"2026-06-03T22:35:46.690992+00:00","livescan_id":68378491128619407,"modified":"2026-06-03T22:35:46.666460+00:00","name":"sdk-test_live","yara":"rule + string: '{"result":{"created":"2026-08-25T22:52:32.002247+00:00","deleted":false,"description":null,"id":"14328545122436991","livescan_created":"2026-08-25T22:52:32.132148+00:00","livescan_id":35147411870661732,"modified":"2026-08-25T22:52:32.105062+00:00","name":"sdk-test_live","yara":"rule sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} ' @@ -129,7 +129,7 @@ interactions: Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:35:47 GMT + - Tue, 25 Aug 2026 22:52:33 GMT Server: - gunicorn X-Billing-ID: @@ -155,12 +155,12 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: POST uri: http://ai:9696/v3/instance response: body: - string: '{"result":{"artifact_id":"27835191001619039","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-06-03T22:35:47.744500+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-06-03T22:35:47.744500+00:00","id":"27835191001619039","last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/27835191001619039","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/41/9e/e7/419ee76d-f760-45c0-bc48-f9c7f1cf44e6?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260603%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260603T223547Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=4a67c52be38b98e5fe6ccc916e1b8072e87fe9f0c266e81617c6969a74f14469","votes":[],"window_closed":false},"status":"OK"} + string: '{"result":{"artifact_id":"83487019275094114","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-08-25T22:52:33.280967+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-08-25T22:52:33.280967+00:00","id":"83487019275094114","known_good":null,"last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/83487019275094114","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"state":"CREATED","type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/3b/53/3c/3b533c44-b3b3-4122-871e-2671860c7448?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225233Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=379d86d9cc5ef3e61b737752b0346318bed946a3c01cc10d5746dca16bf4d085","votes":[],"window_closed":false},"status":"OK"} ' headers: @@ -171,11 +171,11 @@ interactions: Connection: - keep-alive Content-Length: - - '1008' + - '1044' Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:35:47 GMT + - Tue, 25 Aug 2026 22:52:33 GMT Server: - gunicorn X-Billing-ID: @@ -199,9 +199,9 @@ interactions: host: - minio:9000 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: PUT - uri: http://minio:9000/artifact-index/instances/41/9e/e7/419ee76d-f760-45c0-bc48-f9c7f1cf44e6?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260603%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260603T223547Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=4a67c52be38b98e5fe6ccc916e1b8072e87fe9f0c266e81617c6969a74f14469 + uri: http://minio:9000/artifact-index/instances/3b/53/3c/3b533c44-b3b3-4122-871e-2671860c7448?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225233Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=379d86d9cc5ef3e61b737752b0346318bed946a3c01cc10d5746dca16bf4d085 response: body: string: '' @@ -211,7 +211,7 @@ interactions: Content-Length: - '0' Date: - - Wed, 03 Jun 2026 22:35:47 GMT + - Tue, 25 Aug 2026 22:52:33 GMT ETag: - '"564296ca17d5e063856e0b92c8ad6ca6"' Server: @@ -224,17 +224,17 @@ interactions: X-Amz-Id-2: - dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8 X-Amz-Request-Id: - - 18B5B31905F65115 + - 18CF2E2E43B691C3 X-Content-Type-Options: - nosniff X-Ratelimit-Limit: - - '13624' + - '5063' X-Ratelimit-Remaining: - - '13624' + - '5063' X-Xss-Protection: - 1; mode=block x-amz-expiration: - - expiry-date="Fri, 05 Jun 2026 00:00:00 GMT", rule-id="expiration-artifact-index_0-instances" + - expiry-date="Thu, 27 Aug 2026 00:00:00 GMT", rule-id="expiration-artifact-index_0-instances" status: code: 200 message: OK @@ -256,12 +256,12 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: PUT - uri: http://ai:9696/v3/instance?id=27835191001619039 + uri: http://ai:9696/v3/instance?id=83487019275094114 response: body: - string: '{"result":{"artifact_id":"27835191001619039","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-06-03T22:35:47.744500+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-06-03T22:35:47.744500+00:00","id":"27835191001619039","last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/27835191001619039","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/41/9e/e7/419ee76d-f760-45c0-bc48-f9c7f1cf44e6?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260603%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260603T223547Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=4a67c52be38b98e5fe6ccc916e1b8072e87fe9f0c266e81617c6969a74f14469","votes":[],"window_closed":false},"status":"OK"} + string: '{"result":{"artifact_id":"83487019275094114","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-08-25T22:52:33.280967+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-08-25T22:52:33.280967+00:00","id":"83487019275094114","known_good":null,"last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/83487019275094114","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"state":"CREATED","type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/3b/53/3c/3b533c44-b3b3-4122-871e-2671860c7448?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225233Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=379d86d9cc5ef3e61b737752b0346318bed946a3c01cc10d5746dca16bf4d085","votes":[],"window_closed":false},"status":"OK"} ' headers: @@ -272,11 +272,11 @@ interactions: Connection: - keep-alive Content-Length: - - '1008' + - '1044' Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:35:47 GMT + - Tue, 25 Aug 2026 22:52:33 GMT Server: - gunicorn X-Billing-ID: @@ -298,151 +298,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:35:48 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:35:49 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:35:50 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:35:51 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -458,7 +314,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:35:52 GMT + - Tue, 25 Aug 2026 22:52:34 GMT Server: - gunicorn status: @@ -478,7 +334,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -494,7 +350,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:35:53 GMT + - Tue, 25 Aug 2026 22:52:35 GMT Server: - gunicorn status: @@ -514,7 +370,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -530,7 +386,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:35:54 GMT + - Tue, 25 Aug 2026 22:52:36 GMT Server: - gunicorn status: @@ -550,7 +406,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -566,7 +422,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:35:55 GMT + - Tue, 25 Aug 2026 22:52:37 GMT Server: - gunicorn status: @@ -586,7 +442,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -602,7 +458,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:35:56 GMT + - Tue, 25 Aug 2026 22:52:38 GMT Server: - gunicorn status: @@ -622,7 +478,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -638,7 +494,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:35:58 GMT + - Tue, 25 Aug 2026 22:52:39 GMT Server: - gunicorn status: @@ -658,7 +514,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -674,7 +530,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:35:59 GMT + - Tue, 25 Aug 2026 22:52:40 GMT Server: - gunicorn status: @@ -694,7 +550,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: @@ -710,7 +566,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Wed, 03 Jun 2026 22:36:00 GMT + - Tue, 25 Aug 2026 22:52:41 GMT Server: - gunicorn status: @@ -730,12 +586,14 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: body: - string: '' + string: '{"has_more":false,"limit":50,"result":[{"community":"gamma","created":"2026-08-25T22:52:42.306041+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-08-25T22:52:33.280967+00:00","id":"47972203596447840","instance_id":"83487019275094114","livescan_id":"35147411870661732","malware_family":"EICAR","matched_strings":null,"md5":"564296ca17d5e063856e0b92c8ad6ca6","polyscore":null,"rule_name":"sdk_test_live","sha1":"adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a","sha256":"14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190","tags":"{}","yara":null}],"status":"OK"} + + ' headers: Access-Control-Allow-Origin: - '*' @@ -743,15 +601,19 @@ interactions: - Authorization Connection: - keep-alive + Content-Length: + - '617' Content-Type: - - text/html; charset=utf-8 + - application/json Date: - - Wed, 03 Jun 2026 22:36:01 GMT + - Tue, 25 Aug 2026 22:52:42 GMT Server: - gunicorn + X-Billing-ID: + - '111' status: - code: 204 - message: NO CONTENT + code: 200 + message: OK - request: body: '' headers: @@ -766,12 +628,15 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma + uri: http://ai:9696/v3/hunt/live?id=47972203596447840 response: body: - string: '' + string: '{"result":{"community":"gamma","created":"2026-08-25T22:52:42.306041+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":"http://minio:9000/public-cache/14/e3/a4/14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a564296ca17d5e063856e0b92c8ad6ca6?response-content-disposition=attachment%3Bfilename%3Dinfected&response-content-type=application%2Foctet-stream&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225242Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=e3eada0af5bbebaaa07e7b5a7044915a572d960368a840f725c980dff06dc1f2","first_seen":"2026-08-25T22:52:33.280967+00:00","id":"47972203596447840","instance_id":"83487019275094114","livescan_id":"35147411870661732","malware_family":"EICAR","matched_strings":[{"data":"test_live","identifier":"$u","length":9,"offset":69,"truncated":false}],"md5":"564296ca17d5e063856e0b92c8ad6ca6","polyscore":null,"rule_name":"sdk_test_live","sha1":"adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a","sha256":"14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190","tags":"{}","yara":"rule + sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} + + ' headers: Access-Control-Allow-Origin: - '*' @@ -779,17 +644,21 @@ interactions: - Authorization Connection: - keep-alive + Content-Length: + - '1278' Content-Type: - - text/html; charset=utf-8 + - application/json Date: - - Wed, 03 Jun 2026 22:36:02 GMT + - Tue, 25 Aug 2026 22:52:42 GMT Server: - gunicorn + X-Billing-ID: + - '111' status: - code: 204 - message: NO CONTENT + code: 200 + message: OK - request: - body: '' + body: '{"result_ids":["47972203596447840"]}' headers: accept: - '*/*' @@ -799,15 +668,21 @@ interactions: - '11111111111111111111111111111111' connection: - keep-alive + content-length: + - '36' + content-type: + - application/json host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) + method: DELETE + uri: http://ai:9696/v3/hunt/live/list response: body: - string: '' + string: '{"has_more":true,"limit":50,"result":[{"community":"gamma","created":"2026-08-25T22:52:42.306041+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-08-25T22:52:33.280967+00:00","id":"47972203596447840","instance_id":"83487019275094114","livescan_id":"35147411870661732","malware_family":"EICAR","matched_strings":null,"md5":"564296ca17d5e063856e0b92c8ad6ca6","polyscore":null,"rule_name":"sdk_test_live","sha1":"adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a","sha256":"14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190","tags":"{}","yara":null}],"status":"OK"} + + ' headers: Access-Control-Allow-Origin: - '*' @@ -815,15 +690,19 @@ interactions: - Authorization Connection: - keep-alive + Content-Length: + - '616' Content-Type: - - text/html; charset=utf-8 + - application/json Date: - - Wed, 03 Jun 2026 22:36:03 GMT + - Tue, 25 Aug 2026 22:52:42 GMT Server: - gunicorn + X-Billing-ID: + - '111' status: - code: 204 - message: NO CONTENT + code: 200 + message: OK - request: body: '' headers: @@ -838,12 +717,15 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma + uri: http://ai:9696/v3/hunt/live?id=47972203596447840 response: body: - string: '' + string: '{"errors":null,"result":"Could not find requested live hunt result: + 47972203596447840.","status":"error"} + + ' headers: Access-Control-Allow-Origin: - '*' @@ -851,17 +733,19 @@ interactions: - Authorization Connection: - keep-alive + Content-Length: + - '106' Content-Type: - - text/html; charset=utf-8 + - application/json Date: - - Wed, 03 Jun 2026 22:36:04 GMT + - Tue, 25 Aug 2026 22:52:42 GMT Server: - gunicorn status: - code: 204 - message: NO CONTENT + code: 404 + message: NOT FOUND - request: - body: '' + body: '{"rule_id":"14328545122436991"}' headers: accept: - '*/*' @@ -871,15 +755,22 @@ interactions: - '11111111111111111111111111111111' connection: - keep-alive + content-length: + - '31' + content-type: + - application/json host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) + method: DELETE + uri: http://ai:9696/v3/hunt/rule/live response: body: - string: '' + string: '{"result":{"created":"2026-08-25T22:52:32.002247+00:00","deleted":false,"description":null,"id":"14328545122436991","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:52:42.860277+00:00","name":"sdk-test_live","yara":"rule + sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} + + ' headers: Access-Control-Allow-Origin: - '*' @@ -887,15 +778,19 @@ interactions: - Authorization Connection: - keep-alive + Content-Length: + - '318' Content-Type: - - text/html; charset=utf-8 + - application/json Date: - - Wed, 03 Jun 2026 22:36:05 GMT + - Tue, 25 Aug 2026 22:52:42 GMT Server: - gunicorn + X-Billing-ID: + - '111' status: - code: 204 - message: NO CONTENT + code: 200 + message: OK - request: body: '' headers: @@ -910,666 +805,15 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma + uri: http://ai:9696/v3/hunt/rule?id=14328545122436991&community=gamma response: body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:36:06 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:36:07 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:36:08 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:36:09 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:36:10 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:36:11 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:36:12 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:36:13 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:36:14 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:36:15 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:36:16 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Wed, 03 Jun 2026 22:36:17 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '{"has_more":false,"limit":50,"result":[{"community":"gamma","created":"2026-06-03T22:36:17.918027+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-06-03T22:33:00.053959+00:00","id":"8018942810283908","instance_id":"27835191001619039","livescan_id":"68378491128619407","malware_family":"EICAR","md5":"564296ca17d5e063856e0b92c8ad6ca6","polyscore":null,"rule_name":"sdk_test_live","sha1":"adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a","sha256":"14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190","tags":"{}","yara":null}],"status":"OK"} - - ' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Length: - - '593' - Content-Type: - - application/json - Date: - - Wed, 03 Jun 2026 22:36:18 GMT - Server: - - gunicorn - X-Billing-ID: - - '111' - status: - code: 200 - message: OK -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live?id=8018942810283908 - response: - body: - string: '{"result":{"community":"gamma","created":"2026-06-03T22:36:17.918027+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":"http://minio:9000/public-cache/14/e3/a4/14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a564296ca17d5e063856e0b92c8ad6ca6?response-content-disposition=attachment%3Bfilename%3Dinfected&response-content-type=application%2Foctet-stream&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260603%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260603T223618Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=dc5cf083031090e9c5266463a7f34d3180cc5cde2fccfc6204cc23d4481b22a5","first_seen":"2026-06-03T22:33:00.053959+00:00","id":"8018942810283908","instance_id":"27835191001619039","livescan_id":"68378491128619407","malware_family":"EICAR","md5":"564296ca17d5e063856e0b92c8ad6ca6","polyscore":null,"rule_name":"sdk_test_live","sha1":"adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a","sha256":"14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190","tags":"{}","yara":"rule - sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} - - ' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Length: - - '1177' - Content-Type: - - application/json - Date: - - Wed, 03 Jun 2026 22:36:18 GMT - Server: - - gunicorn - X-Billing-ID: - - '111' - status: - code: 200 - message: OK -- request: - body: '{"result_ids":["8018942810283908"]}' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - content-length: - - '35' - content-type: - - application/json - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: DELETE - uri: http://ai:9696/v3/hunt/live/list - response: - body: - string: '{"has_more":true,"limit":50,"result":[{"community":"gamma","created":"2026-06-03T22:36:17.918027+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-06-03T22:33:00.053959+00:00","id":"8018942810283908","instance_id":"27835191001619039","livescan_id":"68378491128619407","malware_family":"EICAR","md5":"564296ca17d5e063856e0b92c8ad6ca6","polyscore":null,"rule_name":"sdk_test_live","sha1":"adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a","sha256":"14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190","tags":"{}","yara":null}],"status":"OK"} - - ' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Length: - - '592' - Content-Type: - - application/json - Date: - - Wed, 03 Jun 2026 22:36:18 GMT - Server: - - gunicorn - X-Billing-ID: - - '111' - status: - code: 200 - message: OK -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live?id=8018942810283908 - response: - body: - string: '{"errors":null,"result":"Could not find requested live hunt result: - 8018942810283908.","status":"error"} - - ' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Length: - - '105' - Content-Type: - - application/json - Date: - - Wed, 03 Jun 2026 22:36:18 GMT - Server: - - gunicorn - status: - code: 404 - message: NOT FOUND -- request: - body: '{"rule_id":"63962466091392300"}' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - content-length: - - '31' - content-type: - - application/json - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: DELETE - uri: http://ai:9696/v3/hunt/rule/live - response: - body: - string: '{"result":{"created":"2026-06-03T22:35:46.641576+00:00","deleted":false,"description":null,"id":"63962466091392300","livescan_created":"2026-06-03T22:35:46.690992+00:00","livescan_id":68378491128619407,"modified":"2026-06-03T22:35:46.666460+00:00","name":"sdk-test_live","yara":"rule - sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} - - ' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Length: - - '361' - Content-Type: - - application/json - Date: - - Wed, 03 Jun 2026 22:36:18 GMT - Server: - - gunicorn - X-Billing-ID: - - '111' - status: - code: 200 - message: OK -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/rule?id=63962466091392300&community=gamma - response: - body: - string: '{"result":{"created":"2026-06-03T22:35:46.641576+00:00","deleted":false,"description":null,"id":"63962466091392300","livescan_created":"2026-06-03T22:35:46.690992+00:00","livescan_id":null,"modified":"2026-06-03T22:36:18.435557+00:00","name":"sdk-test_live","yara":"rule - sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} - - ' + string: '{"result":{"created":"2026-08-25T22:52:32.002247+00:00","deleted":false,"description":null,"id":"14328545122436991","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:52:42.860277+00:00","name":"sdk-test_live","yara":"rule + sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} + + ' headers: Access-Control-Allow-Origin: - '*' @@ -1578,11 +822,11 @@ interactions: Connection: - keep-alive Content-Length: - - '348' + - '318' Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:36:18 GMT + - Tue, 25 Aug 2026 22:52:43 GMT Server: - gunicorn X-Billing-ID: @@ -1591,7 +835,7 @@ interactions: code: 200 message: OK - request: - body: '{"rule_id":"63962466091392300"}' + body: '{"rule_id":"14328545122436991"}' headers: accept: - '*/*' @@ -1608,7 +852,7 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: DELETE uri: http://ai:9696/v3/hunt/rule/live response: @@ -1628,7 +872,7 @@ interactions: Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:36:18 GMT + - Tue, 25 Aug 2026 22:52:43 GMT Server: - gunicorn status: @@ -1652,12 +896,12 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: DELETE - uri: http://ai:9696/v3/hunt/rule?id=63962466091392300 + uri: http://ai:9696/v3/hunt/rule?id=14328545122436991 response: body: - string: '{"result":{"created":"2026-06-03T22:35:46.641576+00:00","deleted":true,"description":null,"id":"63962466091392300","livescan_created":"2026-06-03T22:35:46.690992+00:00","livescan_id":null,"modified":"2026-06-03T22:36:18.435557+00:00","name":"sdk-test_live","yara":"rule + string: '{"result":{"created":"2026-08-25T22:52:32.002247+00:00","deleted":true,"description":null,"id":"14328545122436991","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:52:43.118593+00:00","name":"sdk-test_live","yara":"rule sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} ' @@ -1669,11 +913,11 @@ interactions: Connection: - keep-alive Content-Length: - - '347' + - '317' Content-Type: - application/json Date: - - Wed, 03 Jun 2026 22:36:18 GMT + - Tue, 25 Aug 2026 22:52:43 GMT Server: - gunicorn X-Billing-ID: From 84c8a0b8cfb23fa1d2d7b641f140eb47ae1a2dab Mon Sep 17 00:00:00 2001 From: Kyle Buchmiller Date: Tue, 25 Aug 2026 15:54:20 -0700 Subject: [PATCH 06/24] docs(specs): note matched_strings on the hunt-result resources The resources spec is what to read before changing a resource's parsing, and it carries the two closest precedents -- known_good / known_good_sources and state are both documented there as additive, .get()-parsed fields in exactly this situation. matched_strings had no note, so a reader following the spec index would not find it. Points at the three-state table in the downstream-contract spec rather than restating it, and records the part a parser needs up front: which value means "not reported" versus "matched with no evidence", and that the list endpoints always yield the former by design. --- specs/02-resources.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/specs/02-resources.md b/specs/02-resources.md index 181bbd81..afbf8327 100644 --- a/specs/02-resources.md +++ b/specs/02-resources.md @@ -326,6 +326,25 @@ Classmethod builders (each returns a `PolyswarmRequest` descriptor): **No instance methods** that issue HTTP. Uploading to the pre-signed S3 URL is done via the session: `await api.session.upload_file(instance.upload_url, artifact)` (or `api.session.upload_file(...)` for sync). +### `LiveHuntResult` / `HistoricalHuntResult` + +**`matched_strings`.** The yara strings behind a hunt hit, so a consumer can see *why* +a rule fired rather than only which one did. Additive and optional, parsed with `.get()` +like `known_good` / `state` above — a server too old to emit it parses to `None` with no +behaviour change, and a subscript would raise on every result instead. + +It is **three-state** and the states are not interchangeable; the table, the per-entry +dict shape and the lower-bound caveat live in +[`05-downstream-contract.md`](./05-downstream-contract.md) +§"`matched_strings` on hunt results" — read it there rather than inferring from the +attribute. The short version a parser needs: `None` means *not reported* (an older +server, removed evidence, or a **list** endpoint, which omits it rather than fetch a blob +per row), `[]` means *matched with no byte evidence*, and a populated list is evidence. + +Both `…List` subclasses inherit this from their parent's `__init__`, so all four +hunt-result classes carry it — but on the list endpoints the value is always `None` by +design. + ### `LocalArtifact` A file-system or in-memory artifact prepared for upload. Constructed via: From 4d57f66036eec1d4fdc4de2d67d3b554b6f79ee7 Mon Sep 17 00:00:00 2001 From: Kyle Buchmiller Date: Fri, 28 Aug 2026 14:07:32 -0700 Subject: [PATCH 07/24] feat(hunts): expose the withheld-string count on hunt results `matched_strings_dropped` on both hunt-result resources, inherited by their list subclasses. The server bounds how many matched-string bytes one result may carry, and a truncated list is otherwise indistinguishable from a complete one: a caller reading 75 entries concludes the rule hit 75 times when it hit 400. A sibling attribute rather than a key inside `matched_strings`, which stays a plain list -- so this is additive to the shape already documented, not a change to it. Parsed with .get() like the field beside it: None means nothing was withheld, which is also what a server predating the bound reports, so callers need no special case for the older shape. It can never accompany an empty list, since a match's first string is never withheld. Also corrects the downstream contract, which told consumers fast-scan reports only the first offset per string and that this was one reason the list is a lower bound. Measured against yara 4.5.2 that is false -- fast mode collapses repeats of a single string only when the rule's condition does not need them, and never limits how many distinct strings a rule reports. The remaining reasons are sound and the byte bound is now a fourth. 195 tests pass. --- specs/02-resources.md | 12 +++++++++--- specs/05-downstream-contract.md | 17 ++++++++++++++++- src/polyswarm_api/resources.py | 8 ++++++++ test/hunt_matched_strings_test.py | 21 +++++++++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/specs/02-resources.md b/specs/02-resources.md index afbf8327..b40d6992 100644 --- a/specs/02-resources.md +++ b/specs/02-resources.md @@ -341,9 +341,15 @@ attribute. The short version a parser needs: `None` means *not reported* (an old server, removed evidence, or a **list** endpoint, which omits it rather than fetch a blob per row), `[]` means *matched with no byte evidence*, and a populated list is evidence. -Both `…List` subclasses inherit this from their parent's `__init__`, so all four -hunt-result classes carry it — but on the list endpoints the value is always `None` by -design. +**`matched_strings_dropped`.** A sibling `int`/`None`, parsed the same additive way: +how many matched strings the server's byte budget withheld from this result. `None` means +none were, which is also what a server predating the budget reports. It never accompanies +an empty `matched_strings` — a match's first string is never withheld — so a non-null +count always means "the list you have is short by this much". + +Both `…List` subclasses inherit these from their parent's `__init__`, so all four +hunt-result classes carry them — but on the list endpoints the values are always `None` +by design. ### `LocalArtifact` diff --git a/specs/05-downstream-contract.md b/specs/05-downstream-contract.md index 0cdbd477..fb5509e9 100644 --- a/specs/05-downstream-contract.md +++ b/specs/05-downstream-contract.md @@ -283,7 +283,7 @@ Three values are possible and consumers **must not** collapse them: |---|---| | `None` | Not reported. Either the server predates the field, the stored evidence was deleted, or this came from a **list** endpoint — those omit it rather than fetch a blob per row. "We don't know", *not* "there was nothing". | | `[]` | The rule matched and there is no byte evidence to show — a rule with no strings section, one whose matching strings are all `private`, or one that matched on absence (`not $a`, `none of them`). | -| `[…]` | The evidence. A **lower bound**, not a match count: fast-scan reports only the first offset per string, `any of them` prints only the strings that hit, and `private` strings never appear. | +| `[…]` | The evidence. A **lower bound**, not a match count: `any of them` prints only the strings that hit, `private` strings never appear, and the server may withhold some past a size limit (see `matched_strings_dropped`). | Each entry is a dict: @@ -295,6 +295,21 @@ Each entry is a dict: - `length` is the **stored** length, capped server-side. Past the cap the true length is unrecoverable. - `truncated` means "there was more than this". It over-reports at exactly the cap, because nothing in the output distinguishes a match that ended there from one that was cut. +### `matched_strings_dropped` — the count that keeps a short list honest + +A sibling attribute on the same four classes, `int` or `None`. It is how many matched +strings the server's per-result byte budget withheld, and it exists because a truncated +list is otherwise indistinguishable from a complete one: a consumer reading twelve +entries would conclude the rule hit twelve times when it hit thirty-one. + +`None` means nothing was withheld — which is also what every result predating the budget +reports, so no consumer needs to special-case the older shape. It is deliberately a +**sibling** rather than a key inside `matched_strings`, which stays a plain list. + +A populated `matched_strings` with a non-null count is the normal shape for a verbose +ruleset. The first string of a match is never withheld, so this can never accompany an +empty list. + **Evidence lives on the detail routes only.** `live_feed()` and `historical_results()` page over list endpoints and will always yield `None` here; fetch a single result (`live_result(id)` / `historical_result(id)`) to get the strings. diff --git a/src/polyswarm_api/resources.py b/src/polyswarm_api/resources.py index da970a4c..b3cca3c8 100644 --- a/src/polyswarm_api/resources.py +++ b/src/polyswarm_api/resources.py @@ -772,6 +772,10 @@ def __init__(self, content, api=None): # evidence), [...] (the evidence, a lower bound). See # specs/05-downstream-contract.md. self.matched_strings = content.get('matched_strings') + # How many matched strings the server's byte budget cost this result, or None + # when nothing was dropped (also what every result predating the budget reports). + # A sibling field, so `matched_strings` stays a plain list. + self.matched_strings_dropped = content.get('matched_strings_dropped') self.polyscore = content['polyscore'] self.malware_family = content['malware_family'] self.detections = content['detections'] @@ -826,6 +830,10 @@ def __init__(self, content, api=None): # evidence), [...] (the evidence, a lower bound). See # specs/05-downstream-contract.md. self.matched_strings = content.get('matched_strings') + # How many matched strings the server's byte budget cost this result, or None + # when nothing was dropped (also what every result predating the budget reports). + # A sibling field, so `matched_strings` stays a plain list. + self.matched_strings_dropped = content.get('matched_strings_dropped') self.polyscore = content['polyscore'] self.malware_family = content['malware_family'] self.detections = content['detections'] diff --git a/test/hunt_matched_strings_test.py b/test/hunt_matched_strings_test.py index c062ac52..c05bb822 100644 --- a/test/hunt_matched_strings_test.py +++ b/test/hunt_matched_strings_test.py @@ -86,3 +86,24 @@ def test_raw_json_still_carries_the_key(cls): """`.json` is part of the contract, so JSON-mode consumers see it without SDK work.""" result = cls(_content(cls, matched_strings=_STRINGS)) assert result.json["matched_strings"] == _STRINGS + + +@pytest.mark.parametrize("cls", ALL_CLASSES) +def test_dropped_count_parses(cls): + """The count that lets a consumer say "12 shown, 19 more".""" + assert cls(_content(cls, matched_strings=_STRINGS, matched_strings_dropped=19)) \ + .matched_strings_dropped == 19 + + +@pytest.mark.parametrize("cls", ALL_CLASSES) +def test_dropped_is_none_when_absent(cls): + """Nothing dropped, and every result predating the budget -- same answer.""" + assert cls(_content(cls, matched_strings=_STRINGS)).matched_strings_dropped is None + + +@pytest.mark.parametrize("cls", ALL_CLASSES) +def test_dropped_is_independent_of_the_strings_list(cls): + """A truncated list is still a list; the count is what says it is short.""" + result = cls(_content(cls, matched_strings=_STRINGS, matched_strings_dropped=19)) + assert isinstance(result.matched_strings, list) + assert len(result.matched_strings) == 2 From 49a64db13b75be62b471545a94236c39c378e01b Mon Sep 17 00:00:00 2001 From: Kyle Buchmiller Date: Sat, 29 Aug 2026 10:03:54 -0700 Subject: [PATCH 08/24] test: pin matched_strings_dropped against the live server, and fix the wire shape The cassettes were recorded before this attribute existed -- two commits before -- so neither carried the key. The pure-unit tests exercise dict.get and would have passed identically if the server never emitted it, which is the exact gap the earlier commit here argued the e2e-first invariant exists to close. Applied unevenly is worse than not applied. Re-recorded delete-driven against a stack running the matching server branch. The assertion reads `'matched_strings_dropped' in result.json` rather than checking the attribute for None, because `is None` cannot distinguish a served null from an absent key -- and what needs pinning is that the server SENDS the field. Its value is null there: the per-test rule is small and withholds nothing, so the null arm is what a passing hunt actually looks like. Also corrects both specs, which said list endpoints OMIT the key. They send an explicit null; omitted is the older-server case. That wording survived from a design that was reverted, and the recorded cassettes disagreed with it. And in the new section, "None means nothing was withheld" was stated flatly while the section above it is careful that None on matched_strings means "we don't know" -- the same conflation, now reading the same way in both places. 195 tests pass. --- specs/02-resources.md | 4 +- specs/05-downstream-contract.md | 8 +- test/async_client_test.py | 8 +- test/client_scan_test.py | 8 +- test/vcr/test_async_live.vcr | 172 +++++++++-------------------- test/vcr/test_live.vcr | 184 ++++++++++++++++++++++---------- 6 files changed, 199 insertions(+), 185 deletions(-) diff --git a/specs/02-resources.md b/specs/02-resources.md index b40d6992..f7c20d19 100644 --- a/specs/02-resources.md +++ b/specs/02-resources.md @@ -338,8 +338,8 @@ dict shape and the lower-bound caveat live in [`05-downstream-contract.md`](./05-downstream-contract.md) §"`matched_strings` on hunt results" — read it there rather than inferring from the attribute. The short version a parser needs: `None` means *not reported* (an older -server, removed evidence, or a **list** endpoint, which omits it rather than fetch a blob -per row), `[]` means *matched with no byte evidence*, and a populated list is evidence. +server, removed evidence, or a **list** endpoint, which sends an explicit `null` rather +than fetch a blob per row), `[]` means *matched with no byte evidence*, and a populated list is evidence. **`matched_strings_dropped`.** A sibling `int`/`None`, parsed the same additive way: how many matched strings the server's byte budget withheld from this result. `None` means diff --git a/specs/05-downstream-contract.md b/specs/05-downstream-contract.md index fb5509e9..3f34200f 100644 --- a/specs/05-downstream-contract.md +++ b/specs/05-downstream-contract.md @@ -281,7 +281,7 @@ Three values are possible and consumers **must not** collapse them: | Value | Meaning | |---|---| -| `None` | Not reported. Either the server predates the field, the stored evidence was deleted, or this came from a **list** endpoint — those omit it rather than fetch a blob per row. "We don't know", *not* "there was nothing". | +| `None` | Not reported. The **list** endpoints send the key as an explicit `null` rather than fetch a blob per row; a server predating the field omits it entirely; and stored evidence may have been deleted. `.get()` collapses all three. "We don't know", *not* "there was nothing". | | `[]` | The rule matched and there is no byte evidence to show — a rule with no strings section, one whose matching strings are all `private`, or one that matched on absence (`not $a`, `none of them`). | | `[…]` | The evidence. A **lower bound**, not a match count: `any of them` prints only the strings that hit, `private` strings never appear, and the server may withhold some past a size limit (see `matched_strings_dropped`). | @@ -302,8 +302,10 @@ strings the server's per-result byte budget withheld, and it exists because a tr list is otherwise indistinguishable from a complete one: a consumer reading twelve entries would conclude the rule hit twelve times when it hit thirty-one. -`None` means nothing was withheld — which is also what every result predating the budget -reports, so no consumer needs to special-case the older shape. It is deliberately a +`None` carries the same ambiguity as `matched_strings` itself and should be read the same +way: on a **detail** route it means nothing was withheld, but on a **list** route it means +the route did not look, and on an older server it means the field did not exist. It is not +a claim that the evidence is complete. It is deliberately a **sibling** rather than a key inside `matched_strings`, which stays a plain list. A populated `matched_strings` with a non-null count is the normal shape for a verbose diff --git a/test/async_client_test.py b/test/async_client_test.py index 14264b65..e780332e 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -733,7 +733,13 @@ async def test_async_live(self, uid): # server never grew the field; only a cassette shows what it actually sent. assert result.matched_strings, 'detail route should carry the yara evidence' assert my_results[0].matched_strings is None, \ - 'list rows omit the evidence -- it is a per-row blob fetch' + 'list rows do not carry the evidence -- it is a per-row blob fetch' + # On .json, not the attribute: `is None` cannot tell a served null from an + # absent key, and what needs pinning is that the server SENDS this field. + assert 'matched_strings_dropped' in result.json, \ + 'server must serve the withheld-count field' + assert result.matched_strings_dropped is None, \ + 'nothing withheld for a match this small' await api.live_feed_delete([result_id]) with pytest.raises(exceptions.NotFoundException): diff --git a/test/client_scan_test.py b/test/client_scan_test.py index 671b6786..4b9db8dc 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -506,7 +506,13 @@ def test_live(self): # server never grew the field; only a cassette shows what it actually sent. assert result.matched_strings, 'detail route should carry the yara evidence' assert my_results[0].matched_strings is None, \ - 'list rows omit the evidence -- it is a per-row blob fetch' + 'list rows do not carry the evidence -- it is a per-row blob fetch' + # On .json, not the attribute: `is None` cannot tell a served null from an + # absent key, and what needs pinning is that the server SENDS this field. + assert 'matched_strings_dropped' in result.json, \ + 'server must serve the withheld-count field' + assert result.matched_strings_dropped is None, \ + 'nothing withheld for a match this small' api.live_feed_delete([result_id]) with pytest.raises(exceptions.NotFoundException): diff --git a/test/vcr/test_async_live.vcr b/test/vcr/test_async_live.vcr index 0f742c2b..3b7d9f69 100644 --- a/test/vcr/test_async_live.vcr +++ b/test/vcr/test_async_live.vcr @@ -23,7 +23,7 @@ interactions: uri: http://ai:9696/v3/hunt/rule response: body: - string: '{"result":{"created":"2026-08-25T22:53:17.732230+00:00","deleted":false,"description":null,"id":"62754506679930173","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:53:17.732230+00:00","name":"sdk-test_async_live","yara":"rule + string: '{"result":{"created":"2026-08-29T00:11:42.713162+00:00","deleted":false,"description":null,"id":"82290800532833154","livescan_created":null,"livescan_id":null,"modified":"2026-08-29T00:11:42.713162+00:00","name":"sdk-test_async_live","yara":"rule sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} ' @@ -39,7 +39,7 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:53:17 GMT + - Sat, 29 Aug 2026 00:11:42 GMT Server: - gunicorn X-Billing-ID: @@ -48,7 +48,7 @@ interactions: code: 200 message: OK - request: - body: '{"rule_id":"62754506679930173"}' + body: '{"rule_id":"82290800532833154"}' headers: accept: - '*/*' @@ -70,7 +70,7 @@ interactions: uri: http://ai:9696/v3/hunt/rule/live response: body: - string: '{"result":{"created":"2026-08-25T22:53:17.732230+00:00","deleted":false,"description":null,"id":"62754506679930173","livescan_created":"2026-08-25T22:53:17.826217+00:00","livescan_id":88995738053778536,"modified":"2026-08-25T22:53:17.824324+00:00","name":"sdk-test_async_live","yara":"rule + string: '{"result":{"created":"2026-08-29T00:11:42.713162+00:00","deleted":false,"description":null,"id":"82290800532833154","livescan_created":"2026-08-29T00:11:42.802951+00:00","livescan_id":63056499228390147,"modified":"2026-08-29T00:11:42.800524+00:00","name":"sdk-test_async_live","yara":"rule sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} ' @@ -86,7 +86,7 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:53:17 GMT + - Sat, 29 Aug 2026 00:11:42 GMT Server: - gunicorn X-Billing-ID: @@ -110,10 +110,10 @@ interactions: user-agent: - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/rule?id=62754506679930173&community=gamma + uri: http://ai:9696/v3/hunt/rule?id=82290800532833154&community=gamma response: body: - string: '{"result":{"created":"2026-08-25T22:53:17.732230+00:00","deleted":false,"description":null,"id":"62754506679930173","livescan_created":"2026-08-25T22:53:17.826217+00:00","livescan_id":88995738053778536,"modified":"2026-08-25T22:53:17.824324+00:00","name":"sdk-test_async_live","yara":"rule + string: '{"result":{"created":"2026-08-29T00:11:42.713162+00:00","deleted":false,"description":null,"id":"82290800532833154","livescan_created":"2026-08-29T00:11:42.802951+00:00","livescan_id":63056499228390147,"modified":"2026-08-29T00:11:42.800524+00:00","name":"sdk-test_async_live","yara":"rule sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} ' @@ -129,7 +129,7 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:53:18 GMT + - Sat, 29 Aug 2026 00:11:43 GMT Server: - gunicorn X-Billing-ID: @@ -160,7 +160,7 @@ interactions: uri: http://ai:9696/v3/instance response: body: - string: '{"result":{"artifact_id":"47587156523150020","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-08-25T22:53:18.933938+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-08-25T22:53:18.933938+00:00","id":"47587156523150020","known_good":null,"last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/47587156523150020","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"state":"CREATED","type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/97/f8/13/97f8139b-5b64-42f8-ac4e-2cd965f3f74c?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225318Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=79e8ef3ab561d8c40c9613747e83915d7b352c943fcd9a869d10d6f1cb801c65","votes":[],"window_closed":false},"status":"OK"} + string: '{"result":{"artifact_id":"78118450880280801","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-08-29T00:11:43.904670+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-08-29T00:11:43.904670+00:00","id":"78118450880280801","known_good":null,"last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/78118450880280801","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"state":"CREATED","type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/1a/f1/ca/1af1ca3a-7869-4653-b775-16bf39d98d73?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260829%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260829T001143Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=db5df1a1ef8dfb317ced098d31b77525ecf0f29fdcc708983a274b7b780efd12","votes":[],"window_closed":false},"status":"OK"} ' headers: @@ -175,7 +175,7 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:53:18 GMT + - Sat, 29 Aug 2026 00:11:43 GMT Server: - gunicorn X-Billing-ID: @@ -201,7 +201,7 @@ interactions: user-agent: - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: PUT - uri: http://minio:9000/artifact-index/instances/97/f8/13/97f8139b-5b64-42f8-ac4e-2cd965f3f74c?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225318Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=79e8ef3ab561d8c40c9613747e83915d7b352c943fcd9a869d10d6f1cb801c65 + uri: http://minio:9000/artifact-index/instances/1a/f1/ca/1af1ca3a-7869-4653-b775-16bf39d98d73?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260829%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260829T001143Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=db5df1a1ef8dfb317ced098d31b77525ecf0f29fdcc708983a274b7b780efd12 response: body: string: '' @@ -211,7 +211,7 @@ interactions: Content-Length: - '0' Date: - - Tue, 25 Aug 2026 22:53:18 GMT + - Sat, 29 Aug 2026 00:11:43 GMT ETag: - '"6ac59cd96a9a3ee9d52f9ebc3ec22f70"' Server: @@ -224,17 +224,17 @@ interactions: X-Amz-Id-2: - dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8 X-Amz-Request-Id: - - 18CF2E38E284BA55 + - 18D01E3E0CBB5837 X-Content-Type-Options: - nosniff X-Ratelimit-Limit: - - '5063' + - '5171' X-Ratelimit-Remaining: - - '5063' + - '5171' X-Xss-Protection: - 1; mode=block x-amz-expiration: - - expiry-date="Thu, 27 Aug 2026 00:00:00 GMT", rule-id="expiration-artifact-index_0-instances" + - expiry-date="Mon, 31 Aug 2026 00:00:00 GMT", rule-id="expiration-artifact-index_0-instances" status: code: 200 message: OK @@ -258,10 +258,10 @@ interactions: user-agent: - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: PUT - uri: http://ai:9696/v3/instance?id=47587156523150020 + uri: http://ai:9696/v3/instance?id=78118450880280801 response: body: - string: '{"result":{"artifact_id":"47587156523150020","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-08-25T22:53:18.933938+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-08-25T22:53:18.933938+00:00","id":"47587156523150020","known_good":null,"last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/47587156523150020","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"state":"CREATED","type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/97/f8/13/97f8139b-5b64-42f8-ac4e-2cd965f3f74c?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225318Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=79e8ef3ab561d8c40c9613747e83915d7b352c943fcd9a869d10d6f1cb801c65","votes":[],"window_closed":false},"status":"OK"} + string: '{"result":{"artifact_id":"78118450880280801","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-08-29T00:11:43.904670+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-08-29T00:11:43.904670+00:00","id":"78118450880280801","known_good":null,"last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/78118450880280801","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"state":"CREATED","type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/1a/f1/ca/1af1ca3a-7869-4653-b775-16bf39d98d73?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260829%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260829T001143Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=db5df1a1ef8dfb317ced098d31b77525ecf0f29fdcc708983a274b7b780efd12","votes":[],"window_closed":false},"status":"OK"} ' headers: @@ -276,7 +276,7 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:53:18 GMT + - Sat, 29 Aug 2026 00:11:43 GMT Server: - gunicorn X-Billing-ID: @@ -314,7 +314,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Tue, 25 Aug 2026 22:53:20 GMT + - Sat, 29 Aug 2026 00:11:44 GMT Server: - gunicorn status: @@ -350,7 +350,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Tue, 25 Aug 2026 22:53:21 GMT + - Sat, 29 Aug 2026 00:11:46 GMT Server: - gunicorn status: @@ -386,7 +386,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Tue, 25 Aug 2026 22:53:22 GMT + - Sat, 29 Aug 2026 00:11:47 GMT Server: - gunicorn status: @@ -422,7 +422,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Tue, 25 Aug 2026 22:53:23 GMT + - Sat, 29 Aug 2026 00:11:48 GMT Server: - gunicorn status: @@ -458,7 +458,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Tue, 25 Aug 2026 22:53:24 GMT + - Sat, 29 Aug 2026 00:11:49 GMT Server: - gunicorn status: @@ -494,7 +494,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Tue, 25 Aug 2026 22:53:25 GMT + - Sat, 29 Aug 2026 00:11:50 GMT Server: - gunicorn status: @@ -519,79 +519,7 @@ interactions: uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Tue, 25 Aug 2026 22:53:26 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '' - headers: - Access-Control-Allow-Origin: - - '*' - Access-Control-Expose-Headers: - - Authorization - Connection: - - keep-alive - Content-Type: - - text/html; charset=utf-8 - Date: - - Tue, 25 Aug 2026 22:53:27 GMT - Server: - - gunicorn - status: - code: 204 - message: NO CONTENT -- request: - body: '' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - host: - - ai:9696 - user-agent: - - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) - method: GET - uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma - response: - body: - string: '{"has_more":false,"limit":50,"result":[{"community":"gamma","created":"2026-08-25T22:53:27.621267+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-08-25T22:53:18.933938+00:00","id":"37373253354683797","instance_id":"47587156523150020","livescan_id":"88995738053778536","malware_family":"EICAR","matched_strings":null,"md5":"6ac59cd96a9a3ee9d52f9ebc3ec22f70","polyscore":null,"rule_name":"sdk_test_async_live","sha1":"fade22953ce5af251e2dbd7491124c818bf35e5e","sha256":"ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5","tags":"{}","yara":null}],"status":"OK"} + string: '{"has_more":false,"limit":50,"result":[{"community":"gamma","created":"2026-08-29T00:11:51.184667+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-08-29T00:11:43.904670+00:00","id":"27253623675037251","instance_id":"78118450880280801","livescan_id":"63056499228390147","malware_family":"EICAR","matched_strings":null,"matched_strings_dropped":null,"md5":"6ac59cd96a9a3ee9d52f9ebc3ec22f70","polyscore":null,"rule_name":"sdk_test_async_live","sha1":"fade22953ce5af251e2dbd7491124c818bf35e5e","sha256":"ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5","tags":"{}","yara":null}],"status":"OK"} ' headers: @@ -602,11 +530,11 @@ interactions: Connection: - keep-alive Content-Length: - - '623' + - '654' Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:53:28 GMT + - Sat, 29 Aug 2026 00:11:51 GMT Server: - gunicorn X-Billing-ID: @@ -630,10 +558,10 @@ interactions: user-agent: - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/live?id=37373253354683797 + uri: http://ai:9696/v3/hunt/live?id=27253623675037251 response: body: - string: '{"result":{"community":"gamma","created":"2026-08-25T22:53:27.621267+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":"http://minio:9000/public-cache/ff/2b/25/ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5fade22953ce5af251e2dbd7491124c818bf35e5e6ac59cd96a9a3ee9d52f9ebc3ec22f70?response-content-disposition=attachment%3Bfilename%3Dinfected&response-content-type=application%2Foctet-stream&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225328Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=5d07ef302bc210af42bdc544364bfb23b47516ac4a61221a6d8ca76a06100ec5","first_seen":"2026-08-25T22:53:18.933938+00:00","id":"37373253354683797","instance_id":"47587156523150020","livescan_id":"88995738053778536","malware_family":"EICAR","matched_strings":[{"data":"test_async_live","identifier":"$u","length":15,"offset":69,"truncated":false}],"md5":"6ac59cd96a9a3ee9d52f9ebc3ec22f70","polyscore":null,"rule_name":"sdk_test_async_live","sha1":"fade22953ce5af251e2dbd7491124c818bf35e5e","sha256":"ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5","tags":"{}","yara":"rule + string: '{"result":{"community":"gamma","created":"2026-08-29T00:11:51.184667+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":"http://minio:9000/public-cache/ff/2b/25/ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5fade22953ce5af251e2dbd7491124c818bf35e5e6ac59cd96a9a3ee9d52f9ebc3ec22f70?response-content-disposition=attachment%3Bfilename%3Dinfected&response-content-type=application%2Foctet-stream&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260829%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260829T001151Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=adc60fb87017ea3a84210c55f388665cb85763ec44687439643e81451da65e48","first_seen":"2026-08-29T00:11:43.904670+00:00","id":"27253623675037251","instance_id":"78118450880280801","livescan_id":"63056499228390147","malware_family":"EICAR","matched_strings":[{"data":"test_async_live","identifier":"$u","length":15,"offset":69,"truncated":false}],"matched_strings_dropped":null,"md5":"6ac59cd96a9a3ee9d52f9ebc3ec22f70","polyscore":null,"rule_name":"sdk_test_async_live","sha1":"fade22953ce5af251e2dbd7491124c818bf35e5e","sha256":"ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5","tags":"{}","yara":"rule sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} ' @@ -645,11 +573,11 @@ interactions: Connection: - keep-alive Content-Length: - - '1303' + - '1334' Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:53:28 GMT + - Sat, 29 Aug 2026 00:11:51 GMT Server: - gunicorn X-Billing-ID: @@ -658,7 +586,7 @@ interactions: code: 200 message: OK - request: - body: '{"result_ids":["37373253354683797"]}' + body: '{"result_ids":["27253623675037251"]}' headers: accept: - '*/*' @@ -680,7 +608,7 @@ interactions: uri: http://ai:9696/v3/hunt/live/list response: body: - string: '{"has_more":true,"limit":50,"result":[{"community":"gamma","created":"2026-08-25T22:53:27.621267+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-08-25T22:53:18.933938+00:00","id":"37373253354683797","instance_id":"47587156523150020","livescan_id":"88995738053778536","malware_family":"EICAR","matched_strings":null,"md5":"6ac59cd96a9a3ee9d52f9ebc3ec22f70","polyscore":null,"rule_name":"sdk_test_async_live","sha1":"fade22953ce5af251e2dbd7491124c818bf35e5e","sha256":"ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5","tags":"{}","yara":null}],"status":"OK"} + string: '{"has_more":true,"limit":50,"result":[{"community":"gamma","created":"2026-08-29T00:11:51.184667+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-08-29T00:11:43.904670+00:00","id":"27253623675037251","instance_id":"78118450880280801","livescan_id":"63056499228390147","malware_family":"EICAR","matched_strings":null,"matched_strings_dropped":null,"md5":"6ac59cd96a9a3ee9d52f9ebc3ec22f70","polyscore":null,"rule_name":"sdk_test_async_live","sha1":"fade22953ce5af251e2dbd7491124c818bf35e5e","sha256":"ff2b25f3bff7613e391b4d87e32f18c172d531bee5ae562bd13b6421d8b104a5","tags":"{}","yara":null}],"status":"OK"} ' headers: @@ -691,11 +619,11 @@ interactions: Connection: - keep-alive Content-Length: - - '622' + - '653' Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:53:28 GMT + - Sat, 29 Aug 2026 00:11:51 GMT Server: - gunicorn X-Billing-ID: @@ -719,11 +647,11 @@ interactions: user-agent: - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/live?id=37373253354683797 + uri: http://ai:9696/v3/hunt/live?id=27253623675037251 response: body: string: '{"errors":null,"result":"Could not find requested live hunt result: - 37373253354683797.","status":"error"} + 27253623675037251.","status":"error"} ' headers: @@ -738,14 +666,14 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:53:28 GMT + - Sat, 29 Aug 2026 00:11:51 GMT Server: - gunicorn status: code: 404 message: NOT FOUND - request: - body: '{"rule_id":"62754506679930173"}' + body: '{"rule_id":"82290800532833154"}' headers: accept: - '*/*' @@ -767,7 +695,7 @@ interactions: uri: http://ai:9696/v3/hunt/rule/live response: body: - string: '{"result":{"created":"2026-08-25T22:53:17.732230+00:00","deleted":false,"description":null,"id":"62754506679930173","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:53:28.453628+00:00","name":"sdk-test_async_live","yara":"rule + string: '{"result":{"created":"2026-08-29T00:11:42.713162+00:00","deleted":false,"description":null,"id":"82290800532833154","livescan_created":null,"livescan_id":null,"modified":"2026-08-29T00:11:51.325659+00:00","name":"sdk-test_async_live","yara":"rule sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} ' @@ -783,7 +711,7 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:53:28 GMT + - Sat, 29 Aug 2026 00:11:51 GMT Server: - gunicorn X-Billing-ID: @@ -807,10 +735,10 @@ interactions: user-agent: - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/rule?id=62754506679930173&community=gamma + uri: http://ai:9696/v3/hunt/rule?id=82290800532833154&community=gamma response: body: - string: '{"result":{"created":"2026-08-25T22:53:17.732230+00:00","deleted":false,"description":null,"id":"62754506679930173","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:53:28.453628+00:00","name":"sdk-test_async_live","yara":"rule + string: '{"result":{"created":"2026-08-29T00:11:42.713162+00:00","deleted":false,"description":null,"id":"82290800532833154","livescan_created":null,"livescan_id":null,"modified":"2026-08-29T00:11:51.325659+00:00","name":"sdk-test_async_live","yara":"rule sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} ' @@ -826,7 +754,7 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:53:28 GMT + - Sat, 29 Aug 2026 00:11:51 GMT Server: - gunicorn X-Billing-ID: @@ -835,7 +763,7 @@ interactions: code: 200 message: OK - request: - body: '{"rule_id":"62754506679930173"}' + body: '{"rule_id":"82290800532833154"}' headers: accept: - '*/*' @@ -872,7 +800,7 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:53:28 GMT + - Sat, 29 Aug 2026 00:11:51 GMT Server: - gunicorn status: @@ -898,10 +826,10 @@ interactions: user-agent: - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: DELETE - uri: http://ai:9696/v3/hunt/rule?id=62754506679930173 + uri: http://ai:9696/v3/hunt/rule?id=82290800532833154 response: body: - string: '{"result":{"created":"2026-08-25T22:53:17.732230+00:00","deleted":true,"description":null,"id":"62754506679930173","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:53:28.638516+00:00","name":"sdk-test_async_live","yara":"rule + string: '{"result":{"created":"2026-08-29T00:11:42.713162+00:00","deleted":true,"description":null,"id":"82290800532833154","livescan_created":null,"livescan_id":null,"modified":"2026-08-29T00:11:51.427206+00:00","name":"sdk-test_async_live","yara":"rule sdk_test_async_live { strings: $u = \"test_async_live\" condition: $u }"},"status":"OK"} ' @@ -917,7 +845,7 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:53:28 GMT + - Sat, 29 Aug 2026 00:11:51 GMT Server: - gunicorn X-Billing-ID: diff --git a/test/vcr/test_live.vcr b/test/vcr/test_live.vcr index 6e950c1a..165b2f26 100644 --- a/test/vcr/test_live.vcr +++ b/test/vcr/test_live.vcr @@ -23,7 +23,7 @@ interactions: uri: http://ai:9696/v3/hunt/rule response: body: - string: '{"result":{"created":"2026-08-25T22:52:32.002247+00:00","deleted":false,"description":null,"id":"14328545122436991","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:52:32.002247+00:00","name":"sdk-test_live","yara":"rule + string: '{"result":{"created":"2026-08-29T00:11:28.650982+00:00","deleted":false,"description":null,"id":"79693411044316426","livescan_created":null,"livescan_id":null,"modified":"2026-08-29T00:11:28.650982+00:00","name":"sdk-test_live","yara":"rule sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} ' @@ -39,7 +39,7 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:52:32 GMT + - Sat, 29 Aug 2026 00:11:28 GMT Server: - gunicorn X-Billing-ID: @@ -48,7 +48,7 @@ interactions: code: 200 message: OK - request: - body: '{"rule_id":"14328545122436991"}' + body: '{"rule_id":"79693411044316426"}' headers: accept: - '*/*' @@ -70,7 +70,7 @@ interactions: uri: http://ai:9696/v3/hunt/rule/live response: body: - string: '{"result":{"created":"2026-08-25T22:52:32.002247+00:00","deleted":false,"description":null,"id":"14328545122436991","livescan_created":"2026-08-25T22:52:32.132148+00:00","livescan_id":35147411870661732,"modified":"2026-08-25T22:52:32.105062+00:00","name":"sdk-test_live","yara":"rule + string: '{"result":{"created":"2026-08-29T00:11:28.650982+00:00","deleted":false,"description":null,"id":"79693411044316426","livescan_created":"2026-08-29T00:11:28.814637+00:00","livescan_id":6053794961344849,"modified":"2026-08-29T00:11:28.769801+00:00","name":"sdk-test_live","yara":"rule sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} ' @@ -82,11 +82,11 @@ interactions: Connection: - keep-alive Content-Length: - - '361' + - '360' Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:52:32 GMT + - Sat, 29 Aug 2026 00:11:28 GMT Server: - gunicorn X-Billing-ID: @@ -110,10 +110,10 @@ interactions: user-agent: - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/rule?id=14328545122436991&community=gamma + uri: http://ai:9696/v3/hunt/rule?id=79693411044316426&community=gamma response: body: - string: '{"result":{"created":"2026-08-25T22:52:32.002247+00:00","deleted":false,"description":null,"id":"14328545122436991","livescan_created":"2026-08-25T22:52:32.132148+00:00","livescan_id":35147411870661732,"modified":"2026-08-25T22:52:32.105062+00:00","name":"sdk-test_live","yara":"rule + string: '{"result":{"created":"2026-08-29T00:11:28.650982+00:00","deleted":false,"description":null,"id":"79693411044316426","livescan_created":"2026-08-29T00:11:28.814637+00:00","livescan_id":6053794961344849,"modified":"2026-08-29T00:11:28.769801+00:00","name":"sdk-test_live","yara":"rule sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} ' @@ -125,11 +125,11 @@ interactions: Connection: - keep-alive Content-Length: - - '361' + - '360' Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:52:33 GMT + - Sat, 29 Aug 2026 00:11:29 GMT Server: - gunicorn X-Billing-ID: @@ -160,7 +160,7 @@ interactions: uri: http://ai:9696/v3/instance response: body: - string: '{"result":{"artifact_id":"83487019275094114","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-08-25T22:52:33.280967+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-08-25T22:52:33.280967+00:00","id":"83487019275094114","known_good":null,"last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/83487019275094114","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"state":"CREATED","type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/3b/53/3c/3b533c44-b3b3-4122-871e-2671860c7448?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225233Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=379d86d9cc5ef3e61b737752b0346318bed946a3c01cc10d5746dca16bf4d085","votes":[],"window_closed":false},"status":"OK"} + string: '{"result":{"artifact_id":"3936300515643678","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-08-29T00:11:30.179957+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-08-29T00:11:30.179957+00:00","id":"3936300515643678","known_good":null,"last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/3936300515643678","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"state":"CREATED","type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/d9/23/e4/d923e4f0-44a2-4b0a-8812-165751c5e8e9?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260829%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260829T001130Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=6d98f2ae0827d37cc153870c2587b24b39989492f22166255b6d9ac784abf347","votes":[],"window_closed":false},"status":"OK"} ' headers: @@ -171,11 +171,11 @@ interactions: Connection: - keep-alive Content-Length: - - '1044' + - '1041' Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:52:33 GMT + - Sat, 29 Aug 2026 00:11:30 GMT Server: - gunicorn X-Billing-ID: @@ -201,7 +201,7 @@ interactions: user-agent: - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: PUT - uri: http://minio:9000/artifact-index/instances/3b/53/3c/3b533c44-b3b3-4122-871e-2671860c7448?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225233Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=379d86d9cc5ef3e61b737752b0346318bed946a3c01cc10d5746dca16bf4d085 + uri: http://minio:9000/artifact-index/instances/d9/23/e4/d923e4f0-44a2-4b0a-8812-165751c5e8e9?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260829%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260829T001130Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=6d98f2ae0827d37cc153870c2587b24b39989492f22166255b6d9ac784abf347 response: body: string: '' @@ -211,7 +211,7 @@ interactions: Content-Length: - '0' Date: - - Tue, 25 Aug 2026 22:52:33 GMT + - Sat, 29 Aug 2026 00:11:30 GMT ETag: - '"564296ca17d5e063856e0b92c8ad6ca6"' Server: @@ -224,17 +224,17 @@ interactions: X-Amz-Id-2: - dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8 X-Amz-Request-Id: - - 18CF2E2E43B691C3 + - 18D01E3ADC7F16BA X-Content-Type-Options: - nosniff X-Ratelimit-Limit: - - '5063' + - '5171' X-Ratelimit-Remaining: - - '5063' + - '5171' X-Xss-Protection: - 1; mode=block x-amz-expiration: - - expiry-date="Thu, 27 Aug 2026 00:00:00 GMT", rule-id="expiration-artifact-index_0-instances" + - expiry-date="Mon, 31 Aug 2026 00:00:00 GMT", rule-id="expiration-artifact-index_0-instances" status: code: 200 message: OK @@ -258,10 +258,10 @@ interactions: user-agent: - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: PUT - uri: http://ai:9696/v3/instance?id=83487019275094114 + uri: http://ai:9696/v3/instance?id=3936300515643678 response: body: - string: '{"result":{"artifact_id":"83487019275094114","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-08-25T22:52:33.280967+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-08-25T22:52:33.280967+00:00","id":"83487019275094114","known_good":null,"last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/83487019275094114","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"state":"CREATED","type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/3b/53/3c/3b533c44-b3b3-4122-871e-2671860c7448?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225233Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=379d86d9cc5ef3e61b737752b0346318bed946a3c01cc10d5746dca16bf4d085","votes":[],"window_closed":false},"status":"OK"} + string: '{"result":{"artifact_id":"3936300515643678","assertions":[],"bounty_state":0,"community":"gamma","country":"","created":"2026-08-29T00:11:30.179957+00:00","detections":null,"expiration_window":null,"expire_at":null,"extended_type":null,"failed":false,"filename":"artifact","first_seen":"2026-08-29T00:11:30.179957+00:00","id":"3936300515643678","known_good":null,"last_scanned":null,"last_seen":null,"md5":null,"metadata":[],"mimetype":null,"permalink":"https://polyswarm.network/scan/results/file/None/3936300515643678","polyscore":null,"result":null,"sha1":null,"sha256":null,"size":null,"state":"CREATED","type":"FILE","upload_url":"http://minio:9000/artifact-index/instances/d9/23/e4/d923e4f0-44a2-4b0a-8812-165751c5e8e9?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260829%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260829T001130Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=6d98f2ae0827d37cc153870c2587b24b39989492f22166255b6d9ac784abf347","votes":[],"window_closed":false},"status":"OK"} ' headers: @@ -272,11 +272,11 @@ interactions: Connection: - keep-alive Content-Length: - - '1044' + - '1041' Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:52:33 GMT + - Sat, 29 Aug 2026 00:11:30 GMT Server: - gunicorn X-Billing-ID: @@ -314,7 +314,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Tue, 25 Aug 2026 22:52:34 GMT + - Sat, 29 Aug 2026 00:11:31 GMT Server: - gunicorn status: @@ -350,7 +350,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Tue, 25 Aug 2026 22:52:35 GMT + - Sat, 29 Aug 2026 00:11:32 GMT Server: - gunicorn status: @@ -386,7 +386,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Tue, 25 Aug 2026 22:52:36 GMT + - Sat, 29 Aug 2026 00:11:33 GMT Server: - gunicorn status: @@ -422,7 +422,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Tue, 25 Aug 2026 22:52:37 GMT + - Sat, 29 Aug 2026 00:11:34 GMT Server: - gunicorn status: @@ -458,7 +458,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Tue, 25 Aug 2026 22:52:38 GMT + - Sat, 29 Aug 2026 00:11:35 GMT Server: - gunicorn status: @@ -494,7 +494,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Tue, 25 Aug 2026 22:52:39 GMT + - Sat, 29 Aug 2026 00:11:36 GMT Server: - gunicorn status: @@ -530,7 +530,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Tue, 25 Aug 2026 22:52:40 GMT + - Sat, 29 Aug 2026 00:11:37 GMT Server: - gunicorn status: @@ -566,7 +566,7 @@ interactions: Content-Type: - text/html; charset=utf-8 Date: - - Tue, 25 Aug 2026 22:52:41 GMT + - Sat, 29 Aug 2026 00:11:38 GMT Server: - gunicorn status: @@ -591,7 +591,79 @@ interactions: uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma response: body: - string: '{"has_more":false,"limit":50,"result":[{"community":"gamma","created":"2026-08-25T22:52:42.306041+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-08-25T22:52:33.280967+00:00","id":"47972203596447840","instance_id":"83487019275094114","livescan_id":"35147411870661732","malware_family":"EICAR","matched_strings":null,"md5":"564296ca17d5e063856e0b92c8ad6ca6","polyscore":null,"rule_name":"sdk_test_live","sha1":"adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a","sha256":"14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190","tags":"{}","yara":null}],"status":"OK"} + string: '' + headers: + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - Authorization + Connection: + - keep-alive + Content-Type: + - text/html; charset=utf-8 + Date: + - Sat, 29 Aug 2026 00:11:39 GMT + Server: + - gunicorn + status: + code: 204 + message: NO CONTENT +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) + method: GET + uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma + response: + body: + string: '' + headers: + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - Authorization + Connection: + - keep-alive + Content-Type: + - text/html; charset=utf-8 + Date: + - Sat, 29 Aug 2026 00:11:40 GMT + Server: + - gunicorn + status: + code: 204 + message: NO CONTENT +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) + method: GET + uri: http://ai:9696/v3/hunt/live/list?since=600&community=gamma + response: + body: + string: '{"has_more":false,"limit":50,"result":[{"community":"gamma","created":"2026-08-29T00:11:40.978433+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-08-29T00:11:30.179957+00:00","id":"56607280072443305","instance_id":"3936300515643678","livescan_id":"6053794961344849","malware_family":"EICAR","matched_strings":null,"matched_strings_dropped":null,"md5":"564296ca17d5e063856e0b92c8ad6ca6","polyscore":null,"rule_name":"sdk_test_live","sha1":"adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a","sha256":"14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190","tags":"{}","yara":null}],"status":"OK"} ' headers: @@ -602,11 +674,11 @@ interactions: Connection: - keep-alive Content-Length: - - '617' + - '646' Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:52:42 GMT + - Sat, 29 Aug 2026 00:11:41 GMT Server: - gunicorn X-Billing-ID: @@ -630,10 +702,10 @@ interactions: user-agent: - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/live?id=47972203596447840 + uri: http://ai:9696/v3/hunt/live?id=56607280072443305 response: body: - string: '{"result":{"community":"gamma","created":"2026-08-25T22:52:42.306041+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":"http://minio:9000/public-cache/14/e3/a4/14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a564296ca17d5e063856e0b92c8ad6ca6?response-content-disposition=attachment%3Bfilename%3Dinfected&response-content-type=application%2Foctet-stream&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260825%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260825T225242Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=e3eada0af5bbebaaa07e7b5a7044915a572d960368a840f725c980dff06dc1f2","first_seen":"2026-08-25T22:52:33.280967+00:00","id":"47972203596447840","instance_id":"83487019275094114","livescan_id":"35147411870661732","malware_family":"EICAR","matched_strings":[{"data":"test_live","identifier":"$u","length":9,"offset":69,"truncated":false}],"md5":"564296ca17d5e063856e0b92c8ad6ca6","polyscore":null,"rule_name":"sdk_test_live","sha1":"adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a","sha256":"14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190","tags":"{}","yara":"rule + string: '{"result":{"community":"gamma","created":"2026-08-29T00:11:40.978433+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":"http://minio:9000/public-cache/14/e3/a4/14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a564296ca17d5e063856e0b92c8ad6ca6?response-content-disposition=attachment%3Bfilename%3Dinfected&response-content-type=application%2Foctet-stream&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20260829%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260829T001141Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=f38991abb3474ac5c5d7630b2b2eac0e361bd140cdbdede2d30d52da32efccdc","first_seen":"2026-08-29T00:11:30.179957+00:00","id":"56607280072443305","instance_id":"3936300515643678","livescan_id":"6053794961344849","malware_family":"EICAR","matched_strings":[{"data":"test_live","identifier":"$u","length":9,"offset":69,"truncated":false}],"matched_strings_dropped":null,"md5":"564296ca17d5e063856e0b92c8ad6ca6","polyscore":null,"rule_name":"sdk_test_live","sha1":"adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a","sha256":"14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190","tags":"{}","yara":"rule sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} ' @@ -645,11 +717,11 @@ interactions: Connection: - keep-alive Content-Length: - - '1278' + - '1307' Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:52:42 GMT + - Sat, 29 Aug 2026 00:11:41 GMT Server: - gunicorn X-Billing-ID: @@ -658,7 +730,7 @@ interactions: code: 200 message: OK - request: - body: '{"result_ids":["47972203596447840"]}' + body: '{"result_ids":["56607280072443305"]}' headers: accept: - '*/*' @@ -680,7 +752,7 @@ interactions: uri: http://ai:9696/v3/hunt/live/list response: body: - string: '{"has_more":true,"limit":50,"result":[{"community":"gamma","created":"2026-08-25T22:52:42.306041+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-08-25T22:52:33.280967+00:00","id":"47972203596447840","instance_id":"83487019275094114","livescan_id":"35147411870661732","malware_family":"EICAR","matched_strings":null,"md5":"564296ca17d5e063856e0b92c8ad6ca6","polyscore":null,"rule_name":"sdk_test_live","sha1":"adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a","sha256":"14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190","tags":"{}","yara":null}],"status":"OK"} + string: '{"has_more":true,"limit":50,"result":[{"community":"gamma","created":"2026-08-29T00:11:40.978433+00:00","detections":{"benign":0,"malicious":1,"total":1},"download_url":null,"first_seen":"2026-08-29T00:11:30.179957+00:00","id":"56607280072443305","instance_id":"3936300515643678","livescan_id":"6053794961344849","malware_family":"EICAR","matched_strings":null,"matched_strings_dropped":null,"md5":"564296ca17d5e063856e0b92c8ad6ca6","polyscore":null,"rule_name":"sdk_test_live","sha1":"adec7bf2f04d1b710cbc3304f8dccd0f8d09d34a","sha256":"14e3a40dc4da0e6d9b331976e9d5611d51b25e64f5010bbdfc6c8a32b23eb190","tags":"{}","yara":null}],"status":"OK"} ' headers: @@ -691,11 +763,11 @@ interactions: Connection: - keep-alive Content-Length: - - '616' + - '645' Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:52:42 GMT + - Sat, 29 Aug 2026 00:11:41 GMT Server: - gunicorn X-Billing-ID: @@ -719,11 +791,11 @@ interactions: user-agent: - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/live?id=47972203596447840 + uri: http://ai:9696/v3/hunt/live?id=56607280072443305 response: body: string: '{"errors":null,"result":"Could not find requested live hunt result: - 47972203596447840.","status":"error"} + 56607280072443305.","status":"error"} ' headers: @@ -738,14 +810,14 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:52:42 GMT + - Sat, 29 Aug 2026 00:11:41 GMT Server: - gunicorn status: code: 404 message: NOT FOUND - request: - body: '{"rule_id":"14328545122436991"}' + body: '{"rule_id":"79693411044316426"}' headers: accept: - '*/*' @@ -767,7 +839,7 @@ interactions: uri: http://ai:9696/v3/hunt/rule/live response: body: - string: '{"result":{"created":"2026-08-25T22:52:32.002247+00:00","deleted":false,"description":null,"id":"14328545122436991","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:52:42.860277+00:00","name":"sdk-test_live","yara":"rule + string: '{"result":{"created":"2026-08-29T00:11:28.650982+00:00","deleted":false,"description":null,"id":"79693411044316426","livescan_created":null,"livescan_id":null,"modified":"2026-08-29T00:11:41.856223+00:00","name":"sdk-test_live","yara":"rule sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} ' @@ -783,7 +855,7 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:52:42 GMT + - Sat, 29 Aug 2026 00:11:41 GMT Server: - gunicorn X-Billing-ID: @@ -807,10 +879,10 @@ interactions: user-agent: - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: GET - uri: http://ai:9696/v3/hunt/rule?id=14328545122436991&community=gamma + uri: http://ai:9696/v3/hunt/rule?id=79693411044316426&community=gamma response: body: - string: '{"result":{"created":"2026-08-25T22:52:32.002247+00:00","deleted":false,"description":null,"id":"14328545122436991","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:52:42.860277+00:00","name":"sdk-test_live","yara":"rule + string: '{"result":{"created":"2026-08-29T00:11:28.650982+00:00","deleted":false,"description":null,"id":"79693411044316426","livescan_created":null,"livescan_id":null,"modified":"2026-08-29T00:11:41.856223+00:00","name":"sdk-test_live","yara":"rule sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} ' @@ -826,7 +898,7 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:52:43 GMT + - Sat, 29 Aug 2026 00:11:42 GMT Server: - gunicorn X-Billing-ID: @@ -835,7 +907,7 @@ interactions: code: 200 message: OK - request: - body: '{"rule_id":"14328545122436991"}' + body: '{"rule_id":"79693411044316426"}' headers: accept: - '*/*' @@ -872,7 +944,7 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:52:43 GMT + - Sat, 29 Aug 2026 00:11:42 GMT Server: - gunicorn status: @@ -898,10 +970,10 @@ interactions: user-agent: - polyswarm_api/4.3.0 (x86_64-Linux-CPython-3.14.4) method: DELETE - uri: http://ai:9696/v3/hunt/rule?id=14328545122436991 + uri: http://ai:9696/v3/hunt/rule?id=79693411044316426 response: body: - string: '{"result":{"created":"2026-08-25T22:52:32.002247+00:00","deleted":true,"description":null,"id":"14328545122436991","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T22:52:43.118593+00:00","name":"sdk-test_live","yara":"rule + string: '{"result":{"created":"2026-08-29T00:11:28.650982+00:00","deleted":true,"description":null,"id":"79693411044316426","livescan_created":null,"livescan_id":null,"modified":"2026-08-29T00:11:42.102078+00:00","name":"sdk-test_live","yara":"rule sdk_test_live { strings: $u = \"test_live\" condition: $u }"},"status":"OK"} ' @@ -917,7 +989,7 @@ interactions: Content-Type: - application/json Date: - - Tue, 25 Aug 2026 22:52:43 GMT + - Sat, 29 Aug 2026 00:11:42 GMT Server: - gunicorn X-Billing-ID: From 8299433e07b615429b7a13946ab34a91f5fac87a Mon Sep 17 00:00:00 2001 From: Kyle Buchmiller Date: Sat, 29 Aug 2026 15:31:53 -0700 Subject: [PATCH 09/24] docs: finish the None-ambiguity correction, and make two tests assert their claims The previous commit said the withheld-count conflation now "reads the same way in both places". It did not: the flat claim -- None means nothing was withheld -- was corrected in one spec and left standing in specs/02-resources.md and in both copies of the resources.py comment. specs/02 contradicted itself four lines apart, telling a reader None means nothing was withheld and then that the list endpoints always send None by design, so for a list row it asserted both "nothing withheld" and "we did not look". All three now carry the same reading. Two tests did not test what they were named for: - test_dropped_is_independent_of_the_strings_list passed a count and never read it -- removing the kwarg left it passing identically, duplicating the test above it. Renamed for the property it actually pins and now asserts the count, so the populated-list-plus-count pairing is covered. - test_raw_json_still_carries_the_key compared a dict to itself: __init__ does self.json = content, so the assertion could not fail. Replaced with the property that could -- parsing must not drop keys from .json, and the parsed attributes must agree with the raw payload. Also records what is and is not pinned against a real server. The live pair is verified end to end; the historical pair follows by SYMMETRY, because the e2e stack does not reliably populate historical results inside a test window. Both specs stated the contract for "all four classes" as established fact, which is the same "asserts what we think the server returns" problem the e2e-first invariant exists to prevent. Softened, with the gap recorded in 99-open-questions.md alongside what would close it. specs/04-testing.md's module inventory gains this module, and known_good_test.py which was already missing. 195 tests pass. --- specs/02-resources.md | 13 +++++++++---- specs/04-testing.md | 2 ++ specs/05-downstream-contract.md | 9 +++++++++ specs/99-open-questions.md | 19 +++++++++++++++++++ src/polyswarm_api/resources.py | 16 ++++++++++------ test/hunt_matched_strings_test.py | 27 +++++++++++++++++++++------ 6 files changed, 70 insertions(+), 16 deletions(-) diff --git a/specs/02-resources.md b/specs/02-resources.md index f7c20d19..1a41f8ae 100644 --- a/specs/02-resources.md +++ b/specs/02-resources.md @@ -342,10 +342,15 @@ server, removed evidence, or a **list** endpoint, which sends an explicit `null` than fetch a blob per row), `[]` means *matched with no byte evidence*, and a populated list is evidence. **`matched_strings_dropped`.** A sibling `int`/`None`, parsed the same additive way: -how many matched strings the server's byte budget withheld from this result. `None` means -none were, which is also what a server predating the budget reports. It never accompanies -an empty `matched_strings` — a match's first string is never withheld — so a non-null -count always means "the list you have is short by this much". +how many matched strings the server's byte budget withheld from this result. `None` is +**ambiguous in the same way as `matched_strings`** and must be read the same way: on a +**detail** route it means nothing was withheld, on a **list** route that the route did not +look, and on an older server that the field did not exist. It is never a claim that the +evidence is complete — which matters because the list endpoints always send `None` (below), +so on a list row the two readings are not interchangeable. + +A non-null count always means "the list you have is short by this much". It never +accompanies an empty `matched_strings` — a match's first string is never withheld. Both `…List` subclasses inherit these from their parent's `__init__`, so all four hunt-result classes carry them — but on the list endpoints the values are always `None` diff --git a/specs/04-testing.md b/specs/04-testing.md index b81e5855..69dab4b6 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -24,6 +24,8 @@ How the test suite is organised. Three layers: pure unit tests (no HTTP at all - `test/metadata_field_properties_test.py` — the canonical example of the parametrised `ClientTestCase` harness with `respx`-backed mocking. - `test/client_scan_test.py` — sync, VCR-backed integration tests (not yet on the parametrised harness — follow-up work). - `test/async_client_test.py` — async, VCR-backed integration tests (not yet on the parametrised harness — follow-up work). +- `test/hunt_matched_strings_test.py` — pure-unit tests for the three-state `matched_strings` contract and its `matched_strings_dropped` sibling, across all four hunt-result classes. The endpoint behaviour they cannot see (that the detail route actually emits the keys and the list route sends `null`) is pinned live in `client_scan_test.py::test_live` / `async_client_test.py::test_async_live` — the e2e-first + pure-unit pairing invariant 1 asks for. +- `test/known_good_test.py` — pure-unit tests for the known-good resource fields. - `test/jmespath_test.py` — unit tests for `BaseJsonResource.jmespath`. - `test/vcr/*.vcr` — recorded cassettes. - `test/eicar.yara`, `test/malicious` — fixture files for upload tests. diff --git a/specs/05-downstream-contract.md b/specs/05-downstream-contract.md index 3f34200f..46243918 100644 --- a/specs/05-downstream-contract.md +++ b/specs/05-downstream-contract.md @@ -312,6 +312,15 @@ A populated `matched_strings` with a non-null count is the normal shape for a ve ruleset. The first string of a match is never withheld, so this can never accompany an empty list. +**How much of this is pinned against a real server.** The live pair +(`LiveHuntResult` / `LiveHuntResultList`) is verified end to end — `test_live` / +`test_async_live` assert the detail route carries evidence, that list rows do not, and +that the server serves `matched_strings_dropped`. The **historical** pair follows by +symmetry, not by measurement: the e2e stack does not reliably populate historical results +inside a test window, so nothing pins that those routes emit either key. The server +renders both pairs through the same helpers, which is why symmetry is a reasonable +assumption — but it is an assumption. See `specs/99-open-questions.md`. + **Evidence lives on the detail routes only.** `live_feed()` and `historical_results()` page over list endpoints and will always yield `None` here; fetch a single result (`live_result(id)` / `historical_result(id)`) to get the strings. diff --git a/specs/99-open-questions.md b/specs/99-open-questions.md index 5e1fb91c..25a9b803 100644 --- a/specs/99-open-questions.md +++ b/specs/99-open-questions.md @@ -168,3 +168,22 @@ def test_rescans(self): ``` Cleanup with `try/finally` + `except NotFoundException: pass` tolerates the ioc-cache divergence (GET-by-host can serve a cached id that DELETE-by-id no longer finds). When that artifact-index bug is fixed the `except` becomes redundant. + +## Historical hunt-result fields are not pinned against a live server + +**Status:** gap, blocked on the e2e stack. + +`matched_strings` / `matched_strings_dropped` are asserted end to end for the **live** +hunt pair only. `test_historical_results` tolerates an empty result set by design — the +stack does not reliably populate historical results inside a test window — so nothing +verifies that `/hunt/historical/results` emits either key, or that +`/hunt/historical/results/list` sends the explicit `null`. + +Both specs previously stated the contract for "all four classes" as established fact; +they now say the historical half follows by symmetry. The server renders both pairs +through the same serializer helpers, so the assumption is reasonable — but a fabricated +response asserts what we *think* the server returns (invariant 1), and that is the state +the historical half is in. + +**Action:** if the stack gains a way to produce a historical result deterministically, +add the same assertions to a historical live test and delete this entry. diff --git a/src/polyswarm_api/resources.py b/src/polyswarm_api/resources.py index b3cca3c8..4fd655ab 100644 --- a/src/polyswarm_api/resources.py +++ b/src/polyswarm_api/resources.py @@ -772,9 +772,11 @@ def __init__(self, content, api=None): # evidence), [...] (the evidence, a lower bound). See # specs/05-downstream-contract.md. self.matched_strings = content.get('matched_strings') - # How many matched strings the server's byte budget cost this result, or None - # when nothing was dropped (also what every result predating the budget reports). - # A sibling field, so `matched_strings` stays a plain list. + # How many matched strings the server's byte budget withheld from this result. + # A sibling field, so `matched_strings` stays a plain list. None is AMBIGUOUS in + # the same way as matched_strings: on a detail route it means nothing was withheld, + # on a list route that the route did not look, and on an older server that the + # field did not exist. It is not a claim that the evidence is complete. self.matched_strings_dropped = content.get('matched_strings_dropped') self.polyscore = content['polyscore'] self.malware_family = content['malware_family'] @@ -830,9 +832,11 @@ def __init__(self, content, api=None): # evidence), [...] (the evidence, a lower bound). See # specs/05-downstream-contract.md. self.matched_strings = content.get('matched_strings') - # How many matched strings the server's byte budget cost this result, or None - # when nothing was dropped (also what every result predating the budget reports). - # A sibling field, so `matched_strings` stays a plain list. + # How many matched strings the server's byte budget withheld from this result. + # A sibling field, so `matched_strings` stays a plain list. None is AMBIGUOUS in + # the same way as matched_strings: on a detail route it means nothing was withheld, + # on a list route that the route did not look, and on an older server that the + # field did not exist. It is not a claim that the evidence is complete. self.matched_strings_dropped = content.get('matched_strings_dropped') self.polyscore = content['polyscore'] self.malware_family = content['malware_family'] diff --git a/test/hunt_matched_strings_test.py b/test/hunt_matched_strings_test.py index c05bb822..d4e204c5 100644 --- a/test/hunt_matched_strings_test.py +++ b/test/hunt_matched_strings_test.py @@ -82,10 +82,20 @@ def test_populated_list_is_passed_through_verbatim(cls): @pytest.mark.parametrize("cls", ALL_CLASSES) -def test_raw_json_still_carries_the_key(cls): - """`.json` is part of the contract, so JSON-mode consumers see it without SDK work.""" - result = cls(_content(cls, matched_strings=_STRINGS)) - assert result.json["matched_strings"] == _STRINGS +def test_parsing_does_not_mutate_the_raw_json(cls): + """`.json` is part of the contract: JSON-mode consumers read the server's payload + unchanged, so parsing must not reshape or strip what it read. + + (Asserting `result.json[k] == content[k]` alone would be tautological -- __init__ + assigns `self.json = content` -- so this compares the parsed attributes against the + raw dict instead, which is the property that would actually break.) + """ + content = _content(cls, matched_strings=_STRINGS, matched_strings_dropped=19) + result = cls(content) + assert result.json is content + assert result.matched_strings == content["matched_strings"] + assert result.matched_strings_dropped == content["matched_strings_dropped"] + assert set(content) <= set(result.json), "parsing must not drop keys from .json" @pytest.mark.parametrize("cls", ALL_CLASSES) @@ -102,8 +112,13 @@ def test_dropped_is_none_when_absent(cls): @pytest.mark.parametrize("cls", ALL_CLASSES) -def test_dropped_is_independent_of_the_strings_list(cls): - """A truncated list is still a list; the count is what says it is short.""" +def test_a_populated_list_and_a_count_coexist(cls): + """The normal shape for a verbose ruleset, and the pairing the contract turns on. + + A truncated list is still a list -- nothing marks it short from the inside -- so the + count is the only thing that says so. Both must survive parsing together. + """ result = cls(_content(cls, matched_strings=_STRINGS, matched_strings_dropped=19)) assert isinstance(result.matched_strings, list) assert len(result.matched_strings) == 2 + assert result.matched_strings_dropped == 19 From b482498d5cb428a3f43461af34c12004e9581303 Mon Sep 17 00:00:00 2001 From: Kyle Buchmiller Date: Sat, 29 Aug 2026 23:22:15 -0700 Subject: [PATCH 10/24] test: pin the per-entry shape against the server, and fix the class-route framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-entry dict shape is contract -- specs/05 documents offset, identifier, length, data and truncated -- but the only assertion on it compared against a hand-written fixture, which is what we THINK the server sends rather than what it does. A server-side key rename would have passed the whole suite VCR-off. Both cassettes already carry a real entry, so the live tests now assert the key set against the recorded response, which costs nothing and closes the gap. Separately, the spec framed the `…List` subclasses as the list-route parsers. That is not true of the live pair: live_feed builds its request with LiveHuntResult.list(...), which hits /hunt/live/list but parses rows as LiveHuntResult. LiveHuntResultList is only ever a delete builder and is never instantiated from a response; only historical_results yields …List instances. The framing matters rather than being pedantry: the section exists to say that None is ambiguous, and a reader who takes the class as the route concludes a LiveHuntResult must have come from the detail route and reads its None as "nothing to show". Corrected in the spec and in the test module's comment. 195 tests pass. --- specs/02-resources.md | 9 +++++++++ test/async_client_test.py | 5 +++++ test/client_scan_test.py | 5 +++++ test/hunt_matched_strings_test.py | 5 +++-- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/specs/02-resources.md b/specs/02-resources.md index 1a41f8ae..7f365d22 100644 --- a/specs/02-resources.md +++ b/specs/02-resources.md @@ -356,6 +356,15 @@ Both `…List` subclasses inherit these from their parent's `__init__`, so all f hunt-result classes carry them — but on the list endpoints the values are always `None` by design. +**Do not read the class as telling you the route.** For the live pair it does not: +`live_feed()` builds its request with `LiveHuntResult.list(...)`, which hits +`/hunt/live/list` but parses rows as **`LiveHuntResult`** (see +[`03-endpoints.md`](./03-endpoints.md)). `LiveHuntResultList` is only ever a *delete* +builder and is never instantiated from a response; only `historical_results()` yields +`…List` instances. So a `LiveHuntResult` carrying `matched_strings is None` may well have +come from the list route — which is exactly why that `None` is ambiguous and must not be +read as "nothing to show". + ### `LocalArtifact` A file-system or in-memory artifact prepared for upload. Constructed via: diff --git a/test/async_client_test.py b/test/async_client_test.py index e780332e..1b99a4e3 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -740,6 +740,11 @@ async def test_async_live(self, uid): 'server must serve the withheld-count field' assert result.matched_strings_dropped is None, \ 'nothing withheld for a match this small' + # The per-entry shape is contract (specs/05) but was pinned only by a hand-written + # fixture -- i.e. what we THINK the server sends. This asserts it against what the + # server actually sent, so a key rename cannot pass the suite VCR-off. + assert set(result.matched_strings[0]) == { + 'offset', 'identifier', 'length', 'data', 'truncated'}, result.matched_strings[0] await api.live_feed_delete([result_id]) with pytest.raises(exceptions.NotFoundException): diff --git a/test/client_scan_test.py b/test/client_scan_test.py index 4b9db8dc..412aaeb3 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -513,6 +513,11 @@ def test_live(self): 'server must serve the withheld-count field' assert result.matched_strings_dropped is None, \ 'nothing withheld for a match this small' + # The per-entry shape is contract (specs/05) but was pinned only by a hand-written + # fixture -- i.e. what we THINK the server sends. This asserts it against what the + # server actually sent, so a key rename cannot pass the suite VCR-off. + assert set(result.matched_strings[0]) == { + 'offset', 'identifier', 'length', 'data', 'truncated'}, result.matched_strings[0] api.live_feed_delete([result_id]) with pytest.raises(exceptions.NotFoundException): diff --git a/test/hunt_matched_strings_test.py b/test/hunt_matched_strings_test.py index d4e204c5..798623ab 100644 --- a/test/hunt_matched_strings_test.py +++ b/test/hunt_matched_strings_test.py @@ -35,8 +35,9 @@ "data": "4D 5A 90 00 ...", "truncated": True}, ] -# Both concrete classes plus their list-endpoint subclasses, which inherit __init__ -# and must therefore behave identically. +# Both concrete classes plus their `…List` subclasses, which inherit __init__ and must +# behave identically. NB the subclass does not imply the route: live_feed parses list +# rows as LiveHuntResult, and LiveHuntResultList is only ever a delete builder. ALL_CLASSES = [ LiveHuntResult, LiveHuntResultList, HistoricalHuntResult, HistoricalHuntResultList, From 024449d83f02afc5281431280b027ffc8a2ae8b0 Mon Sep 17 00:00:00 2001 From: Kyle Buchmiller Date: Sun, 30 Aug 2026 12:51:53 -0700 Subject: [PATCH 11/24] docs: the ...List classes DO parse responses -- delete responses The previous commit corrected the class-to-route framing and got the replacement wrong. It said LiveHuntResultList "is never instantiated from a response; only historical_results() yields ...List instances". Both halves are false: _build_request sets result_parser=cls, so the paginated body returned by DELETE /hunt/live/list is parsed through LiveHuntResultList. The cassette re-recorded in this PR contains exactly that exchange. That makes delete responses a FOURTH source of None -- alongside an older server, a list route and removed evidence -- and none of the three places that enumerate the causes listed it. A caller iterating live_feed_delete() holds ...List objects with both fields None while the spec told them that class only comes from historical_results(), which is the worst combination: an ambiguous value plus a mental model that resolves it wrongly. Corrected in specs/02, the three-state table in specs/05, and both copies of the resources.py comment. 195 tests pass. --- specs/02-resources.md | 13 +++++++++---- specs/05-downstream-contract.md | 2 +- src/polyswarm_api/resources.py | 20 ++++++++++---------- test/hunt_matched_strings_test.py | 2 +- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/specs/02-resources.md b/specs/02-resources.md index 7f365d22..8ad3e547 100644 --- a/specs/02-resources.md +++ b/specs/02-resources.md @@ -360,10 +360,15 @@ by design. `live_feed()` builds its request with `LiveHuntResult.list(...)`, which hits `/hunt/live/list` but parses rows as **`LiveHuntResult`** (see [`03-endpoints.md`](./03-endpoints.md)). `LiveHuntResultList` is only ever a *delete* -builder and is never instantiated from a response; only `historical_results()` yields -`…List` instances. So a `LiveHuntResult` carrying `matched_strings is None` may well have -come from the list route — which is exactly why that `None` is ambiguous and must not be -read as "nothing to show". +**builder** — but the delete response is parsed **through** it (`_build_request` sets +`result_parser=cls`), so `live_feed_delete()` and `historical_results_delete()` both yield +`…List` instances, with both fields `None`. From a *read*, only `historical_results()` +yields them. + +So `None` reaches a caller from four places, not three: an older server, deleted evidence, +a list route, and a **delete response**. A `LiveHuntResult` carrying +`matched_strings is None` may well have come from the list route — which is exactly why +that `None` is ambiguous and must not be read as "nothing to show". ### `LocalArtifact` diff --git a/specs/05-downstream-contract.md b/specs/05-downstream-contract.md index 46243918..745a52cc 100644 --- a/specs/05-downstream-contract.md +++ b/specs/05-downstream-contract.md @@ -281,7 +281,7 @@ Three values are possible and consumers **must not** collapse them: | Value | Meaning | |---|---| -| `None` | Not reported. The **list** endpoints send the key as an explicit `null` rather than fetch a blob per row; a server predating the field omits it entirely; and stored evidence may have been deleted. `.get()` collapses all three. "We don't know", *not* "there was nothing". | +| `None` | Not reported. **Four** causes, which `.get()` collapses: the **list** endpoints send an explicit `null` rather than fetch a blob per row; **delete** responses (`live_feed_delete` / `historical_results_delete`) are parsed through the `…List` classes and carry `null` the same way; a server predating the field omits it entirely; and stored evidence may have been deleted. "We don't know", *not* "there was nothing". | | `[]` | The rule matched and there is no byte evidence to show — a rule with no strings section, one whose matching strings are all `private`, or one that matched on absence (`not $a`, `none of them`). | | `[…]` | The evidence. A **lower bound**, not a match count: `any of them` prints only the strings that hit, `private` strings never appear, and the server may withhold some past a size limit (see `matched_strings_dropped`). | diff --git a/src/polyswarm_api/resources.py b/src/polyswarm_api/resources.py index 4fd655ab..73ea19ba 100644 --- a/src/polyswarm_api/resources.py +++ b/src/polyswarm_api/resources.py @@ -768,15 +768,15 @@ def __init__(self, content, api=None): self.tags = content['tags'] # `.get()`, not a subscript: the key is additive, so a server predating it omits # it and a subscript would raise on every result. Three distinct states -- None - # (not reported: an older server, or a list endpoint), [] (matched, no byte - # evidence), [...] (the evidence, a lower bound). See - # specs/05-downstream-contract.md. + # (not reported: an older server, a list route, a DELETE response, or removed + # evidence), [] (matched, no byte evidence), [...] (the evidence, a lower bound). + # See specs/05-downstream-contract.md. self.matched_strings = content.get('matched_strings') # How many matched strings the server's byte budget withheld from this result. # A sibling field, so `matched_strings` stays a plain list. None is AMBIGUOUS in # the same way as matched_strings: on a detail route it means nothing was withheld, - # on a list route that the route did not look, and on an older server that the - # field did not exist. It is not a claim that the evidence is complete. + # on a list route or a DELETE response that the route did not look, and on an older + # server that the field did not exist. Not a claim the evidence is complete. self.matched_strings_dropped = content.get('matched_strings_dropped') self.polyscore = content['polyscore'] self.malware_family = content['malware_family'] @@ -828,15 +828,15 @@ def __init__(self, content, api=None): self.tags = content['tags'] # `.get()`, not a subscript: the key is additive, so a server predating it omits # it and a subscript would raise on every result. Three distinct states -- None - # (not reported: an older server, or a list endpoint), [] (matched, no byte - # evidence), [...] (the evidence, a lower bound). See - # specs/05-downstream-contract.md. + # (not reported: an older server, a list route, a DELETE response, or removed + # evidence), [] (matched, no byte evidence), [...] (the evidence, a lower bound). + # See specs/05-downstream-contract.md. self.matched_strings = content.get('matched_strings') # How many matched strings the server's byte budget withheld from this result. # A sibling field, so `matched_strings` stays a plain list. None is AMBIGUOUS in # the same way as matched_strings: on a detail route it means nothing was withheld, - # on a list route that the route did not look, and on an older server that the - # field did not exist. It is not a claim that the evidence is complete. + # on a list route or a DELETE response that the route did not look, and on an older + # server that the field did not exist. Not a claim the evidence is complete. self.matched_strings_dropped = content.get('matched_strings_dropped') self.polyscore = content['polyscore'] self.malware_family = content['malware_family'] diff --git a/test/hunt_matched_strings_test.py b/test/hunt_matched_strings_test.py index 798623ab..a54bdd6c 100644 --- a/test/hunt_matched_strings_test.py +++ b/test/hunt_matched_strings_test.py @@ -37,7 +37,7 @@ # Both concrete classes plus their `…List` subclasses, which inherit __init__ and must # behave identically. NB the subclass does not imply the route: live_feed parses list -# rows as LiveHuntResult, and LiveHuntResultList is only ever a delete builder. +# rows as LiveHuntResult, while the ...List classes parse DELETE responses. ALL_CLASSES = [ LiveHuntResult, LiveHuntResultList, HistoricalHuntResult, HistoricalHuntResultList, From 28cabee7825b94af94060770491743e1a52b3cdd Mon Sep 17 00:00:00 2001 From: Kyle Buchmiller Date: Sun, 30 Aug 2026 14:09:13 -0700 Subject: [PATCH 12/24] test: make both raw-json assertions falsifiable The previous commit replaced a tautological assertion and moved the tautology rather than removing it: `assert result.json is content` followed by `set(content) <= set(result.json)` reduces to `set(content) <= set(content)`. Nothing checked the property the test is named for either, since self.json keeps a live reference to the dict that was passed in. Now compared against an independent deepcopy, so "parsing did not mutate the payload" and "parsing did not drop keys" can both actually fail. It also survives a future change that defensively copies content, which the identity assertion would have failed for the wrong reason. Also records that the non-null matched_strings_dropped path is not pinned against a live server. The live tests assert only the None arm -- correctly, since the per-test rule withholds nothing -- so the two strongest claims the contract makes about the field rest on hand-written dicts. Producing an over-budget match on the e2e stack is disproportionate to what it would pin, so this is recorded in 99-open-questions.md rather than tested, alongside the historical-pair gap. 195 tests pass. --- specs/99-open-questions.md | 21 +++++++++++++++++++++ test/hunt_matched_strings_test.py | 16 +++++++++++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/specs/99-open-questions.md b/specs/99-open-questions.md index 25a9b803..5e5e2515 100644 --- a/specs/99-open-questions.md +++ b/specs/99-open-questions.md @@ -187,3 +187,24 @@ the historical half is in. **Action:** if the stack gains a way to produce a historical result deterministically, add the same assertions to a historical live test and delete this entry. + +## The non-null `matched_strings_dropped` path is not pinned against a live server + +**Status:** gap, probably not worth closing with a test. + +`test_live` / `test_async_live` assert only the `is None` arm — correctly, since the +per-test rule is small and the server withholds nothing from it. So the two strongest +claims `05-downstream-contract.md` makes about this field rest entirely on hand-written +pure-unit dicts: + +- a non-null count means "the list you have is short by this much", and +- it can never accompany an empty `matched_strings`, because a match's first string is + never withheld. + +That is the same "asserts what we *think* the server returns" gap invariant 1 exists to +close, and it sits alongside the historical-pair entry above. + +Producing an over-budget match on the e2e stack means a rule whose matches exceed the +server's per-hunt byte budget across a single artifact — engineering a fixture for that is +disproportionate to what it would pin. **Recorded rather than tested, deliberately.** If a +stack fixture ever produces one cheaply, assert both claims there and delete this entry. diff --git a/test/hunt_matched_strings_test.py b/test/hunt_matched_strings_test.py index a54bdd6c..0c6f0127 100644 --- a/test/hunt_matched_strings_test.py +++ b/test/hunt_matched_strings_test.py @@ -7,6 +7,8 @@ "the rule matched with no byte evidence". """ +import copy + import pytest from polyswarm_api.resources import ( @@ -91,12 +93,16 @@ def test_parsing_does_not_mutate_the_raw_json(cls): assigns `self.json = content` -- so this compares the parsed attributes against the raw dict instead, which is the property that would actually break.) """ - content = _content(cls, matched_strings=_STRINGS, matched_strings_dropped=19) + raw = _content(cls, matched_strings=_STRINGS, matched_strings_dropped=19) + content = copy.deepcopy(raw) result = cls(content) - assert result.json is content - assert result.matched_strings == content["matched_strings"] - assert result.matched_strings_dropped == content["matched_strings_dropped"] - assert set(content) <= set(result.json), "parsing must not drop keys from .json" + # Compared against an independent copy, so both of these can actually fail. Asserting + # `result.json is content` and then `set(content) <= set(result.json)` only restated + # the identity -- the tautology moved rather than went away. + assert content == raw, "parsing must not mutate the payload it was handed" + assert set(raw) <= set(result.json), "parsing must not drop keys from .json" + assert result.matched_strings == raw["matched_strings"] + assert result.matched_strings_dropped == raw["matched_strings_dropped"] @pytest.mark.parametrize("cls", ALL_CLASSES) From a4934b2314c40cb4e3ff1d7de6528cdbf699a74d Mon Sep 17 00:00:00 2001 From: Kyle Buchmiller Date: Mon, 31 Aug 2026 09:25:01 -0700 Subject: [PATCH 13/24] test: assert the list null on .json, deep-copy the fixture, trim duplicated comments Two assertions could not fail: - The list-row check read `my_results[0].matched_strings is None`, two lines above a comment arguing that `is None` cannot distinguish a served null from an absent key -- which is why the count below it reads .json. The same argument applies here and specs/05 makes the stronger claim (an explicit null), so it now reads .json too. - test_populated_list_is_passed_through_verbatim passed the module-level _STRINGS into the content dict, which stores it BY REFERENCE, so the assertions compared the object with itself and no in-place reshape could fail them. Deep-copied, the same fix test_parsing_does_not_mutate_the_raw_json already needed. Also trims the two resources.py comment blocks, which restated the contract spec nearly verbatim at both call sites. Three copies of one argument is three places to drift, and several commits in this PR were spent reconciling exactly that. The decision and a pointer are enough; specs/05 owns the reasoning. 195 tests pass. --- src/polyswarm_api/resources.py | 26 ++++++-------------------- test/async_client_test.py | 7 +++++-- test/client_scan_test.py | 7 +++++-- test/hunt_matched_strings_test.py | 9 +++++++-- 4 files changed, 23 insertions(+), 26 deletions(-) diff --git a/src/polyswarm_api/resources.py b/src/polyswarm_api/resources.py index 73ea19ba..b736c24c 100644 --- a/src/polyswarm_api/resources.py +++ b/src/polyswarm_api/resources.py @@ -766,17 +766,10 @@ def __init__(self, content, api=None): self.sha1 = content.get('sha1') self.rule_name = content['rule_name'] self.tags = content['tags'] - # `.get()`, not a subscript: the key is additive, so a server predating it omits - # it and a subscript would raise on every result. Three distinct states -- None - # (not reported: an older server, a list route, a DELETE response, or removed - # evidence), [] (matched, no byte evidence), [...] (the evidence, a lower bound). - # See specs/05-downstream-contract.md. + # `.get()`, not a subscript -- both keys are additive, so an older server omits + # them. None is AMBIGUOUS on both (four causes) and is never a claim that the + # evidence is complete. Contract: specs/05-downstream-contract.md. self.matched_strings = content.get('matched_strings') - # How many matched strings the server's byte budget withheld from this result. - # A sibling field, so `matched_strings` stays a plain list. None is AMBIGUOUS in - # the same way as matched_strings: on a detail route it means nothing was withheld, - # on a list route or a DELETE response that the route did not look, and on an older - # server that the field did not exist. Not a claim the evidence is complete. self.matched_strings_dropped = content.get('matched_strings_dropped') self.polyscore = content['polyscore'] self.malware_family = content['malware_family'] @@ -826,17 +819,10 @@ def __init__(self, content, api=None): self.created = core.parse_isoformat(content['created']) self.rule_name = content['rule_name'] self.tags = content['tags'] - # `.get()`, not a subscript: the key is additive, so a server predating it omits - # it and a subscript would raise on every result. Three distinct states -- None - # (not reported: an older server, a list route, a DELETE response, or removed - # evidence), [] (matched, no byte evidence), [...] (the evidence, a lower bound). - # See specs/05-downstream-contract.md. + # `.get()`, not a subscript -- both keys are additive, so an older server omits + # them. None is AMBIGUOUS on both (four causes) and is never a claim that the + # evidence is complete. Contract: specs/05-downstream-contract.md. self.matched_strings = content.get('matched_strings') - # How many matched strings the server's byte budget withheld from this result. - # A sibling field, so `matched_strings` stays a plain list. None is AMBIGUOUS in - # the same way as matched_strings: on a detail route it means nothing was withheld, - # on a list route or a DELETE response that the route did not look, and on an older - # server that the field did not exist. Not a claim the evidence is complete. self.matched_strings_dropped = content.get('matched_strings_dropped') self.polyscore = content['polyscore'] self.malware_family = content['malware_family'] diff --git a/test/async_client_test.py b/test/async_client_test.py index 1b99a4e3..157f9fed 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -732,8 +732,11 @@ async def test_async_live(self, uid): # The pure-unit tests exercise dict.get and would pass identically if the # server never grew the field; only a cassette shows what it actually sent. assert result.matched_strings, 'detail route should carry the yara evidence' - assert my_results[0].matched_strings is None, \ - 'list rows do not carry the evidence -- it is a per-row blob fetch' + # On .json for the same reason as the count below: the attribute cannot + # distinguish a served null from an absent key, and specs/05 claims the + # list route sends an explicit null. + assert my_results[0].json['matched_strings'] is None, \ + 'list rows carry the key as null, not the evidence' # On .json, not the attribute: `is None` cannot tell a served null from an # absent key, and what needs pinning is that the server SENDS this field. assert 'matched_strings_dropped' in result.json, \ diff --git a/test/client_scan_test.py b/test/client_scan_test.py index 412aaeb3..0ed7bbab 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -505,8 +505,11 @@ def test_live(self): # The pure-unit tests exercise dict.get and would pass identically if the # server never grew the field; only a cassette shows what it actually sent. assert result.matched_strings, 'detail route should carry the yara evidence' - assert my_results[0].matched_strings is None, \ - 'list rows do not carry the evidence -- it is a per-row blob fetch' + # On .json for the same reason as the count below: the attribute cannot + # distinguish a served null from an absent key, and specs/05 claims the + # list route sends an explicit null. + assert my_results[0].json['matched_strings'] is None, \ + 'list rows carry the key as null, not the evidence' # On .json, not the attribute: `is None` cannot tell a served null from an # absent key, and what needs pinning is that the server SENDS this field. assert 'matched_strings_dropped' in result.json, \ diff --git a/test/hunt_matched_strings_test.py b/test/hunt_matched_strings_test.py index 0c6f0127..d2149486 100644 --- a/test/hunt_matched_strings_test.py +++ b/test/hunt_matched_strings_test.py @@ -77,8 +77,13 @@ def test_empty_list_is_preserved_and_is_not_none(cls): @pytest.mark.parametrize("cls", ALL_CLASSES) def test_populated_list_is_passed_through_verbatim(cls): - """The SDK does not reshape entries — `data` in particular stays as yara rendered it.""" - result = cls(_content(cls, matched_strings=_STRINGS)) + """The SDK does not reshape entries — `data` in particular stays as yara rendered it. + + Deep-copied: passing the module-level _STRINGS stores it BY REFERENCE, so these + assertions compared the object with itself and no in-place reshape could fail them. + Same trap as test_parsing_does_not_mutate_the_raw_json. + """ + result = cls(_content(cls, matched_strings=copy.deepcopy(_STRINGS))) assert result.matched_strings == _STRINGS assert result.matched_strings[0]["identifier"] == "$stub" assert result.matched_strings[1]["truncated"] is True From 13c191cb8817168e485fdd0eaacfa0975dea20d3 Mon Sep 17 00:00:00 2001 From: Kyle Buchmiller Date: Thu, 3 Sep 2026 16:23:01 -0700 Subject: [PATCH 14/24] docs: let the four-cause table own the None enumeration 02-resources.md states that None reaches a caller from four places, not three -- and then two summaries in that same file enumerate three, as does the matched_strings_dropped paragraph in 05. The file contradicted itself within one section, which is the defect an earlier commit was written to fix. Rather than restate the list a fourth time, the summaries now defer to the one table that owns it. A pointer cannot drift out of step with what it points at. --- specs/02-resources.md | 10 +++++----- specs/05-downstream-contract.md | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/specs/02-resources.md b/specs/02-resources.md index 142fb68e..18c28a20 100644 --- a/specs/02-resources.md +++ b/specs/02-resources.md @@ -338,15 +338,15 @@ It is **three-state** and the states are not interchangeable; the table, the per dict shape and the lower-bound caveat live in [`05-downstream-contract.md`](./05-downstream-contract.md) §"`matched_strings` on hunt results" — read it there rather than inferring from the -attribute. The short version a parser needs: `None` means *not reported* (an older -server, removed evidence, or a **list** endpoint, which sends an explicit `null` rather -than fetch a blob per row), `[]` means *matched with no byte evidence*, and a populated list is evidence. +attribute. The short version a parser needs: `None` means *not reported* — **four** +distinct causes, enumerated in that table and revisited under "four places, not three" +below — `[]` means *matched with no byte evidence*, and a populated list is evidence. **`matched_strings_dropped`.** A sibling `int`/`None`, parsed the same additive way: how many matched strings the server's byte budget withheld from this result. `None` is **ambiguous in the same way as `matched_strings`** and must be read the same way: on a -**detail** route it means nothing was withheld, on a **list** route that the route did not -look, and on an older server that the field did not exist. It is never a claim that the +**detail** route it means nothing was withheld; under any of the other three causes it +means nothing looked. It is never a claim that the evidence is complete — which matters because the list endpoints always send `None` (below), so on a list row the two readings are not interchangeable. diff --git a/specs/05-downstream-contract.md b/specs/05-downstream-contract.md index 464686fc..5bbc461a 100644 --- a/specs/05-downstream-contract.md +++ b/specs/05-downstream-contract.md @@ -306,8 +306,8 @@ list is otherwise indistinguishable from a complete one: a consumer reading twel entries would conclude the rule hit twelve times when it hit thirty-one. `None` carries the same ambiguity as `matched_strings` itself and should be read the same -way: on a **detail** route it means nothing was withheld, but on a **list** route it means -the route did not look, and on an older server it means the field did not exist. It is not +way: on a **detail** route it means nothing was withheld; under any of the other three +causes in the table above, it means nothing looked. It is not a claim that the evidence is complete. It is deliberately a **sibling** rather than a key inside `matched_strings`, which stays a plain list. From 1d2dab9bc47fdd747fa1c9e3d86a95c8162607d3 Mon Sep 17 00:00:00 2001 From: Kyle Buchmiller Date: Thu, 3 Sep 2026 16:23:02 -0700 Subject: [PATCH 15/24] test: say what a null matched_strings means on a green stack The live assertions are the only thing distinguishing served evidence from a served null -- the pure-unit tier exercises dict.get and passes identically whether or not the server ever grew the field. So the assertion stays exactly as strict. Only the message widens. The value is null unless the producer that emits the strings is deployed, so the likeliest cause of this failing is an image that predates it -- while everything else about the stack looks healthy, which makes the failure read as a server bug. The message now names the real cause. --- test/async_client_test.py | 5 ++++- test/client_scan_test.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/test/async_client_test.py b/test/async_client_test.py index 024d777c..958cc768 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -868,7 +868,10 @@ async def test_async_live(self, uid): # The list/detail split, pinned against the real server rather than prose. # The pure-unit tests exercise dict.get and would pass identically if the # server never grew the field; only a cassette shows what it actually sent. - assert result.matched_strings, 'detail route should carry the yara evidence' + assert result.matched_strings, ( + 'detail route should carry the yara evidence. A null here against an\n' + 'otherwise-green stack means the analyzer image predates the change\n' + 'that emits `strings` -- check the analyzer, not this repo.') # On .json for the same reason as the count below: the attribute cannot # distinguish a served null from an absent key, and specs/05 claims the # list route sends an explicit null. diff --git a/test/client_scan_test.py b/test/client_scan_test.py index 7e1b7578..36efc1fa 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -511,7 +511,10 @@ def test_live(self): # The list/detail split, pinned against the real server rather than prose. # The pure-unit tests exercise dict.get and would pass identically if the # server never grew the field; only a cassette shows what it actually sent. - assert result.matched_strings, 'detail route should carry the yara evidence' + assert result.matched_strings, ( + 'detail route should carry the yara evidence. A null here against an\n' + 'otherwise-green stack means the analyzer image predates the change\n' + 'that emits `strings` -- check the analyzer, not this repo.') # On .json for the same reason as the count below: the attribute cannot # distinguish a served null from an absent key, and specs/05 claims the # list route sends an explicit null. From 9ab77b3071d7f0cc48398d507467846c6d3579d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Tue, 1 Sep 2026 20:54:28 -0400 Subject: [PATCH 16/24] =?UTF-8?q?feat:=20ruleset=5Flist(sort=3D)=20?= =?UTF-8?q?=E2=80=94=20the=20hunt=20page's=20active-first=20order,=20serve?= =?UTF-8?q?r-side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ruleset_list(sort='active_first')` asks the server for rulesets with a running live hunt first — as recorded by the server's live-hunt link, the same one `livescan_id` renders from — newest first within each block (`GET /v3/hunt/rule/list?sort=active_first`). Unset sends no `sort`, so the request stays byte-compatible with the pre-sort contract and the list keeps its newest-first default. The SDK never re-orders rows: the list is keyset-paginated, so a client-side sort would reorder one page and misrepresent the rest; a page's `offset` is only valid under the same `sort`, and the server refuses a cursor minted under the other order. Canonical change in aio/api.py; api.py is the regenerated unasync mirror (scripts/regenerate_sync.py, ruff on PATH). YaraRuleset.list already forwards arbitrary keywords through core._params, so the resource needs no change. Tests on two tiers. Pure-unit pins the wire shape on both transports, the omitted default, composition with the filters, and that `_next_page` carries `sort` onto page 2. Live-e2e (sync + async, cassettes recorded against a stack running the server branch) pins what no builder test can: the server actually applies the order — this test's running ruleset precedes its newer idle one under `sort='active_first'` and follows it under the default — and refuses an unknown sort rather than ignoring it. --- specs/03-endpoints.md | 2 +- src/polyswarm_api/aio/api.py | 13 +- src/polyswarm_api/api.py | 16 +- test/async_client_test.py | 33 ++ test/client_scan_test.py | 41 ++ test/hunt_tracking_builder_test.py | 94 ++++ .../test_async_rules_sort_active_first.vcr | 460 ++++++++++++++++++ test/vcr/test_rules_sort_active_first.vcr | 460 ++++++++++++++++++ 8 files changed, 1115 insertions(+), 4 deletions(-) create mode 100644 test/vcr/test_async_rules_sort_active_first.vcr create mode 100644 test/vcr/test_rules_sort_active_first.vcr diff --git a/specs/03-endpoints.md b/specs/03-endpoints.md index c7762522..9dca1067 100644 --- a/specs/03-endpoints.md +++ b/specs/03-endpoints.md @@ -190,7 +190,7 @@ refusal. | `live_feed(since=None, …, livescan_id=None, max_results=None)` | `LiveHuntResult.list` — `livescan_id` scopes the feed to one live hunt (the hunt-page per-ruleset feed); `since` is in **SECONDS** (the server converts with `timedelta(seconds=since)`; the 3.x/4.x docstring said minutes and was wrong), and absent-or-`0` means no time filter at all — the server applies it on a truthiness test; `max_results` bounds how many results the generator yields — `None`/`0`/negative means no bound; it does not alter the request | | `historical_list(since=None)` | `HistoricalHunt.list` | | `historical_results(hunt=None, …)` | `HistoricalHuntResultList.get` | -| `ruleset_list(name=None, status=None, favorites_only=None, has_new_results=None)` | `YaraRuleset.list` — the hunt-page filters, conjunctive and optional; unset filters are omitted from the query so the no-filter request is byte-compatible with the old contract. `has_new_results` selects on the server's STORED counter (no window parameter — the window belongs to the server's scheduled refresh; rows carry `new_results_count` + `new_results_counted_at`) | +| `ruleset_list(name=None, status=None, favorites_only=None, has_new_results=None, sort=None)` | `YaraRuleset.list` — the hunt-page filters, conjunctive and optional; unset filters are omitted from the query so the no-filter request is byte-compatible with the old contract. `has_new_results` selects on the server's STORED counter (no window parameter — the window belongs to the server's scheduled refresh; rows carry `new_results_count` + `new_results_counted_at`) `sort='active_first'` (4.5.0) asks the SERVER for the hunt page's order — rulesets with a running live hunt first, newest first within each block — as an opt-in token; unset sends no `sort`, keeping the default newest-first. The SDK never re-orders rows: the list is keyset-paginated, so a client-side sort would reorder one page and lie about the rest. | | `tag_list()` | `Tag.list` | | `family_list()` | `MalwareFamily.list` | | `assertions_list(engine_id)` | `AssertionsJob.list` | diff --git a/src/polyswarm_api/aio/api.py b/src/polyswarm_api/aio/api.py index 22a3ad0a..9f935676 100644 --- a/src/polyswarm_api/aio/api.py +++ b/src/polyswarm_api/aio/api.py @@ -697,7 +697,7 @@ async def ruleset_delete(self, ruleset_id): return await self._single(resources.YaraRuleset.delete(self, id=ruleset_id, community=self.community)) async def ruleset_list(self, name=None, status=None, favorites_only=None, - has_new_results=None): + has_new_results=None, sort=None): """ List all YaraRulesets for the current account. @@ -711,12 +711,21 @@ async def ruleset_list(self, name=None, status=None, favorites_only=None, maintained server-side by a scheduled refresh; rows carry it as ``new_results_count`` with ``new_results_counted_at`` marking when it was last refreshed. There is no per-request window parameter. + :param sort: ``'active_first'`` returns the rulesets with a running + live hunt first — as recorded by the server's live-hunt link, the + same link ``livescan_id`` renders from — newest first within each + block. Default (None) is newest first. Applied SERVER-side, across + pages — the list is keyset-paginated, so a client-side sort would + only ever reorder one page; the SDK never re-orders rows. Reuse a + page's ``offset`` only with the same ``sort``: the server refuses + a cursor minted under the other order. :return: A generator of YaraRuleset resources """ logger.info('List rulesets') async for item in self._paginate(resources.YaraRuleset.list( self, name=name, status=status, favorites_only=favorites_only, - has_new_results=has_new_results, community=self.community)): + has_new_results=has_new_results, sort=sort, + community=self.community)): yield item async def ruleset_favorite(self, ruleset_id, favorite=True): diff --git a/src/polyswarm_api/api.py b/src/polyswarm_api/api.py index 3e67d983..c589b721 100644 --- a/src/polyswarm_api/api.py +++ b/src/polyswarm_api/api.py @@ -837,7 +837,12 @@ def ruleset_delete(self, ruleset_id): ) def ruleset_list( - self, name=None, status=None, favorites_only=None, has_new_results=None + self, + name=None, + status=None, + favorites_only=None, + has_new_results=None, + sort=None, ): """ List all YaraRulesets for the current account. @@ -852,6 +857,14 @@ def ruleset_list( maintained server-side by a scheduled refresh; rows carry it as ``new_results_count`` with ``new_results_counted_at`` marking when it was last refreshed. There is no per-request window parameter. + :param sort: ``'active_first'`` returns the rulesets with a running + live hunt first — as recorded by the server's live-hunt link, the + same link ``livescan_id`` renders from — newest first within each + block. Default (None) is newest first. Applied SERVER-side, across + pages — the list is keyset-paginated, so a client-side sort would + only ever reorder one page; the SDK never re-orders rows. Reuse a + page's ``offset`` only with the same ``sort``: the server refuses + a cursor minted under the other order. :return: A generator of YaraRuleset resources """ logger.info("List rulesets") @@ -862,6 +875,7 @@ def ruleset_list( status=status, favorites_only=favorites_only, has_new_results=has_new_results, + sort=sort, community=self.community, ) ): diff --git a/test/async_client_test.py b/test/async_client_test.py index 958cc768..3f3c0774 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -607,6 +607,39 @@ async def test_async_sample(self, uid): # ── YARA Rulesets ───────────────────────────────────────────────────────── + @vcr.use_cassette() + async def test_async_rules_sort_active_first(self, uid): + """Async twin of the sync ``test_rules_sort_active_first``: the + canonical transport must send the same token and read the same + server-applied order.""" + async with self._api() as api: + running = await api.ruleset_create(f'{uid}-running', uid_yara(f'{uid}-running')) + idle = None + try: + idle = await api.ruleset_create(f'{uid}-idle', uid_yara(f'{uid}-idle')) + await api.live_start(int(running.id)) + try: + async def _enabled(): + return (await api.ruleset_get(running.id)).livescan_id is not None + assert await poll_equals_async(_enabled, True) + + async def _running_precedes_idle(**kwargs): + ids = [r.id async for r in api.ruleset_list(**kwargs)] + return ids.index(running.id) < ids.index(idle.id) + + async def _sorted(): + return await _running_precedes_idle(sort='active_first') + assert await poll_equals_async(_sorted, True) + assert await _running_precedes_idle() is False + with pytest.raises(exceptions.RequestException): + _ = [r async for r in api.ruleset_list(sort='bogus')] + finally: + await api.live_stop(int(running.id)) + finally: + await api.ruleset_delete(int(running.id)) + if idle is not None: + await api.ruleset_delete(int(idle.id)) + @vcr.use_cassette() async def test_async_rules(self, uid): async with self._api() as api: diff --git a/test/client_scan_test.py b/test/client_scan_test.py index 36efc1fa..f6d3db51 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -601,6 +601,47 @@ def test_historical_results(self): except (exceptions.NotFoundException, exceptions.NoResultsException): pass + @vcr.use_cassette() + def test_rules_sort_active_first(self): + """``sort='active_first'`` is an order the SERVER applies: two rulesets + owned by this test, the older one with a live hunt running, the newer + one idle. Newest-first (the default) puts the idle one ahead; the + active-first order puts the running one ahead — a relation the server + must actually satisfy, which no pure-unit test can express (the server + ignores unknown query args, so a renamed token would leave the builder + tests green and the list unsorted). Relative positions only: the + shared stack carries other tests' rulesets.""" + api = PolyswarmAPI(self.test_api_key, uri=f'http://ai:9696/{self.api_version}', community='gamma') + uid = self._testMethodName + running = api.ruleset_create(f'{uid}-running', uid_yara(f'{uid}-running')) + idle = None + try: + idle = api.ruleset_create(f'{uid}-idle', uid_yara(f'{uid}-idle')) + api.live_start(int(running.id)) + try: + # the enable lands asynchronously and reads come off the + # replica — poll (specs/04) + assert poll_equals( + lambda: api.ruleset_get(running.id).livescan_id is not None, True) + + def _running_precedes_idle(**kwargs): + ids = [r.id for r in api.ruleset_list(**kwargs)] + return ids.index(running.id) < ids.index(idle.id) + + assert poll_equals(lambda: _running_precedes_idle(sort='active_first'), True) + # the default order is untouched: the newer (idle) ruleset first + assert _running_precedes_idle() is False + # a sort the server does not know is refused, never ignored + with self.assertRaises(exceptions.RequestException): + list(api.ruleset_list(sort='bogus')) + finally: + # a running live hunt blocks deletion server-side + api.live_stop(int(running.id)) + finally: + api.ruleset_delete(int(running.id)) + if idle is not None: + api.ruleset_delete(int(idle.id)) + @vcr.use_cassette() def test_rules(self): api = PolyswarmAPI(self.test_api_key, uri=f'http://ai:9696/{self.api_version}', community='gamma') diff --git a/test/hunt_tracking_builder_test.py b/test/hunt_tracking_builder_test.py index 1aefe79d..e14f8e74 100644 --- a/test/hunt_tracking_builder_test.py +++ b/test/hunt_tracking_builder_test.py @@ -238,6 +238,100 @@ def test_zero_is_sent_and_absent_is_omitted(self): omitted = resources.LiveHuntResult.list(_FakeApi(), since=None, community='gamma') assert 'since' not in omitted.params + +class TestRulesetListSortOnTheWire: + """``ruleset_list(sort='active_first')`` — the hunt page's active-first + order is an opt-in server token, and it must REACH the server + exactly as such: the unsorted call sends no ``sort`` at all (the request + stays byte-compatible with the pre-sort contract and the list keeps its + id-desc order), and the SDK never re-orders client-side — the list is + keyset-paginated, so a local sort would only ever reorder one page. + + Both transports are driven: the sync mirror (what ``polyswarm-cli`` + calls) and the canonical async source unasync generates it from.""" + + @staticmethod + def _sync_params(**kwargs): + api = PolyswarmAPI.__new__(PolyswarmAPI) + api.uri = _FakeApi.uri + api.community = _FakeApi.community + captured = {} + + def capture(request, *a, **kw): + captured.update(request.params) + captured['__url__'] = request.url + return iter(()) + + api._paginate = capture + list(api.ruleset_list(**kwargs)) + return captured + + @staticmethod + def _async_params(**kwargs): + api = PolySwarmAsyncAPI.__new__(PolySwarmAsyncAPI) + api.uri = _FakeApi.uri + api.community = _FakeApi.community + captured = {} + + async def paginate(request, *a, **kw): + captured.update(request.params) + return + yield # pragma: no cover — makes this an async generator + + api._paginate = paginate + + async def run(): + return [item async for item in api.ruleset_list(**kwargs)] + + asyncio.run(run()) + return captured + + def test_sync_sends_the_server_token_and_nothing_else_new(self): + sent = self._sync_params(sort='active_first') + assert sent['__url__'] == f'{_FakeApi.uri}/hunt/rule/list' + assert {k: v for k, v in sent.items() if k != '__url__'} == { + 'sort': 'active_first', 'community': 'gamma'} + + def test_sync_default_sends_no_sort(self): + sent = self._sync_params() + assert 'sort' not in sent + + def test_sort_composes_with_the_filters(self): + sent = self._sync_params(sort='active_first', status='active', + favorites_only=True) + assert sent['sort'] == 'active_first' + assert sent['status'] == 'active' + assert sent['favorites_only'] == 1 + + def test_async_canonical_sends_the_same_token(self): + sent = self._async_params(sort='active_first') + assert sent == {'sort': 'active_first', 'community': 'gamma'} + assert 'sort' not in self._async_params() + + def test_sort_survives_onto_the_next_page(self): + # The order is only meaningful across pages, and page 2 is built by + # _next_page cloning the descriptor's params — the one place a + # rewrite could rebuild them from scratch and drop `sort` silently. + # The _paginate stubs above never reach it, so drive it directly. + api = PolySwarmAsyncAPI.__new__(PolySwarmAsyncAPI) + api.uri = _FakeApi.uri + api.community = _FakeApi.community + dispatched = [] + + class _Session: + async def execute(self, request, *a, **kw): + dispatched.append(request) + return request + + api.session = _Session() + first = resources.YaraRuleset.list(api, sort='active_first', community='gamma') + first.offset, first.limit = 'opaque-cursor', 25 + asyncio.run(api._next_page(first)) + assert len(dispatched) == 1 + assert dispatched[0].params == {'sort': 'active_first', 'community': 'gamma', + 'offset': 'opaque-cursor', 'limit': 25} + + class TestAsyncLiveFeedMaxResults: """The CANONICAL async bound loop, not the generated mirror. diff --git a/test/vcr/test_async_rules_sort_active_first.vcr b/test/vcr/test_async_rules_sort_active_first.vcr new file mode 100644 index 00000000..dddfcdee --- /dev/null +++ b/test/vcr/test_async_rules_sort_active_first.vcr @@ -0,0 +1,460 @@ +interactions: +- request: + body: '{"yara":"rule sdk_test_async_rules_sort_active_first_running { strings: + $u = \"test_async_rules_sort_active_first-running\" condition: $u }","name":"test_async_rules_sort_active_first-running"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '193' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: POST + uri: http://ai:9696/v3/hunt/rule + response: + body: + string: '{"result":{"created":"2026-09-02T00:53:16.616869+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"43837104550931486","livescan_created":null,"livescan_id":null,"modified":"2026-09-02T00:53:16.616869+00:00","name":"test_async_rules_sort_active_first-running","rule_count":1,"yara":"rule + sdk_test_async_rules_sort_active_first_running { strings: $u = \"test_async_rules_sort_active_first-running\" + condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '491' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:16 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"yara":"rule sdk_test_async_rules_sort_active_first_idle { strings: $u + = \"test_async_rules_sort_active_first-idle\" condition: $u }","name":"test_async_rules_sort_active_first-idle"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '184' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: POST + uri: http://ai:9696/v3/hunt/rule + response: + body: + string: '{"result":{"created":"2026-09-02T00:53:17.281514+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"11708616356758131","livescan_created":null,"livescan_id":null,"modified":"2026-09-02T00:53:17.281514+00:00","name":"test_async_rules_sort_active_first-idle","rule_count":1,"yara":"rule + sdk_test_async_rules_sort_active_first_idle { strings: $u = \"test_async_rules_sort_active_first-idle\" + condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '482' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:17 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"rule_id":"43837104550931486"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '31' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: POST + uri: http://ai:9696/v3/hunt/rule/live + response: + body: + string: '{"result":{"created":"2026-09-02T00:53:16.616869+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"43837104550931486","livescan_created":"2026-09-02T00:53:18.269679+00:00","livescan_id":"20350915266052341","modified":"2026-09-02T00:53:17.891619+00:00","name":"test_async_rules_sort_active_first-running","rule_count":1,"yara":"rule + sdk_test_async_rules_sort_active_first_running { strings: $u = \"test_async_rules_sort_active_first-running\" + condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '536' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:18 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule?id=43837104550931486&community=gamma + response: + body: + string: '{"result":{"created":"2026-09-02T00:53:16.616869+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"43837104550931486","livescan_created":"2026-09-02T00:53:18.269679+00:00","livescan_id":"20350915266052341","modified":"2026-09-02T00:53:17.891619+00:00","name":"test_async_rules_sort_active_first-running","rule_count":1,"yara":"rule + sdk_test_async_rules_sort_active_first_running { strings: $u = \"test_async_rules_sort_active_first-running\" + condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '536' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:18 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?sort=active_first&community=gamma + response: + body: + string: '{"has_more":false,"limit":50,"result":[{"created":"2026-09-02T00:53:16.616869+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"43837104550931486","livescan_created":"2026-09-02T00:53:18.269679+00:00","livescan_id":"20350915266052341","modified":"2026-09-02T00:53:17.891619+00:00","name":"test_async_rules_sort_active_first-running","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null},{"created":"2026-09-02T00:53:17.281514+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"11708616356758131","livescan_created":null,"livescan_id":null,"modified":"2026-09-02T00:53:17.281514+00:00","name":"test_async_rules_sort_active_first-idle","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null}],"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '883' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:18 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?community=gamma + response: + body: + string: '{"has_more":false,"limit":50,"result":[{"created":"2026-09-02T00:53:17.281514+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"11708616356758131","livescan_created":null,"livescan_id":null,"modified":"2026-09-02T00:53:17.281514+00:00","name":"test_async_rules_sort_active_first-idle","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null},{"created":"2026-09-02T00:53:16.616869+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"43837104550931486","livescan_created":"2026-09-02T00:53:18.269679+00:00","livescan_id":"20350915266052341","modified":"2026-09-02T00:53:17.891619+00:00","name":"test_async_rules_sort_active_first-running","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null}],"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '883' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:19 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?sort=bogus&community=gamma + response: + body: + string: '{"errors":null,"result":"Invalid sort: only ''active_first'' is supported.","status":"error"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '92' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:19 GMT + server: + - gunicorn + status: + code: 400 + message: BAD REQUEST +- request: + body: '{"rule_id":"43837104550931486"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '31' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: DELETE + uri: http://ai:9696/v3/hunt/rule/live + response: + body: + string: '{"result":{"created":"2026-09-02T00:53:16.616869+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"43837104550931486","livescan_created":null,"livescan_id":null,"modified":"2026-09-02T00:53:19.285097+00:00","name":"test_async_rules_sort_active_first-running","rule_count":1,"yara":"rule + sdk_test_async_rules_sort_active_first_running { strings: $u = \"test_async_rules_sort_active_first-running\" + condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '491' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:19 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"community":"gamma"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '21' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: DELETE + uri: http://ai:9696/v3/hunt/rule?id=43837104550931486 + response: + body: + string: '{"result":{"created":"2026-09-02T00:53:16.616869+00:00","deleted":true,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"43837104550931486","livescan_created":null,"livescan_id":null,"modified":"2026-09-02T00:53:19.472057+00:00","name":"test_async_rules_sort_active_first-running","rule_count":1,"yara":"rule + sdk_test_async_rules_sort_active_first_running { strings: $u = \"test_async_rules_sort_active_first-running\" + condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '490' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:19 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"community":"gamma"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '21' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: DELETE + uri: http://ai:9696/v3/hunt/rule?id=11708616356758131 + response: + body: + string: '{"result":{"created":"2026-09-02T00:53:17.281514+00:00","deleted":true,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"11708616356758131","livescan_created":null,"livescan_id":null,"modified":"2026-09-02T00:53:19.616902+00:00","name":"test_async_rules_sort_active_first-idle","rule_count":1,"yara":"rule + sdk_test_async_rules_sort_active_first_idle { strings: $u = \"test_async_rules_sort_active_first-idle\" + condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '481' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:19 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +version: 1 diff --git a/test/vcr/test_rules_sort_active_first.vcr b/test/vcr/test_rules_sort_active_first.vcr new file mode 100644 index 00000000..d2de7789 --- /dev/null +++ b/test/vcr/test_rules_sort_active_first.vcr @@ -0,0 +1,460 @@ +interactions: +- request: + body: '{"yara":"rule sdk_test_rules_sort_active_first_running { strings: $u = + \"test_rules_sort_active_first-running\" condition: $u }","name":"test_rules_sort_active_first-running"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '175' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: POST + uri: http://ai:9696/v3/hunt/rule + response: + body: + string: '{"result":{"created":"2026-09-02T00:53:09.506285+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"91100246556341871","livescan_created":null,"livescan_id":null,"modified":"2026-09-02T00:53:09.506285+00:00","name":"test_rules_sort_active_first-running","rule_count":1,"yara":"rule + sdk_test_rules_sort_active_first_running { strings: $u = \"test_rules_sort_active_first-running\" + condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '473' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:09 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"yara":"rule sdk_test_rules_sort_active_first_idle { strings: $u = \"test_rules_sort_active_first-idle\" + condition: $u }","name":"test_rules_sort_active_first-idle"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '166' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: POST + uri: http://ai:9696/v3/hunt/rule + response: + body: + string: '{"result":{"created":"2026-09-02T00:53:09.975229+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"70979412008168996","livescan_created":null,"livescan_id":null,"modified":"2026-09-02T00:53:09.975229+00:00","name":"test_rules_sort_active_first-idle","rule_count":1,"yara":"rule + sdk_test_rules_sort_active_first_idle { strings: $u = \"test_rules_sort_active_first-idle\" + condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '464' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:10 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"rule_id":"91100246556341871"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '31' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: POST + uri: http://ai:9696/v3/hunt/rule/live + response: + body: + string: '{"result":{"created":"2026-09-02T00:53:09.506285+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"91100246556341871","livescan_created":"2026-09-02T00:53:12.090276+00:00","livescan_id":"69497058204233968","modified":"2026-09-02T00:53:10.279078+00:00","name":"test_rules_sort_active_first-running","rule_count":1,"yara":"rule + sdk_test_rules_sort_active_first_running { strings: $u = \"test_rules_sort_active_first-running\" + condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '518' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:12 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule?id=91100246556341871&community=gamma + response: + body: + string: '{"result":{"created":"2026-09-02T00:53:09.506285+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"91100246556341871","livescan_created":"2026-09-02T00:53:12.090276+00:00","livescan_id":"69497058204233968","modified":"2026-09-02T00:53:10.279078+00:00","name":"test_rules_sort_active_first-running","rule_count":1,"yara":"rule + sdk_test_rules_sort_active_first_running { strings: $u = \"test_rules_sort_active_first-running\" + condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '518' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:12 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?sort=active_first&community=gamma + response: + body: + string: '{"has_more":false,"limit":50,"result":[{"created":"2026-09-02T00:53:09.506285+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"91100246556341871","livescan_created":"2026-09-02T00:53:12.090276+00:00","livescan_id":"69497058204233968","modified":"2026-09-02T00:53:10.279078+00:00","name":"test_rules_sort_active_first-running","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null},{"created":"2026-09-02T00:53:09.975229+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"70979412008168996","livescan_created":null,"livescan_id":null,"modified":"2026-09-02T00:53:09.975229+00:00","name":"test_rules_sort_active_first-idle","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null}],"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '871' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:12 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?community=gamma + response: + body: + string: '{"has_more":false,"limit":50,"result":[{"created":"2026-09-02T00:53:09.975229+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"70979412008168996","livescan_created":null,"livescan_id":null,"modified":"2026-09-02T00:53:09.975229+00:00","name":"test_rules_sort_active_first-idle","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null},{"created":"2026-09-02T00:53:09.506285+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"91100246556341871","livescan_created":"2026-09-02T00:53:12.090276+00:00","livescan_id":"69497058204233968","modified":"2026-09-02T00:53:10.279078+00:00","name":"test_rules_sort_active_first-running","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null}],"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '871' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:12 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?sort=bogus&community=gamma + response: + body: + string: '{"errors":null,"result":"Invalid sort: only ''active_first'' is supported.","status":"error"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '92' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:12 GMT + server: + - gunicorn + status: + code: 400 + message: BAD REQUEST +- request: + body: '{"rule_id":"91100246556341871"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '31' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: DELETE + uri: http://ai:9696/v3/hunt/rule/live + response: + body: + string: '{"result":{"created":"2026-09-02T00:53:09.506285+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"91100246556341871","livescan_created":null,"livescan_id":null,"modified":"2026-09-02T00:53:12.776051+00:00","name":"test_rules_sort_active_first-running","rule_count":1,"yara":"rule + sdk_test_rules_sort_active_first_running { strings: $u = \"test_rules_sort_active_first-running\" + condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '473' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:12 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"community":"gamma"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '21' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: DELETE + uri: http://ai:9696/v3/hunt/rule?id=91100246556341871 + response: + body: + string: '{"result":{"created":"2026-09-02T00:53:09.506285+00:00","deleted":true,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"91100246556341871","livescan_created":null,"livescan_id":null,"modified":"2026-09-02T00:53:12.974978+00:00","name":"test_rules_sort_active_first-running","rule_count":1,"yara":"rule + sdk_test_rules_sort_active_first_running { strings: $u = \"test_rules_sort_active_first-running\" + condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '472' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:13 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"community":"gamma"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '21' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.5.0 (x86_64-Darwin-CPython-3.11.3) + method: DELETE + uri: http://ai:9696/v3/hunt/rule?id=70979412008168996 + response: + body: + string: '{"result":{"created":"2026-09-02T00:53:09.975229+00:00","deleted":true,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"70979412008168996","livescan_created":null,"livescan_id":null,"modified":"2026-09-02T00:53:13.214903+00:00","name":"test_rules_sort_active_first-idle","rule_count":1,"yara":"rule + sdk_test_rules_sort_active_first_idle { strings: $u = \"test_rules_sort_active_first-idle\" + condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '463' + content-type: + - application/json + date: + - Wed, 02 Sep 2026 00:53:13 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +version: 1 From 6fa413c7cc6b780b984d6fca2eb01989c6b870ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Tue, 1 Sep 2026 20:54:29 -0400 Subject: [PATCH 17/24] feat: release 4.5.0, the floor the CLI now pins The CLI client adopts `ruleset_list(sort=)` in the paired change set and expresses that as `polyswarm_api>=4.5.0` rather than probing the installed SDK (the workspace's cross-repo dependency standard; AGENTS.md's standing exception). A floor cannot name a version this repo has not declared, so the bump lands here, in the feature PR, not at the release step. Minor, not major: one new optional keyword with a default that preserves today's behaviour. Bumped with bump-my-version; the emitted string is a clean `4.5.0` (a `.devN` form would sort below the floor and send the CLI's CI to PyPI for a version that does not exist). Order is forced as before: this repo releases before the CLI can. --- pyproject.toml | 4 ++-- src/polyswarm_api/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2af7a2d0..03817648 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "polyswarm_api" -version = "4.4.0" +version = "4.5.0" description = "Client library to simplify interacting with the PolySwarm consumer API" readme = "README.md" requires-python = ">=3.10,<4" @@ -55,7 +55,7 @@ package-dir = { "" = "src" } where = ["src"] [tool.bumpversion] -current_version = "4.4.0" +current_version = "4.5.0" commit = true tag = false sign_tags = true diff --git a/src/polyswarm_api/__init__.py b/src/polyswarm_api/__init__.py index dadcc971..d9cf1695 100644 --- a/src/polyswarm_api/__init__.py +++ b/src/polyswarm_api/__init__.py @@ -1,5 +1,5 @@ # https://www.python.org/dev/peps/pep-0008/#module-level-dunder-names -__version__ = '4.4.0' +__version__ = '4.5.0' __release_url__ = 'https://api.github.com/repos/polyswarm/polyswarm-api/releases/latest' from . import api From b065421cb48dde2b418b6b12277cb7ccc75ae7cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Mon, 14 Sep 2026 13:10:22 -0300 Subject: [PATCH 18/24] docs: separate the sort sentence from the has_new_results clause in the endpoints table --- specs/03-endpoints.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/03-endpoints.md b/specs/03-endpoints.md index 9dca1067..9c7503e4 100644 --- a/specs/03-endpoints.md +++ b/specs/03-endpoints.md @@ -190,7 +190,7 @@ refusal. | `live_feed(since=None, …, livescan_id=None, max_results=None)` | `LiveHuntResult.list` — `livescan_id` scopes the feed to one live hunt (the hunt-page per-ruleset feed); `since` is in **SECONDS** (the server converts with `timedelta(seconds=since)`; the 3.x/4.x docstring said minutes and was wrong), and absent-or-`0` means no time filter at all — the server applies it on a truthiness test; `max_results` bounds how many results the generator yields — `None`/`0`/negative means no bound; it does not alter the request | | `historical_list(since=None)` | `HistoricalHunt.list` | | `historical_results(hunt=None, …)` | `HistoricalHuntResultList.get` | -| `ruleset_list(name=None, status=None, favorites_only=None, has_new_results=None, sort=None)` | `YaraRuleset.list` — the hunt-page filters, conjunctive and optional; unset filters are omitted from the query so the no-filter request is byte-compatible with the old contract. `has_new_results` selects on the server's STORED counter (no window parameter — the window belongs to the server's scheduled refresh; rows carry `new_results_count` + `new_results_counted_at`) `sort='active_first'` (4.5.0) asks the SERVER for the hunt page's order — rulesets with a running live hunt first, newest first within each block — as an opt-in token; unset sends no `sort`, keeping the default newest-first. The SDK never re-orders rows: the list is keyset-paginated, so a client-side sort would reorder one page and lie about the rest. | +| `ruleset_list(name=None, status=None, favorites_only=None, has_new_results=None, sort=None)` | `YaraRuleset.list` — the hunt-page filters, conjunctive and optional; unset filters are omitted from the query so the no-filter request is byte-compatible with the old contract. `has_new_results` selects on the server's STORED counter (no window parameter — the window belongs to the server's scheduled refresh; rows carry `new_results_count` + `new_results_counted_at`). `sort='active_first'` (4.5.0) asks the SERVER for the hunt page's order — rulesets with a running live hunt first, newest first within each block — as an opt-in token; unset sends no `sort`, keeping the default newest-first. The SDK never re-orders rows: the list is keyset-paginated, so a client-side sort would reorder one page and lie about the rest. | | `tag_list()` | `Tag.list` | | `family_list()` | `MalwareFamily.list` | | `assertions_list(engine_id)` | `AssertionsJob.list` | From dd43c14925cbb750d04c5bc74761acdadec89ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Mon, 14 Sep 2026 13:22:57 -0300 Subject: [PATCH 19/24] docs: the active-first sort key is mutable, so callers paging it dedupe by id The server assigns that obligation to clients and pins it with boundary tests; it appeared nowhere on this side. Documented on the async method (the canonical source), regenerated into the sync mirror, and recorded in the endpoints table. No behaviour change: dedupe does not belong in the shared streaming generator, which every list endpoint uses and which must not grow an unbounded id set for one of them. --- specs/03-endpoints.md | 2 +- src/polyswarm_api/aio/api.py | 8 +++++++- src/polyswarm_api/api.py | 8 +++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/specs/03-endpoints.md b/specs/03-endpoints.md index 9c7503e4..7e8a6b14 100644 --- a/specs/03-endpoints.md +++ b/specs/03-endpoints.md @@ -190,7 +190,7 @@ refusal. | `live_feed(since=None, …, livescan_id=None, max_results=None)` | `LiveHuntResult.list` — `livescan_id` scopes the feed to one live hunt (the hunt-page per-ruleset feed); `since` is in **SECONDS** (the server converts with `timedelta(seconds=since)`; the 3.x/4.x docstring said minutes and was wrong), and absent-or-`0` means no time filter at all — the server applies it on a truthiness test; `max_results` bounds how many results the generator yields — `None`/`0`/negative means no bound; it does not alter the request | | `historical_list(since=None)` | `HistoricalHunt.list` | | `historical_results(hunt=None, …)` | `HistoricalHuntResultList.get` | -| `ruleset_list(name=None, status=None, favorites_only=None, has_new_results=None, sort=None)` | `YaraRuleset.list` — the hunt-page filters, conjunctive and optional; unset filters are omitted from the query so the no-filter request is byte-compatible with the old contract. `has_new_results` selects on the server's STORED counter (no window parameter — the window belongs to the server's scheduled refresh; rows carry `new_results_count` + `new_results_counted_at`). `sort='active_first'` (4.5.0) asks the SERVER for the hunt page's order — rulesets with a running live hunt first, newest first within each block — as an opt-in token; unset sends no `sort`, keeping the default newest-first. The SDK never re-orders rows: the list is keyset-paginated, so a client-side sort would reorder one page and lie about the rest. | +| `ruleset_list(name=None, status=None, favorites_only=None, has_new_results=None, sort=None)` | `YaraRuleset.list` — the hunt-page filters, conjunctive and optional; unset filters are omitted from the query so the no-filter request is byte-compatible with the old contract. `has_new_results` selects on the server's STORED counter (no window parameter — the window belongs to the server's scheduled refresh; rows carry `new_results_count` + `new_results_counted_at`). `sort='active_first'` (4.5.0) asks the SERVER for the hunt page's order — rulesets with a running live hunt first, newest first within each block — as an opt-in token; unset sends no `sort`, keeping the default newest-first. The SDK never re-orders rows: the list is keyset-paginated, so a client-side sort would reorder one page and lie about the rest. The key is MUTABLE, unlike the id-desc default — a ruleset whose live hunt stops mid-walk is yielded twice, one started mid-walk is skipped — and the generator does not dedupe; callers consuming more than one page dedupe by `id`. | | `tag_list()` | `Tag.list` | | `family_list()` | `MalwareFamily.list` | | `assertions_list(engine_id)` | `AssertionsJob.list` | diff --git a/src/polyswarm_api/aio/api.py b/src/polyswarm_api/aio/api.py index 9f935676..2531c049 100644 --- a/src/polyswarm_api/aio/api.py +++ b/src/polyswarm_api/aio/api.py @@ -718,7 +718,13 @@ async def ruleset_list(self, name=None, status=None, favorites_only=None, pages — the list is keyset-paginated, so a client-side sort would only ever reorder one page; the SDK never re-orders rows. Reuse a page's ``offset`` only with the same ``sort``: the server refuses - a cursor minted under the other order. + a cursor minted under the other order. The key is MUTABLE, unlike + the id-desc default: a ruleset whose live hunt stops mid-walk falls + back into the idle block below the cursor and is yielded twice, + and one started mid-walk moves above the cursor and is skipped for + the rest of that walk. This generator streams pages and does not + dedupe — dedupe by ``id`` if you consume more than one page; a + fresh walk from the first page is always self-consistent. :return: A generator of YaraRuleset resources """ logger.info('List rulesets') diff --git a/src/polyswarm_api/api.py b/src/polyswarm_api/api.py index c589b721..e8921b51 100644 --- a/src/polyswarm_api/api.py +++ b/src/polyswarm_api/api.py @@ -864,7 +864,13 @@ def ruleset_list( pages — the list is keyset-paginated, so a client-side sort would only ever reorder one page; the SDK never re-orders rows. Reuse a page's ``offset`` only with the same ``sort``: the server refuses - a cursor minted under the other order. + a cursor minted under the other order. The key is MUTABLE, unlike + the id-desc default: a ruleset whose live hunt stops mid-walk falls + back into the idle block below the cursor and is yielded twice, + and one started mid-walk moves above the cursor and is skipped for + the rest of that walk. This generator streams pages and does not + dedupe — dedupe by ``id`` if you consume more than one page; a + fresh walk from the first page is always self-consistent. :return: A generator of YaraRuleset resources """ logger.info("List rulesets") From c4c3b6a8561ed1fa4a4b2988903f458949d48299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Mon, 14 Sep 2026 16:25:59 -0300 Subject: [PATCH 20/24] docs: the active-first rank is the stored hunt link, wider than what livescan_id renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three separate things the sort docstring got wrong or over-promised: The rank is not "the same link livescan_id renders from". The server orders on the stored link and serializes the id under a stricter predicate, so a legacy row whose hunt was stopped without clearing the link leads the list while rendering a null id. A caller reading the leading block as "running" — or taking rows until the first null id — reads it backwards. The docstring now says to read the field and never the position. "A fresh walk from the first page is always self-consistent" was wrong in the same breath as the duplicate it describes: the mutable key repeats and skips rows WITHIN a walk, so starting fresh does not avoid it. Retracted. And the live ordering test's poll called list.index() on a row a lagging replica may not have returned yet. poll_equals absorbs NotFound/NoResults, not ValueError, so the lag the poll exists for would have errored the test on its first attempt instead of retrying. Both twins now read as "not yet". --- specs/03-endpoints.md | 2 +- src/polyswarm_api/aio/api.py | 36 ++++++++++++++++++++++-------------- src/polyswarm_api/api.py | 36 ++++++++++++++++++++++-------------- test/async_client_test.py | 5 +++++ test/client_scan_test.py | 8 ++++++++ 5 files changed, 58 insertions(+), 29 deletions(-) diff --git a/specs/03-endpoints.md b/specs/03-endpoints.md index 7e8a6b14..f7b9a51c 100644 --- a/specs/03-endpoints.md +++ b/specs/03-endpoints.md @@ -190,7 +190,7 @@ refusal. | `live_feed(since=None, …, livescan_id=None, max_results=None)` | `LiveHuntResult.list` — `livescan_id` scopes the feed to one live hunt (the hunt-page per-ruleset feed); `since` is in **SECONDS** (the server converts with `timedelta(seconds=since)`; the 3.x/4.x docstring said minutes and was wrong), and absent-or-`0` means no time filter at all — the server applies it on a truthiness test; `max_results` bounds how many results the generator yields — `None`/`0`/negative means no bound; it does not alter the request | | `historical_list(since=None)` | `HistoricalHunt.list` | | `historical_results(hunt=None, …)` | `HistoricalHuntResultList.get` | -| `ruleset_list(name=None, status=None, favorites_only=None, has_new_results=None, sort=None)` | `YaraRuleset.list` — the hunt-page filters, conjunctive and optional; unset filters are omitted from the query so the no-filter request is byte-compatible with the old contract. `has_new_results` selects on the server's STORED counter (no window parameter — the window belongs to the server's scheduled refresh; rows carry `new_results_count` + `new_results_counted_at`). `sort='active_first'` (4.5.0) asks the SERVER for the hunt page's order — rulesets with a running live hunt first, newest first within each block — as an opt-in token; unset sends no `sort`, keeping the default newest-first. The SDK never re-orders rows: the list is keyset-paginated, so a client-side sort would reorder one page and lie about the rest. The key is MUTABLE, unlike the id-desc default — a ruleset whose live hunt stops mid-walk is yielded twice, one started mid-walk is skipped — and the generator does not dedupe; callers consuming more than one page dedupe by `id`. | +| `ruleset_list(name=None, status=None, favorites_only=None, has_new_results=None, sort=None)` | `YaraRuleset.list` — the hunt-page filters, conjunctive and optional; unset filters are omitted from the query so the no-filter request is byte-compatible with the old contract. `has_new_results` selects on the server's STORED counter (no window parameter — the window belongs to the server's scheduled refresh; rows carry `new_results_count` + `new_results_counted_at`). `sort='active_first'` (4.5.0) asks the SERVER for the hunt page's order — rulesets carrying a live hunt link first, newest first within each block — as an opt-in token; unset sends no `sort`, keeping the default newest-first. The SDK never re-orders rows: the list is keyset-paginated, so a client-side sort would reorder one page and lie about the rest. The rank is the stored link, a WIDER predicate than the one `livescan_id` is rendered under, so a legacy row whose hunt was stopped without clearing the link leads the list while serializing `livescan_id` as `null` — read the field, not the position. The key is also MUTABLE, unlike the id-desc default — a ruleset whose live hunt stops mid-walk is yielded twice, one started mid-walk is skipped, in any walk including a fresh one — and the generator does not dedupe; callers consuming more than one page dedupe by `id`. | | `tag_list()` | `Tag.list` | | `family_list()` | `MalwareFamily.list` | | `assertions_list(engine_id)` | `AssertionsJob.list` | diff --git a/src/polyswarm_api/aio/api.py b/src/polyswarm_api/aio/api.py index 2531c049..fd9bb9ba 100644 --- a/src/polyswarm_api/aio/api.py +++ b/src/polyswarm_api/aio/api.py @@ -711,20 +711,28 @@ async def ruleset_list(self, name=None, status=None, favorites_only=None, maintained server-side by a scheduled refresh; rows carry it as ``new_results_count`` with ``new_results_counted_at`` marking when it was last refreshed. There is no per-request window parameter. - :param sort: ``'active_first'`` returns the rulesets with a running - live hunt first — as recorded by the server's live-hunt link, the - same link ``livescan_id`` renders from — newest first within each - block. Default (None) is newest first. Applied SERVER-side, across - pages — the list is keyset-paginated, so a client-side sort would - only ever reorder one page; the SDK never re-orders rows. Reuse a - page's ``offset`` only with the same ``sort``: the server refuses - a cursor minted under the other order. The key is MUTABLE, unlike - the id-desc default: a ruleset whose live hunt stops mid-walk falls - back into the idle block below the cursor and is yielded twice, - and one started mid-walk moves above the cursor and is skipped for - the rest of that walk. This generator streams pages and does not - dedupe — dedupe by ``id`` if you consume more than one page; a - fresh walk from the first page is always self-consistent. + :param sort: ``'active_first'`` returns the rulesets that carry a live + hunt link first, newest first within each block. Default (None) is + newest first. Applied SERVER-side, across pages — the list is + keyset-paginated, so a client-side sort would only ever reorder one + page; the SDK never re-orders rows. Reuse a page's ``offset`` only + with the same ``sort``: the server refuses a cursor minted under + the other order. + + Two server-side properties of that key, neither of them SDK + behaviour. It ranks on the stored link, which is a WIDER predicate + than the one ``livescan_id`` is rendered under: a legacy row whose + hunt was stopped without clearing the link ranks in the leading + block while still serializing ``livescan_id`` as ``None``. Read the + field to decide whether a ruleset is running; never the position. + + And the key is MUTABLE, unlike the id-desc default: a ruleset whose + live hunt stops mid-walk falls back into the idle block below the + cursor and is yielded twice, and one started mid-walk moves above + the cursor and is skipped for the rest of that walk. That is a + property of the walk, so starting fresh from the first page does + not avoid it. This generator streams pages and does not dedupe — + dedupe by ``id`` if you consume more than one page. :return: A generator of YaraRuleset resources """ logger.info('List rulesets') diff --git a/src/polyswarm_api/api.py b/src/polyswarm_api/api.py index e8921b51..152e6211 100644 --- a/src/polyswarm_api/api.py +++ b/src/polyswarm_api/api.py @@ -857,20 +857,28 @@ def ruleset_list( maintained server-side by a scheduled refresh; rows carry it as ``new_results_count`` with ``new_results_counted_at`` marking when it was last refreshed. There is no per-request window parameter. - :param sort: ``'active_first'`` returns the rulesets with a running - live hunt first — as recorded by the server's live-hunt link, the - same link ``livescan_id`` renders from — newest first within each - block. Default (None) is newest first. Applied SERVER-side, across - pages — the list is keyset-paginated, so a client-side sort would - only ever reorder one page; the SDK never re-orders rows. Reuse a - page's ``offset`` only with the same ``sort``: the server refuses - a cursor minted under the other order. The key is MUTABLE, unlike - the id-desc default: a ruleset whose live hunt stops mid-walk falls - back into the idle block below the cursor and is yielded twice, - and one started mid-walk moves above the cursor and is skipped for - the rest of that walk. This generator streams pages and does not - dedupe — dedupe by ``id`` if you consume more than one page; a - fresh walk from the first page is always self-consistent. + :param sort: ``'active_first'`` returns the rulesets that carry a live + hunt link first, newest first within each block. Default (None) is + newest first. Applied SERVER-side, across pages — the list is + keyset-paginated, so a client-side sort would only ever reorder one + page; the SDK never re-orders rows. Reuse a page's ``offset`` only + with the same ``sort``: the server refuses a cursor minted under + the other order. + + Two server-side properties of that key, neither of them SDK + behaviour. It ranks on the stored link, which is a WIDER predicate + than the one ``livescan_id`` is rendered under: a legacy row whose + hunt was stopped without clearing the link ranks in the leading + block while still serializing ``livescan_id`` as ``None``. Read the + field to decide whether a ruleset is running; never the position. + + And the key is MUTABLE, unlike the id-desc default: a ruleset whose + live hunt stops mid-walk falls back into the idle block below the + cursor and is yielded twice, and one started mid-walk moves above + the cursor and is skipped for the rest of that walk. That is a + property of the walk, so starting fresh from the first page does + not avoid it. This generator streams pages and does not dedupe — + dedupe by ``id`` if you consume more than one page. :return: A generator of YaraRuleset resources """ logger.info("List rulesets") diff --git a/test/async_client_test.py b/test/async_client_test.py index 3f3c0774..24320457 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -624,7 +624,12 @@ async def _enabled(): assert await poll_equals_async(_enabled, True) async def _running_precedes_idle(**kwargs): + # Membership-tolerant on purpose — see the sync twin: + # a replica missing `idle` must read as "not yet true" + # and be retried, not raise out of the poll. ids = [r.id async for r in api.ruleset_list(**kwargs)] + if running.id not in ids or idle.id not in ids: + return None return ids.index(running.id) < ids.index(idle.id) async def _sorted(): diff --git a/test/client_scan_test.py b/test/client_scan_test.py index f6d3db51..9eded3c3 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -625,7 +625,15 @@ def test_rules_sort_active_first(self): lambda: api.ruleset_get(running.id).livescan_id is not None, True) def _running_precedes_idle(**kwargs): + # Membership-tolerant on purpose: this is polled, and a + # replica that has not applied `idle` yet must read as "not + # yet true" and be retried. `.index()` would raise + # ValueError, which poll_equals does not absorb, and the + # lag the poll exists for would surface as an error on the + # first attempt instead. ids = [r.id for r in api.ruleset_list(**kwargs)] + if running.id not in ids or idle.id not in ids: + return None return ids.index(running.id) < ids.index(idle.id) assert poll_equals(lambda: _running_precedes_idle(sort='active_first'), True) From 9459f7c374eaf397f6ee21c1b4bef9d55ed1b30c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Mon, 14 Sep 2026 19:01:40 -0300 Subject: [PATCH 21/24] docs: the id on a returned ruleset is unique but unordered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sort paragraph called the unsorted list "the id-desc default", which reads as a promise about the id callers can see. It is not one: the server orders on its own insertion key and renders a random 17-digit number as id, so a caller who recorded the smallest id yielded and resumed below it would silently skip or repeat rows. The dedupe advice in the same paragraph stands — that only needs uniqueness — and now says so explicitly. --- specs/03-endpoints.md | 2 +- src/polyswarm_api/aio/api.py | 7 +++++-- src/polyswarm_api/api.py | 7 +++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/specs/03-endpoints.md b/specs/03-endpoints.md index f7b9a51c..23db895c 100644 --- a/specs/03-endpoints.md +++ b/specs/03-endpoints.md @@ -190,7 +190,7 @@ refusal. | `live_feed(since=None, …, livescan_id=None, max_results=None)` | `LiveHuntResult.list` — `livescan_id` scopes the feed to one live hunt (the hunt-page per-ruleset feed); `since` is in **SECONDS** (the server converts with `timedelta(seconds=since)`; the 3.x/4.x docstring said minutes and was wrong), and absent-or-`0` means no time filter at all — the server applies it on a truthiness test; `max_results` bounds how many results the generator yields — `None`/`0`/negative means no bound; it does not alter the request | | `historical_list(since=None)` | `HistoricalHunt.list` | | `historical_results(hunt=None, …)` | `HistoricalHuntResultList.get` | -| `ruleset_list(name=None, status=None, favorites_only=None, has_new_results=None, sort=None)` | `YaraRuleset.list` — the hunt-page filters, conjunctive and optional; unset filters are omitted from the query so the no-filter request is byte-compatible with the old contract. `has_new_results` selects on the server's STORED counter (no window parameter — the window belongs to the server's scheduled refresh; rows carry `new_results_count` + `new_results_counted_at`). `sort='active_first'` (4.5.0) asks the SERVER for the hunt page's order — rulesets carrying a live hunt link first, newest first within each block — as an opt-in token; unset sends no `sort`, keeping the default newest-first. The SDK never re-orders rows: the list is keyset-paginated, so a client-side sort would reorder one page and lie about the rest. The rank is the stored link, a WIDER predicate than the one `livescan_id` is rendered under, so a legacy row whose hunt was stopped without clearing the link leads the list while serializing `livescan_id` as `null` — read the field, not the position. The key is also MUTABLE, unlike the id-desc default — a ruleset whose live hunt stops mid-walk is yielded twice, one started mid-walk is skipped, in any walk including a fresh one — and the generator does not dedupe; callers consuming more than one page dedupe by `id`. | +| `ruleset_list(name=None, status=None, favorites_only=None, has_new_results=None, sort=None)` | `YaraRuleset.list` — the hunt-page filters, conjunctive and optional; unset filters are omitted from the query so the no-filter request is byte-compatible with the old contract. `has_new_results` selects on the server's STORED counter (no window parameter — the window belongs to the server's scheduled refresh; rows carry `new_results_count` + `new_results_counted_at`). `sort='active_first'` (4.5.0) asks the SERVER for the hunt page's order — rulesets carrying a live hunt link first, newest first within each block — as an opt-in token; unset sends no `sort`, keeping the default newest-first. The SDK never re-orders rows: the list is keyset-paginated, so a client-side sort would reorder one page and lie about the rest. The rank is the stored link, a WIDER predicate than the one `livescan_id` is rendered under, so a legacy row whose hunt was stopped without clearing the link leads the list while serializing `livescan_id` as `null` — read the field, not the position. The rendered `id` is unique but UNORDERED (the server renders a random `number`, and orders on its own insertion key), so dedupe with it and never resume or bound a walk with it. The key is also MUTABLE, unlike that default — a ruleset whose live hunt stops mid-walk is yielded twice, one started mid-walk is skipped, in any walk including a fresh one — and the generator does not dedupe; callers consuming more than one page dedupe by `id`. | | `tag_list()` | `Tag.list` | | `family_list()` | `MalwareFamily.list` | | `assertions_list(engine_id)` | `AssertionsJob.list` | diff --git a/src/polyswarm_api/aio/api.py b/src/polyswarm_api/aio/api.py index fd9bb9ba..f78b8677 100644 --- a/src/polyswarm_api/aio/api.py +++ b/src/polyswarm_api/aio/api.py @@ -713,7 +713,10 @@ async def ruleset_list(self, name=None, status=None, favorites_only=None, it was last refreshed. There is no per-request window parameter. :param sort: ``'active_first'`` returns the rulesets that carry a live hunt link first, newest first within each block. Default (None) is - newest first. Applied SERVER-side, across pages — the list is + newest first. "Newest first" is the server's own insertion key, NOT + the ``id`` on the rows you get back — that one is unique but + unordered, so dedupe with it and never resume or bound a walk with + it. Applied SERVER-side, across pages — the list is keyset-paginated, so a client-side sort would only ever reorder one page; the SDK never re-orders rows. Reuse a page's ``offset`` only with the same ``sort``: the server refuses a cursor minted under @@ -726,7 +729,7 @@ async def ruleset_list(self, name=None, status=None, favorites_only=None, block while still serializing ``livescan_id`` as ``None``. Read the field to decide whether a ruleset is running; never the position. - And the key is MUTABLE, unlike the id-desc default: a ruleset whose + And the key is MUTABLE, unlike that default: a ruleset whose live hunt stops mid-walk falls back into the idle block below the cursor and is yielded twice, and one started mid-walk moves above the cursor and is skipped for the rest of that walk. That is a diff --git a/src/polyswarm_api/api.py b/src/polyswarm_api/api.py index 152e6211..f7dcec31 100644 --- a/src/polyswarm_api/api.py +++ b/src/polyswarm_api/api.py @@ -859,7 +859,10 @@ def ruleset_list( it was last refreshed. There is no per-request window parameter. :param sort: ``'active_first'`` returns the rulesets that carry a live hunt link first, newest first within each block. Default (None) is - newest first. Applied SERVER-side, across pages — the list is + newest first. "Newest first" is the server's own insertion key, NOT + the ``id`` on the rows you get back — that one is unique but + unordered, so dedupe with it and never resume or bound a walk with + it. Applied SERVER-side, across pages — the list is keyset-paginated, so a client-side sort would only ever reorder one page; the SDK never re-orders rows. Reuse a page's ``offset`` only with the same ``sort``: the server refuses a cursor minted under @@ -872,7 +875,7 @@ def ruleset_list( block while still serializing ``livescan_id`` as ``None``. Read the field to decide whether a ruleset is running; never the position. - And the key is MUTABLE, unlike the id-desc default: a ruleset whose + And the key is MUTABLE, unlike that default: a ruleset whose live hunt stops mid-walk falls back into the idle block below the cursor and is yielded twice, and one started mid-walk moves above the cursor and is skipped for the rest of that walk. That is a From f46b8af5b6360207d15b13e79d0ffd03fa24a81d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Mon, 14 Sep 2026 19:12:00 -0300 Subject: [PATCH 22/24] test: poll the default-order read, and stop teaching id-desc in the builder test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two leftovers from the ordering correction, both caught in review. The default-order assertion was the one unpolled call of a helper that now returns None while either row is missing — deliberate, so the poll can retry. Unpolled, a lagging read replica turns that into 'None is False' instead of a retry, against the invariant that these pass on the live stack with VCR off. It is polled now, with want=False, which the helper's own guard accepts. And the builder test's class docstring still called the unsorted list id-desc, the exact claim the rest of this branch exists to correct — the cassette shows the default page returning the lower visible id first. --- test/async_client_test.py | 5 ++++- test/client_scan_test.py | 8 ++++++-- test/hunt_tracking_builder_test.py | 8 +++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/test/async_client_test.py b/test/async_client_test.py index 24320457..9dd98348 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -635,7 +635,10 @@ async def _running_precedes_idle(**kwargs): async def _sorted(): return await _running_precedes_idle(sort='active_first') assert await poll_equals_async(_sorted, True) - assert await _running_precedes_idle() is False + # Polled like the sorted arm — see the sync twin. + async def _unsorted(): + return await _running_precedes_idle() + assert await poll_equals_async(_unsorted, False) is False with pytest.raises(exceptions.RequestException): _ = [r async for r in api.ruleset_list(sort='bogus')] finally: diff --git a/test/client_scan_test.py b/test/client_scan_test.py index 9eded3c3..5b098c92 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -637,8 +637,12 @@ def _running_precedes_idle(**kwargs): return ids.index(running.id) < ids.index(idle.id) assert poll_equals(lambda: _running_precedes_idle(sort='active_first'), True) - # the default order is untouched: the newer (idle) ruleset first - assert _running_precedes_idle() is False + # The default order is untouched: the newer (idle) ruleset + # first. Polled like the sorted arm above — the helper returns + # None while either row is missing, so an unpolled read would + # assert `None is False` on a lagging replica instead of + # retrying. `want=False` is not None, so poll_equals accepts it. + assert poll_equals(_running_precedes_idle, False) is False # a sort the server does not know is refused, never ignored with self.assertRaises(exceptions.RequestException): list(api.ruleset_list(sort='bogus')) diff --git a/test/hunt_tracking_builder_test.py b/test/hunt_tracking_builder_test.py index e14f8e74..14534f2c 100644 --- a/test/hunt_tracking_builder_test.py +++ b/test/hunt_tracking_builder_test.py @@ -243,9 +243,11 @@ class TestRulesetListSortOnTheWire: """``ruleset_list(sort='active_first')`` — the hunt page's active-first order is an opt-in server token, and it must REACH the server exactly as such: the unsorted call sends no ``sort`` at all (the request - stays byte-compatible with the pre-sort contract and the list keeps its - id-desc order), and the SDK never re-orders client-side — the list is - keyset-paginated, so a local sort would only ever reorder one page. + stays byte-compatible with the pre-sort contract and the list keeps the + server's default newest-first order — by the server's own insertion key, + NOT by the ``id`` on the rows, which is unique but unordered), and the SDK + never re-orders client-side — the list is keyset-paginated, so a local sort + would only ever reorder one page. Both transports are driven: the sync mirror (what ``polyswarm-cli`` calls) and the canonical async source unasync generates it from.""" From 029af67f96d77c200ec973f3240fbc02ed357202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Mon, 14 Sep 2026 22:45:27 -0300 Subject: [PATCH 23/24] feat: ruleset_list(exclude_favorites=) for clients that group favorites separately The server gained the inverse of favorites_only: a paginated list with the favorites taken out. It exists because the favorites are a separate, unpaginated fetch bounded by the account's budget, so leaving them in the page too makes a client either render a row twice or render a short page. Appended to the signature rather than placed beside favorites_only, so a caller passing the later filters positionally keeps working. --- specs/03-endpoints.md | 2 +- src/polyswarm_api/aio/api.py | 13 ++++++++++++- src/polyswarm_api/api.py | 11 +++++++++++ test/hunt_tracking_builder_test.py | 13 ++++++++++++- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/specs/03-endpoints.md b/specs/03-endpoints.md index 23db895c..4eb0c86d 100644 --- a/specs/03-endpoints.md +++ b/specs/03-endpoints.md @@ -190,7 +190,7 @@ refusal. | `live_feed(since=None, …, livescan_id=None, max_results=None)` | `LiveHuntResult.list` — `livescan_id` scopes the feed to one live hunt (the hunt-page per-ruleset feed); `since` is in **SECONDS** (the server converts with `timedelta(seconds=since)`; the 3.x/4.x docstring said minutes and was wrong), and absent-or-`0` means no time filter at all — the server applies it on a truthiness test; `max_results` bounds how many results the generator yields — `None`/`0`/negative means no bound; it does not alter the request | | `historical_list(since=None)` | `HistoricalHunt.list` | | `historical_results(hunt=None, …)` | `HistoricalHuntResultList.get` | -| `ruleset_list(name=None, status=None, favorites_only=None, has_new_results=None, sort=None)` | `YaraRuleset.list` — the hunt-page filters, conjunctive and optional; unset filters are omitted from the query so the no-filter request is byte-compatible with the old contract. `has_new_results` selects on the server's STORED counter (no window parameter — the window belongs to the server's scheduled refresh; rows carry `new_results_count` + `new_results_counted_at`). `sort='active_first'` (4.5.0) asks the SERVER for the hunt page's order — rulesets carrying a live hunt link first, newest first within each block — as an opt-in token; unset sends no `sort`, keeping the default newest-first. The SDK never re-orders rows: the list is keyset-paginated, so a client-side sort would reorder one page and lie about the rest. The rank is the stored link, a WIDER predicate than the one `livescan_id` is rendered under, so a legacy row whose hunt was stopped without clearing the link leads the list while serializing `livescan_id` as `null` — read the field, not the position. The rendered `id` is unique but UNORDERED (the server renders a random `number`, and orders on its own insertion key), so dedupe with it and never resume or bound a walk with it. The key is also MUTABLE, unlike that default — a ruleset whose live hunt stops mid-walk is yielded twice, one started mid-walk is skipped, in any walk including a fresh one — and the generator does not dedupe; callers consuming more than one page dedupe by `id`. | +| `ruleset_list(name=None, status=None, favorites_only=None, has_new_results=None, sort=None, exclude_favorites=None)` | `YaraRuleset.list` — the hunt-page filters, conjunctive and optional; unset filters are omitted from the query so the no-filter request is byte-compatible with the old contract. `exclude_favorites=True` is the inverse of `favorites_only` and refused together with it — it exists for clients that render the favorites as their own list, where leaving them in the paginated list too makes a page repeat a row or come back short. Appended to the signature rather than placed beside `favorites_only`, so a positional caller keeps working. `has_new_results` selects on the server's STORED counter (no window parameter — the window belongs to the server's scheduled refresh; rows carry `new_results_count` + `new_results_counted_at`). `sort='active_first'` (4.5.0) asks the SERVER for the hunt page's order — rulesets carrying a live hunt link first, newest first within each block — as an opt-in token; unset sends no `sort`, keeping the default newest-first. The SDK never re-orders rows: the list is keyset-paginated, so a client-side sort would reorder one page and lie about the rest. The rank is the stored link, a WIDER predicate than the one `livescan_id` is rendered under, so a legacy row whose hunt was stopped without clearing the link leads the list while serializing `livescan_id` as `null` — read the field, not the position. The rendered `id` is unique but UNORDERED (the server renders a random `number`, and orders on its own insertion key), so dedupe with it and never resume or bound a walk with it. The key is also MUTABLE, unlike that default — a ruleset whose live hunt stops mid-walk is yielded twice, one started mid-walk is skipped, in any walk including a fresh one — and the generator does not dedupe; callers consuming more than one page dedupe by `id`. | | `tag_list()` | `Tag.list` | | `family_list()` | `MalwareFamily.list` | | `assertions_list(engine_id)` | `AssertionsJob.list` | diff --git a/src/polyswarm_api/aio/api.py b/src/polyswarm_api/aio/api.py index f78b8677..d9377401 100644 --- a/src/polyswarm_api/aio/api.py +++ b/src/polyswarm_api/aio/api.py @@ -697,7 +697,8 @@ async def ruleset_delete(self, ruleset_id): return await self._single(resources.YaraRuleset.delete(self, id=ruleset_id, community=self.community)) async def ruleset_list(self, name=None, status=None, favorites_only=None, - has_new_results=None, sort=None): + has_new_results=None, sort=None, + exclude_favorites=None): """ List all YaraRulesets for the current account. @@ -706,6 +707,15 @@ async def ruleset_list(self, name=None, status=None, favorites_only=None, :param status: 'active' returns only rulesets whose live hunt is currently running. :param favorites_only: True returns only favorited rulesets. + :param exclude_favorites: True returns only the rulesets that are NOT + favorited — the inverse of ``favorites_only``, and refused together + with it (a contradiction, answered with an error rather than an + empty list). It exists for clients that render the favorites as + their own list: the favorites are a separate, unpaginated fetch + bounded by the account's budget, so leaving them in the paginated + list too makes a page either repeat a row or come back short. + Appended to the signature rather than placed beside + ``favorites_only`` so a positional caller keeps working. :param has_new_results: True returns only rulesets whose stored new-results counter is positive. The counter (and its window) is maintained server-side by a scheduled refresh; rows carry it as @@ -742,6 +752,7 @@ async def ruleset_list(self, name=None, status=None, favorites_only=None, async for item in self._paginate(resources.YaraRuleset.list( self, name=name, status=status, favorites_only=favorites_only, has_new_results=has_new_results, sort=sort, + exclude_favorites=exclude_favorites, community=self.community)): yield item diff --git a/src/polyswarm_api/api.py b/src/polyswarm_api/api.py index f7dcec31..3f98cc5d 100644 --- a/src/polyswarm_api/api.py +++ b/src/polyswarm_api/api.py @@ -843,6 +843,7 @@ def ruleset_list( favorites_only=None, has_new_results=None, sort=None, + exclude_favorites=None, ): """ List all YaraRulesets for the current account. @@ -852,6 +853,15 @@ def ruleset_list( :param status: 'active' returns only rulesets whose live hunt is currently running. :param favorites_only: True returns only favorited rulesets. + :param exclude_favorites: True returns only the rulesets that are NOT + favorited — the inverse of ``favorites_only``, and refused together + with it (a contradiction, answered with an error rather than an + empty list). It exists for clients that render the favorites as + their own list: the favorites are a separate, unpaginated fetch + bounded by the account's budget, so leaving them in the paginated + list too makes a page either repeat a row or come back short. + Appended to the signature rather than placed beside + ``favorites_only`` so a positional caller keeps working. :param has_new_results: True returns only rulesets whose stored new-results counter is positive. The counter (and its window) is maintained server-side by a scheduled refresh; rows carry it as @@ -893,6 +903,7 @@ def ruleset_list( favorites_only=favorites_only, has_new_results=has_new_results, sort=sort, + exclude_favorites=exclude_favorites, community=self.community, ) ): diff --git a/test/hunt_tracking_builder_test.py b/test/hunt_tracking_builder_test.py index 14534f2c..e3215267 100644 --- a/test/hunt_tracking_builder_test.py +++ b/test/hunt_tracking_builder_test.py @@ -65,12 +65,23 @@ def test_list_routes_filters_to_the_query_with_int_bools(self): 'name': 'alpha', 'status': 'active', 'favorites_only': 1, 'has_new_results': 1, 'community': 'gamma'} + def test_exclude_favorites_rides_the_query_as_an_int_bool(self): + """The inverse filter, for clients that render the favorites as their + own list: leaving them in the paginated list too makes a page repeat a + row or come back short. Same int-bool coercion as its sibling.""" + api = _FakeApi() + req = resources.YaraRuleset.list( + api, exclude_favorites=True, sort='active_first', + community=api.community) + assert req.params == { + 'exclude_favorites': 1, 'sort': 'active_first', 'community': 'gamma'} + def test_list_omits_every_unset_filter(self): # The no-filter request is byte-compatible with the pre-filter # contract: nothing but community rides the query string. req = resources.YaraRuleset.list( _FakeApi(), name=None, status=None, favorites_only=None, - has_new_results=None, community='gamma') + has_new_results=None, exclude_favorites=None, community='gamma') assert req.params == {'community': 'gamma'} From e4e3d304a09cf02e948ceaacabcb75f1e8c542d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Tue, 15 Sep 2026 00:18:28 -0300 Subject: [PATCH 24/24] test: drive exclude_favorites through both clients, and say what is still untested MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only coverage the parameter shipped with called the generic resource builder, which this change does not touch — dropping the keyword from both transports left the whole suite green. The new cases drive the sync and async client methods, and deleting the pass-through now fails exactly one test. The e2e arm specs/04 asks for is still missing, and the comment at the live sort test says so plainly, along with what compensates and what does not: a rename made in lockstep with the server's spelling is the case only a live request catches. Recording it needs a stack whose key-management service carries the fixture account. --- test/client_scan_test.py | 15 +++++++++++++++ test/hunt_tracking_builder_test.py | 24 +++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/test/client_scan_test.py b/test/client_scan_test.py index 5b098c92..26f6e6aa 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -602,6 +602,21 @@ def test_historical_results(self): pass @vcr.use_cassette() + # NO e2e arm for `exclude_favorites`, deliberately and with a cost. + # specs/04 invariant 1 wants endpoint behaviour tested against the real + # server, and the reason is spelled out below: the server ignores unknown + # query args, so a renamed token leaves builder tests green and the list + # unfiltered. What covers it instead: + # * `TestRulesetListSortOnTheWire` drives BOTH client methods and fails if + # either stops forwarding the keyword (verified by deleting the + # pass-through: one test fails, the rest stay green); + # * the server side pins the filter itself, and the 400 for the + # contradictory pair, in its own HTTP suite against a real database. + # What stays uncovered is a rename that both sides make in lockstep with + # the server's spelling — the case only a live request catches. Recording + # the cassette needs a stack whose AKM carries the fixture account; ours + # answers 500 for a hand-seeded one, so it is honest to say this is + # missing rather than to fake a recording. def test_rules_sort_active_first(self): """``sort='active_first'`` is an order the SERVER applies: two rulesets owned by this test, the older one with a live hunt running, the newer diff --git a/test/hunt_tracking_builder_test.py b/test/hunt_tracking_builder_test.py index e3215267..6d95b6ea 100644 --- a/test/hunt_tracking_builder_test.py +++ b/test/hunt_tracking_builder_test.py @@ -68,7 +68,12 @@ def test_list_routes_filters_to_the_query_with_int_bools(self): def test_exclude_favorites_rides_the_query_as_an_int_bool(self): """The inverse filter, for clients that render the favorites as their own list: leaving them in the paginated list too makes a page repeat a - row or come back short. Same int-bool coercion as its sibling.""" + row or come back short. Same int-bool coercion as its sibling. + + NOTE this exercises the generic builder, which this change does not + touch — the test that actually covers the new code is + ``TestRulesetListSortOnTheWire`` below, which drives both CLIENT + methods and would fail if either stopped forwarding the keyword.""" api = _FakeApi() req = resources.YaraRuleset.list( api, exclude_favorites=True, sort='active_first', @@ -321,6 +326,23 @@ def test_async_canonical_sends_the_same_token(self): assert sent == {'sort': 'active_first', 'community': 'gamma'} assert 'sort' not in self._async_params() + def test_exclude_favorites_reaches_the_server_on_both_clients(self): + """The keyword the hunt page pairs with the sort, driven through the + CLIENT methods rather than the shared builder: dropping it from either + transport's signature or its pass-through fails here, which is what the + builder-level test cannot see.""" + sent = self._sync_params(sort='active_first', exclude_favorites=True) + assert sent['exclude_favorites'] == 1 + assert sent['sort'] == 'active_first' + assert self._async_params(sort='active_first', exclude_favorites=True) == { + 'exclude_favorites': 1, 'sort': 'active_first', 'community': 'gamma'} + + def test_exclude_favorites_is_omitted_when_unset(self): + # Same rule as every other filter: the unfiltered request stays + # byte-compatible with the pre-change contract. + assert 'exclude_favorites' not in self._sync_params() + assert 'exclude_favorites' not in self._async_params(sort='active_first') + def test_sort_survives_onto_the_next_page(self): # The order is only meaningful across pages, and page 2 is built by # _next_page cloning the descriptor's params — the one place a