From fbc7f5b8c528b38bbab5bcc296f51aeeb4e5f78e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Thu, 20 Aug 2026 20:32:26 -0400 Subject: [PATCH 01/54] feat: render ruleset tracking and hunt provenance fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two formatter legs, both getattr-guarded so the CLI still renders results parsed by an SDK release that predates the fields: - ruleset: Favorite / Favorited at, Rules in ruleset (absent when the server had no answer — never shown as 0), Historical hunts triggered, and New live results in window (only when the caller asked the list to include counts). - hunt: Source Ruleset Id, the source's last-modified at freeze time, and 'Source ruleset changed since this hunt froze it: yes/no' — the label names the reference point deliberately; unknown prints nothing. --- src/polyswarm/formatters/text.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/polyswarm/formatters/text.py b/src/polyswarm/formatters/text.py index 1810eba6..bbdecc08 100644 --- a/src/polyswarm/formatters/text.py +++ b/src/polyswarm/formatters/text.py @@ -201,6 +201,17 @@ def hunt(self, result, write=True): self._close_group() if result.ruleset_name is not None: output.append(self._white(f'Ruleset Name: {result.ruleset_name}')) + # Source-rule provenance — getattr-guarded so the formatter also + # renders results parsed by an SDK release that predates the fields. + if getattr(result, 'rule_id', None) is not None: + output.append(self._white(f'Source Ruleset Id: {result.rule_id}')) + if getattr(result, 'rule_modified', None) is not None: + output.append(self._white(f'Source ruleset last modified at freeze: {result.rule_modified}')) + if getattr(result, 'source_rule_changed', None) is not None: + # Tri-state upstream: None (unknown) prints nothing; the label + # names the reference point so it can't read as "edited recently". + changed = 'yes' if result.source_rule_changed else 'no' + output.append(self._white(f'Source ruleset changed since this hunt froze it: {changed}')) if result.yara: output.append(self._white(f'Ruleset Contents:\n{result.yara}')) return self._output(output, write) @@ -284,6 +295,18 @@ def ruleset(self, result, write=True, contents=False): output.append(self._white(f'Description: {result.description}')) output.append(self._white(f'Created at: {result.created}')) output.append(self._white(f'Modified at: {result.modified}')) + # Tracking fields are guarded with getattr so this formatter also + # renders results parsed by an SDK release that predates them. + if getattr(result, 'favorite', None) is not None and result.favorite: + output.append(self._yellow('Favorite: yes')) + if getattr(result, 'favorited_at', None) is not None: + output.append(self._white(f'Favorited at: {result.favorited_at}')) + if getattr(result, 'rule_count', None) is not None: + output.append(self._white(f'Rules in ruleset: {result.rule_count}')) + if getattr(result, 'historical_hunt_count', None) is not None: + output.append(self._white(f'Historical hunts triggered: {result.historical_hunt_count}')) + if getattr(result, 'new_results_count', None) is not None: + output.append(self._white(f'New live results in window: {result.new_results_count}')) if contents: output.append(self._white(f'Ruleset Contents:\n{result.yara}')) return self._output(output, write) From 9847c8ebd194005266746e0a4b8d445545251ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Thu, 20 Aug 2026 21:38:52 -0400 Subject: [PATCH 02/54] test: pin the hunt-page formatter legs The getattr guards (an old-SDK result without the attributes renders, new lines omitted), zero-distinct-from-absent for the counters, the truthy-only favorite leg, and the reference point in the changed-since-freeze label. --- tests/formatter_hunt_fields_test.py | 81 +++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/formatter_hunt_fields_test.py diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py new file mode 100644 index 00000000..96449b0f --- /dev/null +++ b/tests/formatter_hunt_fields_test.py @@ -0,0 +1,81 @@ +"""The hunt-page tracking legs of the text formatter. + +Pins two contracts: + +* the getattr guards — the formatter renders results parsed by an SDK release + that predates the fields (attributes absent entirely) without raising, and + simply omits the new lines; and +* the None/False/0 semantics — ``rule_count=0`` and ``historical_hunt_count=0`` + render as real zeros (distinct from an omitted None), ``favorite=False`` + prints nothing (truthy-only leg), and ``source_rule_changed``'s label names + its reference point ("since this hunt froze it") so it can't read as + "edited recently". +""" +import io +import types +from unittest import TestCase + +from polyswarm.formatters import text + + +def _ruleset(**overrides): + base = dict(id='5', livescan_id=None, livescan_created=None, name='n', + description='d', created='c', modified='m', yara=None) + base.update(overrides) + return types.SimpleNamespace(**base) + + +def _hunt(**overrides): + base = dict(id='9', status='PENDING', progress=None, active=None, + created='c', summary=None, results_csv_uri=None, + ruleset_name='n', yara=None) + base.update(overrides) + return types.SimpleNamespace(**base) + + +class FormatterHuntFieldsTest(TestCase): + def _render(self, method, result, **kwargs): + out = io.StringIO() + getattr(text.TextOutput(color=False, output=out), method)(result, **kwargs) + return out.getvalue() + + def test_ruleset_tracking_fields_render_with_zero_distinct_from_absent(self): + rendered = self._render('ruleset', _ruleset( + favorite=True, favorited_at='2026-08-20', rule_count=0, + historical_hunt_count=0, new_results_count=3)) + assert 'Favorite: yes' in rendered + assert 'Favorited at: 2026-08-20' in rendered + assert 'Rules in ruleset: 0' in rendered + assert 'Historical hunts triggered: 0' in rendered + assert 'New live results in window: 3' in rendered + + def test_ruleset_none_and_false_fields_are_omitted(self): + rendered = self._render('ruleset', _ruleset( + favorite=False, favorited_at=None, rule_count=None, + historical_hunt_count=None, new_results_count=None)) + assert 'Favorite' not in rendered + assert 'Rules in ruleset' not in rendered + assert 'Historical hunts triggered' not in rendered + assert 'New live results' not in rendered + + def test_old_sdk_ruleset_without_the_attributes_renders(self): + rendered = self._render('ruleset', _ruleset()) + assert 'Ruleset Id: 5' in rendered + assert 'Favorite' not in rendered + + def test_hunt_provenance_fields_render_with_the_reference_point(self): + rendered = self._render('hunt', _hunt( + rule_id='5', rule_modified='2026-08-20', source_rule_changed=False)) + assert 'Source Ruleset Id: 5' in rendered + assert 'Source ruleset last modified at freeze: 2026-08-20' in rendered + assert 'Source ruleset changed since this hunt froze it: no' in rendered + + def test_hunt_unknown_tri_state_prints_nothing(self): + rendered = self._render('hunt', _hunt( + rule_id=None, rule_modified=None, source_rule_changed=None)) + assert 'Source' not in rendered + + def test_old_sdk_hunt_without_the_attributes_renders(self): + rendered = self._render('hunt', _hunt()) + assert 'Hunt Id: 9' in rendered + assert 'Source' not in rendered From 674d43f20e20aff54c5048ede8c56714552f22f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Thu, 20 Aug 2026 22:05:19 -0400 Subject: [PATCH 03/54] feat: rules list --include-counts Maps to the server's include_counts so the 'New live results in window' formatter leg is reachable from the CLI (only live-hunting rulesets carry a count; the param is omitted unless asked). --- src/polyswarm/client/rules.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index 5a544b90..a9edc28d 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -29,11 +29,16 @@ def delete(ctx, rule_id): @rules.command('list', short_help='List all rulesets.') +@click.option('--include-counts', is_flag=True, + help='Attach each live-hunting ruleset\'s new-results count for the ' + 'last 24 hours.') @click.pass_context -def list_rules(ctx): +def list_rules(ctx, include_counts): api = ctx.obj['api'] output = ctx.obj['output'] - for ruleset in api.ruleset_list(): + # Omit the param entirely unless asked — the flag maps to the server's + # include_counts and only rulesets with a running live hunt carry a count. + for ruleset in api.ruleset_list(include_counts=include_counts or None): output.ruleset(ruleset) From ffa5e8a85c9cc66eeac67b92861589cf16ceafa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Thu, 20 Aug 2026 22:21:30 -0400 Subject: [PATCH 04/54] test: pin the --include-counts wire plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag must reach ruleset_list(include_counts=True) and the unflagged run must omit the param entirely — the SDK drops None, and the exact wire value is load-bearing (the server only accepts '0'/'1'/'false'/ 'true'). --- tests/formatter_hunt_fields_test.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 96449b0f..46ebbde6 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -79,3 +79,32 @@ def test_old_sdk_hunt_without_the_attributes_renders(self): rendered = self._render('hunt', _hunt()) assert 'Hunt Id: 9' in rendered assert 'Source' not in rendered + + +class RulesListIncludeCountsFlagTest(TestCase): + """`rules list --include-counts` plumbs to ``ruleset_list(include_counts=True)`` + and the unflagged run omits the param entirely (``None`` is dropped by the + SDK's request builder — the server only accepts '0'/'1'/'false'/'true', so + the exact wire value is load-bearing).""" + + def _run(self, args): + from unittest import mock + from click.testing import CliRunner + from polyswarm.client import polyswarm as client + with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_list', + return_value=iter(())) as ruleset_list: + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'rules', 'list'] + args, + catch_exceptions=False) + assert result.exit_code == 0, result.output + return ruleset_list + + def test_flag_sends_include_counts_true(self): + ruleset_list = self._run(['--include-counts']) + ruleset_list.assert_called_once_with(include_counts=True) + + def test_no_flag_omits_the_param(self): + ruleset_list = self._run([]) + ruleset_list.assert_called_once_with(include_counts=None) From ff1dc77424d02d081d4f00469f91456f9c9148ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Fri, 21 Aug 2026 10:27:27 -0400 Subject: [PATCH 05/54] fix: only the flag passes include_counts; pin the legs to real SDK resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings: - Blocking: the unconditional include_counts= kwarg made plain 'rules list' a hard dependency on an SDK newer than the pin's floor — 4.3.0's ruleset_list takes no arguments, so every unflagged run would TypeError against the published SDK (CI could not see it: the branch archive install picks up the new SDK). The kwargs are now built conditionally; only --include-counts requires the new SDK, matching the degradation claim the PR body makes. - The flag tests now autospec the mock, turning both assertions into signature checks against the installed SDK — the check that would have caught the above locally. - The rendering tests build REAL SDK resources from literal dicts, so an SDK attribute rename fails the test instead of silently dropping a line (the getattr guards convert mismatches into omission); they also pin that favorited_at/rule_modified arrive as parsed datetimes. SimpleNamespace remains only for the old-SDK degradation cases, where absent attributes are the point. - specs/02-commands.md documents the new flag and the floor-SDK constraint; specs/03-formatters.md records the non-obvious rendering semantics (0-vs-None, truthy-only favorite, the tri-state and its reference point). Dead 'is not None' half of the favorite guard dropped. --- specs/02-commands.md | 2 +- specs/03-formatters.md | 16 +++++ src/polyswarm/client/rules.py | 10 ++- src/polyswarm/formatters/text.py | 2 +- tests/formatter_hunt_fields_test.py | 104 +++++++++++++++++----------- 5 files changed, 90 insertions(+), 44 deletions(-) diff --git a/specs/02-commands.md b/specs/02-commands.md index fdf6149f..11b933ca 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -30,7 +30,7 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | | `family` (`families.py`) | Malware-family CRUD | `family_{create,update,delete,get,list}` | -| `rules` (`rules.py`) | YARA ruleset CRUD | `ruleset_{create,delete,update,get,list}` | +| `rules` (`rules.py`) | YARA ruleset CRUD; `list --include-counts` attaches each live-hunting ruleset's 24h new-results count (the flag is the ONLY path that passes `include_counts=` — plain `rules list` sends no kwargs so it keeps working on the pin's floor SDK) | `ruleset_{create,delete,update,get,list}` | | `metadata` (`metadata.py`) | Rerun metadata; scan lookup; IP/URL analysis | `rerun_metadata`, `scan_lookup`, `submit_url` | | `activity` (`event.py`) | List account activity/events | `event_list` | | `account` (`account.py`) | Account whois / features | `account_whois`, `account_features` | diff --git a/specs/03-formatters.md b/specs/03-formatters.md index b717ae16..4dd91e70 100644 --- a/specs/03-formatters.md +++ b/specs/03-formatters.md @@ -146,3 +146,19 @@ here with no substitute. Both attributes ship in SDK **4.1.0**, but the dependen fail silently on 4.1.0 (see [`05-sdk-contract.md`](./05-sdk-contract.md) §Version pin) — so every supported install has them. `JSONOutput` needs no change — it dumps the resource's `.json`, which already carries the raw `state` and `known_good` keys. + +## Hunt-page tracking fields (rulesets + historical hunts) + +Rendering rules that are deliberate, not incidental — all getattr-guarded so +an SDK release predating the fields renders without the lines: + +- `rule_count` / `historical_hunt_count`: `0` renders as a real zero; + `None` (the server had no answer) omits the line — never shown as 0. +- `favorite` is truthy-only ("Favorite: yes"): False and old-SDK-absent both + print nothing, deliberately indistinguishable. +- `new_results_count` renders only when the caller asked the list to include + counts (the server sends `None` otherwise, and only live-hunting rulesets + carry a number). +- `source_rule_changed` is tri-state: `None` means UNKNOWN, not "unchanged", + and prints nothing; the label names its reference point — "changed since + this hunt froze it" — so it cannot read as "edited recently". diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index a9edc28d..831501cb 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -36,9 +36,13 @@ def delete(ctx, rule_id): def list_rules(ctx, include_counts): api = ctx.obj['api'] output = ctx.obj['output'] - # Omit the param entirely unless asked — the flag maps to the server's - # include_counts and only rulesets with a running live hunt carry a count. - for ruleset in api.ruleset_list(include_counts=include_counts or None): + # The kwarg is only passed when the flag is given: an installed SDK at the + # pin's floor (4.3.0) has a zero-argument ruleset_list, so an unconditional + # include_counts= would break plain `rules list` for everyone — only the + # new flag may require the new SDK. Only rulesets with a running live hunt + # carry a count. + kwargs = {'include_counts': True} if include_counts else {} + for ruleset in api.ruleset_list(**kwargs): output.ruleset(ruleset) diff --git a/src/polyswarm/formatters/text.py b/src/polyswarm/formatters/text.py index bbdecc08..6be52d78 100644 --- a/src/polyswarm/formatters/text.py +++ b/src/polyswarm/formatters/text.py @@ -297,7 +297,7 @@ def ruleset(self, result, write=True, contents=False): output.append(self._white(f'Modified at: {result.modified}')) # Tracking fields are guarded with getattr so this formatter also # renders results parsed by an SDK release that predates them. - if getattr(result, 'favorite', None) is not None and result.favorite: + if getattr(result, 'favorite', None): output.append(self._yellow('Favorite: yes')) if getattr(result, 'favorited_at', None) is not None: output.append(self._white(f'Favorited at: {result.favorited_at}')) diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 46ebbde6..abc0eca8 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -1,36 +1,62 @@ -"""The hunt-page tracking legs of the text formatter. - -Pins two contracts: - -* the getattr guards — the formatter renders results parsed by an SDK release - that predates the fields (attributes absent entirely) without raising, and - simply omits the new lines; and -* the None/False/0 semantics — ``rule_count=0`` and ``historical_hunt_count=0`` - render as real zeros (distinct from an omitted None), ``favorite=False`` - prints nothing (truthy-only leg), and ``source_rule_changed``'s label names - its reference point ("since this hunt froze it") so it can't read as - "edited recently". +"""The hunt-page tracking legs of the text formatter, and the flag that +reaches them. + +Pins three contracts: + +* the rendering legs against REAL SDK resources built from literal dicts (not + hand-built namespaces): the getattr guards convert an attribute-name + mismatch into silent omission, so only real resources couple these tests to + the SDK's actual attribute names — and they additionally pin that + ``favorited_at`` / ``rule_modified`` arrive as parsed datetimes; +* the old-SDK degradation path — a result object without the attributes at + all (SimpleNamespace on purpose: an installed SDK predating the fields has + no such attributes to build from) renders without raising and simply omits + the new lines; and +* the ``--include-counts`` wire plumbing: the kwarg is passed ONLY when + flagged (an SDK at the pin's floor has a zero-argument ``ruleset_list``, so + the unflagged path must not send it), asserted through an autospec'd mock so + the call is signature-checked against the installed SDK. """ import io import types -from unittest import TestCase +from unittest import TestCase, mock +from click.testing import CliRunner + +from polyswarm.client import polyswarm as client from polyswarm.formatters import text +from polyswarm_api import resources def _ruleset(**overrides): - base = dict(id='5', livescan_id=None, livescan_created=None, name='n', - description='d', created='c', modified='m', yara=None) - base.update(overrides) - return types.SimpleNamespace(**base) + content = dict(id='5', livescan_id=None, livescan_created=None, name='n', + description='d', created='2026-08-20T00:00:00+00:00', + modified='2026-08-20T00:00:00+00:00', deleted=False, yara=None) + content.update(overrides) + return resources.YaraRuleset(content, api=None) def _hunt(**overrides): - base = dict(id='9', status='PENDING', progress=None, active=None, - created='c', summary=None, results_csv_uri=None, - ruleset_name='n', yara=None) - base.update(overrides) - return types.SimpleNamespace(**base) + content = dict(id='9', status='PENDING', progress=0.0, active=None, + created='2026-08-20T00:00:00+00:00', summary=None, + results_csv_uri=None, ruleset_name='n', yara=None) + content.update(overrides) + return resources.HistoricalHunt(content, api=None) + + +def _old_sdk_ruleset(): + """A result parsed by an SDK release that predates the tracking fields: + the attributes are ABSENT, not None — SimpleNamespace is deliberate, since + the installed (new) SDK cannot build such an object.""" + return types.SimpleNamespace( + id='5', livescan_id=None, livescan_created=None, name='n', + description='d', created='c', modified='m', yara=None) + + +def _old_sdk_hunt(): + return types.SimpleNamespace( + id='9', status='PENDING', progress=None, active=None, created='c', + summary=None, results_csv_uri=None, ruleset_name='n', yara=None) class FormatterHuntFieldsTest(TestCase): @@ -41,10 +67,11 @@ def _render(self, method, result, **kwargs): def test_ruleset_tracking_fields_render_with_zero_distinct_from_absent(self): rendered = self._render('ruleset', _ruleset( - favorite=True, favorited_at='2026-08-20', rule_count=0, + favorite=True, favorited_at='2026-08-20T12:00:00+00:00', rule_count=0, historical_hunt_count=0, new_results_count=3)) assert 'Favorite: yes' in rendered - assert 'Favorited at: 2026-08-20' in rendered + # parse_isoformat: the SDK hands the formatter a datetime, not the wire string + assert 'Favorited at: 2026-08-20 12:00:00+00:00' in rendered assert 'Rules in ruleset: 0' in rendered assert 'Historical hunts triggered: 0' in rendered assert 'New live results in window: 3' in rendered @@ -59,15 +86,16 @@ def test_ruleset_none_and_false_fields_are_omitted(self): assert 'New live results' not in rendered def test_old_sdk_ruleset_without_the_attributes_renders(self): - rendered = self._render('ruleset', _ruleset()) + rendered = self._render('ruleset', _old_sdk_ruleset()) assert 'Ruleset Id: 5' in rendered assert 'Favorite' not in rendered def test_hunt_provenance_fields_render_with_the_reference_point(self): rendered = self._render('hunt', _hunt( - rule_id='5', rule_modified='2026-08-20', source_rule_changed=False)) + rule_id='5', rule_modified='2026-08-20T12:00:00+00:00', + source_rule_changed=False)) assert 'Source Ruleset Id: 5' in rendered - assert 'Source ruleset last modified at freeze: 2026-08-20' in rendered + assert 'Source ruleset last modified at freeze: 2026-08-20 12:00:00+00:00' in rendered assert 'Source ruleset changed since this hunt froze it: no' in rendered def test_hunt_unknown_tri_state_prints_nothing(self): @@ -76,23 +104,21 @@ def test_hunt_unknown_tri_state_prints_nothing(self): assert 'Source' not in rendered def test_old_sdk_hunt_without_the_attributes_renders(self): - rendered = self._render('hunt', _hunt()) + rendered = self._render('hunt', _old_sdk_hunt()) assert 'Hunt Id: 9' in rendered assert 'Source' not in rendered class RulesListIncludeCountsFlagTest(TestCase): - """`rules list --include-counts` plumbs to ``ruleset_list(include_counts=True)`` - and the unflagged run omits the param entirely (``None`` is dropped by the - SDK's request builder — the server only accepts '0'/'1'/'false'/'true', so - the exact wire value is load-bearing).""" + """`rules list --include-counts` passes ``include_counts=True``; the + UNFLAGGED run passes nothing at all — an installed SDK at the pin's floor + (4.3.0) has a zero-argument ``ruleset_list``, so plain `rules list` must + keep working there and only the flag may require the new SDK. autospec + makes both assertions signature checks against the installed SDK.""" def _run(self, args): - from unittest import mock - from click.testing import CliRunner - from polyswarm.client import polyswarm as client with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_list', - return_value=iter(())) as ruleset_list: + autospec=True, return_value=iter(())) as ruleset_list: result = CliRunner().invoke( client.polyswarm_cli, ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', @@ -103,8 +129,8 @@ def _run(self, args): def test_flag_sends_include_counts_true(self): ruleset_list = self._run(['--include-counts']) - ruleset_list.assert_called_once_with(include_counts=True) + ruleset_list.assert_called_once_with(mock.ANY, include_counts=True) - def test_no_flag_omits_the_param(self): + def test_no_flag_passes_no_kwargs_at_all(self): ruleset_list = self._run([]) - ruleset_list.assert_called_once_with(include_counts=None) + ruleset_list.assert_called_once_with(mock.ANY) From 9b506feaed0c54e95d4844b91750ea0cc03ab8bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Wed, 26 Aug 2026 11:03:09 -0400 Subject: [PATCH 06/54] feat(rules): favorite command; zero-arg list on the stored-counter contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rules favorite [--unfavorite]: the CLI leg of the favorite capability (API, SDK and CLI land together as one change set). Renders the toggle state plus the server-owned 'Favorites used: N of M' budget, and converts the machine-readable FAVORITE_LIMIT refusal into a clean actionable message at exit 2 — the central mapping's server-refusal code (a ClickException would exit 1, the code reserved for no-results/not-found). Pinned end-to-end against a real recorded 400 (tests/cli_test.py::test_ruleset_favorite_limit_text), not just a hand-built mock, so a rename of the error shape on either side fails a test. - The command guards the SDK surface: ruleset_favorite ships in the paired SDK change and does not exist on the declared floor (published 4.3.0), so on a floor install the command fails with a clean upgrade message instead of an AttributeError traceback — the same only-the-new-surface-may-require-the-new-SDK principle as the withdrawn flag below. Every pre-existing command works unchanged on the floor; the floor itself cannot move until the SDK releases (specs/05 documents the exception and the follow-up bump). Every test touching the new surface is guarded on the narrowest dependency it actually needs (the method for command tests, the resource class for formatter fixture tests) so a rename on either side skips only the tests that need it, not the whole suite silently. - drop --include-counts: it wrapped a per-request server aggregate that is withdrawn (no count is computed on a request path; the badge is a stored, server-refreshed counter) — and it crashed on the declared floor SDK, which CI's branch-name SDK install could never surface. With it gone, list_rules is zero-argument again. - the new-results badge renders as 'New live results (last 24h)' — the fixed product window, since a caller can no longer choose one — with the new_results_counted_at staleness marker beside it. - specs: the sdk-contract floor header follows the pin (>=4.3.0, moved by #264; the header had lagged at 4.2.0), the imports table records RequestException/.request.errors as a real SDK dependency, and the command/formatter tables cover the favorite leg, its floor degradation, and the stored-counter render. --- specs/02-commands.md | 2 +- specs/03-formatters.md | 11 +- specs/05-sdk-contract.md | 7 +- src/polyswarm/client/rules.py | 67 +++++++++-- src/polyswarm/formatters/base.py | 3 + src/polyswarm/formatters/json.py | 3 + src/polyswarm/formatters/text.py | 23 +++- tests/cli_test.py | 67 +++++++++-- tests/formatter_hunt_fields_test.py | 175 ++++++++++++++++++++++++---- 9 files changed, 310 insertions(+), 48 deletions(-) diff --git a/specs/02-commands.md b/specs/02-commands.md index 11b933ca..350728a7 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -30,7 +30,7 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | | `family` (`families.py`) | Malware-family CRUD | `family_{create,update,delete,get,list}` | -| `rules` (`rules.py`) | YARA ruleset CRUD; `list --include-counts` attaches each live-hunting ruleset's 24h new-results count (the flag is the ONLY path that passes `include_counts=` — plain `rules list` sends no kwargs so it keeps working on the pin's floor SDK) | `ruleset_{create,delete,update,get,list}` | +| `rules` (`rules.py`) | YARA ruleset CRUD plus `favorite [--unfavorite]` (the star toggle: renders the new state + the server-owned "N of M used" budget, and converts the machine-readable `FAVORITE_LIMIT` refusal into a clean actionable message at exit 2 — the server-refusal code, never 1). Every PRE-EXISTING command works unchanged on the pin's floor: `rules list` calls a zero-argument `ruleset_list()`, and the hunt-page fields arrive as plain response fields the formatters getattr-guard. `rules favorite` is the one command that needs the paired SDK's `ruleset_favorite`; on the floor it degrades to a clean upgrade message (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor) | `ruleset_{create,delete,update,get,list,favorite}` | | `metadata` (`metadata.py`) | Rerun metadata; scan lookup; IP/URL analysis | `rerun_metadata`, `scan_lookup`, `submit_url` | | `activity` (`event.py`) | List account activity/events | `event_list` | | `account` (`account.py`) | Account whois / features | `account_whois`, `account_features` | diff --git a/specs/03-formatters.md b/specs/03-formatters.md index 4dd91e70..0981d6ce 100644 --- a/specs/03-formatters.md +++ b/specs/03-formatters.md @@ -156,9 +156,14 @@ an SDK release predating the fields renders without the lines: `None` (the server had no answer) omits the line — never shown as 0. - `favorite` is truthy-only ("Favorite: yes"): False and old-SDK-absent both print nothing, deliberately indistinguishable. -- `new_results_count` renders only when the caller asked the list to include - counts (the server sends `None` otherwise, and only live-hunting rulesets - carry a number). +- `new_results_count` is the server's STORED badge (refreshed by its + scheduled job — the window is the fixed 24 h product window, which the + label names, since a caller cannot choose it): a number renders with its + `new_results_counted_at` staleness marker beside it; `None` (never + refreshed / no live hunt / old SDK) omits both lines. +- `ruleset_favorite` renders the toggle response: `Favorite: yes/no`, the + `favorited_at` timestamp when starred, and the server-owned budget as + "Favorites used: N of M" — the client never counts. - `source_rule_changed` is tri-state: `None` means UNKNOWN, not "unchanged", and prints nothing; the label names its reference point — "changed since this hunt froze it" — so it cannot read as "edited recently". diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index 3957a916..fd84e565 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -19,6 +19,7 @@ How the CLI depends on the `polyswarm-api` SDK: which parts of the SDK's public | `from polyswarm_api import settings` | Defaults: `DEFAULT_SCAN_TIMEOUT`, `DEFAULT_REPORT_TIMEOUT`, etc. | | `from polyswarm_api import resources` | Result-parser classes for power-user calls (e.g. `resources.ArtifactInstance`); resource attributes the formatters read. | | `from polyswarm_api import exceptions as api_exceptions` | Caught in `ExceptionHandlingGroup` and `utils.parallel_executor` (`NoResultsException`, `NotFoundException`, `FailedInstanceException`, `PolyswarmException`). | +| `from polyswarm_api import exceptions` (bare, not aliased) | `RequestException` — caught by `rules favorite` (`client/rules.py`) to read the machine-readable `FAVORITE_LIMIT` refusal off `exc.request.errors['code']`. The SDK does not raise a typed exception for this refusal by design (specs/05 on the server side): `.request.errors` is a plain dict the server's error envelope populates, pinned end-to-end by `tests/cli_test.py::test_ruleset_favorite_limit_text` against a real recorded 400 (not a hand-built mock), so a rename on either side fails that cassette. | | `from polyswarm_api.core import parse_isoformat` | Date rendering in `formatters/text.py`. | | `import polyswarm_api` (`__version__`) | `--api-version`. | @@ -74,15 +75,17 @@ When a CLI feature needs an SDK surface that doesn't exist yet: **Read the declared version off the archive's own tree, and mind pre-release suffixes.** PEP 440 orders `4.2.0.dev1 < 4.2.0`, so a `develop` head carrying a dev suffix (the SDK's `pyproject.toml` has a `[tool.bumpversion.parts.dev]`) would *not* satisfy a `>=4.2.0` floor even though it looks like 4.2.0 — and the archive build would be silently replaced from PyPI. Check the version string in the SDK branch's `pyproject.toml` / `__init__.py`, not the last release tag. For the current floor both were read from `origin/develop`: `version = "4.2.0"` and `__version__ = '4.2.0'`, no suffix. -### Current floor — `polyswarm_api>=4.2.0` +### Current floor — `polyswarm_api>=4.3.0` -Two behaviours the CLI relies on only exist from **4.2.0**; on 4.1.0 both fail *silently*, which is why the floor is a hard requirement rather than a preference: +The floor moved to **4.3.0** with #264 (`pyproject.toml` has said `>=4.3.0` since then; this header lagged at 4.2.0 — the drift itself is why the floor lives in ONE authoritative place, the pin, and this doc must follow it). The 4.2.0 rationale below still holds transitively; on 4.1.0 both behaviours fail *silently*, which is why the floor is a hard requirement rather than a preference: 1. **`llm_report_create` sends the client's community.** 4.2.0 passes `community=self.community` when it builds the report resource; 4.1.0 omits it. `report llm-create` (`client/report.py`) supplies no community of its own — it relies entirely on the client's — so on 4.1.0 a report requested for a sample in a private community is created without one. No error, wrong resource. 2. **A streaming download answered `204 No Content` raises `NoResultsException`.** The streaming path bypasses `parse_response`, so the 204 has to be raised by the session itself; 4.2.0 does that, 4.1.0 has no such raise anywhere in its session. The CLI's `download` commands depend on it for the no-results **exit code `1`** (§No-results signalling); against 4.1.0 an empty response reads as a successful download and exits `0`. The known-good rendering attributes (`ArtifactInstance.state`, `.known_good`/`.known_good_sources`, read by `formatters/text.py` — see [`03-formatters.md`](./03-formatters.md) §Known-good artifact instances) ship in **4.1.0**, so they are *not* what sets the floor; they are simply covered by it. +**One command exceeds the floor, by design, with a guarded degradation:** `rules favorite` wraps `ruleset_favorite`, which does not exist on published 4.3.0 — it ships in the paired SDK change and reaches PyPI with the next SDK release. The command guards with `getattr` and fails with a clean upgrade message (exit 2) on a floor install; every other command works unchanged there, which is why the floor itself does not move (moving it has the two preconditions above, and neither holds until the SDK releases). When the SDK release lands on PyPI, bumping the floor and dropping the guard is the follow-up. + ## Worked example — the httpx SDK migration The SDK's move to an `httpx`-based, three-layer architecture (pure-dataclass `PolyswarmRequest`, session-based execution, lazy generators) removed several 3.x affordances the CLI had reached into: diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index 831501cb..0626292f 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -1,5 +1,8 @@ import click +from polyswarm_api import exceptions as api_exceptions + +from polyswarm import exceptions from polyswarm.client import utils @@ -29,23 +32,65 @@ def delete(ctx, rule_id): @rules.command('list', short_help='List all rulesets.') -@click.option('--include-counts', is_flag=True, - help='Attach each live-hunting ruleset\'s new-results count for the ' - 'last 24 hours.') @click.pass_context -def list_rules(ctx, include_counts): +def list_rules(ctx): api = ctx.obj['api'] output = ctx.obj['output'] - # The kwarg is only passed when the flag is given: an installed SDK at the - # pin's floor (4.3.0) has a zero-argument ruleset_list, so an unconditional - # include_counts= would break plain `rules list` for everyone — only the - # new flag may require the new SDK. Only rulesets with a running live hunt - # carry a count. - kwargs = {'include_counts': True} if include_counts else {} - for ruleset in api.ruleset_list(**kwargs): + # Zero-argument on purpose: every hunt-page field this renders (counts, + # favorites, tracking) arrives as a plain response field the formatters + # getattr-guard, so the command needs NO new SDK behaviour and works + # unchanged on the pin's floor (4.3.0). The new-results badge is a STORED + # server-side counter refreshed on a schedule — there is no per-request + # count to ask for. + for ruleset in api.ruleset_list(): output.ruleset(ruleset) +@rules.command('favorite', short_help='Favorite (star) or unfavorite a ruleset.') +@click.argument('rule_id', type=click.INT, required=True) +@click.option('--unfavorite', is_flag=True, help='Remove the star instead.') +@click.pass_context +def favorite(ctx, rule_id, unfavorite): + """Star a ruleset for the whole team (or unstar with --unfavorite). + + Stars are shared by the team and capped server-side; the response renders + the new state plus the budget ("N of M favorites used"). When the budget + is full the server refuses with a machine-readable FAVORITE_LIMIT error, + rendered here as a clean message rather than a traceback (still exit 2 — + the central mapping's server-refusal code; exit 1 means no-results). + """ + api = ctx.obj['api'] + output = ctx.obj['output'] + toggle = getattr(api, 'ruleset_favorite', None) + if toggle is None: + # The declared floor (published polyswarm-api 4.3.0) predates the + # favorite surface — it ships in the paired SDK change. Every OTHER + # command keeps working on the floor (list is zero-argument again); + # only this command needs the newer SDK, and on the floor it must + # fail with a clean upgrade message, never an AttributeError + # traceback. (Same principle as the withdrawn --include-counts flag: + # a new surface may require the new SDK; existing surfaces may not.) + raise exceptions.PolyswarmException( + 'rules favorite requires a polyswarm-api release newer than ' + '4.3.0 (the paired SDK change adds ruleset_favorite). ' + 'Upgrade polyswarm-api to use this command.') + try: + output.ruleset_favorite(toggle(rule_id, not unfavorite)) + except api_exceptions.RequestException as exc: + errors = getattr(exc.request, 'errors', None) or {} + if isinstance(errors, dict) and errors.get('code') == 'FAVORITE_LIMIT': + # The one refusal a user fixes themselves (unstar something): + # say so cleanly. A CLI PolyswarmException keeps the central + # exit-code mapping's 2 (server refusal) — a ClickException + # would exit 1, the code reserved for no-results/not-found. + raise exceptions.PolyswarmException( + f"Favorite limit reached ({errors.get('favorites_used')} of " + f"{errors.get('favorites_limit')} used). Unfavorite another " + f'ruleset first: `polyswarm rules favorite --unfavorite`.' + ) from exc + raise + + @rules.command('update', short_help='Update a ruleset.') @click.argument('rule_id', type=click.INT, required=True) @click.option('-n', '--name', type=str, help='Name of the ruleset.') diff --git a/src/polyswarm/formatters/base.py b/src/polyswarm/formatters/base.py index 8f28c3cf..dac67ee3 100644 --- a/src/polyswarm/formatters/base.py +++ b/src/polyswarm/formatters/base.py @@ -22,6 +22,9 @@ def local_artifact(self, result): def ruleset(self, result, contents=False): raise NotImplementedError + def ruleset_favorite(self, result): + raise NotImplementedError + def iocs(self, iocs, write=True): raise NotImplementedError diff --git a/src/polyswarm/formatters/json.py b/src/polyswarm/formatters/json.py index 445e0d01..4157f371 100644 --- a/src/polyswarm/formatters/json.py +++ b/src/polyswarm/formatters/json.py @@ -109,6 +109,9 @@ def local_artifact(self, artifact): def ruleset(self, result, contents=False): click.echo(self._to_json(result.json), file=self.out) + def ruleset_favorite(self, result): + click.echo(self._to_json(result.json), file=self.out) + def metadata(self, result): click.echo(self._to_json(result.json), file=self.out) diff --git a/src/polyswarm/formatters/text.py b/src/polyswarm/formatters/text.py index 6be52d78..0eac9f60 100644 --- a/src/polyswarm/formatters/text.py +++ b/src/polyswarm/formatters/text.py @@ -306,11 +306,32 @@ def ruleset(self, result, write=True, contents=False): if getattr(result, 'historical_hunt_count', None) is not None: output.append(self._white(f'Historical hunts triggered: {result.historical_hunt_count}')) if getattr(result, 'new_results_count', None) is not None: - output.append(self._white(f'New live results in window: {result.new_results_count}')) + # The window is the server's fixed 24 h product window (the badge + # is a stored counter its scheduled refresh maintains — a caller + # cannot choose the window, so the label must not imply one), and + # the marker says how fresh the stored number is. + output.append(self._white(f'New live results (last 24h): {result.new_results_count}')) + if getattr(result, 'new_results_counted_at', None) is not None: + output.append(self._white(f'New-results count refreshed at: {result.new_results_counted_at}')) if contents: output.append(self._white(f'Ruleset Contents:\n{result.yara}')) return self._output(output, write) + def ruleset_favorite(self, result, write=True): + output = [] + output.append(self._blue(f'Ruleset Id: {result.id}')) + starred = getattr(result, 'favorite', None) + output.append(self._yellow('Favorite: yes') if starred + else self._white('Favorite: no')) + if getattr(result, 'favorited_at', None) is not None: + output.append(self._white(f'Favorited at: {result.favorited_at}')) + used = getattr(result, 'favorites_used', None) + limit = getattr(result, 'favorites_limit', None) + if used is not None and limit is not None: + # server-owned budget counters — the client never counts + output.append(self._white(f'Favorites used: {used} of {limit}')) + return self._output(output, write) + def tag_link(self, result, write=True): output = [] output.append(self._blue(f'SHA256: {result.sha256}')) diff --git a/tests/cli_test.py b/tests/cli_test.py index 6f23b7c3..7b988dd4 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -8,7 +8,10 @@ from polyswarm_api import resources from pathlib import Path +import unittest + import vcr as vcr_ +from polyswarm_api.api import PolyswarmAPI import click from click.testing import CliRunner @@ -18,6 +21,14 @@ vcr = vcr_.VCR(cassette_library_dir='tests/vcr', path_transformer=vcr_.VCR.ensure_suffix('.vcr')) +# The favorite surface ships in the paired SDK change; on the declared floor +# (published polyswarm_api 4.3.0) `rules favorite` deliberately degrades to +# the upgrade message, so its cassette tests must skip there — the suite has +# to stay honest on both installs the pin permits. +_needs_favorite_method = unittest.skipUnless( + hasattr(PolyswarmAPI, 'ruleset_favorite'), + 'paired SDK method (ruleset_favorite) not installed') + class BaseTestCase(TestCase): def __init__(self, *args, **kwargs): @@ -174,23 +185,23 @@ class LiveHuntTest(BaseTestCase): @vcr.use_cassette() def test_live_hunt_start_json(self): result = self._run_cli([ - '--output-format', 'json', 'live', 'start', '17388152480558505']) + '--output-format', 'json', 'live', 'start', '44051669277897879']) self._assert_json_result(result, self.click_vcr(result)) @vcr.use_cassette() def test_live_hunt_start_text(self): result = self._run_cli([ - '--output-format', 'text', 'live', 'start', '17388152480558505']) + '--output-format', 'text', 'live', 'start', '44051669277897879']) self._assert_text_result(result, self.click_vcr(result)) @vcr.use_cassette() def test_live_hunt_stop_json(self): - result = self._run_cli(['--output-format', 'json', 'live', 'stop', '17388152480558505']) + result = self._run_cli(['--output-format', 'json', 'live', 'stop', '44051669277897879']) self._assert_json_result(result, self.click_vcr(result)) @vcr.use_cassette() def test_live_hunt_stop_text(self): - result = self._run_cli(['--output-format', 'text', 'live', 'stop', '17388152480558505']) + result = self._run_cli(['--output-format', 'text', 'live', 'stop', '44051669277897879']) self._assert_text_result(result, self.click_vcr(result)) @@ -210,13 +221,13 @@ def test_historical_hunt_create_text(self): @vcr.use_cassette() def test_historical_hunt_delete_json(self): result = self._run_cli([ - '--output-format', 'json', 'historical', 'delete', '75914219779430298']) + '--output-format', 'json', 'historical', 'delete', '32808041501095355']) self._assert_json_result(result, self.click_vcr(result)) @vcr.use_cassette() def test_historical_hunt_delete_text(self): result = self._run_cli([ - '--output-format', 'text', 'historical', 'delete', '96916002705221564']) + '--output-format', 'text', 'historical', 'delete', '3220090199138422']) self._assert_text_result(result, self.click_vcr(result)) @vcr.use_cassette() @@ -240,19 +251,19 @@ def test_ruleset_create_json(self): @vcr.use_cassette() def test_ruleset_view_json(self): result = self._run_cli([ - '--output-format', 'json', 'rules', 'view', '27214252780064715']) + '--output-format', 'json', 'rules', 'view', '78562964231669682']) self._assert_json_result(result, self.click_vcr(result)) @vcr.use_cassette() def test_ruleset_update_json(self): result = self._run_cli([ - '--output-format', 'json', 'rules', 'update', '71213140536342873', '--name', 'test2']) + '--output-format', 'json', 'rules', 'update', '4202182245812695', '--name', 'test2']) self._assert_json_result(result, self.click_vcr(result)) @vcr.use_cassette() def test_ruleset_delete_json(self): result = self._run_cli([ - '--output-format', 'json', 'rules', 'delete', '71213140536342873']) + '--output-format', 'json', 'rules', 'delete', '4202182245812695']) self._assert_json_result(result, self.click_vcr(result)) @vcr.use_cassette() @@ -261,6 +272,44 @@ def test_ruleset_list_json(self): '--output-format', 'json', 'rules', 'list']) self._assert_json_result(result, self.click_vcr(result)) + @_needs_favorite_method + @vcr.use_cassette() + def test_ruleset_favorite_text(self): + result = self._run_cli([ + '--output-format', 'text', 'rules', 'favorite', '96652060989160147']) + self._assert_text_result(result, self.click_vcr(result)) + + @_needs_favorite_method + @vcr.use_cassette() + def test_ruleset_unfavorite_text(self): + result = self._run_cli([ + '--output-format', 'text', 'rules', 'favorite', '96652060989160147', + '--unfavorite']) + self._assert_text_result(result, self.click_vcr(result)) + + @_needs_favorite_method + @vcr.use_cassette() + def test_ruleset_favorite_json(self): + result = self._run_cli([ + '--output-format', 'json', 'rules', 'favorite', '14883307518120680']) + self._assert_json_result(result, self.click_vcr(result)) + + @_needs_favorite_method + @vcr.use_cassette() + def test_ruleset_favorite_limit_text(self): + # The server's machine-readable FAVORITE_LIMIT refusal, recorded off + # the real wire (a stack with all five team slots held): pins where + # the error body actually lives (exc.request.errors, code string + # included) — the unit test's hand-built mock cannot notice either + # side renaming it — and the clean actionable message at exit 2, the + # central mapping's server-refusal code. + result = self._run_cli([ + '--output-format', 'text', 'rules', 'favorite', '45874884769561543']) + expected = self.click_vcr(result) + self._assert_text_result(result, expected, expected_return_code=2) + assert 'Favorite limit reached (5 of 5 used)' in expected + assert '--unfavorite' in expected + class SubmissionTest(BaseTestCase): @vcr.use_cassette() diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index abc0eca8..a3fb3426 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -12,20 +12,41 @@ all (SimpleNamespace on purpose: an installed SDK predating the fields has no such attributes to build from) renders without raising and simply omits the new lines; and -* the ``--include-counts`` wire plumbing: the kwarg is passed ONLY when - flagged (an SDK at the pin's floor has a zero-argument ``ruleset_list``, so - the unflagged path must not send it), asserted through an autospec'd mock so - the call is signature-checked against the installed SDK. +* the command plumbing: ``rules list`` calls a ZERO-argument + ``ruleset_list()`` (the pin's floor, 4.3.0, has exactly that signature — + no new SDK behaviour is required anywhere in this change), and + ``rules favorite`` renders the toggle response and converts the + machine-readable FAVORITE_LIMIT refusal into a clean message. Both are + asserted through autospec'd mocks so every call is signature-checked + against the installed SDK. """ import io import types +import unittest from unittest import TestCase, mock from click.testing import CliRunner from polyswarm.client import polyswarm as client from polyswarm.formatters import text -from polyswarm_api import resources +from polyswarm_api import exceptions, resources +from polyswarm_api.api import PolyswarmAPI + +# The favorite surface ships in the paired SDK change; the pin's floor +# (published 4.3.0) has neither the method nor the resource. These tests must +# stay honest on BOTH installs: everything that needs the new surface skips +# on the floor (where `rules favorite` itself degrades to the clean upgrade +# message its own floor test pins with create=True). +# Two guards, deliberately as NARROW as each dependency: the command tests +# need only the METHOD (keying them on the resource too would let a resource +# rename silently skip the whole command suite while CI stays green), and the +# formatter fixture tests need only the RESOURCE class they instantiate. +_needs_favorite_method = unittest.skipUnless( + hasattr(PolyswarmAPI, 'ruleset_favorite'), + 'paired SDK method (ruleset_favorite) not installed') +_needs_favorite_resource = unittest.skipUnless( + hasattr(resources, 'YaraRulesetFavorite'), + 'paired SDK resource (YaraRulesetFavorite) not installed') def _ruleset(**overrides): @@ -74,7 +95,36 @@ def test_ruleset_tracking_fields_render_with_zero_distinct_from_absent(self): assert 'Favorited at: 2026-08-20 12:00:00+00:00' in rendered assert 'Rules in ruleset: 0' in rendered assert 'Historical hunts triggered: 0' in rendered - assert 'New live results in window: 3' in rendered + assert 'New live results (last 24h): 3' in rendered + + def test_ruleset_staleness_marker_renders_beside_the_count(self): + # The stored badge's marker: how fresh the number is. Rendered only + # with a count (the server sends them together). + rendered = self._render('ruleset', _ruleset( + new_results_count=0, + new_results_counted_at='2026-08-25T12:00:00+00:00')) + assert 'New live results (last 24h): 0' in rendered + assert 'New-results count refreshed at: 2026-08-25 12:00:00+00:00' in rendered + + @_needs_favorite_resource + def test_ruleset_favorite_response_renders_state_and_budget(self): + rendered = self._render('ruleset_favorite', resources.YaraRulesetFavorite( + {'id': '5', 'favorite': True, + 'favorited_at': '2026-08-25T12:00:00+00:00', + 'favorites_used': 3, 'favorites_limit': 5}, api=None)) + assert 'Ruleset Id: 5' in rendered + assert 'Favorite: yes' in rendered + assert 'Favorited at: 2026-08-25 12:00:00+00:00' in rendered + assert 'Favorites used: 3 of 5' in rendered + + @_needs_favorite_resource + def test_ruleset_unfavorite_response_renders_no_state(self): + rendered = self._render('ruleset_favorite', resources.YaraRulesetFavorite( + {'id': '5', 'favorite': False, 'favorited_at': None, + 'favorites_used': 2, 'favorites_limit': 5}, api=None)) + assert 'Favorite: no' in rendered + assert 'Favorited at' not in rendered + assert 'Favorites used: 2 of 5' in rendered def test_ruleset_none_and_false_fields_are_omitted(self): rendered = self._render('ruleset', _ruleset( @@ -109,28 +159,111 @@ def test_old_sdk_hunt_without_the_attributes_renders(self): assert 'Source' not in rendered -class RulesListIncludeCountsFlagTest(TestCase): - """`rules list --include-counts` passes ``include_counts=True``; the - UNFLAGGED run passes nothing at all — an installed SDK at the pin's floor - (4.3.0) has a zero-argument ``ruleset_list``, so plain `rules list` must - keep working there and only the flag may require the new SDK. autospec - makes both assertions signature checks against the installed SDK.""" +class RulesListZeroArgTest(TestCase): + """`rules list` calls a zero-argument ``ruleset_list()`` — the pin's + floor (4.3.0) has exactly that signature, so the command needs no new SDK + behaviour at all. autospec makes the assertion a signature check against + the installed SDK.""" - def _run(self, args): + def test_list_passes_no_kwargs_at_all(self): with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_list', autospec=True, return_value=iter(())) as ruleset_list: result = CliRunner().invoke( client.polyswarm_cli, ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', - 'rules', 'list'] + args, + 'rules', 'list'], catch_exceptions=False) assert result.exit_code == 0, result.output - return ruleset_list + ruleset_list.assert_called_once_with(mock.ANY) - def test_flag_sends_include_counts_true(self): - ruleset_list = self._run(['--include-counts']) - ruleset_list.assert_called_once_with(mock.ANY, include_counts=True) - def test_no_flag_passes_no_kwargs_at_all(self): - ruleset_list = self._run([]) - ruleset_list.assert_called_once_with(mock.ANY) +class RulesFavoriteCommandTest(TestCase): + """`rules favorite` — the CLI leg of the favorite capability: renders the + toggle response (state + server-owned budget counters), passes the right + boolean for --unfavorite, and converts the machine-readable FAVORITE_LIMIT + refusal into a clean actionable message instead of a traceback.""" + + def _invoke(self, args, side_effect=None, return_value=None): + with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_favorite', + autospec=True, side_effect=side_effect, + return_value=return_value) as toggle: + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'rules', 'favorite'] + args, + catch_exceptions=False) + return result, toggle + + @staticmethod + def _response(favorite): + return resources.YaraRulesetFavorite( + {'id': '5', 'favorite': favorite, + 'favorited_at': '2026-08-25T12:00:00+00:00' if favorite else None, + 'favorites_used': 1, 'favorites_limit': 5}, api=None) + + @_needs_favorite_method + @_needs_favorite_resource + def test_favorite_calls_the_sdk_and_renders_the_budget(self): + result, toggle = self._invoke(['5'], return_value=self._response(True)) + assert result.exit_code == 0, result.output + toggle.assert_called_once_with(mock.ANY, 5, True) + assert 'Favorite: yes' in result.output + assert 'Favorites used: 1 of 5' in result.output + + @_needs_favorite_method + @_needs_favorite_resource + def test_unfavorite_flag_flips_the_boolean(self): + result, toggle = self._invoke(['5', '--unfavorite'], + return_value=self._response(False)) + assert result.exit_code == 0, result.output + toggle.assert_called_once_with(mock.ANY, 5, False) + assert 'Favorite: no' in result.output + + @_needs_favorite_method + def test_favorite_limit_refusal_is_a_clean_message_at_exit_2(self): + # Exit 2 is the central mapping's server-refusal code; exit 1 is + # reserved for no-results/not-found. The friendly message rides a CLI + # PolyswarmException so ExceptionHandlingGroup logs it cleanly. + request = mock.Mock() + request.errors = {'code': 'FAVORITE_LIMIT', + 'favorites_used': 5, 'favorites_limit': 5} + refusal = exceptions.RequestException(request) + with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_favorite', + autospec=True, side_effect=refusal): + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'rules', 'favorite', '5']) + assert result.exit_code == 2, result.output + assert 'Favorite limit reached (5 of 5 used)' in result.output + assert '--unfavorite' in result.output # names the way out + assert 'Traceback' not in result.output + + def test_favorite_on_the_floor_sdk_degrades_cleanly(self): + # The declared floor (published 4.3.0) has no ruleset_favorite: the + # command must fail with a clean upgrade message at exit 2, never an + # AttributeError traceback — CI's branch-name SDK install can never + # surface this, so the test simulates the floor by nulling the method. + with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_favorite', + new=None, create=True): + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'rules', 'favorite', '5']) + assert result.exit_code == 2, result.output + assert 'requires a polyswarm-api release newer than 4.3.0' in result.output + assert 'AttributeError' not in result.output + + @_needs_favorite_method + def test_other_refusals_still_raise(self): + request = mock.Mock() + request.errors = None + refusal = exceptions.RequestException(request) + with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_favorite', + autospec=True, side_effect=refusal): + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'rules', 'favorite', '5']) + assert result.exit_code == 2 # PolyswarmException family + assert 'FAVORITE_LIMIT' not in result.output From b430a14f976031af2c0c4e1455ebd67a2603983d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Mart=C3=ADnez?= Date: Wed, 26 Aug 2026 11:03:11 -0400 Subject: [PATCH 07/54] test: re-record hunt cassettes against the stored-counter server All ruleset/live/historical cassettes (and their click snapshots) re-recorded against a stack running the paired server branch: every ruleset body carries the four tracking keys, the LIST bodies additionally carry the stored new_results_count / new_results_counted_at pair (the detail serializer deliberately does not render the badge), livescan_id is a digit string on every surface, and the fixture ids are this recording run's own resources. New recordings cover the favorite toggle's happy path in both output formats, unfavorite, and a real FAVORITE_LIMIT refusal recorded against a genuinely full team budget. --- .../test_historical_hunt_create_json.click | 21 +-- .../vcr/test_historical_hunt_create_json.vcr | 131 ++++++------------ .../test_historical_hunt_create_text.click | 16 +-- .../vcr/test_historical_hunt_create_text.vcr | 131 ++++++------------ .../test_historical_hunt_delete_json.click | 21 +-- .../vcr/test_historical_hunt_delete_json.vcr | 107 +++++--------- .../test_historical_hunt_delete_text.click | 22 +-- .../vcr/test_historical_hunt_delete_text.vcr | 107 +++++--------- .../vcr/test_historical_hunt_list_json.click | 28 +++- tests/vcr/test_historical_hunt_list_json.vcr | 40 +++--- .../vcr/test_historical_hunt_list_text.click | 19 ++- tests/vcr/test_historical_hunt_list_text.vcr | 40 +++--- tests/vcr/test_live_hunt_start_json.click | 23 +-- tests/vcr/test_live_hunt_start_json.vcr | 64 +++++---- tests/vcr/test_live_hunt_start_text.click | 16 ++- tests/vcr/test_live_hunt_start_text.vcr | 64 +++++---- tests/vcr/test_live_hunt_stop_json.click | 23 +-- tests/vcr/test_live_hunt_stop_json.vcr | 64 +++++---- tests/vcr/test_live_hunt_stop_text.click | 12 +- tests/vcr/test_live_hunt_stop_text.vcr | 64 +++++---- tests/vcr/test_ruleset_create_json.click | 22 +-- tests/vcr/test_ruleset_create_json.vcr | 131 ++++++------------ tests/vcr/test_ruleset_delete_json.click | 21 +-- tests/vcr/test_ruleset_delete_json.vcr | 107 +++++--------- tests/vcr/test_ruleset_favorite_json.click | 4 + tests/vcr/test_ruleset_favorite_json.vcr | 48 +++++++ .../test_ruleset_favorite_limit_text.click | 4 + .../vcr/test_ruleset_favorite_limit_text.vcr | 47 +++++++ tests/vcr/test_ruleset_favorite_text.click | 10 ++ tests/vcr/test_ruleset_favorite_text.vcr | 48 +++++++ tests/vcr/test_ruleset_list_json.click | 22 ++- tests/vcr/test_ruleset_list_json.vcr | 40 +++--- tests/vcr/test_ruleset_unfavorite_text.click | 8 ++ tests/vcr/test_ruleset_unfavorite_text.vcr | 48 +++++++ tests/vcr/test_ruleset_update_json.click | 21 +-- tests/vcr/test_ruleset_update_json.vcr | 109 +++++---------- tests/vcr/test_ruleset_view_json.click | 23 +-- tests/vcr/test_ruleset_view_json.vcr | 58 ++++---- 38 files changed, 923 insertions(+), 861 deletions(-) create mode 100644 tests/vcr/test_ruleset_favorite_json.click create mode 100644 tests/vcr/test_ruleset_favorite_json.vcr create mode 100644 tests/vcr/test_ruleset_favorite_limit_text.click create mode 100644 tests/vcr/test_ruleset_favorite_limit_text.vcr create mode 100644 tests/vcr/test_ruleset_favorite_text.click create mode 100644 tests/vcr/test_ruleset_favorite_text.vcr create mode 100644 tests/vcr/test_ruleset_unfavorite_text.click create mode 100644 tests/vcr/test_ruleset_unfavorite_text.vcr diff --git a/tests/vcr/test_historical_hunt_create_json.click b/tests/vcr/test_historical_hunt_create_json.click index 08798308..e2d1f319 100644 --- a/tests/vcr/test_historical_hunt_create_json.click +++ b/tests/vcr/test_historical_hunt_create_json.click @@ -1,15 +1,18 @@ -result: '{"created": "2022-05-26T19:08:30.323397", "id": "96916002705221564", "progress": - null, "results_csv_uri": null, "ruleset_name": "eicar.yara", "status": "PENDING", - "summary": null, "yara": "rule eicar_av_test {\n /*\n Per standard, match - only if entire file is EICAR string plus optional trailing whitespace.\n The +result: '{"account_number": "111", "archives_in_flight": 0, "archives_scanned": 0, + "archives_total": 0, "communities": ["gamma"], "created": "2026-08-25T18:27:00.017968+00:00", + "failed_max_retries": 0, "failed_other": 0, "id": "58957063950682201", "progress": + null, "results_csv_uri": null, "rule_id": null, "rule_modified": null, "ruleset_name": + "eicar.yara", "source_rule_changed": null, "status": "PENDING", "summary": null, + "user_account_number": "111", "yara": "rule eicar_av_test : eicar match {\n /*\n Per + standard, match only if entire file is EICAR string plus optional trailing whitespace.\n The raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match just - the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin - Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all - of them\n}"} + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, PDFs, + etc)\n */\n\n meta:\n description = \"Standard AV test, checking for + an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring + = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"} ' diff --git a/tests/vcr/test_historical_hunt_create_json.vcr b/tests/vcr/test_historical_hunt_create_json.vcr index 983df6c0..384aee42 100644 --- a/tests/vcr/test_historical_hunt_create_json.vcr +++ b/tests/vcr/test_historical_hunt_create_json.vcr @@ -1,117 +1,72 @@ interactions: - request: - body: '{"yara": "rule eicar_av_test {\n /*\n Per standard, match only - if entire file is EICAR string plus optional trailing whitespace.\n The + body: '{"yara":"rule eicar_av_test : eicar match {\n /*\n Per standard, + match only if entire file is EICAR string plus optional trailing whitespace.\n The raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin - Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all - of them\n}", "ruleset_name": "eicar.yara"}' + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, checking + for an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring + = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of + them\n}","ruleset_name":"eicar.yara","community":"gamma"}' headers: - Accept: + accept: - '*/*' - Accept-Encoding: + accept-encoding: - gzip, deflate - Authorization: + authorization: - '11111111111111111111111111111111' - Connection: + connection: - keep-alive - Content-Length: - - '1143' - Content-Type: + content-length: + - '1192' + content-type: - application/json - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: POST uri: http://artifact-index-e2e:9696/v3/hunt/historical response: body: - string: ' - - Redirecting... - -

Redirecting...

- -

You should be redirected automatically to target URL: http://artifact-index-e2e:9696/v3/hunt/historical/. If - not click the link.' - headers: - Content-Length: - - '307' - Content-Type: - - text/html; charset=utf-8 - Date: - - Thu, 26 May 2022 19:08:30 GMT - Location: - - http://artifact-index-e2e:9696/v3/hunt/historical/ - Server: - - Werkzeug/1.0.1 Python/3.9.6 - status: - code: 308 - message: PERMANENT REDIRECT -- request: - body: '{"yara": "rule eicar_av_test {\n /*\n Per standard, match only - if entire file is EICAR string plus optional trailing whitespace.\n The - raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description - = \"This is a standard AV test, intended to verify that BinaryAlert is working - correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference - = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex - = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin - Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all - of them\n}", "ruleset_name": "eicar.yara"}' - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Authorization: - - '11111111111111111111111111111111' - Connection: - - keep-alive - Content-Length: - - '1143' - Content-Type: - - application/json - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) - method: POST - uri: http://artifact-index-e2e:9696/v3/hunt/historical/ - response: - body: - string: '{"result":{"created":"2022-05-26T19:08:30.323397","id":"96916002705221564","progress":null,"results_csv_uri":null,"ruleset_name":"eicar.yara","status":"PENDING","summary":null,"yara":"rule - eicar_av_test {\n /*\n Per standard, match only if entire file is - EICAR string plus optional trailing whitespace.\n The raw EICAR string - to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + string: '{"result":{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"communities":["gamma"],"created":"2026-08-25T18:27:00.017968+00:00","failed_max_retries":0,"failed_other":0,"id":"58957063950682201","progress":null,"results_csv_uri":null,"rule_id":null,"rule_modified":null,"ruleset_name":"eicar.yara","source_rule_changed":null,"status":"PENDING","summary":null,"user_account_number":"111","yara":"rule + eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = - \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring - = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, + checking for an EICAR substring\"\n author = \"Austin Byers | Airbnb + CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"},"status":"OK"} ' headers: - Content-Length: - - '1303' - Content-Type: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '1582' + content-type: - application/json - Date: - - Thu, 26 May 2022 19:08:30 GMT - Server: - - Werkzeug/1.0.1 Python/3.9.6 - X-Billing-ID: - - '1' + date: + - Tue, 25 Aug 2026 18:27:00 GMT + server: + - gunicorn + x-billing-id: + - '111' status: code: 200 message: OK diff --git a/tests/vcr/test_historical_hunt_create_text.click b/tests/vcr/test_historical_hunt_create_text.click index 362500b2..cebc53bd 100644 --- a/tests/vcr/test_historical_hunt_create_text.click +++ b/tests/vcr/test_historical_hunt_create_text.click @@ -1,15 +1,15 @@ -result: "Hunt Id: 75914219779430298\nStatus: PENDING\nCreated at: 2022-05-26 19:08:30.527759\n\ - Ruleset Name: eicar.yara\nRuleset Contents:\nrule eicar_av_test {\n /*\n \ - \ Per standard, match only if entire file is EICAR string plus optional trailing\ - \ whitespace.\n The raw EICAR string to be matched is:\n X5O!P%@AP[4\\\ +result: "Hunt Id: 67346782704448208\nStatus: PENDING\nCreated at: 2026-08-25 18:27:00.531607+00:00\n\ + Ruleset Name: eicar.yara\nRuleset Contents:\nrule eicar_av_test : eicar match {\n\ + \ /*\n Per standard, match only if entire file is EICAR string plus optional\ + \ trailing whitespace.\n The raw EICAR string to be matched is:\n X5O!P%@AP[4\\\ PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n\ \ description = \"This is a standard AV test, intended to verify that BinaryAlert\ \ is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n \ \ reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n\ \ $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\\ $H\\+H\\*\\s*$/\n\n condition:\n all of them\n}\n\nrule eicar_substring_test\ - \ {\n /*\n More generic - match just the embedded EICAR string (e.g. in\ - \ packed executables, PDFs, etc)\n */\n\n meta:\n description = \"\ - Standard AV test, checking for an EICAR substring\"\n author = \"Austin Byers\ - \ | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\ + \ : eicar substring {\n /*\n More generic - match just the embedded EICAR\ + \ string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description\ + \ = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin\ + \ Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\ \n\n condition:\n all of them\n}\n\n" diff --git a/tests/vcr/test_historical_hunt_create_text.vcr b/tests/vcr/test_historical_hunt_create_text.vcr index b93f9613..b5253c5b 100644 --- a/tests/vcr/test_historical_hunt_create_text.vcr +++ b/tests/vcr/test_historical_hunt_create_text.vcr @@ -1,117 +1,72 @@ interactions: - request: - body: '{"yara": "rule eicar_av_test {\n /*\n Per standard, match only - if entire file is EICAR string plus optional trailing whitespace.\n The + body: '{"yara":"rule eicar_av_test : eicar match {\n /*\n Per standard, + match only if entire file is EICAR string plus optional trailing whitespace.\n The raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin - Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all - of them\n}", "ruleset_name": "eicar.yara"}' + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, checking + for an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring + = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of + them\n}","ruleset_name":"eicar.yara","community":"gamma"}' headers: - Accept: + accept: - '*/*' - Accept-Encoding: + accept-encoding: - gzip, deflate - Authorization: + authorization: - '11111111111111111111111111111111' - Connection: + connection: - keep-alive - Content-Length: - - '1143' - Content-Type: + content-length: + - '1192' + content-type: - application/json - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: POST uri: http://artifact-index-e2e:9696/v3/hunt/historical response: body: - string: ' - - Redirecting... - -

Redirecting...

- -

You should be redirected automatically to target URL: http://artifact-index-e2e:9696/v3/hunt/historical/. If - not click the link.' - headers: - Content-Length: - - '307' - Content-Type: - - text/html; charset=utf-8 - Date: - - Thu, 26 May 2022 19:08:30 GMT - Location: - - http://artifact-index-e2e:9696/v3/hunt/historical/ - Server: - - Werkzeug/1.0.1 Python/3.9.6 - status: - code: 308 - message: PERMANENT REDIRECT -- request: - body: '{"yara": "rule eicar_av_test {\n /*\n Per standard, match only - if entire file is EICAR string plus optional trailing whitespace.\n The - raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description - = \"This is a standard AV test, intended to verify that BinaryAlert is working - correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference - = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex - = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin - Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all - of them\n}", "ruleset_name": "eicar.yara"}' - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Authorization: - - '11111111111111111111111111111111' - Connection: - - keep-alive - Content-Length: - - '1143' - Content-Type: - - application/json - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) - method: POST - uri: http://artifact-index-e2e:9696/v3/hunt/historical/ - response: - body: - string: '{"result":{"created":"2022-05-26T19:08:30.527759","id":"75914219779430298","progress":null,"results_csv_uri":null,"ruleset_name":"eicar.yara","status":"PENDING","summary":null,"yara":"rule - eicar_av_test {\n /*\n Per standard, match only if entire file is - EICAR string plus optional trailing whitespace.\n The raw EICAR string - to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + string: '{"result":{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"communities":["gamma"],"created":"2026-08-25T18:27:00.531607+00:00","failed_max_retries":0,"failed_other":0,"id":"67346782704448208","progress":null,"results_csv_uri":null,"rule_id":null,"rule_modified":null,"ruleset_name":"eicar.yara","source_rule_changed":null,"status":"PENDING","summary":null,"user_account_number":"111","yara":"rule + eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = - \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring - = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, + checking for an EICAR substring\"\n author = \"Austin Byers | Airbnb + CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"},"status":"OK"} ' headers: - Content-Length: - - '1303' - Content-Type: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '1582' + content-type: - application/json - Date: - - Thu, 26 May 2022 19:08:30 GMT - Server: - - Werkzeug/1.0.1 Python/3.9.6 - X-Billing-ID: - - '1' + date: + - Tue, 25 Aug 2026 18:27:00 GMT + server: + - gunicorn + x-billing-id: + - '111' status: code: 200 message: OK diff --git a/tests/vcr/test_historical_hunt_delete_json.click b/tests/vcr/test_historical_hunt_delete_json.click index 48004f53..4c7f80e8 100644 --- a/tests/vcr/test_historical_hunt_delete_json.click +++ b/tests/vcr/test_historical_hunt_delete_json.click @@ -1,15 +1,18 @@ -result: '{"created": "2022-05-26T19:08:30.527759", "id": "75914219779430298", "progress": - null, "results_csv_uri": null, "ruleset_name": "eicar.yara", "status": "DELETING", - "summary": null, "yara": "rule eicar_av_test {\n /*\n Per standard, match - only if entire file is EICAR string plus optional trailing whitespace.\n The +result: '{"account_number": "111", "archives_in_flight": 0, "archives_scanned": 0, + "archives_total": 0, "communities": ["gamma"], "created": "2026-08-25T18:25:52.397034+00:00", + "failed_max_retries": 0, "failed_other": 0, "id": "32808041501095355", "progress": + null, "results_csv_uri": null, "rule_id": null, "rule_modified": null, "ruleset_name": + null, "source_rule_changed": null, "status": "DELETING", "summary": null, "user_account_number": + "111", "yara": "rule eicar_av_test : eicar match {\n /*\n Per standard, + match only if entire file is EICAR string plus optional trailing whitespace.\n The raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match just - the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin - Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all - of them\n}"} + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, PDFs, + etc)\n */\n\n meta:\n description = \"Standard AV test, checking for + an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring + = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"} ' diff --git a/tests/vcr/test_historical_hunt_delete_json.vcr b/tests/vcr/test_historical_hunt_delete_json.vcr index 89cae161..b94d26cc 100644 --- a/tests/vcr/test_historical_hunt_delete_json.vcr +++ b/tests/vcr/test_historical_hunt_delete_json.vcr @@ -1,91 +1,60 @@ interactions: - request: - body: null + body: '{"community":"gamma"}' headers: - Accept: + accept: - '*/*' - Accept-Encoding: + accept-encoding: - gzip, deflate - Authorization: + authorization: - '11111111111111111111111111111111' - Connection: + connection: - keep-alive - Content-Length: - - '0' - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) - method: DELETE - uri: http://artifact-index-e2e:9696/v3/hunt/historical?id=75914219779430298 - response: - body: - string: ' - - Redirecting... - -

Redirecting...

- -

You should be redirected automatically to target URL: http://artifact-index-e2e:9696/v3/hunt/historical/?id=75914219779430298. If - not click the link.' - headers: - Content-Length: - - '349' - Content-Type: - - text/html; charset=utf-8 - Date: - - Thu, 26 May 2022 19:15:28 GMT - Location: - - http://artifact-index-e2e:9696/v3/hunt/historical/?id=75914219779430298 - Server: - - Werkzeug/1.0.1 Python/3.9.6 - status: - code: 308 - message: PERMANENT REDIRECT -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Authorization: - - '11111111111111111111111111111111' - Connection: - - keep-alive - Content-Length: - - '0' - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) + content-length: + - '21' + content-type: + - application/json + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: DELETE - uri: http://artifact-index-e2e:9696/v3/hunt/historical/?id=75914219779430298 + uri: http://artifact-index-e2e:9696/v3/hunt/historical?id=32808041501095355 response: body: - string: '{"result":{"created":"2022-05-26T19:08:30.527759","id":"75914219779430298","progress":null,"results_csv_uri":null,"ruleset_name":"eicar.yara","status":"DELETING","summary":null,"yara":"rule - eicar_av_test {\n /*\n Per standard, match only if entire file is - EICAR string plus optional trailing whitespace.\n The raw EICAR string - to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + string: '{"result":{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"communities":["gamma"],"created":"2026-08-25T18:25:52.397034+00:00","failed_max_retries":0,"failed_other":0,"id":"32808041501095355","progress":null,"results_csv_uri":null,"rule_id":null,"rule_modified":null,"ruleset_name":null,"source_rule_changed":null,"status":"DELETING","summary":null,"user_account_number":"111","yara":"rule + eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = - \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring - = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, + checking for an EICAR substring\"\n author = \"Austin Byers | Airbnb + CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"},"status":"OK"} ' headers: - Content-Length: - - '1304' - Content-Type: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '1575' + content-type: - application/json - Date: - - Thu, 26 May 2022 19:15:28 GMT - Server: - - Werkzeug/1.0.1 Python/3.9.6 - X-Billing-ID: - - '1' + date: + - Tue, 25 Aug 2026 18:27:00 GMT + server: + - gunicorn + x-billing-id: + - '111' status: code: 200 message: OK diff --git a/tests/vcr/test_historical_hunt_delete_text.click b/tests/vcr/test_historical_hunt_delete_text.click index 6bcf5b27..d4913923 100644 --- a/tests/vcr/test_historical_hunt_delete_text.click +++ b/tests/vcr/test_historical_hunt_delete_text.click @@ -1,16 +1,16 @@ -result: "Successfully deleted Hunt:\nHunt Id: 96916002705221564\nStatus: DELETING\n\ - Created at: 2022-05-26 19:08:30.323397\nRuleset Name: eicar.yara\nRuleset Contents:\n\ - rule eicar_av_test {\n /*\n Per standard, match only if entire file is\ - \ EICAR string plus optional trailing whitespace.\n The raw EICAR string to\ - \ be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n\ +result: "Successfully deleted Hunt:\nHunt Id: 3220090199138422\nStatus: DELETING\n\ + Created at: 2026-08-25 18:25:52.550248+00:00\nRuleset Contents:\nrule eicar_av_test\ + \ : eicar match {\n /*\n Per standard, match only if entire file is EICAR\ + \ string plus optional trailing whitespace.\n The raw EICAR string to be matched\ + \ is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n\ \ */\n\n meta:\n description = \"This is a standard AV test, intended\ \ to verify that BinaryAlert is working correctly.\"\n author = \"Austin\ \ Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\ \n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\\ )7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n\ - \ all of them\n}\n\nrule eicar_substring_test {\n /*\n More generic\ - \ - match just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n\ - \ */\n\n meta:\n description = \"Standard AV test, checking for an\ - \ EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n\ - \ $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n\ - \ all of them\n}\n\n" + \ all of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n\ + \ More generic - match just the embedded EICAR string (e.g. in packed executables,\ + \ PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, checking\ + \ for an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n\ + \ strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\ + \n\n condition:\n all of them\n}\n\n" diff --git a/tests/vcr/test_historical_hunt_delete_text.vcr b/tests/vcr/test_historical_hunt_delete_text.vcr index 8dd487bb..9b8dddc8 100644 --- a/tests/vcr/test_historical_hunt_delete_text.vcr +++ b/tests/vcr/test_historical_hunt_delete_text.vcr @@ -1,91 +1,60 @@ interactions: - request: - body: null + body: '{"community":"gamma"}' headers: - Accept: + accept: - '*/*' - Accept-Encoding: + accept-encoding: - gzip, deflate - Authorization: + authorization: - '11111111111111111111111111111111' - Connection: + connection: - keep-alive - Content-Length: - - '0' - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) - method: DELETE - uri: http://artifact-index-e2e:9696/v3/hunt/historical?id=96916002705221564 - response: - body: - string: ' - - Redirecting... - -

Redirecting...

- -

You should be redirected automatically to target URL: http://artifact-index-e2e:9696/v3/hunt/historical/?id=96916002705221564. If - not click the link.' - headers: - Content-Length: - - '349' - Content-Type: - - text/html; charset=utf-8 - Date: - - Thu, 26 May 2022 19:15:28 GMT - Location: - - http://artifact-index-e2e:9696/v3/hunt/historical/?id=96916002705221564 - Server: - - Werkzeug/1.0.1 Python/3.9.6 - status: - code: 308 - message: PERMANENT REDIRECT -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Authorization: - - '11111111111111111111111111111111' - Connection: - - keep-alive - Content-Length: - - '0' - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) + content-length: + - '21' + content-type: + - application/json + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: DELETE - uri: http://artifact-index-e2e:9696/v3/hunt/historical/?id=96916002705221564 + uri: http://artifact-index-e2e:9696/v3/hunt/historical?id=3220090199138422 response: body: - string: '{"result":{"created":"2022-05-26T19:08:30.323397","id":"96916002705221564","progress":null,"results_csv_uri":null,"ruleset_name":"eicar.yara","status":"DELETING","summary":null,"yara":"rule - eicar_av_test {\n /*\n Per standard, match only if entire file is - EICAR string plus optional trailing whitespace.\n The raw EICAR string - to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + string: '{"result":{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"communities":["gamma"],"created":"2026-08-25T18:25:52.550248+00:00","failed_max_retries":0,"failed_other":0,"id":"3220090199138422","progress":null,"results_csv_uri":null,"rule_id":null,"rule_modified":null,"ruleset_name":null,"source_rule_changed":null,"status":"DELETING","summary":null,"user_account_number":"111","yara":"rule + eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = - \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring - = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, + checking for an EICAR substring\"\n author = \"Austin Byers | Airbnb + CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"},"status":"OK"} ' headers: - Content-Length: - - '1304' - Content-Type: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '1574' + content-type: - application/json - Date: - - Thu, 26 May 2022 19:15:28 GMT - Server: - - Werkzeug/1.0.1 Python/3.9.6 - X-Billing-ID: - - '1' + date: + - Tue, 25 Aug 2026 18:27:00 GMT + server: + - gunicorn + x-billing-id: + - '111' status: code: 200 message: OK diff --git a/tests/vcr/test_historical_hunt_list_json.click b/tests/vcr/test_historical_hunt_list_json.click index bc69e1a2..0440b812 100644 --- a/tests/vcr/test_historical_hunt_list_json.click +++ b/tests/vcr/test_historical_hunt_list_json.click @@ -1,9 +1,25 @@ -result: '{"created": "2023-08-23T15:14:52.254659", "id": "30246442833374528", "progress": - null, "results_csv_uri": null, "ruleset_name": "eicar.yara", "status": "PENDING", - "summary": null, "yara": null} +result: '{"account_number": "111", "archives_in_flight": 0, "archives_scanned": 0, + "archives_total": 0, "created": "2026-08-25T18:27:00.531607+00:00", "id": "67346782704448208", + "progress": null, "results_csv_uri": null, "rule_id": null, "rule_modified": null, + "ruleset_name": "eicar.yara", "source_rule_changed": null, "status": "PENDING", + "summary": null, "user_account_number": "111", "yara": null} - {"created": "2023-08-23T15:12:38.073323", "id": "76083665328102613", "progress": - 100.0, "results_csv_uri": null, "ruleset_name": "eicar.yara", "status": "STOPPED", - "summary": null, "yara": null} + {"account_number": "111", "archives_in_flight": 0, "archives_scanned": 0, "archives_total": + 0, "created": "2026-08-25T18:27:00.017968+00:00", "id": "58957063950682201", "progress": + null, "results_csv_uri": null, "rule_id": null, "rule_modified": null, "ruleset_name": + "eicar.yara", "source_rule_changed": null, "status": "PENDING", "summary": null, + "user_account_number": "111", "yara": null} + + {"account_number": "111", "archives_in_flight": 0, "archives_scanned": 0, "archives_total": + 0, "created": "2026-08-25T18:25:52.550248+00:00", "id": "3220090199138422", "progress": + null, "results_csv_uri": null, "rule_id": null, "rule_modified": null, "ruleset_name": + null, "source_rule_changed": null, "status": "DELETING", "summary": null, "user_account_number": + "111", "yara": null} + + {"account_number": "111", "archives_in_flight": 0, "archives_scanned": 0, "archives_total": + 0, "created": "2026-08-25T18:25:52.397034+00:00", "id": "32808041501095355", "progress": + null, "results_csv_uri": null, "rule_id": null, "rule_modified": null, "ruleset_name": + null, "source_rule_changed": null, "status": "DELETING", "summary": null, "user_account_number": + "111", "yara": null} ' diff --git a/tests/vcr/test_historical_hunt_list_json.vcr b/tests/vcr/test_historical_hunt_list_json.vcr index 7a09c347..1861c740 100644 --- a/tests/vcr/test_historical_hunt_list_json.vcr +++ b/tests/vcr/test_historical_hunt_list_json.vcr @@ -1,37 +1,43 @@ interactions: - request: - body: null + body: '' headers: - Accept: + accept: - '*/*' - Accept-Encoding: + accept-encoding: - gzip, deflate - Authorization: + authorization: - '11111111111111111111111111111111' - Connection: + connection: - keep-alive - User-Agent: - - polyswarm-api/3.4.2 (x86_64-Linux-CPython-3.10.7) + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: GET uri: http://artifact-index-e2e:9696/v3/hunt/historical/list?community=gamma response: body: - string: '{"has_more":false,"limit":2,"result":[{"created":"2023-08-23T15:14:52.254659","id":"30246442833374528","progress":null,"results_csv_uri":null,"ruleset_name":"eicar.yara","status":"PENDING","summary":null,"yara":null},{"created":"2023-08-23T15:12:38.073323","id":"76083665328102613","progress":100.0,"results_csv_uri":null,"ruleset_name":"eicar.yara","status":"STOPPED","summary":null,"yara":null}],"status":"OK"} + string: '{"has_more":false,"limit":50,"result":[{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"created":"2026-08-25T18:27:00.531607+00:00","id":"67346782704448208","progress":null,"results_csv_uri":null,"rule_id":null,"rule_modified":null,"ruleset_name":"eicar.yara","source_rule_changed":null,"status":"PENDING","summary":null,"user_account_number":"111","yara":null},{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"created":"2026-08-25T18:27:00.017968+00:00","id":"58957063950682201","progress":null,"results_csv_uri":null,"rule_id":null,"rule_modified":null,"ruleset_name":"eicar.yara","source_rule_changed":null,"status":"PENDING","summary":null,"user_account_number":"111","yara":null},{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"created":"2026-08-25T18:25:52.550248+00:00","id":"3220090199138422","progress":null,"results_csv_uri":null,"rule_id":null,"rule_modified":null,"ruleset_name":null,"source_rule_changed":null,"status":"DELETING","summary":null,"user_account_number":"111","yara":null},{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"created":"2026-08-25T18:25:52.397034+00:00","id":"32808041501095355","progress":null,"results_csv_uri":null,"rule_id":null,"rule_modified":null,"ruleset_name":null,"source_rule_changed":null,"status":"DELETING","summary":null,"user_account_number":"111","yara":null}],"status":"OK"} ' headers: - Connection: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: - keep-alive - Content-Length: - - '413' - Content-Type: + content-length: + - '1488' + content-type: - application/json - Date: - - Wed, 23 Aug 2023 15:15:11 GMT - Server: + date: + - Tue, 25 Aug 2026 18:27:00 GMT + server: - gunicorn - X-Billing-ID: - - '1' + x-billing-id: + - '111' status: code: 200 message: OK diff --git a/tests/vcr/test_historical_hunt_list_text.click b/tests/vcr/test_historical_hunt_list_text.click index c7cb71db..b768fd9f 100644 --- a/tests/vcr/test_historical_hunt_list_text.click +++ b/tests/vcr/test_historical_hunt_list_text.click @@ -1,21 +1,26 @@ -result: 'Hunt Id: 30246442833374528 +result: 'Hunt Id: 67346782704448208 Status: PENDING - Created at: 2023-08-23 15:14:52.254659 + Created at: 2026-08-25 18:27:00.531607+00:00 Ruleset Name: eicar.yara - Hunt Id: 76083665328102613 + Hunt Id: 58957063950682201 - Status: STOPPED - - Progress: 100.00% + Status: PENDING - Created at: 2023-08-23 15:12:38.073323 + Created at: 2026-08-25 18:27:00.017968+00:00 Ruleset Name: eicar.yara + Hunt Id: 3220090199138422 + + Status: DELETING + + Created at: 2026-08-25 18:25:52.550248+00:00 + + ' diff --git a/tests/vcr/test_historical_hunt_list_text.vcr b/tests/vcr/test_historical_hunt_list_text.vcr index 7a09c347..c041bbdd 100644 --- a/tests/vcr/test_historical_hunt_list_text.vcr +++ b/tests/vcr/test_historical_hunt_list_text.vcr @@ -1,37 +1,43 @@ interactions: - request: - body: null + body: '' headers: - Accept: + accept: - '*/*' - Accept-Encoding: + accept-encoding: - gzip, deflate - Authorization: + authorization: - '11111111111111111111111111111111' - Connection: + connection: - keep-alive - User-Agent: - - polyswarm-api/3.4.2 (x86_64-Linux-CPython-3.10.7) + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: GET uri: http://artifact-index-e2e:9696/v3/hunt/historical/list?community=gamma response: body: - string: '{"has_more":false,"limit":2,"result":[{"created":"2023-08-23T15:14:52.254659","id":"30246442833374528","progress":null,"results_csv_uri":null,"ruleset_name":"eicar.yara","status":"PENDING","summary":null,"yara":null},{"created":"2023-08-23T15:12:38.073323","id":"76083665328102613","progress":100.0,"results_csv_uri":null,"ruleset_name":"eicar.yara","status":"STOPPED","summary":null,"yara":null}],"status":"OK"} + string: '{"has_more":false,"limit":50,"result":[{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"created":"2026-08-25T18:27:00.531607+00:00","id":"67346782704448208","progress":null,"results_csv_uri":null,"rule_id":null,"rule_modified":null,"ruleset_name":"eicar.yara","source_rule_changed":null,"status":"PENDING","summary":null,"user_account_number":"111","yara":null},{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"created":"2026-08-25T18:27:00.017968+00:00","id":"58957063950682201","progress":null,"results_csv_uri":null,"rule_id":null,"rule_modified":null,"ruleset_name":"eicar.yara","source_rule_changed":null,"status":"PENDING","summary":null,"user_account_number":"111","yara":null},{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"created":"2026-08-25T18:25:52.550248+00:00","id":"3220090199138422","progress":null,"results_csv_uri":null,"rule_id":null,"rule_modified":null,"ruleset_name":null,"source_rule_changed":null,"status":"DELETING","summary":null,"user_account_number":"111","yara":null}],"status":"OK"} ' headers: - Connection: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: - keep-alive - Content-Length: - - '413' - Content-Type: + content-length: + - '1133' + content-type: - application/json - Date: - - Wed, 23 Aug 2023 15:15:11 GMT - Server: + date: + - Tue, 25 Aug 2026 18:27:01 GMT + server: - gunicorn - X-Billing-ID: - - '1' + x-billing-id: + - '111' status: code: 200 message: OK diff --git a/tests/vcr/test_live_hunt_start_json.click b/tests/vcr/test_live_hunt_start_json.click index fa12dd1c..7d2b5160 100644 --- a/tests/vcr/test_live_hunt_start_json.click +++ b/tests/vcr/test_live_hunt_start_json.click @@ -1,16 +1,17 @@ -result: '{"created": "2022-05-26T18:25:35.109366", "deleted": false, "description": - null, "id": "17388152480558505", "livescan_created": "2022-05-26T19:37:18.353094", - "livescan_id": 51856636346307547, "modified": "2022-05-26T19:37:18.284777", "name": - "eicar", "yara": "rule eicar_av_test {\n /*\n Per standard, match only - if entire file is EICAR string plus optional trailing whitespace.\n The raw - EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description +result: '{"created": "2026-08-25T18:25:52.247009+00:00", "deleted": false, "description": + null, "favorite": false, "favorited_at": null, "historical_hunt_count": 0, "id": + "44051669277897879", "livescan_created": "2026-08-25T18:26:47.595698+00:00", "livescan_id": + "60545835221721456", "modified": "2026-08-25T18:26:47.582495+00:00", "name": "recording-live", + "rule_count": 2, "yara": "rule eicar_av_test : eicar match {\n /*\n Per + standard, match only if entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match just - the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin - Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all - of them\n}"} + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, PDFs, + etc)\n */\n\n meta:\n description = \"Standard AV test, checking for + an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring + = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"} ' diff --git a/tests/vcr/test_live_hunt_start_json.vcr b/tests/vcr/test_live_hunt_start_json.vcr index 04cfe55b..36bcf591 100644 --- a/tests/vcr/test_live_hunt_start_json.vcr +++ b/tests/vcr/test_live_hunt_start_json.vcr @@ -1,52 +1,60 @@ interactions: - request: - body: '{"rule_id": "17388152480558505"}' + body: '{"rule_id":"44051669277897879"}' headers: - Accept: + accept: - '*/*' - Accept-Encoding: + accept-encoding: - gzip, deflate - Authorization: + authorization: - '11111111111111111111111111111111' - Connection: + connection: - keep-alive - Content-Length: - - '32' - Content-Type: + content-length: + - '31' + content-type: - application/json - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: POST uri: http://artifact-index-e2e:9696/v3/hunt/rule/live response: body: - string: '{"result":{"created":"2022-05-26T18:25:35.109366","deleted":false,"description":null,"id":"17388152480558505","livescan_created":"2022-05-26T19:37:18.353094","livescan_id":51856636346307547,"modified":"2022-05-26T19:37:18.284777","name":"eicar","yara":"rule - eicar_av_test {\n /*\n Per standard, match only if entire file is - EICAR string plus optional trailing whitespace.\n The raw EICAR string - to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + string: '{"result":{"created":"2026-08-25T18:25:52.247009+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"44051669277897879","livescan_created":"2026-08-25T18:26:47.595698+00:00","livescan_id":"60545835221721456","modified":"2026-08-25T18:26:47.582495+00:00","name":"recording-live","rule_count":2,"yara":"rule + eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = - \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring - = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, + checking for an EICAR substring\"\n author = \"Austin Byers | Airbnb + CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"},"status":"OK"} ' headers: - Content-Length: - - '1372' - Content-Type: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '1511' + content-type: - application/json - Date: - - Thu, 26 May 2022 19:37:18 GMT - Server: - - Werkzeug/1.0.1 Python/3.9.6 - X-Billing-ID: - - '1' + date: + - Tue, 25 Aug 2026 18:26:47 GMT + server: + - gunicorn + x-billing-id: + - '111' status: code: 200 message: OK diff --git a/tests/vcr/test_live_hunt_start_text.click b/tests/vcr/test_live_hunt_start_text.click index 5448ac4a..0b03df7d 100644 --- a/tests/vcr/test_live_hunt_start_text.click +++ b/tests/vcr/test_live_hunt_start_text.click @@ -1,16 +1,20 @@ -result: 'Ruleset Id: 17388152480558505 +result: 'Ruleset Id: 44051669277897879 - Live Hunt Id: 51856636346307547 + Live Hunt Id: 22291404616795831 - Live Hunt Created at: 2022-05-26T19:37:18.353094 + Live Hunt Created at: 2026-08-25T18:26:50.161371+00:00 - Name: eicar + Name: recording-live Description: None - Created at: 2022-05-26 18:25:35.109366 + Created at: 2026-08-25 18:25:52.247009+00:00 - Modified at: 2022-05-26 19:37:18.284777 + Modified at: 2026-08-25 18:26:49.909685+00:00 + + Rules in ruleset: 2 + + Historical hunts triggered: 0 ' diff --git a/tests/vcr/test_live_hunt_start_text.vcr b/tests/vcr/test_live_hunt_start_text.vcr index 04cfe55b..9031e41e 100644 --- a/tests/vcr/test_live_hunt_start_text.vcr +++ b/tests/vcr/test_live_hunt_start_text.vcr @@ -1,52 +1,60 @@ interactions: - request: - body: '{"rule_id": "17388152480558505"}' + body: '{"rule_id":"44051669277897879"}' headers: - Accept: + accept: - '*/*' - Accept-Encoding: + accept-encoding: - gzip, deflate - Authorization: + authorization: - '11111111111111111111111111111111' - Connection: + connection: - keep-alive - Content-Length: - - '32' - Content-Type: + content-length: + - '31' + content-type: - application/json - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: POST uri: http://artifact-index-e2e:9696/v3/hunt/rule/live response: body: - string: '{"result":{"created":"2022-05-26T18:25:35.109366","deleted":false,"description":null,"id":"17388152480558505","livescan_created":"2022-05-26T19:37:18.353094","livescan_id":51856636346307547,"modified":"2022-05-26T19:37:18.284777","name":"eicar","yara":"rule - eicar_av_test {\n /*\n Per standard, match only if entire file is - EICAR string plus optional trailing whitespace.\n The raw EICAR string - to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + string: '{"result":{"created":"2026-08-25T18:25:52.247009+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"44051669277897879","livescan_created":"2026-08-25T18:26:50.161371+00:00","livescan_id":"22291404616795831","modified":"2026-08-25T18:26:49.909685+00:00","name":"recording-live","rule_count":2,"yara":"rule + eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = - \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring - = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, + checking for an EICAR substring\"\n author = \"Austin Byers | Airbnb + CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"},"status":"OK"} ' headers: - Content-Length: - - '1372' - Content-Type: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '1511' + content-type: - application/json - Date: - - Thu, 26 May 2022 19:37:18 GMT - Server: - - Werkzeug/1.0.1 Python/3.9.6 - X-Billing-ID: - - '1' + date: + - Tue, 25 Aug 2026 18:26:50 GMT + server: + - gunicorn + x-billing-id: + - '111' status: code: 200 message: OK diff --git a/tests/vcr/test_live_hunt_stop_json.click b/tests/vcr/test_live_hunt_stop_json.click index 50e506e0..f0df8f68 100644 --- a/tests/vcr/test_live_hunt_stop_json.click +++ b/tests/vcr/test_live_hunt_stop_json.click @@ -1,16 +1,17 @@ -result: '{"created": "2022-05-26T18:25:35.109366", "deleted": false, "description": - null, "id": "17388152480558505", "livescan_created": "2022-05-26T19:37:18.353094", - "livescan_id": null, "modified": "2022-05-26T19:49:57.892219", "name": "eicar", - "yara": "rule eicar_av_test {\n /*\n Per standard, match only if entire - file is EICAR string plus optional trailing whitespace.\n The raw EICAR string - to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description +result: '{"created": "2026-08-25T18:25:52.247009+00:00", "deleted": false, "description": + null, "favorite": false, "favorited_at": null, "historical_hunt_count": 0, "id": + "44051669277897879", "livescan_created": null, "livescan_id": null, "modified": + "2026-08-25T18:26:48.614597+00:00", "name": "recording-live", "rule_count": 2, "yara": + "rule eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The raw EICAR + string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match just - the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin - Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all - of them\n}"} + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, PDFs, + etc)\n */\n\n meta:\n description = \"Standard AV test, checking for + an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring + = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"} ' diff --git a/tests/vcr/test_live_hunt_stop_json.vcr b/tests/vcr/test_live_hunt_stop_json.vcr index e7447ba4..690f2e66 100644 --- a/tests/vcr/test_live_hunt_stop_json.vcr +++ b/tests/vcr/test_live_hunt_stop_json.vcr @@ -1,52 +1,60 @@ interactions: - request: - body: '{"rule_id": "17388152480558505"}' + body: '{"rule_id":"44051669277897879"}' headers: - Accept: + accept: - '*/*' - Accept-Encoding: + accept-encoding: - gzip, deflate - Authorization: + authorization: - '11111111111111111111111111111111' - Connection: + connection: - keep-alive - Content-Length: - - '32' - Content-Type: + content-length: + - '31' + content-type: - application/json - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: DELETE uri: http://artifact-index-e2e:9696/v3/hunt/rule/live response: body: - string: '{"result":{"created":"2022-05-26T18:25:35.109366","deleted":false,"description":null,"id":"17388152480558505","livescan_created":"2022-05-26T19:37:18.353094","livescan_id":null,"modified":"2022-05-26T19:49:57.892219","name":"eicar","yara":"rule - eicar_av_test {\n /*\n Per standard, match only if entire file is - EICAR string plus optional trailing whitespace.\n The raw EICAR string - to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + string: '{"result":{"created":"2026-08-25T18:25:52.247009+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"44051669277897879","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:26:48.614597+00:00","name":"recording-live","rule_count":2,"yara":"rule + eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = - \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring - = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, + checking for an EICAR substring\"\n author = \"Austin Byers | Airbnb + CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"},"status":"OK"} ' headers: - Content-Length: - - '1359' - Content-Type: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '1466' + content-type: - application/json - Date: - - Thu, 26 May 2022 19:49:57 GMT - Server: - - Werkzeug/1.0.1 Python/3.9.6 - X-Billing-ID: - - '1' + date: + - Tue, 25 Aug 2026 18:26:48 GMT + server: + - gunicorn + x-billing-id: + - '111' status: code: 200 message: OK diff --git a/tests/vcr/test_live_hunt_stop_text.click b/tests/vcr/test_live_hunt_stop_text.click index f3cb901e..71a5af3d 100644 --- a/tests/vcr/test_live_hunt_stop_text.click +++ b/tests/vcr/test_live_hunt_stop_text.click @@ -1,12 +1,16 @@ -result: 'Ruleset Id: 17388152480558505 +result: 'Ruleset Id: 44051669277897879 - Name: eicar + Name: recording-live Description: None - Created at: 2022-05-26 18:25:35.109366 + Created at: 2026-08-25 18:25:52.247009+00:00 - Modified at: 2022-05-26 19:49:57.892219 + Modified at: 2026-08-25 18:26:51.237276+00:00 + + Rules in ruleset: 2 + + Historical hunts triggered: 0 ' diff --git a/tests/vcr/test_live_hunt_stop_text.vcr b/tests/vcr/test_live_hunt_stop_text.vcr index e7447ba4..61d9678c 100644 --- a/tests/vcr/test_live_hunt_stop_text.vcr +++ b/tests/vcr/test_live_hunt_stop_text.vcr @@ -1,52 +1,60 @@ interactions: - request: - body: '{"rule_id": "17388152480558505"}' + body: '{"rule_id":"44051669277897879"}' headers: - Accept: + accept: - '*/*' - Accept-Encoding: + accept-encoding: - gzip, deflate - Authorization: + authorization: - '11111111111111111111111111111111' - Connection: + connection: - keep-alive - Content-Length: - - '32' - Content-Type: + content-length: + - '31' + content-type: - application/json - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: DELETE uri: http://artifact-index-e2e:9696/v3/hunt/rule/live response: body: - string: '{"result":{"created":"2022-05-26T18:25:35.109366","deleted":false,"description":null,"id":"17388152480558505","livescan_created":"2022-05-26T19:37:18.353094","livescan_id":null,"modified":"2022-05-26T19:49:57.892219","name":"eicar","yara":"rule - eicar_av_test {\n /*\n Per standard, match only if entire file is - EICAR string plus optional trailing whitespace.\n The raw EICAR string - to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + string: '{"result":{"created":"2026-08-25T18:25:52.247009+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"44051669277897879","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:26:51.237276+00:00","name":"recording-live","rule_count":2,"yara":"rule + eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = - \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring - = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, + checking for an EICAR substring\"\n author = \"Austin Byers | Airbnb + CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"},"status":"OK"} ' headers: - Content-Length: - - '1359' - Content-Type: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '1466' + content-type: - application/json - Date: - - Thu, 26 May 2022 19:49:57 GMT - Server: - - Werkzeug/1.0.1 Python/3.9.6 - X-Billing-ID: - - '1' + date: + - Tue, 25 Aug 2026 18:26:51 GMT + server: + - gunicorn + x-billing-id: + - '111' status: code: 200 message: OK diff --git a/tests/vcr/test_ruleset_create_json.click b/tests/vcr/test_ruleset_create_json.click index 3beb63d0..1aa9d238 100644 --- a/tests/vcr/test_ruleset_create_json.click +++ b/tests/vcr/test_ruleset_create_json.click @@ -1,15 +1,17 @@ -result: '{"created": "2022-05-26T19:08:31.291890", "deleted": false, "description": - null, "id": "71213140536342873", "livescan_created": null, "livescan_id": null, - "modified": "2022-05-26T19:08:31.291890", "name": "test", "yara": "rule eicar_av_test - {\n /*\n Per standard, match only if entire file is EICAR string plus optional - trailing whitespace.\n The raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description +result: '{"created": "2026-08-25T18:26:22.498052+00:00", "deleted": false, "description": + null, "favorite": false, "favorited_at": null, "historical_hunt_count": 0, "id": + "77454540525125655", "livescan_created": null, "livescan_id": null, "modified": + "2026-08-25T18:26:22.498052+00:00", "name": "test", "rule_count": 2, "yara": "rule + eicar_av_test : eicar match {\n /*\n Per standard, match only if entire + file is EICAR string plus optional trailing whitespace.\n The raw EICAR string + to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match just - the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin - Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all - of them\n}"} + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, PDFs, + etc)\n */\n\n meta:\n description = \"Standard AV test, checking for + an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring + = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"} ' diff --git a/tests/vcr/test_ruleset_create_json.vcr b/tests/vcr/test_ruleset_create_json.vcr index d6d5c419..532f8b20 100644 --- a/tests/vcr/test_ruleset_create_json.vcr +++ b/tests/vcr/test_ruleset_create_json.vcr @@ -1,117 +1,72 @@ interactions: - request: - body: '{"yara": "rule eicar_av_test {\n /*\n Per standard, match only - if entire file is EICAR string plus optional trailing whitespace.\n The + body: '{"yara":"rule eicar_av_test : eicar match {\n /*\n Per standard, + match only if entire file is EICAR string plus optional trailing whitespace.\n The raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin - Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all - of them\n}", "name": "test"}' + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, checking + for an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring + = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of + them\n}","name":"test"}' headers: - Accept: + accept: - '*/*' - Accept-Encoding: + accept-encoding: - gzip, deflate - Authorization: + authorization: - '11111111111111111111111111111111' - Connection: + connection: - keep-alive - Content-Length: - - '1129' - Content-Type: + content-length: + - '1158' + content-type: - application/json - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: POST uri: http://artifact-index-e2e:9696/v3/hunt/rule response: body: - string: ' - - Redirecting... - -

Redirecting...

- -

You should be redirected automatically to target URL: http://artifact-index-e2e:9696/v3/hunt/rule/. If - not click the link.' - headers: - Content-Length: - - '295' - Content-Type: - - text/html; charset=utf-8 - Date: - - Thu, 26 May 2022 19:08:31 GMT - Location: - - http://artifact-index-e2e:9696/v3/hunt/rule/ - Server: - - Werkzeug/1.0.1 Python/3.9.6 - status: - code: 308 - message: PERMANENT REDIRECT -- request: - body: '{"yara": "rule eicar_av_test {\n /*\n Per standard, match only - if entire file is EICAR string plus optional trailing whitespace.\n The - raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description - = \"This is a standard AV test, intended to verify that BinaryAlert is working - correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference - = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex - = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin - Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all - of them\n}", "name": "test"}' - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Authorization: - - '11111111111111111111111111111111' - Connection: - - keep-alive - Content-Length: - - '1129' - Content-Type: - - application/json - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) - method: POST - uri: http://artifact-index-e2e:9696/v3/hunt/rule/ - response: - body: - string: '{"result":{"created":"2022-05-26T19:08:31.291890","deleted":false,"description":null,"id":"71213140536342873","livescan_created":null,"livescan_id":null,"modified":"2022-05-26T19:08:31.291890","name":"test","yara":"rule - eicar_av_test {\n /*\n Per standard, match only if entire file is - EICAR string plus optional trailing whitespace.\n The raw EICAR string - to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + string: '{"result":{"created":"2026-08-25T18:26:22.498052+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"77454540525125655","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:26:22.498052+00:00","name":"test","rule_count":2,"yara":"rule + eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = - \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring - = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, + checking for an EICAR substring\"\n author = \"Austin Byers | Airbnb + CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"},"status":"OK"} ' headers: - Content-Length: - - '1334' - Content-Type: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '1456' + content-type: - application/json - Date: - - Thu, 26 May 2022 19:08:31 GMT - Server: - - Werkzeug/1.0.1 Python/3.9.6 - X-Billing-ID: - - '1' + date: + - Tue, 25 Aug 2026 18:26:22 GMT + server: + - gunicorn + x-billing-id: + - '111' status: code: 200 message: OK diff --git a/tests/vcr/test_ruleset_delete_json.click b/tests/vcr/test_ruleset_delete_json.click index e602db94..f4247118 100644 --- a/tests/vcr/test_ruleset_delete_json.click +++ b/tests/vcr/test_ruleset_delete_json.click @@ -1,15 +1,16 @@ -result: '{"created": "2022-05-26T19:08:31.291890", "deleted": true, "description": - null, "id": "71213140536342873", "livescan_created": null, "livescan_id": null, - "modified": "2022-05-26T20:00:32.664781", "name": "test2", "yara": "rule eicar_av_test - {\n /*\n Per standard, match only if entire file is EICAR string plus optional - trailing whitespace.\n The raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description +result: '{"created": "2026-08-25T18:25:51.926590+00:00", "deleted": true, "description": + null, "favorite": false, "favorited_at": null, "historical_hunt_count": 0, "id": + "4202182245812695", "livescan_created": null, "livescan_id": null, "modified": "2026-08-25T18:26:38.540071+00:00", + "name": "test2", "rule_count": 2, "yara": "rule eicar_av_test : eicar match {\n /*\n Per + standard, match only if entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match just - the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin - Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all - of them\n}"} + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, PDFs, + etc)\n */\n\n meta:\n description = \"Standard AV test, checking for + an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring + = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"} ' diff --git a/tests/vcr/test_ruleset_delete_json.vcr b/tests/vcr/test_ruleset_delete_json.vcr index 00ff5807..a9509dc6 100644 --- a/tests/vcr/test_ruleset_delete_json.vcr +++ b/tests/vcr/test_ruleset_delete_json.vcr @@ -1,91 +1,60 @@ interactions: - request: - body: null + body: '{"community":"gamma"}' headers: - Accept: + accept: - '*/*' - Accept-Encoding: + accept-encoding: - gzip, deflate - Authorization: + authorization: - '11111111111111111111111111111111' - Connection: + connection: - keep-alive - Content-Length: - - '0' - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) - method: DELETE - uri: http://artifact-index-e2e:9696/v3/hunt/rule?id=71213140536342873 - response: - body: - string: ' - - Redirecting... - -

Redirecting...

- -

You should be redirected automatically to target URL: http://artifact-index-e2e:9696/v3/hunt/rule/?id=71213140536342873. If - not click the link.' - headers: - Content-Length: - - '337' - Content-Type: - - text/html; charset=utf-8 - Date: - - Thu, 26 May 2022 20:00:32 GMT - Location: - - http://artifact-index-e2e:9696/v3/hunt/rule/?id=71213140536342873 - Server: - - Werkzeug/1.0.1 Python/3.9.6 - status: - code: 308 - message: PERMANENT REDIRECT -- request: - body: null - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Authorization: - - '11111111111111111111111111111111' - Connection: - - keep-alive - Content-Length: - - '0' - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) + content-length: + - '21' + content-type: + - application/json + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: DELETE - uri: http://artifact-index-e2e:9696/v3/hunt/rule/?id=71213140536342873 + uri: http://artifact-index-e2e:9696/v3/hunt/rule?id=4202182245812695 response: body: - string: '{"result":{"created":"2022-05-26T19:08:31.291890","deleted":true,"description":null,"id":"71213140536342873","livescan_created":null,"livescan_id":null,"modified":"2022-05-26T20:00:32.664781","name":"test2","yara":"rule - eicar_av_test {\n /*\n Per standard, match only if entire file is - EICAR string plus optional trailing whitespace.\n The raw EICAR string - to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + string: '{"result":{"created":"2026-08-25T18:25:51.926590+00:00","deleted":true,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"4202182245812695","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:26:38.540071+00:00","name":"test2","rule_count":2,"yara":"rule + eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = - \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring - = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, + checking for an EICAR substring\"\n author = \"Austin Byers | Airbnb + CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"},"status":"OK"} ' headers: - Content-Length: - - '1334' - Content-Type: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '1455' + content-type: - application/json - Date: - - Thu, 26 May 2022 20:00:32 GMT - Server: - - Werkzeug/1.0.1 Python/3.9.6 - X-Billing-ID: - - '1' + date: + - Tue, 25 Aug 2026 18:26:38 GMT + server: + - gunicorn + x-billing-id: + - '111' status: code: 200 message: OK diff --git a/tests/vcr/test_ruleset_favorite_json.click b/tests/vcr/test_ruleset_favorite_json.click new file mode 100644 index 00000000..1aed5331 --- /dev/null +++ b/tests/vcr/test_ruleset_favorite_json.click @@ -0,0 +1,4 @@ +result: '{"favorite": true, "favorited_at": "2026-08-26T15:01:51.102476+00:00", "favorites_limit": + 5, "favorites_used": 1, "id": "14883307518120680"} + + ' diff --git a/tests/vcr/test_ruleset_favorite_json.vcr b/tests/vcr/test_ruleset_favorite_json.vcr new file mode 100644 index 00000000..f8db4998 --- /dev/null +++ b/tests/vcr/test_ruleset_favorite_json.vcr @@ -0,0 +1,48 @@ +interactions: +- request: + body: '{"id":"14883307518120680","favorite":1}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '39' + content-type: + - application/json + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: PUT + uri: http://artifact-index-e2e:9696/v3/hunt/rule/favorite?community=gamma + response: + body: + string: '{"result":{"favorite":true,"favorited_at":"2026-08-26T15:01:51.102476+00:00","favorites_limit":5,"favorites_used":1,"id":"14883307518120680"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '157' + content-type: + - application/json + date: + - Wed, 26 Aug 2026 15:01:51 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/vcr/test_ruleset_favorite_limit_text.click b/tests/vcr/test_ruleset_favorite_limit_text.click new file mode 100644 index 00000000..068d3427 --- /dev/null +++ b/tests/vcr/test_ruleset_favorite_limit_text.click @@ -0,0 +1,4 @@ +result: 'error [polyswarm.client.polyswarm]: Favorite limit reached (5 of 5 used). + Unfavorite another ruleset first: `polyswarm rules favorite --unfavorite`. + + ' diff --git a/tests/vcr/test_ruleset_favorite_limit_text.vcr b/tests/vcr/test_ruleset_favorite_limit_text.vcr new file mode 100644 index 00000000..3ed2b15a --- /dev/null +++ b/tests/vcr/test_ruleset_favorite_limit_text.vcr @@ -0,0 +1,47 @@ +interactions: +- request: + body: '{"id":"45874884769561543","favorite":1}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '39' + content-type: + - application/json + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: PUT + uri: http://artifact-index-e2e:9696/v3/hunt/rule/favorite?community=gamma + response: + body: + string: '{"errors":{"code":"FAVORITE_LIMIT","favorites_limit":5,"favorites_used":5},"result":"Favorite + limit reached (5 of 5 used).","status":"error"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '142' + content-type: + - application/json + date: + - Wed, 26 Aug 2026 15:02:21 GMT + server: + - gunicorn + status: + code: 400 + message: BAD REQUEST +version: 1 diff --git a/tests/vcr/test_ruleset_favorite_text.click b/tests/vcr/test_ruleset_favorite_text.click new file mode 100644 index 00000000..ee1cd6f2 --- /dev/null +++ b/tests/vcr/test_ruleset_favorite_text.click @@ -0,0 +1,10 @@ +result: 'Ruleset Id: 96652060989160147 + + Favorite: yes + + Favorited at: 2026-08-26 15:01:51.592707+00:00 + + Favorites used: 2 of 5 + + + ' diff --git a/tests/vcr/test_ruleset_favorite_text.vcr b/tests/vcr/test_ruleset_favorite_text.vcr new file mode 100644 index 00000000..4d3b3a31 --- /dev/null +++ b/tests/vcr/test_ruleset_favorite_text.vcr @@ -0,0 +1,48 @@ +interactions: +- request: + body: '{"id":"96652060989160147","favorite":1}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '39' + content-type: + - application/json + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: PUT + uri: http://artifact-index-e2e:9696/v3/hunt/rule/favorite?community=gamma + response: + body: + string: '{"result":{"favorite":true,"favorited_at":"2026-08-26T15:01:51.592707+00:00","favorites_limit":5,"favorites_used":2,"id":"96652060989160147"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '157' + content-type: + - application/json + date: + - Wed, 26 Aug 2026 15:01:51 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/vcr/test_ruleset_list_json.click b/tests/vcr/test_ruleset_list_json.click index e40d4a72..f91d4ce3 100644 --- a/tests/vcr/test_ruleset_list_json.click +++ b/tests/vcr/test_ruleset_list_json.click @@ -1,9 +1,19 @@ -result: '{"created": "2023-08-23T15:16:04.148857", "deleted": false, "description": - null, "id": "27214252780064715", "livescan_created": null, "livescan_id": null, - "modified": "2023-08-23T15:16:04.148857", "name": "eicar2", "yara": null} +result: '{"created": "2026-08-25T18:26:22.498052+00:00", "deleted": false, "description": + null, "favorite": false, "favorited_at": null, "historical_hunt_count": 0, "id": + "77454540525125655", "livescan_created": null, "livescan_id": null, "modified": + "2026-08-25T18:26:22.498052+00:00", "name": "test", "new_results_count": null, "new_results_counted_at": + null, "rule_count": 2, "yara": null} - {"created": "2023-08-23T15:16:01.359801", "deleted": false, "description": null, - "id": "1023947781069864", "livescan_created": null, "livescan_id": null, "modified": - "2023-08-23T15:16:01.359801", "name": "eicar1", "yara": null} + {"created": "2026-08-25T18:25:52.247009+00:00", "deleted": false, "description": + null, "favorite": false, "favorited_at": null, "historical_hunt_count": 0, "id": + "44051669277897879", "livescan_created": null, "livescan_id": null, "modified": + "2026-08-25T18:25:52.247009+00:00", "name": "recording-live", "new_results_count": + null, "new_results_counted_at": null, "rule_count": 2, "yara": null} + + {"created": "2026-08-25T18:25:51.613644+00:00", "deleted": false, "description": + null, "favorite": false, "favorited_at": null, "historical_hunt_count": 0, "id": + "78562964231669682", "livescan_created": null, "livescan_id": null, "modified": + "2026-08-25T18:25:51.613644+00:00", "name": "recording-view", "new_results_count": + null, "new_results_counted_at": null, "rule_count": 2, "yara": null} ' diff --git a/tests/vcr/test_ruleset_list_json.vcr b/tests/vcr/test_ruleset_list_json.vcr index a0e8e73a..1a501412 100644 --- a/tests/vcr/test_ruleset_list_json.vcr +++ b/tests/vcr/test_ruleset_list_json.vcr @@ -1,37 +1,43 @@ interactions: - request: - body: null + body: '' headers: - Accept: + accept: - '*/*' - Accept-Encoding: + accept-encoding: - gzip, deflate - Authorization: + authorization: - '11111111111111111111111111111111' - Connection: + connection: - keep-alive - User-Agent: - - polyswarm-api/3.4.2 (x86_64-Linux-CPython-3.10.7) + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: GET uri: http://artifact-index-e2e:9696/v3/hunt/rule/list?community=gamma response: body: - string: '{"has_more":false,"limit":2,"result":[{"created":"2023-08-23T15:16:04.148857","deleted":false,"description":null,"id":"27214252780064715","livescan_created":null,"livescan_id":null,"modified":"2023-08-23T15:16:04.148857","name":"eicar2","yara":null},{"created":"2023-08-23T15:16:01.359801","deleted":false,"description":null,"id":"1023947781069864","livescan_created":null,"livescan_id":null,"modified":"2023-08-23T15:16:01.359801","name":"eicar1","yara":null}],"status":"OK"} + string: '{"has_more":false,"limit":50,"result":[{"created":"2026-08-25T18:26:22.498052+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"77454540525125655","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:26:22.498052+00:00","name":"test","new_results_count":null,"new_results_counted_at":null,"rule_count":2,"yara":null},{"created":"2026-08-25T18:25:52.247009+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"44051669277897879","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:25:52.247009+00:00","name":"recording-live","new_results_count":null,"new_results_counted_at":null,"rule_count":2,"yara":null},{"created":"2026-08-25T18:25:51.613644+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"78562964231669682","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:25:51.613644+00:00","name":"recording-view","new_results_count":null,"new_results_counted_at":null,"rule_count":2,"yara":null}],"status":"OK"} ' headers: - Connection: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: - keep-alive - Content-Length: - - '477' - Content-Type: + content-length: + - '1140' + content-type: - application/json - Date: - - Wed, 23 Aug 2023 15:16:24 GMT - Server: + date: + - Tue, 25 Aug 2026 18:26:38 GMT + server: - gunicorn - X-Billing-ID: - - '1' + x-billing-id: + - '111' status: code: 200 message: OK diff --git a/tests/vcr/test_ruleset_unfavorite_text.click b/tests/vcr/test_ruleset_unfavorite_text.click new file mode 100644 index 00000000..cfa47a75 --- /dev/null +++ b/tests/vcr/test_ruleset_unfavorite_text.click @@ -0,0 +1,8 @@ +result: 'Ruleset Id: 96652060989160147 + + Favorite: no + + Favorites used: 1 of 5 + + + ' diff --git a/tests/vcr/test_ruleset_unfavorite_text.vcr b/tests/vcr/test_ruleset_unfavorite_text.vcr new file mode 100644 index 00000000..2ef66d20 --- /dev/null +++ b/tests/vcr/test_ruleset_unfavorite_text.vcr @@ -0,0 +1,48 @@ +interactions: +- request: + body: '{"id":"96652060989160147","favorite":0}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '39' + content-type: + - application/json + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: PUT + uri: http://artifact-index-e2e:9696/v3/hunt/rule/favorite?community=gamma + response: + body: + string: '{"result":{"favorite":false,"favorited_at":null,"favorites_limit":5,"favorites_used":1,"id":"96652060989160147"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '128' + content-type: + - application/json + date: + - Wed, 26 Aug 2026 15:01:51 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/vcr/test_ruleset_update_json.click b/tests/vcr/test_ruleset_update_json.click index 38a22f8c..4c8bce44 100644 --- a/tests/vcr/test_ruleset_update_json.click +++ b/tests/vcr/test_ruleset_update_json.click @@ -1,15 +1,16 @@ -result: '{"created": "2022-05-26T19:08:31.291890", "deleted": false, "description": - null, "id": "71213140536342873", "livescan_created": null, "livescan_id": null, - "modified": "2022-05-26T19:59:46.678724", "name": "test2", "yara": "rule eicar_av_test - {\n /*\n Per standard, match only if entire file is EICAR string plus optional - trailing whitespace.\n The raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description +result: '{"created": "2026-08-25T18:25:51.926590+00:00", "deleted": false, "description": + null, "favorite": false, "favorited_at": null, "historical_hunt_count": 0, "id": + "4202182245812695", "livescan_created": null, "livescan_id": null, "modified": "2026-08-25T18:26:35.087131+00:00", + "name": "test2", "rule_count": 2, "yara": "rule eicar_av_test : eicar match {\n /*\n Per + standard, match only if entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match just - the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin - Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all - of them\n}"} + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, PDFs, + etc)\n */\n\n meta:\n description = \"Standard AV test, checking for + an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring + = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"} ' diff --git a/tests/vcr/test_ruleset_update_json.vcr b/tests/vcr/test_ruleset_update_json.vcr index b7d74069..4f1495a1 100644 --- a/tests/vcr/test_ruleset_update_json.vcr +++ b/tests/vcr/test_ruleset_update_json.vcr @@ -1,95 +1,60 @@ interactions: - request: - body: '{"name": "test2"}' + body: '{"name":"test2","community":"gamma"}' headers: - Accept: + accept: - '*/*' - Accept-Encoding: + accept-encoding: - gzip, deflate - Authorization: + authorization: - '11111111111111111111111111111111' - Connection: + connection: - keep-alive - Content-Length: - - '17' - Content-Type: + content-length: + - '36' + content-type: - application/json - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: PUT - uri: http://artifact-index-e2e:9696/v3/hunt/rule?id=71213140536342873 + uri: http://artifact-index-e2e:9696/v3/hunt/rule?id=4202182245812695 response: body: - string: ' - - Redirecting... - -

Redirecting...

- -

You should be redirected automatically to target URL: http://artifact-index-e2e:9696/v3/hunt/rule/?id=71213140536342873. If - not click the link.' - headers: - Content-Length: - - '337' - Content-Type: - - text/html; charset=utf-8 - Date: - - Thu, 26 May 2022 19:59:46 GMT - Location: - - http://artifact-index-e2e:9696/v3/hunt/rule/?id=71213140536342873 - Server: - - Werkzeug/1.0.1 Python/3.9.6 - status: - code: 308 - message: PERMANENT REDIRECT -- request: - body: '{"name": "test2"}' - headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate - Authorization: - - '11111111111111111111111111111111' - Connection: - - keep-alive - Content-Length: - - '17' - Content-Type: - - application/json - User-Agent: - - polyswarm-api/3.0.0 (x86_64-Linux-CPython-3.6.5) - method: PUT - uri: http://artifact-index-e2e:9696/v3/hunt/rule/?id=71213140536342873 - response: - body: - string: '{"result":{"created":"2022-05-26T19:08:31.291890","deleted":false,"description":null,"id":"71213140536342873","livescan_created":null,"livescan_id":null,"modified":"2022-05-26T19:59:46.678724","name":"test2","yara":"rule - eicar_av_test {\n /*\n Per standard, match only if entire file is - EICAR string plus optional trailing whitespace.\n The raw EICAR string - to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + string: '{"result":{"created":"2026-08-25T18:25:51.926590+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"4202182245812695","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:26:35.087131+00:00","name":"test2","rule_count":2,"yara":"rule + eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = - \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring - = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, + checking for an EICAR substring\"\n author = \"Austin Byers | Airbnb + CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"},"status":"OK"} ' headers: - Content-Length: - - '1335' - Content-Type: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '1456' + content-type: - application/json - Date: - - Thu, 26 May 2022 19:59:46 GMT - Server: - - Werkzeug/1.0.1 Python/3.9.6 - X-Billing-ID: - - '1' + date: + - Tue, 25 Aug 2026 18:26:38 GMT + server: + - gunicorn + x-billing-id: + - '111' status: code: 200 message: OK diff --git a/tests/vcr/test_ruleset_view_json.click b/tests/vcr/test_ruleset_view_json.click index 2eb9e292..11ceb0a7 100644 --- a/tests/vcr/test_ruleset_view_json.click +++ b/tests/vcr/test_ruleset_view_json.click @@ -1,16 +1,17 @@ -result: '{"community": "_public", "created": "2023-08-23T15:16:04.148857", "deleted": - false, "description": null, "id": "27214252780064715", "livescan_created": null, - "livescan_id": null, "modified": "2023-08-23T15:16:04.148857", "name": "eicar2", - "yara": "rule eicar_av_test {\n /*\n Per standard, match only if entire - file is EICAR string plus optional trailing whitespace.\n The raw EICAR string - to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description +result: '{"created": "2026-08-25T18:25:51.613644+00:00", "deleted": false, "description": + null, "favorite": false, "favorited_at": null, "historical_hunt_count": 0, "id": + "78562964231669682", "livescan_created": null, "livescan_id": null, "modified": + "2026-08-25T18:25:51.613644+00:00", "name": "recording-view", "rule_count": 2, "yara": + "rule eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The raw EICAR + string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match just - the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = \"Austin - Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all - of them\n}"} + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, PDFs, + etc)\n */\n\n meta:\n description = \"Standard AV test, checking for + an EICAR substring\"\n author = \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring + = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"} ' diff --git a/tests/vcr/test_ruleset_view_json.vcr b/tests/vcr/test_ruleset_view_json.vcr index df916de1..f626df67 100644 --- a/tests/vcr/test_ruleset_view_json.vcr +++ b/tests/vcr/test_ruleset_view_json.vcr @@ -1,50 +1,56 @@ interactions: - request: - body: null + body: '' headers: - Accept: + accept: - '*/*' - Accept-Encoding: + accept-encoding: - gzip, deflate - Authorization: + authorization: - '11111111111111111111111111111111' - Connection: + connection: - keep-alive - User-Agent: - - polyswarm-api/3.4.2 (x86_64-Linux-CPython-3.10.7) + host: + - artifact-index-e2e:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: GET - uri: http://artifact-index-e2e:9696/v3/hunt/rule?id=27214252780064715&community=gamma + uri: http://artifact-index-e2e:9696/v3/hunt/rule?id=78562964231669682&community=gamma response: body: - string: '{"result":{"community":"_public","created":"2023-08-23T15:16:04.148857","deleted":false,"description":null,"id":"27214252780064715","livescan_created":null,"livescan_id":null,"modified":"2023-08-23T15:16:04.148857","name":"eicar2","yara":"rule - eicar_av_test {\n /*\n Per standard, match only if entire file is - EICAR string plus optional trailing whitespace.\n The raw EICAR string - to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description + string: '{"result":{"created":"2026-08-25T18:25:51.613644+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"78562964231669682","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:25:51.613644+00:00","name":"recording-view","rule_count":2,"yara":"rule + eicar_av_test : eicar match {\n /*\n Per standard, match only if + entire file is EICAR string plus optional trailing whitespace.\n The + raw EICAR string to be matched is:\n X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*\n */\n\n meta:\n description = \"This is a standard AV test, intended to verify that BinaryAlert is working correctly.\"\n author = \"Austin Byers | Airbnb CSIRT\"\n reference = \"http://www.eicar.org/86-0-Intended-use.html\"\n\n strings:\n $eicar_regex = /^X5O!P%@AP\\[4\\\\PZX54\\(P\\^\\)7CC\\)7\\}\\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\\$H\\+H\\*\\s*$/\n\n condition:\n all - of them\n}\n\nrule eicar_substring_test {\n /*\n More generic - match - just the embedded EICAR string (e.g. in packed executables, PDFs, etc)\n */\n\n meta:\n description - = \"Standard AV test, checking for an EICAR substring\"\n author = - \"Austin Byers | Airbnb CSIRT\"\n\n strings:\n $eicar_substring - = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all + of them\n}\n\nrule eicar_substring_test : eicar substring {\n /*\n More + generic - match just the embedded EICAR string (e.g. in packed executables, + PDFs, etc)\n */\n\n meta:\n description = \"Standard AV test, + checking for an EICAR substring\"\n author = \"Austin Byers | Airbnb + CSIRT\"\n\n strings:\n $eicar_substring = \"$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\"\n\n condition:\n all of them\n}"},"status":"OK"} ' headers: - Connection: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: - keep-alive - Content-Length: - - '1358' - Content-Type: + content-length: + - '1466' + content-type: - application/json - Date: - - Wed, 23 Aug 2023 15:16:57 GMT - Server: + date: + - Tue, 25 Aug 2026 18:26:33 GMT + server: - gunicorn - X-Billing-ID: - - '1' + x-billing-id: + - '111' status: code: 200 message: OK From 871ede18df502514db7ea441870816ff7d9f6b06 Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 27 Aug 2026 21:30:57 -0300 Subject: [PATCH 08/54] feat(cli): guard SDK-dependent options behind a signature check A new OPTION may require the newer SDK; an existing INVOCATION may not. Passing an unknown keyword to an older SDK raises a bare TypeError that ExceptionHandlingGroup renders as a traceback plus 'Please contact support', so the guard fires only when the caller actually uses the new option and produces a clean upgrade message instead. Inspects the installed signature rather than catching TypeError, so a genuine argument error inside the SDK is never mistaken for a version mismatch. Two commands need it, which is what earns a helper over an inline check. --- src/polyswarm/client/utils.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/polyswarm/client/utils.py b/src/polyswarm/client/utils.py index 20504e34..cf585ee5 100644 --- a/src/polyswarm/client/utils.py +++ b/src/polyswarm/client/utils.py @@ -1,5 +1,6 @@ import logging import functools +import inspect import sys import click @@ -24,6 +25,31 @@ def parse_hashes(hashes, hash_file=None): return [h.strip('\n') for h in hashes] +#################################################### +# SDK-surface guards +#################################################### + + +def require_sdk_kwargs(method, names, what): + """Refuse cleanly when the installed SDK predates a keyword this command needs. + + The declared floor (published `polyswarm_api` 4.3.0) predates the + hunt-page surface, and passing an unknown keyword to an older SDK raises a + bare TypeError that `ExceptionHandlingGroup` renders as a traceback plus + 'Please contact support'. A new OPTION may require the newer SDK; an + existing invocation may not — so this is checked only when the caller + actually uses the option, leaving every pre-existing command working + unchanged on the floor (see specs/05-sdk-contract.md). + """ + parameters = inspect.signature(method).parameters + missing = [n for n in names if n not in parameters] + if missing: + raise exceptions.PolyswarmException( + f'{what} requires a polyswarm-api release newer than 4.3.0 ' + f'(the paired SDK change adds {", ".join(missing)}). ' + f'Upgrade polyswarm-api to use it.') + + #################################################### # Click parameters validators #################################################### From ab55ecbfd00085651aadf11836881102879a2bef Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 27 Aug 2026 21:30:58 -0300 Subject: [PATCH 09/54] feat(hunt): filter rules list, and scope and bound live feed rules view renders a per-ruleset new-results badge and there was no way to list the results it counts: live feed gains --livescan-id, the badge's drill-down. It also gains --max-results, and rules list gains the four server-side filters the SDK exposes (--name, --status, --favorites-only, --has-new-results). The list is keyset-paginated, so filtering locally would mean walking every page. --since is documented in MINUTES to match the corrected wire unit; its 1440 default is unchanged and now means the 24h it was always meant to mean. 0 means no time filter at all. Every new option is forwarded only when passed, so an unfiltered rules list and a plain live feed still reach the floor SDK's own signatures untouched. specs/05 records all three floor-exceeding surfaces and why the floor itself does not move. --- specs/02-commands.md | 4 ++-- specs/05-sdk-contract.md | 10 +++++++++- src/polyswarm/client/live.py | 33 ++++++++++++++++++++++++++++++--- src/polyswarm/client/rules.py | 32 ++++++++++++++++++++++++-------- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/specs/02-commands.md b/specs/02-commands.md index 350728a7..ce10e6ac 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -25,12 +25,12 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `report` (`report.py`) | Create/fetch/download reports; `prompt-config` subgroup; LLM reports | `report_create`, `report_wait_for`, `report_download`, `report_get`, `llm_report_{create,get,download}`, `prompt_config_{create,get,update,list}` | | `report-template` (`report_template.py`) | Manage report templates + logos | `report_template_{create,update,get,list}`, `report_template_logo_{download,upload}` | | `engine` → `votes` / `assertions` (`engine.py`) | Consolidated votes/assertions bundles per engine | `votes_{create,get,delete,list}`, `assertions_{create,get,delete,list}` | -| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | +| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **MINUTES** (default 1440 — 24h, the window the ruleset badge counts; `0` means no time filter at all), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules view` renders) and `--max-results` (stop after N; unset means every page, as before). Those two need the paired SDK and are forwarded only when passed, so every pre-existing invocation is unchanged on the pin's floor — see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | | `historical` (`historical.py`) | Historical hunts: CRUD + results | `historical_{get,create,update,list}`, `historical_delete_multiple`, `historical_delete_list`, `historical_results_multiple`, `historical_result`, `historical_results_delete` | | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | | `family` (`families.py`) | Malware-family CRUD | `family_{create,update,delete,get,list}` | -| `rules` (`rules.py`) | YARA ruleset CRUD plus `favorite [--unfavorite]` (the star toggle: renders the new state + the server-owned "N of M used" budget, and converts the machine-readable `FAVORITE_LIMIT` refusal into a clean actionable message at exit 2 — the server-refusal code, never 1). Every PRE-EXISTING command works unchanged on the pin's floor: `rules list` calls a zero-argument `ruleset_list()`, and the hunt-page fields arrive as plain response fields the formatters getattr-guard. `rules favorite` is the one command that needs the paired SDK's `ruleset_favorite`; on the floor it degrades to a clean upgrade message (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor) | `ruleset_{create,delete,update,get,list,favorite}` | +| `rules` (`rules.py`) | YARA ruleset CRUD plus `favorite [--unfavorite]` (the star toggle: renders the new state + the server-owned "N of M used" budget, and converts the machine-readable `FAVORITE_LIMIT` refusal into a clean actionable message at exit 2 — the server-refusal code, never 1). `list` takes the server-side filters `--name` / `--status active` / `--favorites-only` / `--has-new-results` (conjunctive; the list is keyset-paginated, so filtering locally would mean walking every page). Every pre-existing INVOCATION works unchanged on the pin's floor: an unfiltered `rules list` still calls a zero-argument `ruleset_list()`, and the hunt-page fields arrive as plain response fields the formatters getattr-guard. What needs the paired SDK is `rules favorite` and the new `rules list` filters; each degrades to a clean upgrade message on the floor (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor) | `ruleset_{create,delete,update,get,list,favorite}` | | `metadata` (`metadata.py`) | Rerun metadata; scan lookup; IP/URL analysis | `rerun_metadata`, `scan_lookup`, `submit_url` | | `activity` (`event.py`) | List account activity/events | `event_list` | | `account` (`account.py`) | Account whois / features | `account_whois`, `account_features` | diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index fd84e565..e65935da 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -84,7 +84,15 @@ The floor moved to **4.3.0** with #264 (`pyproject.toml` has said `>=4.3.0` sinc The known-good rendering attributes (`ArtifactInstance.state`, `.known_good`/`.known_good_sources`, read by `formatters/text.py` — see [`03-formatters.md`](./03-formatters.md) §Known-good artifact instances) ship in **4.1.0**, so they are *not* what sets the floor; they are simply covered by it. -**One command exceeds the floor, by design, with a guarded degradation:** `rules favorite` wraps `ruleset_favorite`, which does not exist on published 4.3.0 — it ships in the paired SDK change and reaches PyPI with the next SDK release. The command guards with `getattr` and fails with a clean upgrade message (exit 2) on a floor install; every other command works unchanged there, which is why the floor itself does not move (moving it has the two preconditions above, and neither holds until the SDK releases). When the SDK release lands on PyPI, bumping the floor and dropping the guard is the follow-up. +**Three surfaces exceed the floor, by design, each with a guarded degradation.** All ship in the paired SDK change and reach PyPI with the next SDK release: + +| Surface | Needs from the SDK | Guard | +|---|---|---| +| `rules favorite` | `ruleset_favorite` | `getattr` on the method | +| `rules list --name/--status/--favorites-only/--has-new-results` | `ruleset_list(**filters)` | `utils.require_sdk_kwargs` | +| `live feed --livescan-id/--max-results` | `live_feed(livescan_id=, max_results=)` | `utils.require_sdk_kwargs` | + +The distinction that keeps the floor where it is: a new **option** may require the newer SDK, but an existing **invocation** may not. So the guards fire only when the caller actually uses the new surface — an unfiltered `rules list` and a plain `live feed` still reach the floor's own signatures untouched — and a floor install gets a clean upgrade message at exit 2 rather than the `TypeError`/`AttributeError` traceback a bare call would raise. That is why the floor itself does not move; moving it has the two preconditions above, and neither holds until the SDK releases. `require_sdk_kwargs` inspects the installed signature rather than catching `TypeError`, so a genuine argument error inside the SDK is never mistaken for a version mismatch. When the SDK release lands on PyPI, bumping the floor and dropping all three guards is the follow-up. ## Worked example — the httpx SDK migration diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index 1e02f27e..871989b2 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -2,6 +2,8 @@ import click +from polyswarm.client import utils + logger = logging.getLogger(__name__) @@ -32,19 +34,44 @@ def live_stop(ctx, ruleset_id): @live.command('feed', short_help='Get results from live hunt.') @click.option('-s', '--since', type=click.INT, default=1440, - help='How far back in seconds to request results (default: 1440).') + help='How far back in MINUTES to request results ' + '(default: 1440 — 24h, the window the ruleset badge counts). ' + 'Pass 0 for no time filter at all.') +@click.option('-i', '--livescan-id', + help="Scope the feed to one live hunt (a ruleset's Live Hunt Id).") +@click.option('-m', '--max-results', type=click.INT, + help='Stop after this many results. Unset means every page, as before.') @click.option('-r', '--rule-name', help='Filter results on this rule name.') @click.option('-f', '--family', help='Filter hunt results based on the family name.') @click.option('-l', '--polyscore-lower', help='Polyscore lower bound for the hunt results.') @click.option('-u', '--polyscore-upper', help='Polyscore upper bound for the hunt results.') @click.option('-p', '--private', is_flag=True, help='Filter results to only your private community.') @click.pass_context -def live_results(ctx, since, rule_name, family, polyscore_lower, polyscore_upper, private): +def live_results(ctx, since, livescan_id, max_results, rule_name, family, + polyscore_lower, polyscore_upper, private): + """Show live-hunt results. + + `--livescan-id` is the drill-down for the per-ruleset new-results badge + that `rules view` renders: the badge counts a hunt's recent results, and + this is how you list them. + """ api = ctx.obj['api'] output = ctx.obj['output'] + # Both new options ride one guard: they are the only part of this command + # that needs the paired SDK, and a bare kwarg would be a TypeError + # traceback on the pin's floor. Every existing invocation is untouched. + kwargs = {} + if livescan_id is not None: + kwargs['livescan_id'] = livescan_id + if max_results is not None: + kwargs['max_results'] = max_results + if kwargs: + utils.require_sdk_kwargs(api.live_feed, sorted(kwargs), 'live feed ' + + ' and '.join('--' + k.replace('_', '-') for k in sorted(kwargs))) for result in api.live_feed( since, rule_name=rule_name, family=family, - polyscore_lower=polyscore_lower, polyscore_upper=polyscore_upper, community='private' if private else None): + polyscore_lower=polyscore_lower, polyscore_upper=polyscore_upper, + community='private' if private else None, **kwargs): output.live_result(result) diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index 0626292f..80cc1cba 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -32,17 +32,33 @@ def delete(ctx, rule_id): @rules.command('list', short_help='List all rulesets.') +@click.option('-n', '--name', help='Substring match on the ruleset name (case-insensitive).') +@click.option('-s', '--status', type=click.Choice(['active']), + help='Only rulesets whose live hunt is currently running.') +@click.option('--favorites-only', is_flag=True, help='Only favorited (starred) rulesets.') +@click.option('--has-new-results', is_flag=True, + help='Only rulesets whose stored new-results counter is positive.') @click.pass_context -def list_rules(ctx): +def list_rules(ctx, name, status, favorites_only, has_new_results): + """List rulesets, optionally filtered. All filters are conjunctive. + + Filtering is applied SERVER-side: the list is keyset-paginated, so a + client filtering locally would have to walk every page to find matches. + """ api = ctx.obj['api'] output = ctx.obj['output'] - # Zero-argument on purpose: every hunt-page field this renders (counts, - # favorites, tracking) arrives as a plain response field the formatters - # getattr-guard, so the command needs NO new SDK behaviour and works - # unchanged on the pin's floor (4.3.0). The new-results badge is a STORED - # server-side counter refreshed on a schedule — there is no per-request - # count to ask for. - for ruleset in api.ruleset_list(): + # UNFILTERED `rules list` stays a zero-argument call, so it keeps working + # on the pin's floor (4.3.0) exactly as before — every field it renders is + # a plain response field the formatters getattr-guard. Only a caller who + # actually passes a filter needs the paired SDK, and that caller gets a + # clean upgrade message instead of a TypeError traceback. + kwargs = {k: v for k, v in (('name', name), ('status', status), + ('favorites_only', favorites_only or None), + ('has_new_results', has_new_results or None)) + if v is not None} + if kwargs: + utils.require_sdk_kwargs(api.ruleset_list, sorted(kwargs), 'rules list filtering') + for ruleset in api.ruleset_list(**kwargs): output.ruleset(ruleset) From 78b93a5940f89e8f137d238d5a5a796b6c946cf5 Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 27 Aug 2026 21:30:59 -0300 Subject: [PATCH 10/54] test(hunt): pin the new options, their forwarding and floor degradation Covers the three decisions the plumbing now makes: an unfiltered list still calls a zero-argument ruleset_list(), a filtered one forwards exactly the filters given (a False flag must not become favorites_only=False, a filter the caller never asked for), and live feed forwards the two new kwargs only when passed. Each new surface is also pinned to degrade with a clean message at exit 2 against a stand-in carrying the floor signature, never a traceback. --- tests/formatter_hunt_fields_test.py | 97 +++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 5 deletions(-) diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index a3fb3426..16adcb82 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -12,13 +12,16 @@ all (SimpleNamespace on purpose: an installed SDK predating the fields has no such attributes to build from) renders without raising and simply omits the new lines; and -* the command plumbing: ``rules list`` calls a ZERO-argument - ``ruleset_list()`` (the pin's floor, 4.3.0, has exactly that signature — - no new SDK behaviour is required anywhere in this change), and +* the command plumbing: an UNFILTERED ``rules list`` still calls a + zero-argument ``ruleset_list()`` (the pin's floor, 4.3.0, has exactly + that signature, so the common invocation needs no new SDK behaviour), + a FILTERED one forwards exactly the filters given, ``live feed`` + forwards ``--livescan-id`` / ``--max-results`` only when passed, and ``rules favorite`` renders the toggle response and converts the - machine-readable FAVORITE_LIMIT refusal into a clean message. Both are + machine-readable FAVORITE_LIMIT refusal into a clean message. All are asserted through autospec'd mocks so every call is signature-checked - against the installed SDK. + against the installed SDK; the options that DO need the paired SDK are + pinned to degrade with a clean upgrade message on the floor. """ import io import types @@ -176,6 +179,90 @@ def test_list_passes_no_kwargs_at_all(self): assert result.exit_code == 0, result.output ruleset_list.assert_called_once_with(mock.ANY) + def test_filters_are_forwarded_only_when_given(self): + """A filtered list forwards exactly the filters passed and nothing + else — the flags default to False, and a False flag must not become + `favorites_only=False`, which would be a filter the caller never + asked for.""" + with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_list', + autospec=True, return_value=iter(())) as ruleset_list: + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'rules', 'list', '--name', 'alpha', '--favorites-only'], + catch_exceptions=False) + assert result.exit_code == 0, result.output + ruleset_list.assert_called_once_with( + mock.ANY, name='alpha', favorites_only=True) + + def test_filtering_on_a_floor_sdk_is_a_clean_message_not_a_traceback(self): + """The published floor's ``ruleset_list()`` takes no filters. Using one + there must produce the upgrade message at exit 2 (the server-refusal + code), never the TypeError traceback a bare kwarg would raise. + + The floor is simulated by a stand-in with the FLOOR signature, which is + what the guard inspects.""" + def floor_ruleset_list(self): + return iter(()) + + with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_list', + floor_ruleset_list): + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'rules', 'list', '--name', 'alpha']) + assert result.exit_code == 2, result.output + assert 'newer than 4.3.0' in result.output + assert 'Traceback' not in result.output + + +class LiveFeedOptionsTest(TestCase): + """`live feed` — the badge's drill-down (--livescan-id) and its bound + (--max-results). Both are forwarded only when passed, so every existing + invocation reaches the SDK exactly as it did before.""" + + def _invoke(self, *extra): + with mock.patch('polyswarm_api.api.PolyswarmAPI.live_feed', + autospec=True, return_value=iter(())) as live_feed: + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'live', 'feed', *extra], + catch_exceptions=False) + return result, live_feed + + def test_plain_feed_forwards_neither_new_kwarg(self): + result, live_feed = self._invoke() + assert result.exit_code == 0, result.output + _, kwargs = live_feed.call_args + assert 'livescan_id' not in kwargs and 'max_results' not in kwargs + # the default window is 1440 MINUTES (24h), passed positionally + assert live_feed.call_args[0][1] == 1440 + + def test_livescan_id_and_max_results_are_forwarded(self): + result, live_feed = self._invoke( + '--livescan-id', '72927285313305230', '--max-results', '5') + assert result.exit_code == 0, result.output + _, kwargs = live_feed.call_args + assert kwargs['livescan_id'] == '72927285313305230' + assert kwargs['max_results'] == 5 + + def test_new_options_on_a_floor_sdk_are_a_clean_message(self): + def floor_live_feed(self, since=None, rule_name=None, family=None, + polyscore_lower=None, polyscore_upper=None, + community=None): + return iter(()) + + with mock.patch('polyswarm_api.api.PolyswarmAPI.live_feed', + floor_live_feed): + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'live', 'feed', '--livescan-id', '7']) + assert result.exit_code == 2, result.output + assert 'newer than 4.3.0' in result.output + assert 'Traceback' not in result.output + class RulesFavoriteCommandTest(TestCase): """`rules favorite` — the CLI leg of the favorite capability: renders the From c5f505f7af459ef07e61495672501127b3910dbe Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 27 Aug 2026 21:48:05 -0300 Subject: [PATCH 11/54] fix(cli): name the SDK floor once, and refuse a negative --max-results The floor version was hardcoded in three places, so the follow-up bump that drops the guards had three chances to miss one. SDK_FLOOR states it once. --max-results takes IntRange(min=0): 0 is meaningful (no bound, matching the SDK) but a negative is a typo, and refusing it at the interface beats silently treating it as unbounded. --- src/polyswarm/client/live.py | 6 ++++-- src/polyswarm/client/rules.py | 6 +++--- src/polyswarm/client/utils.py | 10 ++++++++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index 871989b2..23678bb6 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -39,8 +39,10 @@ def live_stop(ctx, ruleset_id): 'Pass 0 for no time filter at all.') @click.option('-i', '--livescan-id', help="Scope the feed to one live hunt (a ruleset's Live Hunt Id).") -@click.option('-m', '--max-results', type=click.INT, - help='Stop after this many results. Unset means every page, as before.') +@click.option('-m', '--max-results', type=click.IntRange(min=0), + help='Stop after this many results. Unset or 0 means no bound — ' + 'every page, as before. A negative is refused here rather ' + 'than silently meaning unbounded.') @click.option('-r', '--rule-name', help='Filter results on this rule name.') @click.option('-f', '--family', help='Filter hunt results based on the family name.') @click.option('-l', '--polyscore-lower', help='Polyscore lower bound for the hunt results.') diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index 80cc1cba..99b11ac8 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -87,9 +87,9 @@ def favorite(ctx, rule_id, unfavorite): # traceback. (Same principle as the withdrawn --include-counts flag: # a new surface may require the new SDK; existing surfaces may not.) raise exceptions.PolyswarmException( - 'rules favorite requires a polyswarm-api release newer than ' - '4.3.0 (the paired SDK change adds ruleset_favorite). ' - 'Upgrade polyswarm-api to use this command.') + f'rules favorite requires a polyswarm-api release newer than ' + f'{utils.SDK_FLOOR} (the paired SDK change adds ruleset_favorite). ' + f'Upgrade polyswarm-api to use this command.') try: output.ruleset_favorite(toggle(rule_id, not unfavorite)) except api_exceptions.RequestException as exc: diff --git a/src/polyswarm/client/utils.py b/src/polyswarm/client/utils.py index cf585ee5..d09332a5 100644 --- a/src/polyswarm/client/utils.py +++ b/src/polyswarm/client/utils.py @@ -12,6 +12,12 @@ logger = logging.getLogger(__name__) HASH_VALIDATORS = resources.Hash.SUPPORTED_HASH_TYPES +# The published SDK floor this repo pins (pyproject.toml, and +# specs/05-sdk-contract.md Current floor). Named once so the follow-up bump +# that drops the guards has a single place to look instead of three +# hand-written strings that can drift apart. +SDK_FLOOR = '4.3.0' + #################################################### # Input parsers #################################################### @@ -33,7 +39,7 @@ def parse_hashes(hashes, hash_file=None): def require_sdk_kwargs(method, names, what): """Refuse cleanly when the installed SDK predates a keyword this command needs. - The declared floor (published `polyswarm_api` 4.3.0) predates the + The declared floor (published `polyswarm_api` SDK_FLOOR) predates the hunt-page surface, and passing an unknown keyword to an older SDK raises a bare TypeError that `ExceptionHandlingGroup` renders as a traceback plus 'Please contact support'. A new OPTION may require the newer SDK; an @@ -45,7 +51,7 @@ def require_sdk_kwargs(method, names, what): missing = [n for n in names if n not in parameters] if missing: raise exceptions.PolyswarmException( - f'{what} requires a polyswarm-api release newer than 4.3.0 ' + f'{what} requires a polyswarm-api release newer than {SDK_FLOOR} ' f'(the paired SDK change adds {", ".join(missing)}). ' f'Upgrade polyswarm-api to use it.') From f0182f774d13b34eb515c0070a91b432f18ea6ce Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 27 Aug 2026 21:48:06 -0300 Subject: [PATCH 12/54] fix(tests,specs): guard the fixture tests on the attribute, not the class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floor guards were keyed on the resource CLASS, but YaraRuleset and HistoricalHunt exist on the published floor — they simply do not parse the tracking and provenance keys. Resources declare attributes explicitly, so on a floor install the formatter's getattr guards return None and these tests FAIL rather than skip. test_ruleset_none_and_false_fields_are_omitted was worse: it asserts absence, so it passed vacuously there, looking like coverage while pinning nothing. Keyed on the attribute the fixture actually needs. specs/05's import row also claimed a bare `from polyswarm_api import exceptions` while rules.py aliases it — the drift the spec convention exists to prevent. --- specs/05-sdk-contract.md | 2 +- tests/formatter_hunt_fields_test.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index e65935da..101cd1c5 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -19,7 +19,7 @@ How the CLI depends on the `polyswarm-api` SDK: which parts of the SDK's public | `from polyswarm_api import settings` | Defaults: `DEFAULT_SCAN_TIMEOUT`, `DEFAULT_REPORT_TIMEOUT`, etc. | | `from polyswarm_api import resources` | Result-parser classes for power-user calls (e.g. `resources.ArtifactInstance`); resource attributes the formatters read. | | `from polyswarm_api import exceptions as api_exceptions` | Caught in `ExceptionHandlingGroup` and `utils.parallel_executor` (`NoResultsException`, `NotFoundException`, `FailedInstanceException`, `PolyswarmException`). | -| `from polyswarm_api import exceptions` (bare, not aliased) | `RequestException` — caught by `rules favorite` (`client/rules.py`) to read the machine-readable `FAVORITE_LIMIT` refusal off `exc.request.errors['code']`. The SDK does not raise a typed exception for this refusal by design (specs/05 on the server side): `.request.errors` is a plain dict the server's error envelope populates, pinned end-to-end by `tests/cli_test.py::test_ruleset_favorite_limit_text` against a real recorded 400 (not a hand-built mock), so a rename on either side fails that cassette. | +| `from polyswarm_api import exceptions as api_exceptions` | `RequestException` — caught by `rules favorite` (`client/rules.py`) to read the machine-readable `FAVORITE_LIMIT` refusal off `exc.request.errors['code']`. The SDK does not raise a typed exception for this refusal by design (specs/05 on the server side): `.request.errors` is a plain dict the server's error envelope populates, pinned end-to-end by `tests/cli_test.py::test_ruleset_favorite_limit_text` against a real recorded 400 (not a hand-built mock), so a rename on either side fails that cassette. | | `from polyswarm_api.core import parse_isoformat` | Date rendering in `formatters/text.py`. | | `import polyswarm_api` (`__version__`) | `--api-version`. | diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 16adcb82..1dab121d 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -50,6 +50,28 @@ _needs_favorite_resource = unittest.skipUnless( hasattr(resources, 'YaraRulesetFavorite'), 'paired SDK resource (YaraRulesetFavorite) not installed') +# The tracking/provenance fixtures need a NARROWER guard still. YaraRuleset and +# HistoricalHunt exist on the floor — they simply do not PARSE the new keys, and +# resources declare their attributes explicitly, so on a floor install the +# formatter's getattr guards return None: the render tests FAIL rather than skip, +# and the absence-only test passes VACUOUSLY, which looks like coverage while +# pinning nothing. Key them on the attribute the fixture actually needs. +_needs_tracking_fields = unittest.skipUnless( + hasattr(resources.YaraRuleset({'id': '0', 'livescan_id': None, + 'livescan_created': None, 'name': 'n', + 'description': 'd', 'deleted': False, + 'created': '2026-08-20T00:00:00+00:00', + 'modified': '2026-08-20T00:00:00+00:00', + 'yara': None}, api=None), 'rule_count'), + 'paired SDK does not parse the ruleset tracking fields') +_needs_provenance_fields = unittest.skipUnless( + hasattr(resources.HistoricalHunt({'id': '0', 'status': 'PENDING', + 'progress': 0.0, 'active': None, + 'created': '2026-08-20T00:00:00+00:00', + 'summary': None, 'results_csv_uri': None, + 'ruleset_name': 'n', 'yara': None}, + api=None), 'source_rule_changed'), + 'paired SDK does not parse the hunt provenance fields') def _ruleset(**overrides): @@ -89,6 +111,7 @@ def _render(self, method, result, **kwargs): getattr(text.TextOutput(color=False, output=out), method)(result, **kwargs) return out.getvalue() + @_needs_tracking_fields def test_ruleset_tracking_fields_render_with_zero_distinct_from_absent(self): rendered = self._render('ruleset', _ruleset( favorite=True, favorited_at='2026-08-20T12:00:00+00:00', rule_count=0, @@ -100,6 +123,7 @@ def test_ruleset_tracking_fields_render_with_zero_distinct_from_absent(self): assert 'Historical hunts triggered: 0' in rendered assert 'New live results (last 24h): 3' in rendered + @_needs_tracking_fields def test_ruleset_staleness_marker_renders_beside_the_count(self): # The stored badge's marker: how fresh the number is. Rendered only # with a count (the server sends them together). @@ -129,6 +153,7 @@ def test_ruleset_unfavorite_response_renders_no_state(self): assert 'Favorited at' not in rendered assert 'Favorites used: 2 of 5' in rendered + @_needs_tracking_fields def test_ruleset_none_and_false_fields_are_omitted(self): rendered = self._render('ruleset', _ruleset( favorite=False, favorited_at=None, rule_count=None, @@ -143,6 +168,7 @@ def test_old_sdk_ruleset_without_the_attributes_renders(self): assert 'Ruleset Id: 5' in rendered assert 'Favorite' not in rendered + @_needs_provenance_fields def test_hunt_provenance_fields_render_with_the_reference_point(self): rendered = self._render('hunt', _hunt( rule_id='5', rule_modified='2026-08-20T12:00:00+00:00', From 33680eae26cbbacbc01cef2a32e58319c1dc29f3 Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 27 Aug 2026 21:59:44 -0300 Subject: [PATCH 13/54] fix(cli): 0 results is not a new option, and never render None of None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --max-results 0 is documented as the pre-existing unbounded behaviour, but it was forwarded to the SDK and therefore tripped the floor guard: a caller asking for exactly what the floor already does got 'requires a polyswarm-api release newer than 4.3.0'. That contradicts the rule specs/05 states as the reason the floor does not move — a new OPTION may require the newer SDK, an existing INVOCATION may not. 0 now stays out of kwargs and off the wire entirely. The FAVORITE_LIMIT counters are advisory and an envelope can carry the code without them; interpolating them unguarded rendered 'Favorite limit reached (None of None used).' at the user. The server's own message is the fallback. SDK_FLOOR is now tied to the pin by a test — it existed only so the guard messages could name the floor, and nothing failed if it drifted from pyproject.toml while every message named the wrong version. The guard tests assert against the constant rather than a literal. specs/03-formatters.md still declared the 4.2.0 floor, which is the drift specs/05 names the pin to prevent. --- specs/03-formatters.md | 2 +- src/polyswarm/client/live.py | 6 ++- src/polyswarm/client/rules.py | 14 ++++-- tests/formatter_hunt_fields_test.py | 72 ++++++++++++++++++++++++++++- 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/specs/03-formatters.md b/specs/03-formatters.md index 0981d6ce..c520fda6 100644 --- a/specs/03-formatters.md +++ b/specs/03-formatters.md @@ -142,7 +142,7 @@ either field) never raises `AttributeError`; an SDK without `.state` simply neve the known-good branch, which is the safe fallback — the pre-known-good rendering. That degradation is belt-and-braces, not a supported configuration: `.state` is load-bearing here with no substitute. Both attributes ship in SDK **4.1.0**, but the dependency floor is -`polyswarm_api>=4.2.0` — set by two *other* behaviours the CLI depends on, both of which +`polyswarm_api>=4.3.0` — set by two *other* behaviours the CLI depends on, both of which fail silently on 4.1.0 (see [`05-sdk-contract.md`](./05-sdk-contract.md) §Version pin) — so every supported install has them. `JSONOutput` needs no change — it dumps the resource's `.json`, which already carries the raw `state` and `known_good` keys. diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index 23678bb6..1f867d66 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -65,7 +65,11 @@ def live_results(ctx, since, livescan_id, max_results, rule_name, family, kwargs = {} if livescan_id is not None: kwargs['livescan_id'] = livescan_id - if max_results is not None: + # Truthiness, not `is not None`: --max-results 0 IS the pre-existing + # unbounded behaviour, so it must not reach the SDK and must not trip the + # floor guard. A new option may require the newer SDK; an invocation that + # asks for what the floor already does may not (specs/05 Current floor). + if max_results: kwargs['max_results'] = max_results if kwargs: utils.require_sdk_kwargs(api.live_feed, sorted(kwargs), 'live feed ' + diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index 99b11ac8..a9ee3c9f 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -99,10 +99,18 @@ def favorite(ctx, rule_id, unfavorite): # say so cleanly. A CLI PolyswarmException keeps the central # exit-code mapping's 2 (server refusal) — a ClickException # would exit 1, the code reserved for no-results/not-found. + used = errors.get('favorites_used') + limit = errors.get('favorites_limit') + if used is None or limit is None: + # The counters are advisory and the envelope may carry only the + # code. Fall back to the server's own message rather than + # rendering "(None of None used)" at the user. + budget = str(exc.request.result or 'Favorite limit reached.') + else: + budget = f'Favorite limit reached ({used} of {limit} used).' raise exceptions.PolyswarmException( - f"Favorite limit reached ({errors.get('favorites_used')} of " - f"{errors.get('favorites_limit')} used). Unfavorite another " - f'ruleset first: `polyswarm rules favorite --unfavorite`.' + f'{budget} Unfavorite another ruleset first: ' + f'`polyswarm rules favorite --unfavorite`.' ) from exc raise diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 1dab121d..31cdf667 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -24,6 +24,7 @@ pinned to degrade with a clean upgrade message on the floor. """ import io +import pathlib import types import unittest from unittest import TestCase, mock @@ -31,6 +32,7 @@ from click.testing import CliRunner from polyswarm.client import polyswarm as client +from polyswarm.client import utils from polyswarm.formatters import text from polyswarm_api import exceptions, resources from polyswarm_api.api import PolyswarmAPI @@ -238,7 +240,7 @@ def floor_ruleset_list(self): ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', 'rules', 'list', '--name', 'alpha']) assert result.exit_code == 2, result.output - assert 'newer than 4.3.0' in result.output + assert f'newer than {utils.SDK_FLOOR}' in result.output assert 'Traceback' not in result.output @@ -273,6 +275,36 @@ def test_livescan_id_and_max_results_are_forwarded(self): assert kwargs['livescan_id'] == '72927285313305230' assert kwargs['max_results'] == 5 + def test_zero_max_results_is_unbounded_and_never_reaches_the_sdk(self): + """--max-results 0 is documented as the pre-existing unbounded + behaviour, so it must not be forwarded — and therefore must not trip the + floor guard for an invocation the floor already serves.""" + result, live_feed = self._invoke('--max-results', '0') + assert result.exit_code == 0, result.output + _, kwargs = live_feed.call_args + assert 'max_results' not in kwargs + + def test_zero_max_results_does_not_trip_the_floor_guard(self): + def floor_live_feed(self, since=None, rule_name=None, family=None, + polyscore_lower=None, polyscore_upper=None, + community=None): + return iter(()) + + with mock.patch('polyswarm_api.api.PolyswarmAPI.live_feed', + floor_live_feed): + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'live', 'feed', '--max-results', '0']) + assert result.exit_code == 0, result.output + + def test_a_negative_max_results_is_refused_at_the_interface(self): + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'live', 'feed', '--max-results', '-1']) + assert result.exit_code != 0 + def test_new_options_on_a_floor_sdk_are_a_clean_message(self): def floor_live_feed(self, since=None, rule_name=None, family=None, polyscore_lower=None, polyscore_upper=None, @@ -286,7 +318,7 @@ def floor_live_feed(self, since=None, rule_name=None, family=None, ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', 'live', 'feed', '--livescan-id', '7']) assert result.exit_code == 2, result.output - assert 'newer than 4.3.0' in result.output + assert f'newer than {utils.SDK_FLOOR}' in result.output assert 'Traceback' not in result.output @@ -352,6 +384,25 @@ def test_favorite_limit_refusal_is_a_clean_message_at_exit_2(self): assert '--unfavorite' in result.output # names the way out assert 'Traceback' not in result.output + def test_favorite_limit_without_counters_uses_the_server_message(self): + # The counters are advisory; an envelope can carry the code without + # them. Interpolating them unguarded rendered "(None of None used)" at + # the user, so the server's own message is the fallback. + request = mock.Mock() + request.errors = {'code': 'FAVORITE_LIMIT'} + request.result = 'Favorite limit reached (5 of 5 used).' + refusal = exceptions.RequestException(request) + with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_favorite', + autospec=True, side_effect=refusal): + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'rules', 'favorite', '5']) + assert result.exit_code == 2, result.output + assert 'None of None' not in result.output + assert 'Favorite limit reached (5 of 5 used).' in result.output + assert '--unfavorite' in result.output + def test_favorite_on_the_floor_sdk_degrades_cleanly(self): # The declared floor (published 4.3.0) has no ruleset_favorite: the # command must fail with a clean upgrade message at exit 2, never an @@ -380,3 +431,20 @@ def test_other_refusals_still_raise(self): 'rules', 'favorite', '5']) assert result.exit_code == 2 # PolyswarmException family assert 'FAVORITE_LIMIT' not in result.output + +class SdkFloorConstantTest(TestCase): + """``utils.SDK_FLOOR`` must equal the lower bound in ``pyproject.toml``. + + specs/05-sdk-contract.md makes the pin the one authoritative floor, and the + constant only exists so the guard messages can name it. Nothing else ties + the two together: the follow-up bump edits the pin, and a stale constant + would leave every upgrade message naming the wrong version while the suite + stayed green — the exact drift specs/05 says the pin exists to prevent.""" + + def test_the_constant_matches_the_pin(self): + import re + pyproject = (pathlib.Path(__file__).resolve().parent.parent + / 'pyproject.toml').read_text() + match = re.search(r'polyswarm_api>=([0-9]+\.[0-9]+\.[0-9]+)', pyproject) + assert match, 'polyswarm_api pin not found in pyproject.toml' + assert utils.SDK_FLOOR == match.group(1) From 236ec78aaf298e15103b5823dd68f6b55a07d765 Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 27 Aug 2026 22:09:53 -0300 Subject: [PATCH 14/54] fix(cli): point --livescan-id at the command that renders the badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The help text sent users to 'rules view', which is the one ruleset command that deliberately does not carry new_results_count — the badge is a list-serializer field. It names 'rules list' now, in the docstring and specs/02. exc.request.result was read unguarded inside the handler whose whole job is to avoid a traceback; the only test built a Mock, which has every attribute, so it could never fail on this. getattr now, pinned by a request object that really lacks it. Also guards the counters-fallback test on the floor (it patched ruleset_favorite with autospec and no create=True, so it errored rather than skipped there), and signature-checks all four rules-list filters instead of two — autospec is what makes those assertions a check against the installed SDK, and a kwarg rename would otherwise ship as the floor guard refusing on an SDK that has the surface. The imports table had two rows with an identical left column after the earlier alias correction; folded into one. --- specs/02-commands.md | 2 +- specs/05-sdk-contract.md | 3 +-- src/polyswarm/client/live.py | 5 +++-- src/polyswarm/client/rules.py | 5 ++++- tests/formatter_hunt_fields_test.py | 32 +++++++++++++++++++++++++++-- 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/specs/02-commands.md b/specs/02-commands.md index ce10e6ac..7880aac2 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -25,7 +25,7 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `report` (`report.py`) | Create/fetch/download reports; `prompt-config` subgroup; LLM reports | `report_create`, `report_wait_for`, `report_download`, `report_get`, `llm_report_{create,get,download}`, `prompt_config_{create,get,update,list}` | | `report-template` (`report_template.py`) | Manage report templates + logos | `report_template_{create,update,get,list}`, `report_template_logo_{download,upload}` | | `engine` → `votes` / `assertions` (`engine.py`) | Consolidated votes/assertions bundles per engine | `votes_{create,get,delete,list}`, `assertions_{create,get,delete,list}` | -| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **MINUTES** (default 1440 — 24h, the window the ruleset badge counts; `0` means no time filter at all), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules view` renders) and `--max-results` (stop after N; unset means every page, as before). Those two need the paired SDK and are forwarded only when passed, so every pre-existing invocation is unchanged on the pin's floor — see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | +| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **MINUTES** (default 1440 — 24h, the window the ruleset badge counts; `0` means no time filter at all), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry it) and `--max-results` (stop after N; unset means every page, as before). Those two need the paired SDK and are forwarded only when passed, so every pre-existing invocation is unchanged on the pin's floor — see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | | `historical` (`historical.py`) | Historical hunts: CRUD + results | `historical_{get,create,update,list}`, `historical_delete_multiple`, `historical_delete_list`, `historical_results_multiple`, `historical_result`, `historical_results_delete` | | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index 101cd1c5..4522b7e6 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -18,8 +18,7 @@ How the CLI depends on the `polyswarm-api` SDK: which parts of the SDK's public | `from polyswarm_api.api import PolyswarmAPI` | Base class of the `Polyswarm` wrapper (`src/polyswarm/polyswarm.py`). | | `from polyswarm_api import settings` | Defaults: `DEFAULT_SCAN_TIMEOUT`, `DEFAULT_REPORT_TIMEOUT`, etc. | | `from polyswarm_api import resources` | Result-parser classes for power-user calls (e.g. `resources.ArtifactInstance`); resource attributes the formatters read. | -| `from polyswarm_api import exceptions as api_exceptions` | Caught in `ExceptionHandlingGroup` and `utils.parallel_executor` (`NoResultsException`, `NotFoundException`, `FailedInstanceException`, `PolyswarmException`). | -| `from polyswarm_api import exceptions as api_exceptions` | `RequestException` — caught by `rules favorite` (`client/rules.py`) to read the machine-readable `FAVORITE_LIMIT` refusal off `exc.request.errors['code']`. The SDK does not raise a typed exception for this refusal by design (specs/05 on the server side): `.request.errors` is a plain dict the server's error envelope populates, pinned end-to-end by `tests/cli_test.py::test_ruleset_favorite_limit_text` against a real recorded 400 (not a hand-built mock), so a rename on either side fails that cassette. | +| `from polyswarm_api import exceptions as api_exceptions` | Caught in `ExceptionHandlingGroup` and `utils.parallel_executor` (`NoResultsException`, `NotFoundException`, `FailedInstanceException`, `PolyswarmException`). Also `RequestException`, caught by `rules favorite` (`client/rules.py`) to read the machine-readable `FAVORITE_LIMIT` refusal off `exc.request.errors['code']`. The SDK does not raise a typed exception for that refusal by design: `.request.errors` is a plain dict the server's error envelope populates, pinned end-to-end by `tests/cli_test.py::test_ruleset_favorite_limit_text` against a real recorded 400 (not a hand-built mock), so a rename on either side fails that cassette. | | `from polyswarm_api.core import parse_isoformat` | Date rendering in `formatters/text.py`. | | `import polyswarm_api` (`__version__`) | `--api-version`. | diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index 1f867d66..9f92fa31 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -54,8 +54,9 @@ def live_results(ctx, since, livescan_id, max_results, rule_name, family, """Show live-hunt results. `--livescan-id` is the drill-down for the per-ruleset new-results badge - that `rules view` renders: the badge counts a hunt's recent results, and - this is how you list them. + that `rules list` renders (the detail view deliberately does not carry + it): the badge counts a hunt's recent results, and this is how you list + them. """ api = ctx.obj['api'] output = ctx.obj['output'] diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index a9ee3c9f..38cd1d6b 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -105,7 +105,10 @@ def favorite(ctx, rule_id, unfavorite): # The counters are advisory and the envelope may carry only the # code. Fall back to the server's own message rather than # rendering "(None of None used)" at the user. - budget = str(exc.request.result or 'Favorite limit reached.') + # getattr, like `.errors` above: this block exists to avoid a + # traceback, so it must not raise one reaching for a fallback. + budget = str(getattr(exc.request, 'result', None) + or 'Favorite limit reached.') else: budget = f'Favorite limit reached ({used} of {limit} used).' raise exceptions.PolyswarmException( diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 31cdf667..e3a8c5b5 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -217,11 +217,17 @@ def test_filters_are_forwarded_only_when_given(self): result = CliRunner().invoke( client.polyswarm_cli, ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', - 'rules', 'list', '--name', 'alpha', '--favorites-only'], + 'rules', 'list', '--name', 'alpha', '--favorites-only', + '--status', 'active', '--has-new-results'], catch_exceptions=False) assert result.exit_code == 0, result.output + # All four filters, because autospec is what turns this into a + # SIGNATURE check against the installed SDK: a kwarg name that only + # this side renamed would otherwise ship as require_sdk_kwargs + # refusing on an SDK that does have the surface. ruleset_list.assert_called_once_with( - mock.ANY, name='alpha', favorites_only=True) + mock.ANY, name='alpha', status='active', + favorites_only=True, has_new_results=True) def test_filtering_on_a_floor_sdk_is_a_clean_message_not_a_traceback(self): """The published floor's ``ruleset_list()`` takes no filters. Using one @@ -384,6 +390,7 @@ def test_favorite_limit_refusal_is_a_clean_message_at_exit_2(self): assert '--unfavorite' in result.output # names the way out assert 'Traceback' not in result.output + @_needs_favorite_method def test_favorite_limit_without_counters_uses_the_server_message(self): # The counters are advisory; an envelope can carry the code without # them. Interpolating them unguarded rendered "(None of None used)" at @@ -403,6 +410,27 @@ def test_favorite_limit_without_counters_uses_the_server_message(self): assert 'Favorite limit reached (5 of 5 used).' in result.output assert '--unfavorite' in result.output + @_needs_favorite_method + def test_favorite_limit_on_a_request_without_result_still_has_no_traceback(self): + # A Mock has every attribute, so the test above cannot fail on a missing + # `.result`. This one uses a real object that genuinely lacks it — the + # handler exists to avoid a traceback and must not raise one reaching + # for its own fallback. + class BareRequest: + errors = {'code': 'FAVORITE_LIMIT'} + + refusal = exceptions.RequestException(BareRequest()) + with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_favorite', + autospec=True, side_effect=refusal): + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'rules', 'favorite', '5']) + assert result.exit_code == 2, result.output + assert 'Traceback' not in result.output + assert 'None' not in result.output + assert '--unfavorite' in result.output + def test_favorite_on_the_floor_sdk_degrades_cleanly(self): # The declared floor (published 4.3.0) has no ruleset_favorite: the # command must fail with a clean upgrade message at exit 2, never an From 11c59c8e4cae915a8a9ecf0bc6a23117f43fb7f0 Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 27 Aug 2026 22:20:59 -0300 Subject: [PATCH 15/54] fix(cli): fail open on a **kwargs SDK, type the id, document the guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit require_sdk_kwargs refused any method declared **kwargs: signature() reports one VAR_KEYWORD parameter rather than the names it accepts, so the guard would have told a user to upgrade an SDK that already supports the option. It fails open there now — the reason for inspecting the signature at all is to avoid a confusing upgrade message on a working install. --livescan-id takes click.INT like every other id option; Python ints are arbitrary precision, so a 17-digit id survives exactly (the server renders it as a string for JS consumers, not for us) and a typo is refused before it reaches the server. specs/04 gains the floor-guard convention this change introduced. It is load-bearing and lived in no spec: guard on the narrowest dependency, because a class-level guard does NOT skip a test whose resource exists but does not parse the attribute — the render tests fail and an absence-asserting test passes vacuously. Also drops the 'exit 2 is the server-refusal code' claim from a comment and from specs/02: ExceptionHandlingGroup maps 2 to a broad bucket, so the supportable contract is '2, not 1'. --- specs/02-commands.md | 2 +- specs/04-testing.md | 24 ++++++++++++++++++++++++ src/polyswarm/client/live.py | 7 +++++-- src/polyswarm/client/rules.py | 10 ++++++---- src/polyswarm/client/utils.py | 7 +++++++ tests/formatter_hunt_fields_test.py | 12 +++++++++++- 6 files changed, 54 insertions(+), 8 deletions(-) diff --git a/specs/02-commands.md b/specs/02-commands.md index 7880aac2..4e079f14 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -30,7 +30,7 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | | `family` (`families.py`) | Malware-family CRUD | `family_{create,update,delete,get,list}` | -| `rules` (`rules.py`) | YARA ruleset CRUD plus `favorite [--unfavorite]` (the star toggle: renders the new state + the server-owned "N of M used" budget, and converts the machine-readable `FAVORITE_LIMIT` refusal into a clean actionable message at exit 2 — the server-refusal code, never 1). `list` takes the server-side filters `--name` / `--status active` / `--favorites-only` / `--has-new-results` (conjunctive; the list is keyset-paginated, so filtering locally would mean walking every page). Every pre-existing INVOCATION works unchanged on the pin's floor: an unfiltered `rules list` still calls a zero-argument `ruleset_list()`, and the hunt-page fields arrive as plain response fields the formatters getattr-guard. What needs the paired SDK is `rules favorite` and the new `rules list` filters; each degrades to a clean upgrade message on the floor (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor) | `ruleset_{create,delete,update,get,list,favorite}` | +| `rules` (`rules.py`) | YARA ruleset CRUD plus `favorite [--unfavorite]` (the star toggle: renders the new state + the server-owned "N of M used" budget, and converts the machine-readable `FAVORITE_LIMIT` refusal into a clean actionable message at exit 2, never 1 — 1 is reserved for no-results/not-found; 2 is the broad bucket `ExceptionHandlingGroup` maps the PolyswarmException hierarchies to). `list` takes the server-side filters `--name` / `--status active` / `--favorites-only` / `--has-new-results` (conjunctive; the list is keyset-paginated, so filtering locally would mean walking every page). Every pre-existing INVOCATION works unchanged on the pin's floor: an unfiltered `rules list` still calls a zero-argument `ruleset_list()`, and the hunt-page fields arrive as plain response fields the formatters getattr-guard. What needs the paired SDK is `rules favorite` and the new `rules list` filters; each degrades to a clean upgrade message on the floor (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor) | `ruleset_{create,delete,update,get,list,favorite}` | | `metadata` (`metadata.py`) | Rerun metadata; scan lookup; IP/URL analysis | `rerun_metadata`, `scan_lookup`, `submit_url` | | `activity` (`event.py`) | List account activity/events | `event_list` | | `account` (`account.py`) | Account whois / features | `account_whois`, `account_features` | diff --git a/specs/04-testing.md b/specs/04-testing.md index 7b100e64..5b0bb8e8 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -61,3 +61,27 @@ Use it **only** for that. Argument parsing, SDK calls, generator consumption, `c ## Incremental — to be expanded This spec describes the harness as it stands. Not yet documented (add as the suite grows): a per-command coverage matrix, a documented "VCR-off against live e2e" CI job, and conventions for fixture/`.click` generation. See [`99-open-questions.md`](./99-open-questions.md). + +## Staying honest on both installs the pin permits + +`pyproject.toml` pins a **floor**, not an exact SDK, so the suite can run against +either the floor or a newer paired SDK. A test that needs a surface the floor does +not have must **skip** there — not fail, and above all not pass vacuously. + +Guard on the **narrowest dependency the test actually has**, because the failure +modes differ by level: + +| The test needs | Guard on | Why not something broader | +|---|---|---| +| an API **method** (`rules favorite` → `ruleset_favorite`) | `hasattr(PolyswarmAPI, '')` | keying it on the resource class too would let a resource rename silently skip the whole command suite while CI stays green | +| a **keyword** on an existing method (`rules list --name`, `live feed --livescan-id`) | `utils.require_sdk_kwargs` at runtime, so only the invocation that uses the option is affected | a module-level skip would drop coverage of the unfiltered call, which the floor does support | +| a **parsed attribute** on a resource (`rule_count`, `source_rule_changed`) | `hasattr(, '')` | the resource CLASS exists on the floor and simply does not parse the key, so a class-level guard does not skip — the render tests FAIL and an absence-asserting test passes **vacuously**, which looks like coverage while pinning nothing | + +That last row is the one that bites: `getattr`-guarded formatter legs turn a missing +attribute into silent omission, so a test asserting a line is *absent* cannot tell +"correctly omitted" from "the SDK never parsed it". Build the resource and check the +attribute. + +Whatever names the floor, name it **once** — `utils.SDK_FLOOR`, which a test ties to +the pin in `pyproject.toml`. The version is otherwise easy to drift: nothing fails if +a hardcoded literal in a guard message goes stale. diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index 9f92fa31..5dbf35a0 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -37,8 +37,11 @@ def live_stop(ctx, ruleset_id): help='How far back in MINUTES to request results ' '(default: 1440 — 24h, the window the ruleset badge counts). ' 'Pass 0 for no time filter at all.') -@click.option('-i', '--livescan-id', - help="Scope the feed to one live hunt (a ruleset's Live Hunt Id).") +@click.option('-i', '--livescan-id', type=click.INT, + help="Scope the feed to one live hunt (a ruleset's Live Hunt Id). " + 'Ids are 17-digit numbers; click.INT matches every other id ' + 'option in the CLI and rejects a typo before it reaches the ' + 'server.') @click.option('-m', '--max-results', type=click.IntRange(min=0), help='Stop after this many results. Unset or 0 means no bound — ' 'every page, as before. A negative is refused here rather ' diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index 38cd1d6b..722e2437 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -31,7 +31,7 @@ def delete(ctx, rule_id): output.ruleset(api.ruleset_delete(rule_id)) -@rules.command('list', short_help='List all rulesets.') +@rules.command('list', short_help='List rulesets, optionally filtered.') @click.option('-n', '--name', help='Substring match on the ruleset name (case-insensitive).') @click.option('-s', '--status', type=click.Choice(['active']), help='Only rulesets whose live hunt is currently running.') @@ -96,9 +96,11 @@ def favorite(ctx, rule_id, unfavorite): errors = getattr(exc.request, 'errors', None) or {} if isinstance(errors, dict) and errors.get('code') == 'FAVORITE_LIMIT': # The one refusal a user fixes themselves (unstar something): - # say so cleanly. A CLI PolyswarmException keeps the central - # exit-code mapping's 2 (server refusal) — a ClickException - # would exit 1, the code reserved for no-results/not-found. + # say so cleanly. A CLI PolyswarmException exits 2 through the + # central mapping — a ClickException would exit 1, the code + # reserved for no-results/not-found. (2 is a broad bucket, not a + # server-refusal code specifically; the contract here is "2, not + # 1".) used = errors.get('favorites_used') limit = errors.get('favorites_limit') if used is None or limit is None: diff --git a/src/polyswarm/client/utils.py b/src/polyswarm/client/utils.py index d09332a5..d2032da0 100644 --- a/src/polyswarm/client/utils.py +++ b/src/polyswarm/client/utils.py @@ -48,6 +48,13 @@ def require_sdk_kwargs(method, names, what): unchanged on the floor (see specs/05-sdk-contract.md). """ parameters = inspect.signature(method).parameters + if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()): + # A method declared `**kwargs` accepts every name, but signature() + # reports one VAR_KEYWORD parameter rather than the names it takes, so + # a naive membership test would refuse an SDK that supports the option. + # Fail OPEN here: the point of inspecting the signature is to avoid a + # confusing upgrade message on a working install. + return missing = [n for n in names if n not in parameters] if missing: raise exceptions.PolyswarmException( diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index e3a8c5b5..afab910c 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -278,7 +278,10 @@ def test_livescan_id_and_max_results_are_forwarded(self): '--livescan-id', '72927285313305230', '--max-results', '5') assert result.exit_code == 0, result.output _, kwargs = live_feed.call_args - assert kwargs['livescan_id'] == '72927285313305230' + # click.INT, and Python ints are arbitrary precision — a 17-digit id + # survives exactly, which is the whole reason the server renders it as + # a string for JS consumers. + assert kwargs['livescan_id'] == 72927285313305230 assert kwargs['max_results'] == 5 def test_zero_max_results_is_unbounded_and_never_reaches_the_sdk(self): @@ -311,6 +314,13 @@ def test_a_negative_max_results_is_refused_at_the_interface(self): 'live', 'feed', '--max-results', '-1']) assert result.exit_code != 0 + def test_a_non_numeric_livescan_id_is_refused_before_the_server(self): + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'live', 'feed', '--livescan-id', 'not-an-id']) + assert result.exit_code != 0 + def test_new_options_on_a_floor_sdk_are_a_clean_message(self): def floor_live_feed(self, since=None, rule_name=None, family=None, polyscore_lower=None, polyscore_upper=None, From 45e4235ca159f78497783b027efda8ddccb31ada Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 27 Aug 2026 22:29:44 -0300 Subject: [PATCH 16/54] fix(tests): guard the re-recorded text cassettes on the floor too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_live_hunt_start_text / test_live_hunt_stop_text expect 'Rules in ruleset' and 'Historical hunts triggered' since the cassettes were re-recorded, but those lines only render when the SDK PARSES the attributes. On a floor install the formatter's getattr guard omits them and both tests FAIL — the exact case the specs/04 section this change adds legislates against. The guards are now needed in two modules, which is what earns them a shared home rather than a second copy of the resource-building boilerplate. Verified both directions: the tests run against the paired SDK and skip when the attribute is absent. specs/02 also records that the two hunt --since options differ in unit on purpose — live feed is minutes, historical list is seconds, because they are different endpoints and the server reads each accordingly. Without saying so the remaining 'seconds' reads as a missed rename. --- specs/02-commands.md | 2 ++ tests/_sdk_guards.py | 43 +++++++++++++++++++++++++++++ tests/cli_test.py | 12 ++++++++ tests/formatter_hunt_fields_test.py | 34 ++++------------------- 4 files changed, 63 insertions(+), 28 deletions(-) create mode 100644 tests/_sdk_guards.py diff --git a/specs/02-commands.md b/specs/02-commands.md index 4e079f14..dfa76678 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -26,6 +26,8 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `report-template` (`report_template.py`) | Manage report templates + logos | `report_template_{create,update,get,list}`, `report_template_logo_{download,upload}` | | `engine` → `votes` / `assertions` (`engine.py`) | Consolidated votes/assertions bundles per engine | `votes_{create,get,delete,list}`, `assertions_{create,get,delete,list}` | | `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **MINUTES** (default 1440 — 24h, the window the ruleset badge counts; `0` means no time filter at all), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry it) and `--max-results` (stop after N; unset means every page, as before). Those two need the paired SDK and are forwarded only when passed, so every pre-existing invocation is unchanged on the pin's floor — see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | +> **The two hunt `--since` options differ in unit, deliberately.** `live feed --since` is MINUTES; `historical list --since` is SECONDS. They are different endpoints and the server reads each accordingly (`timedelta(minutes=...)` for the live feed, `timedelta(seconds=...)` for historical). The live feed's unit was corrected because every surface around it — its own default of 1440, the ruleset badge's 24h window — was written for minutes; the historical endpoint was left alone because nothing there disagrees. This is recorded so the remaining `seconds` is not read as a missed rename. + | `historical` (`historical.py`) | Historical hunts: CRUD + results | `historical_{get,create,update,list}`, `historical_delete_multiple`, `historical_delete_list`, `historical_results_multiple`, `historical_result`, `historical_results_delete` | | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | diff --git a/tests/_sdk_guards.py b/tests/_sdk_guards.py new file mode 100644 index 00000000..6cf13ac6 --- /dev/null +++ b/tests/_sdk_guards.py @@ -0,0 +1,43 @@ +"""Skip guards keyed on the SDK surface a test actually needs. + +``pyproject.toml`` pins a FLOOR, not an exact SDK, so the suite runs against +either the floor or a newer paired SDK and a test needing a surface the floor +lacks must SKIP there — not fail, and above all not pass vacuously. The full +convention, including why the guard is keyed on the narrowest dependency, is in +``specs/04-testing.md`` §Staying honest on both installs the pin permits. + +Shared because two modules need the same guards: the formatter unit tests build +resources directly, and the cassette tests render CLI output whose lines only +appear when the SDK parses the underlying attribute.""" +import unittest + +from polyswarm_api import resources +from polyswarm_api.api import PolyswarmAPI + +_RULESET = {'id': '0', 'livescan_id': None, 'livescan_created': None, + 'name': 'n', 'description': 'd', 'deleted': False, + 'created': '2026-08-20T00:00:00+00:00', + 'modified': '2026-08-20T00:00:00+00:00', 'yara': None} +_HUNT = {'id': '0', 'status': 'PENDING', 'progress': 0.0, 'active': None, + 'created': '2026-08-20T00:00:00+00:00', 'summary': None, + 'results_csv_uri': None, 'ruleset_name': 'n', 'yara': None} + +# Keyed on the METHOD, never also on the resource class: that would let a +# resource rename silently skip the whole command suite while CI stays green. +needs_favorite_method = unittest.skipUnless( + hasattr(PolyswarmAPI, 'ruleset_favorite'), + 'paired SDK method (ruleset_favorite) not installed') +needs_favorite_resource = unittest.skipUnless( + hasattr(resources, 'YaraRulesetFavorite'), + 'paired SDK resource (YaraRulesetFavorite) not installed') + +# Keyed on the ATTRIBUTE, not the class: YaraRuleset and HistoricalHunt exist on +# the floor and simply do not parse these keys, so a class-level guard does not +# skip — the render assertions fail, and an absence-asserting test passes +# vacuously, which looks like coverage while pinning nothing. +needs_tracking_fields = unittest.skipUnless( + hasattr(resources.YaraRuleset(_RULESET, api=None), 'rule_count'), + 'paired SDK does not parse the ruleset tracking fields') +needs_provenance_fields = unittest.skipUnless( + hasattr(resources.HistoricalHunt(_HUNT, api=None), 'source_rule_changed'), + 'paired SDK does not parse the hunt provenance fields') diff --git a/tests/cli_test.py b/tests/cli_test.py index 7b988dd4..2eabb773 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -11,6 +11,8 @@ import unittest import vcr as vcr_ + +from tests._sdk_guards import needs_tracking_fields as _needs_tracking_fields from polyswarm_api.api import PolyswarmAPI import click from click.testing import CliRunner @@ -188,6 +190,11 @@ def test_live_hunt_start_json(self): '--output-format', 'json', 'live', 'start', '44051669277897879']) self._assert_json_result(result, self.click_vcr(result)) + # The re-recorded .click expects 'Rules in ruleset' / 'Historical hunts + # triggered', which render only when the SDK PARSES those attributes — + # on the floor the formatter's getattr guard omits them and this fails + # rather than skips (specs/04 §Staying honest on both installs). + @_needs_tracking_fields @vcr.use_cassette() def test_live_hunt_start_text(self): result = self._run_cli([ @@ -199,6 +206,11 @@ def test_live_hunt_stop_json(self): result = self._run_cli(['--output-format', 'json', 'live', 'stop', '44051669277897879']) self._assert_json_result(result, self.click_vcr(result)) + # The re-recorded .click expects 'Rules in ruleset' / 'Historical hunts + # triggered', which render only when the SDK PARSES those attributes — + # on the floor the formatter's getattr guard omits them and this fails + # rather than skips (specs/04 §Staying honest on both installs). + @_needs_tracking_fields @vcr.use_cassette() def test_live_hunt_stop_text(self): result = self._run_cli(['--output-format', 'text', 'live', 'stop', '44051669277897879']) diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index afab910c..f907859a 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -46,34 +46,12 @@ # need only the METHOD (keying them on the resource too would let a resource # rename silently skip the whole command suite while CI stays green), and the # formatter fixture tests need only the RESOURCE class they instantiate. -_needs_favorite_method = unittest.skipUnless( - hasattr(PolyswarmAPI, 'ruleset_favorite'), - 'paired SDK method (ruleset_favorite) not installed') -_needs_favorite_resource = unittest.skipUnless( - hasattr(resources, 'YaraRulesetFavorite'), - 'paired SDK resource (YaraRulesetFavorite) not installed') -# The tracking/provenance fixtures need a NARROWER guard still. YaraRuleset and -# HistoricalHunt exist on the floor — they simply do not PARSE the new keys, and -# resources declare their attributes explicitly, so on a floor install the -# formatter's getattr guards return None: the render tests FAIL rather than skip, -# and the absence-only test passes VACUOUSLY, which looks like coverage while -# pinning nothing. Key them on the attribute the fixture actually needs. -_needs_tracking_fields = unittest.skipUnless( - hasattr(resources.YaraRuleset({'id': '0', 'livescan_id': None, - 'livescan_created': None, 'name': 'n', - 'description': 'd', 'deleted': False, - 'created': '2026-08-20T00:00:00+00:00', - 'modified': '2026-08-20T00:00:00+00:00', - 'yara': None}, api=None), 'rule_count'), - 'paired SDK does not parse the ruleset tracking fields') -_needs_provenance_fields = unittest.skipUnless( - hasattr(resources.HistoricalHunt({'id': '0', 'status': 'PENDING', - 'progress': 0.0, 'active': None, - 'created': '2026-08-20T00:00:00+00:00', - 'summary': None, 'results_csv_uri': None, - 'ruleset_name': 'n', 'yara': None}, - api=None), 'source_rule_changed'), - 'paired SDK does not parse the hunt provenance fields') +from tests._sdk_guards import ( # noqa: E402 + needs_favorite_method as _needs_favorite_method, + needs_favorite_resource as _needs_favorite_resource, + needs_provenance_fields as _needs_provenance_fields, + needs_tracking_fields as _needs_tracking_fields, +) def _ruleset(**overrides): From e0312b097b0057cf2b79b40512c2dee156776db9 Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 27 Aug 2026 22:37:42 -0300 Subject: [PATCH 17/54] docs(specs): keep the --since note out of the command table A blockquote between rows terminates a GFM table, so the note split the catalogue: everything from 'historical' down rendered as a paragraph of pipe-delimited text, including the rules row this change rewrote. Moved below the last row. --- specs/02-commands.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/02-commands.md b/specs/02-commands.md index dfa76678..48ae6eda 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -26,8 +26,6 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `report-template` (`report_template.py`) | Manage report templates + logos | `report_template_{create,update,get,list}`, `report_template_logo_{download,upload}` | | `engine` → `votes` / `assertions` (`engine.py`) | Consolidated votes/assertions bundles per engine | `votes_{create,get,delete,list}`, `assertions_{create,get,delete,list}` | | `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **MINUTES** (default 1440 — 24h, the window the ruleset badge counts; `0` means no time filter at all), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry it) and `--max-results` (stop after N; unset means every page, as before). Those two need the paired SDK and are forwarded only when passed, so every pre-existing invocation is unchanged on the pin's floor — see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | -> **The two hunt `--since` options differ in unit, deliberately.** `live feed --since` is MINUTES; `historical list --since` is SECONDS. They are different endpoints and the server reads each accordingly (`timedelta(minutes=...)` for the live feed, `timedelta(seconds=...)` for historical). The live feed's unit was corrected because every surface around it — its own default of 1440, the ruleset badge's 24h window — was written for minutes; the historical endpoint was left alone because nothing there disagrees. This is recorded so the remaining `seconds` is not read as a missed rename. - | `historical` (`historical.py`) | Historical hunts: CRUD + results | `historical_{get,create,update,list}`, `historical_delete_multiple`, `historical_delete_list`, `historical_results_multiple`, `historical_result`, `historical_results_delete` | | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | @@ -40,6 +38,8 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `bundle` (`bundle.py`) | Sample bundle tasks | `sample_bundle_task_create`, `sample_bundle_task_get`, `sample_bundle_download` | | `sample` (`sample.py`) | Fetch a consolidated sample view | `sample` | +> **The two hunt `--since` options differ in unit, deliberately.** `live feed --since` is MINUTES; `historical list --since` is SECONDS. They are different endpoints and the server reads each accordingly (`timedelta(minutes=...)` for the live feed, `timedelta(seconds=...)` for historical). The live feed's unit was corrected because every surface around it — its own default of 1440, the ruleset badge's 24h window — was written for minutes; the historical endpoint was left alone because nothing there disagrees. This is recorded so the remaining `seconds` is not read as a missed rename. + ## Adding to the catalogue When you add or materially change a group, update its row (and add per-subcommand detail here if the behaviour is non-obvious). The `AGENTS.md` §"When adding a new command family" checklist covers the wiring + formatter + test steps. From 4c6f2473e71e2e806457f3eeace3b8bd4d17e080 Mon Sep 17 00:00:00 2001 From: Samuel Date: Thu, 27 Aug 2026 22:47:34 -0300 Subject: [PATCH 18/54] fix(cli): finish the retractions and use the shared guard everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'exit 2 is the server-refusal code' claim was dropped from a comment and specs/02 last commit but survived in the command DOCSTRING — which is what 'rules favorite --help' prints — and in two test docstrings. ExceptionHandlingGroup maps 2 to a broad bucket, so the supportable contract is '2, not 1'. cli_test.py hand-rolled a second copy of _needs_favorite_method against the same hasattr, which is the drift tests/_sdk_guards.py was added to stop; the last hardcoded '4.3.0' in a guard assertion now reads SDK_FLOOR, the constant SdkFloorConstantTest ties to the pin. Two dangling references: a comment cited '--include-counts', withdrawn inside this PR and present nowhere in the tree, and another still said list is zero-argument after this change gave it filters. specs/03 attributed the floor to the behaviours that set 4.2.0 — 4.3.0 came from the #264 bump — and now points at specs/05 rather than restating it. specs/04 and specs/05 said 'utils.' for helpers that live in client/utils.py, not the top-level utils.py specs/01 documents. Also records why exc.request is read directly: RequestException.__init__ assigns it unconditionally, so a guard there would be dead code. Raised twice in review; written down so it stays settled. --- specs/03-formatters.md | 5 ++++- specs/04-testing.md | 4 ++-- specs/05-sdk-contract.md | 4 ++-- src/polyswarm/client/rules.py | 15 ++++++++++----- tests/cli_test.py | 14 +++++--------- tests/formatter_hunt_fields_test.py | 5 +++-- 6 files changed, 26 insertions(+), 21 deletions(-) diff --git a/specs/03-formatters.md b/specs/03-formatters.md index c520fda6..166d315a 100644 --- a/specs/03-formatters.md +++ b/specs/03-formatters.md @@ -142,7 +142,10 @@ either field) never raises `AttributeError`; an SDK without `.state` simply neve the known-good branch, which is the safe fallback — the pre-known-good rendering. That degradation is belt-and-braces, not a supported configuration: `.state` is load-bearing here with no substitute. Both attributes ship in SDK **4.1.0**, but the dependency floor is -`polyswarm_api>=4.3.0` — set by two *other* behaviours the CLI depends on, both of which +`polyswarm_api>=4.3.0` — the pin's current value (moved there by the #264 release +bump). Its *rationale* is two behaviours that landed in 4.2.0 and still hold +transitively; [05-sdk-contract.md](./05-sdk-contract.md) §Current floor is +authoritative. Both of which fail silently on 4.1.0 (see [`05-sdk-contract.md`](./05-sdk-contract.md) §Version pin) — so every supported install has them. `JSONOutput` needs no change — it dumps the resource's `.json`, which already carries the raw `state` and `known_good` keys. diff --git a/specs/04-testing.md b/specs/04-testing.md index 5b0bb8e8..2a47245f 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -74,7 +74,7 @@ modes differ by level: | The test needs | Guard on | Why not something broader | |---|---|---| | an API **method** (`rules favorite` → `ruleset_favorite`) | `hasattr(PolyswarmAPI, '')` | keying it on the resource class too would let a resource rename silently skip the whole command suite while CI stays green | -| a **keyword** on an existing method (`rules list --name`, `live feed --livescan-id`) | `utils.require_sdk_kwargs` at runtime, so only the invocation that uses the option is affected | a module-level skip would drop coverage of the unfiltered call, which the floor does support | +| a **keyword** on an existing method (`rules list --name`, `live feed --livescan-id`) | `client/utils.py`’s `require_sdk_kwargs` at runtime, so only the invocation that uses the option is affected | a module-level skip would drop coverage of the unfiltered call, which the floor does support | | a **parsed attribute** on a resource (`rule_count`, `source_rule_changed`) | `hasattr(, '')` | the resource CLASS exists on the floor and simply does not parse the key, so a class-level guard does not skip — the render tests FAIL and an absence-asserting test passes **vacuously**, which looks like coverage while pinning nothing | That last row is the one that bites: `getattr`-guarded formatter legs turn a missing @@ -82,6 +82,6 @@ attribute into silent omission, so a test asserting a line is *absent* cannot te "correctly omitted" from "the SDK never parsed it". Build the resource and check the attribute. -Whatever names the floor, name it **once** — `utils.SDK_FLOOR`, which a test ties to +Whatever names the floor, name it **once** — `client/utils.py`’s `SDK_FLOOR`, which a test ties to the pin in `pyproject.toml`. The version is otherwise easy to drift: nothing fails if a hardcoded literal in a guard message goes stale. diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index 4522b7e6..d645e5ac 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -88,8 +88,8 @@ The known-good rendering attributes (`ArtifactInstance.state`, `.known_good`/`.k | Surface | Needs from the SDK | Guard | |---|---|---| | `rules favorite` | `ruleset_favorite` | `getattr` on the method | -| `rules list --name/--status/--favorites-only/--has-new-results` | `ruleset_list(**filters)` | `utils.require_sdk_kwargs` | -| `live feed --livescan-id/--max-results` | `live_feed(livescan_id=, max_results=)` | `utils.require_sdk_kwargs` | +| `rules list --name/--status/--favorites-only/--has-new-results` | `ruleset_list(**filters)` | `client/utils.py`’s `require_sdk_kwargs` | +| `live feed --livescan-id/--max-results` | `live_feed(livescan_id=, max_results=)` | `client/utils.py`’s `require_sdk_kwargs` | The distinction that keeps the floor where it is: a new **option** may require the newer SDK, but an existing **invocation** may not. So the guards fire only when the caller actually uses the new surface — an unfiltered `rules list` and a plain `live feed` still reach the floor's own signatures untouched — and a floor install gets a clean upgrade message at exit 2 rather than the `TypeError`/`AttributeError` traceback a bare call would raise. That is why the floor itself does not move; moving it has the two preconditions above, and neither holds until the SDK releases. `require_sdk_kwargs` inspects the installed signature rather than catching `TypeError`, so a genuine argument error inside the SDK is never mistaken for a version mismatch. When the SDK release lands on PyPI, bumping the floor and dropping all three guards is the follow-up. diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index 722e2437..66419173 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -72,8 +72,8 @@ def favorite(ctx, rule_id, unfavorite): Stars are shared by the team and capped server-side; the response renders the new state plus the budget ("N of M favorites used"). When the budget is full the server refuses with a machine-readable FAVORITE_LIMIT error, - rendered here as a clean message rather than a traceback (still exit 2 — - the central mapping's server-refusal code; exit 1 means no-results). + rendered here as a clean message rather than a traceback (exit 2, not 1 — + 1 is reserved for no-results/not-found). """ api = ctx.obj['api'] output = ctx.obj['output'] @@ -81,11 +81,11 @@ def favorite(ctx, rule_id, unfavorite): if toggle is None: # The declared floor (published polyswarm-api 4.3.0) predates the # favorite surface — it ships in the paired SDK change. Every OTHER - # command keeps working on the floor (list is zero-argument again); + # command keeps working on the floor (an unfiltered list still is); # only this command needs the newer SDK, and on the floor it must # fail with a clean upgrade message, never an AttributeError - # traceback. (Same principle as the withdrawn --include-counts flag: - # a new surface may require the new SDK; existing surfaces may not.) + # traceback. The principle: a new OPTION may require the newer SDK; an + # existing INVOCATION may not. raise exceptions.PolyswarmException( f'rules favorite requires a polyswarm-api release newer than ' f'{utils.SDK_FLOOR} (the paired SDK change adds ruleset_favorite). ' @@ -93,6 +93,11 @@ def favorite(ctx, rule_id, unfavorite): try: output.ruleset_favorite(toggle(rule_id, not unfavorite)) except api_exceptions.RequestException as exc: + # `exc.request` is read directly on purpose: RequestException.__init__ + # assigns self.request unconditionally, so the attribute always exists, + # and a None request flows safely through the getattr below. Guarding it + # too would be dead code. (Raised in review more than once; recorded so + # it stays settled.) errors = getattr(exc.request, 'errors', None) or {} if isinstance(errors, dict) and errors.get('code') == 'FAVORITE_LIMIT': # The one refusal a user fixes themselves (unstar something): diff --git a/tests/cli_test.py b/tests/cli_test.py index 2eabb773..da776c5b 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -12,7 +12,10 @@ import vcr as vcr_ -from tests._sdk_guards import needs_tracking_fields as _needs_tracking_fields +from tests._sdk_guards import ( + needs_favorite_method as _needs_favorite_method, + needs_tracking_fields as _needs_tracking_fields, +) from polyswarm_api.api import PolyswarmAPI import click from click.testing import CliRunner @@ -23,13 +26,6 @@ vcr = vcr_.VCR(cassette_library_dir='tests/vcr', path_transformer=vcr_.VCR.ensure_suffix('.vcr')) -# The favorite surface ships in the paired SDK change; on the declared floor -# (published polyswarm_api 4.3.0) `rules favorite` deliberately degrades to -# the upgrade message, so its cassette tests must skip there — the suite has -# to stay honest on both installs the pin permits. -_needs_favorite_method = unittest.skipUnless( - hasattr(PolyswarmAPI, 'ruleset_favorite'), - 'paired SDK method (ruleset_favorite) not installed') class BaseTestCase(TestCase): @@ -314,7 +310,7 @@ def test_ruleset_favorite_limit_text(self): # the error body actually lives (exc.request.errors, code string # included) — the unit test's hand-built mock cannot notice either # side renaming it — and the clean actionable message at exit 2, the - # central mapping's server-refusal code. + # central mapping's refusal path (exit 2, not 1). result = self._run_cli([ '--output-format', 'text', 'rules', 'favorite', '45874884769561543']) expected = self.click_vcr(result) diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index f907859a..c19ea84e 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -360,7 +360,7 @@ def test_unfavorite_flag_flips_the_boolean(self): @_needs_favorite_method def test_favorite_limit_refusal_is_a_clean_message_at_exit_2(self): - # Exit 2 is the central mapping's server-refusal code; exit 1 is + # Exit 2 is the central mapping's code for this, never 1; exit 1 is # reserved for no-results/not-found. The friendly message rides a CLI # PolyswarmException so ExceptionHandlingGroup logs it cleanly. request = mock.Mock() @@ -431,7 +431,8 @@ def test_favorite_on_the_floor_sdk_degrades_cleanly(self): ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', 'rules', 'favorite', '5']) assert result.exit_code == 2, result.output - assert 'requires a polyswarm-api release newer than 4.3.0' in result.output + assert (f'requires a polyswarm-api release newer than {utils.SDK_FLOOR}' + in result.output) assert 'AttributeError' not in result.output @_needs_favorite_method From b914239d9b059600b6782345f8f0b4583dd50945 Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 28 Aug 2026 01:34:27 -0300 Subject: [PATCH 19/54] fix(live): default --since to 86400 seconds The 1440 default was written as 24*60 against a docstring that said minutes; the server reads seconds, so the real window was 24 minutes while the badge beside it counts 24 hours. Fixing the caller rather than the wire gets the same 24h with no break for existing integrations. historical list --since is seconds too, so the two now agree. Also trims the FAVORITE_LIMIT handler and the SDK guard comments. --- specs/02-commands.md | 11 +++++++-- src/polyswarm/client/live.py | 16 +++++-------- src/polyswarm/client/rules.py | 37 +++++++++-------------------- src/polyswarm/client/utils.py | 21 ++++------------ tests/formatter_hunt_fields_test.py | 5 ++-- 5 files changed, 33 insertions(+), 57 deletions(-) diff --git a/specs/02-commands.md b/specs/02-commands.md index 48ae6eda..907c4f44 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -25,7 +25,7 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `report` (`report.py`) | Create/fetch/download reports; `prompt-config` subgroup; LLM reports | `report_create`, `report_wait_for`, `report_download`, `report_get`, `llm_report_{create,get,download}`, `prompt_config_{create,get,update,list}` | | `report-template` (`report_template.py`) | Manage report templates + logos | `report_template_{create,update,get,list}`, `report_template_logo_{download,upload}` | | `engine` → `votes` / `assertions` (`engine.py`) | Consolidated votes/assertions bundles per engine | `votes_{create,get,delete,list}`, `assertions_{create,get,delete,list}` | -| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **MINUTES** (default 1440 — 24h, the window the ruleset badge counts; `0` means no time filter at all), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry it) and `--max-results` (stop after N; unset means every page, as before). Those two need the paired SDK and are forwarded only when passed, so every pre-existing invocation is unchanged on the pin's floor — see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | +| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **SECONDS** (default 86400 — 24h, matching the window the ruleset badge counts; `0` means no time filter at all), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry it) and `--max-results` (stop after N; unset means every page, as before). Those two need the paired SDK and are forwarded only when passed, so every pre-existing invocation is unchanged on the pin's floor — see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | | `historical` (`historical.py`) | Historical hunts: CRUD + results | `historical_{get,create,update,list}`, `historical_delete_multiple`, `historical_delete_list`, `historical_results_multiple`, `historical_result`, `historical_results_delete` | | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | @@ -38,7 +38,14 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `bundle` (`bundle.py`) | Sample bundle tasks | `sample_bundle_task_create`, `sample_bundle_task_get`, `sample_bundle_download` | | `sample` (`sample.py`) | Fetch a consolidated sample view | `sample` | -> **The two hunt `--since` options differ in unit, deliberately.** `live feed --since` is MINUTES; `historical list --since` is SECONDS. They are different endpoints and the server reads each accordingly (`timedelta(minutes=...)` for the live feed, `timedelta(seconds=...)` for historical). The live feed's unit was corrected because every surface around it — its own default of 1440, the ruleset badge's 24h window — was written for minutes; the historical endpoint was left alone because nothing there disagrees. This is recorded so the remaining `seconds` is not read as a missed rename. +> **`live feed --since` defaults to 86400 seconds (24h), not 1440.** The old +> default was written as `24 * 60` against an SDK docstring that said the +> parameter was minutes; the server has always read **seconds**, so the real +> default window was 24 minutes while the ruleset badge beside it counts 24 +> hours. The fix is here rather than on the wire: the endpoint takes ~197k +> requests per 30 days carrying `since` from clients outside our control, and +> re-basing the server to minutes would widen every one of them 60x with no +> error. `historical list --since` is seconds too — the two agree. ## Adding to the catalogue diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index 5dbf35a0..49108739 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -33,9 +33,9 @@ def live_stop(ctx, ruleset_id): @live.command('feed', short_help='Get results from live hunt.') -@click.option('-s', '--since', type=click.INT, default=1440, - help='How far back in MINUTES to request results ' - '(default: 1440 — 24h, the window the ruleset badge counts). ' +@click.option('-s', '--since', type=click.INT, default=86400, + help='How far back in SECONDS to request results ' + '(default: 86400 — 24h, the window the ruleset badge counts). ' 'Pass 0 for no time filter at all.') @click.option('-i', '--livescan-id', type=click.INT, help="Scope the feed to one live hunt (a ruleset's Live Hunt Id). " @@ -63,16 +63,12 @@ def live_results(ctx, since, livescan_id, max_results, rule_name, family, """ api = ctx.obj['api'] output = ctx.obj['output'] - # Both new options ride one guard: they are the only part of this command - # that needs the paired SDK, and a bare kwarg would be a TypeError - # traceback on the pin's floor. Every existing invocation is untouched. + # Both new options share one floor guard; existing invocations are untouched. kwargs = {} if livescan_id is not None: kwargs['livescan_id'] = livescan_id - # Truthiness, not `is not None`: --max-results 0 IS the pre-existing - # unbounded behaviour, so it must not reach the SDK and must not trip the - # floor guard. A new option may require the newer SDK; an invocation that - # asks for what the floor already does may not (specs/05 Current floor). + # Truthiness: 0 is the pre-existing unbounded behaviour, so it must not + # reach the SDK or trip the floor guard (specs/05 §Current floor). if max_results: kwargs['max_results'] = max_results if kwargs: diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index 66419173..b198b6e3 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -47,11 +47,8 @@ def list_rules(ctx, name, status, favorites_only, has_new_results): """ api = ctx.obj['api'] output = ctx.obj['output'] - # UNFILTERED `rules list` stays a zero-argument call, so it keeps working - # on the pin's floor (4.3.0) exactly as before — every field it renders is - # a plain response field the formatters getattr-guard. Only a caller who - # actually passes a filter needs the paired SDK, and that caller gets a - # clean upgrade message instead of a TypeError traceback. + # Unfiltered stays a zero-argument call, so it keeps working on the floor; + # only a caller passing a filter needs the paired SDK. kwargs = {k: v for k, v in (('name', name), ('status', status), ('favorites_only', favorites_only or None), ('has_new_results', has_new_results or None)) @@ -93,31 +90,19 @@ def favorite(ctx, rule_id, unfavorite): try: output.ruleset_favorite(toggle(rule_id, not unfavorite)) except api_exceptions.RequestException as exc: - # `exc.request` is read directly on purpose: RequestException.__init__ - # assigns self.request unconditionally, so the attribute always exists, - # and a None request flows safely through the getattr below. Guarding it - # too would be dead code. (Raised in review more than once; recorded so - # it stays settled.) + # `exc.request` needs no guard: __init__ always assigns it, and a None + # request flows safely through the getattr. (Raised twice in review.) errors = getattr(exc.request, 'errors', None) or {} if isinstance(errors, dict) and errors.get('code') == 'FAVORITE_LIMIT': - # The one refusal a user fixes themselves (unstar something): - # say so cleanly. A CLI PolyswarmException exits 2 through the - # central mapping — a ClickException would exit 1, the code - # reserved for no-results/not-found. (2 is a broad bucket, not a - # server-refusal code specifically; the contract here is "2, not - # 1".) used = errors.get('favorites_used') limit = errors.get('favorites_limit') - if used is None or limit is None: - # The counters are advisory and the envelope may carry only the - # code. Fall back to the server's own message rather than - # rendering "(None of None used)" at the user. - # getattr, like `.errors` above: this block exists to avoid a - # traceback, so it must not raise one reaching for a fallback. - budget = str(getattr(exc.request, 'result', None) - or 'Favorite limit reached.') - else: - budget = f'Favorite limit reached ({used} of {limit} used).' + # Counters are advisory; fall back rather than render "(None of None)". + budget = (f'Favorite limit reached ({used} of {limit} used).' + if used is not None and limit is not None + else str(getattr(exc.request, 'result', None) + or 'Favorite limit reached.')) + # PolyswarmException exits 2; ClickException would exit 1, reserved + # for no-results/not-found. raise exceptions.PolyswarmException( f'{budget} Unfavorite another ruleset first: ' f'`polyswarm rules favorite --unfavorite`.' diff --git a/src/polyswarm/client/utils.py b/src/polyswarm/client/utils.py index d2032da0..c11b831a 100644 --- a/src/polyswarm/client/utils.py +++ b/src/polyswarm/client/utils.py @@ -12,10 +12,7 @@ logger = logging.getLogger(__name__) HASH_VALIDATORS = resources.Hash.SUPPORTED_HASH_TYPES -# The published SDK floor this repo pins (pyproject.toml, and -# specs/05-sdk-contract.md Current floor). Named once so the follow-up bump -# that drops the guards has a single place to look instead of three -# hand-written strings that can drift apart. +# The published SDK floor; tracks pyproject.toml's pin (SdkFloorConstantTest). SDK_FLOOR = '4.3.0' #################################################### @@ -39,22 +36,12 @@ def parse_hashes(hashes, hash_file=None): def require_sdk_kwargs(method, names, what): """Refuse cleanly when the installed SDK predates a keyword this command needs. - The declared floor (published `polyswarm_api` SDK_FLOOR) predates the - hunt-page surface, and passing an unknown keyword to an older SDK raises a - bare TypeError that `ExceptionHandlingGroup` renders as a traceback plus - 'Please contact support'. A new OPTION may require the newer SDK; an - existing invocation may not — so this is checked only when the caller - actually uses the option, leaving every pre-existing command working - unchanged on the floor (see specs/05-sdk-contract.md). + Called only when the caller actually uses the option, so existing + invocations keep working on the floor. See specs/05-sdk-contract.md. """ parameters = inspect.signature(method).parameters if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()): - # A method declared `**kwargs` accepts every name, but signature() - # reports one VAR_KEYWORD parameter rather than the names it takes, so - # a naive membership test would refuse an SDK that supports the option. - # Fail OPEN here: the point of inspecting the signature is to avoid a - # confusing upgrade message on a working install. - return + return # **kwargs accepts every name; fail open rather than false-refuse missing = [n for n in names if n not in parameters] if missing: raise exceptions.PolyswarmException( diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index c19ea84e..0e0a2450 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -248,8 +248,9 @@ def test_plain_feed_forwards_neither_new_kwarg(self): assert result.exit_code == 0, result.output _, kwargs = live_feed.call_args assert 'livescan_id' not in kwargs and 'max_results' not in kwargs - # the default window is 1440 MINUTES (24h), passed positionally - assert live_feed.call_args[0][1] == 1440 + # the default window is 86400 SECONDS (24h), passed positionally — the + # wire is seconds and stays seconds, so the CLI default carries the 24h + assert live_feed.call_args[0][1] == 86400 def test_livescan_id_and_max_results_are_forwarded(self): result, live_feed = self._invoke( From 7528f362b24d12baa94bf216a77ea3707f9417cc Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 28 Aug 2026 01:48:45 -0300 Subject: [PATCH 20/54] fix(cli): harden the skip guards and finish two spec edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard probes asked hasattr() for keys the probe payloads omitted. That works only because the SDK assigns every attribute unconditionally — verified, and the guarded tests do run — but it made the guards depend on that; the keys are in the payloads now, so a silent skip-everything cannot arise from an SDK style change. The FAVORITE_LIMIT fallback interpolated exc.request.result unchecked; result is the parsed body, so a dict would have reached the user as a repr. Two edits I left half-done: specs/03's floor paragraph lost its sentence, and specs/05 still named 4.2.0 above the heading that says 4.3.0. Drops the imports the shared-guard move orphaned. --- specs/03-formatters.md | 6 +++--- specs/05-sdk-contract.md | 2 +- src/polyswarm/client/rules.py | 6 ++++-- tests/_sdk_guards.py | 8 ++++++-- tests/cli_test.py | 2 -- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/specs/03-formatters.md b/specs/03-formatters.md index 166d315a..f432c531 100644 --- a/specs/03-formatters.md +++ b/specs/03-formatters.md @@ -143,9 +143,9 @@ the known-good branch, which is the safe fallback — the pre-known-good renderi degradation is belt-and-braces, not a supported configuration: `.state` is load-bearing here with no substitute. Both attributes ship in SDK **4.1.0**, but the dependency floor is `polyswarm_api>=4.3.0` — the pin's current value (moved there by the #264 release -bump). Its *rationale* is two behaviours that landed in 4.2.0 and still hold -transitively; [05-sdk-contract.md](./05-sdk-contract.md) §Current floor is -authoritative. Both of which +bump); its *rationale* is two behaviours that landed in 4.2.0 and still hold +transitively, and [05-sdk-contract.md](./05-sdk-contract.md) §Current floor is +authoritative. Those two fail silently on 4.1.0 (see [`05-sdk-contract.md`](./05-sdk-contract.md) §Version pin) — so every supported install has them. `JSONOutput` needs no change — it dumps the resource's `.json`, which already carries the raw `state` and `known_good` keys. diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index d645e5ac..025924f9 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -72,7 +72,7 @@ When a CLI feature needs an SDK surface that doesn't exist yet: - There is **no lock file / compiled requirements** to keep in step: `pyproject.toml` is the only place the SDK version is expressed, and CI installs the SDK straight from the SDK repo's branch archive (see §Coordinated changes). A pin change is a one-file change *in this repo*, but it is not free of interactions — see below. - **The floor must be satisfied by the SDK archive CI installs, and by PyPI.** CI installs the archive build and *then* runs `pip install .[tests]`; if the archive's declared version is below the floor, that second install silently pulls a newer SDK from PyPI **over** the archive build, and CI stops testing the SDK branch at all — the mechanism §Coordinated changes rests on, defeated with no error. Symmetrically, a floor above the newest **published** version breaks `pip install polyswarm-cli` for every consumer the moment it reaches `master`. So a floor bump has two preconditions: the version is on PyPI, and the SDK's `develop` declares at least that version. - **Read the declared version off the archive's own tree, and mind pre-release suffixes.** PEP 440 orders `4.2.0.dev1 < 4.2.0`, so a `develop` head carrying a dev suffix (the SDK's `pyproject.toml` has a `[tool.bumpversion.parts.dev]`) would *not* satisfy a `>=4.2.0` floor even though it looks like 4.2.0 — and the archive build would be silently replaced from PyPI. Check the version string in the SDK branch's `pyproject.toml` / `__init__.py`, not the last release tag. For the current floor both were read from `origin/develop`: `version = "4.2.0"` and `__version__ = '4.2.0'`, no suffix. + **Read the declared version off the archive's own tree, and mind pre-release suffixes.** PEP 440 orders `4.2.0.dev1 < 4.2.0`, so a `develop` head carrying a dev suffix (the SDK's `pyproject.toml` has a `[tool.bumpversion.parts.dev]`) would *not* satisfy a `>=4.2.0` floor even though it looks like 4.2.0 — and the archive build would be silently replaced from PyPI. Check the version string in the SDK branch's `pyproject.toml` / `__init__.py`, not the last release tag. When the floor was last verified this way both were read from `origin/develop` as `4.2.0`, no suffix; the pin has since moved to 4.3.0 (§Current floor), and a future bump should be re-checked the same way. ### Current floor — `polyswarm_api>=4.3.0` diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index b198b6e3..71d44cac 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -97,10 +97,12 @@ def favorite(ctx, rule_id, unfavorite): used = errors.get('favorites_used') limit = errors.get('favorites_limit') # Counters are advisory; fall back rather than render "(None of None)". + server_msg = getattr(exc.request, 'result', None) + # `result` is the parsed body: only usable here if it is a string. budget = (f'Favorite limit reached ({used} of {limit} used).' if used is not None and limit is not None - else str(getattr(exc.request, 'result', None) - or 'Favorite limit reached.')) + else (server_msg if isinstance(server_msg, str) + else 'Favorite limit reached.')) # PolyswarmException exits 2; ClickException would exit 1, reserved # for no-results/not-found. raise exceptions.PolyswarmException( diff --git a/tests/_sdk_guards.py b/tests/_sdk_guards.py index 6cf13ac6..7f2d271b 100644 --- a/tests/_sdk_guards.py +++ b/tests/_sdk_guards.py @@ -17,10 +17,14 @@ _RULESET = {'id': '0', 'livescan_id': None, 'livescan_created': None, 'name': 'n', 'description': 'd', 'deleted': False, 'created': '2026-08-20T00:00:00+00:00', - 'modified': '2026-08-20T00:00:00+00:00', 'yara': None} + 'modified': '2026-08-20T00:00:00+00:00', 'yara': None, + # the probed keys are present so the guard does not depend on the + # SDK assigning absent ones + 'rule_count': 1, 'favorite': False, 'historical_hunt_count': 0} _HUNT = {'id': '0', 'status': 'PENDING', 'progress': 0.0, 'active': None, 'created': '2026-08-20T00:00:00+00:00', 'summary': None, - 'results_csv_uri': None, 'ruleset_name': 'n', 'yara': None} + 'results_csv_uri': None, 'ruleset_name': 'n', 'yara': None, + 'rule_id': '1', 'rule_modified': None, 'source_rule_changed': False} # Keyed on the METHOD, never also on the resource class: that would let a # resource rename silently skip the whole command suite while CI stays green. diff --git a/tests/cli_test.py b/tests/cli_test.py index da776c5b..e662bd7b 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -8,7 +8,6 @@ from polyswarm_api import resources from pathlib import Path -import unittest import vcr as vcr_ @@ -16,7 +15,6 @@ needs_favorite_method as _needs_favorite_method, needs_tracking_fields as _needs_tracking_fields, ) -from polyswarm_api.api import PolyswarmAPI import click from click.testing import CliRunner From 7cdfc0cbf789efbae32b43d9c6bb9b77d37940c7 Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 28 Aug 2026 12:08:27 -0300 Subject: [PATCH 21/54] fix(tests): skip the option-passing tests on a floor SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_filters_are_forwarded_only_when_given` and `test_livescan_id_and_max_results_are_forwarded` pass a keyword the floor SDK does not accept, so `require_sdk_kwargs` refused and the command exited 2 — both asserted `exit_code == 0` and FAILED there rather than skipping. CI never caught it: the branch-name match installs the paired SDK, so the floor install the pin permits is the one nobody exercises. Guard them on the parameter's presence in the installed signature, the narrowest dependency a keyword-passing test has. The plain-invocation tests stay unguarded — the floor supports those, and skipping them would drop the coverage that matters most. specs/04's table prescribed `require_sdk_kwargs` for this row. That is product code and never skips a test, so the row described exactly the bug above; its reasoning about keeping the unfiltered call covered was right and is kept. Also drops two imports left over from moving the guards into `tests/_sdk_guards.py`. --- specs/04-testing.md | 2 +- tests/_sdk_guards.py | 18 ++++++++++++++++++ tests/formatter_hunt_fields_test.py | 6 ++++-- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/specs/04-testing.md b/specs/04-testing.md index 2a47245f..613d9e33 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -74,7 +74,7 @@ modes differ by level: | The test needs | Guard on | Why not something broader | |---|---|---| | an API **method** (`rules favorite` → `ruleset_favorite`) | `hasattr(PolyswarmAPI, '')` | keying it on the resource class too would let a resource rename silently skip the whole command suite while CI stays green | -| a **keyword** on an existing method (`rules list --name`, `live feed --livescan-id`) | `client/utils.py`’s `require_sdk_kwargs` at runtime, so only the invocation that uses the option is affected | a module-level skip would drop coverage of the unfiltered call, which the floor does support | +| a **keyword** on an existing method (`rules list --name`, `live feed --livescan-id`) | the parameter's presence in the installed signature — `inspect.signature().parameters`. Guard **only the test that passes the option**; leave the plain-invocation test unguarded | a class- or module-level skip would drop coverage of the unfiltered call, which the floor does support. Do **not** reach for `client/utils.py`’s `require_sdk_kwargs` here: that is product code, so on the floor it refuses and the command exits 2 — the test FAILS instead of skipping | | a **parsed attribute** on a resource (`rule_count`, `source_rule_changed`) | `hasattr(, '')` | the resource CLASS exists on the floor and simply does not parse the key, so a class-level guard does not skip — the render tests FAIL and an absence-asserting test passes **vacuously**, which looks like coverage while pinning nothing | That last row is the one that bites: `getattr`-guarded formatter legs turn a missing diff --git a/tests/_sdk_guards.py b/tests/_sdk_guards.py index 7f2d271b..94ed202a 100644 --- a/tests/_sdk_guards.py +++ b/tests/_sdk_guards.py @@ -9,6 +9,7 @@ Shared because two modules need the same guards: the formatter unit tests build resources directly, and the cassette tests render CLI output whose lines only appear when the SDK parses the underlying attribute.""" +import inspect import unittest from polyswarm_api import resources @@ -45,3 +46,20 @@ needs_provenance_fields = unittest.skipUnless( hasattr(resources.HistoricalHunt(_HUNT, api=None), 'source_rule_changed'), 'paired SDK does not parse the hunt provenance fields') + + +def _accepts(method, param): + return param in inspect.signature(method).parameters + + +# Keyed on the PARAMETER, not the method: both methods exist on the floor and +# simply reject the keyword, so `require_sdk_kwargs` refuses and the command +# exits 2 — a test passing the option would FAIL there rather than skip. Only +# the option-passing tests take these; the plain-invocation ones must stay +# unguarded, since the floor supports them. +needs_ruleset_list_filters = unittest.skipUnless( + _accepts(PolyswarmAPI.ruleset_list, 'name'), + 'paired SDK ruleset_list does not accept the filter keywords') +needs_live_feed_options = unittest.skipUnless( + _accepts(PolyswarmAPI.live_feed, 'max_results'), + 'paired SDK live_feed does not accept livescan_id/max_results') diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 0e0a2450..809d0589 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -26,7 +26,6 @@ import io import pathlib import types -import unittest from unittest import TestCase, mock from click.testing import CliRunner @@ -35,7 +34,6 @@ from polyswarm.client import utils from polyswarm.formatters import text from polyswarm_api import exceptions, resources -from polyswarm_api.api import PolyswarmAPI # The favorite surface ships in the paired SDK change; the pin's floor # (published 4.3.0) has neither the method nor the resource. These tests must @@ -49,7 +47,9 @@ from tests._sdk_guards import ( # noqa: E402 needs_favorite_method as _needs_favorite_method, needs_favorite_resource as _needs_favorite_resource, + needs_live_feed_options as _needs_live_feed_options, needs_provenance_fields as _needs_provenance_fields, + needs_ruleset_list_filters as _needs_ruleset_list_filters, needs_tracking_fields as _needs_tracking_fields, ) @@ -185,6 +185,7 @@ def test_list_passes_no_kwargs_at_all(self): assert result.exit_code == 0, result.output ruleset_list.assert_called_once_with(mock.ANY) + @_needs_ruleset_list_filters def test_filters_are_forwarded_only_when_given(self): """A filtered list forwards exactly the filters passed and nothing else — the flags default to False, and a False flag must not become @@ -252,6 +253,7 @@ def test_plain_feed_forwards_neither_new_kwarg(self): # wire is seconds and stays seconds, so the CLI default carries the 24h assert live_feed.call_args[0][1] == 86400 + @_needs_live_feed_options def test_livescan_id_and_max_results_are_forwarded(self): result, live_feed = self._invoke( '--livescan-id', '72927285313305230', '--max-results', '5') From 55defbd7c10c8116f90450eddbb1548e2cc1cab8 Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 28 Aug 2026 13:43:46 -0300 Subject: [PATCH 22/54] docs(live): stop promising the feed matches the badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `live feed --livescan-id` was documented as the drill-down for the ruleset new-results badge — "this is how you list them". It lists a subset: the badge counts the hunt across every community it runs in, public and private together, while the feed shows one at a time and this command always sends one. A user drilling down on a multi-community hunt sees fewer rows than the badge reported, or none at all. Documents the asymmetry instead of implying an equivalence that does not hold. No behaviour change. --- specs/02-commands.md | 2 +- src/polyswarm/client/live.py | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/specs/02-commands.md b/specs/02-commands.md index 907c4f44..0b5b52e7 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -25,7 +25,7 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `report` (`report.py`) | Create/fetch/download reports; `prompt-config` subgroup; LLM reports | `report_create`, `report_wait_for`, `report_download`, `report_get`, `llm_report_{create,get,download}`, `prompt_config_{create,get,update,list}` | | `report-template` (`report_template.py`) | Manage report templates + logos | `report_template_{create,update,get,list}`, `report_template_logo_{download,upload}` | | `engine` → `votes` / `assertions` (`engine.py`) | Consolidated votes/assertions bundles per engine | `votes_{create,get,delete,list}`, `assertions_{create,get,delete,list}` | -| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **SECONDS** (default 86400 — 24h, matching the window the ruleset badge counts; `0` means no time filter at all), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry it) and `--max-results` (stop after N; unset means every page, as before). Those two need the paired SDK and are forwarded only when passed, so every pre-existing invocation is unchanged on the pin's floor — see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | +| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **SECONDS** (default 86400 — 24h, matching the window the ruleset badge counts; `0` means no time filter at all), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry it; the badge counts the hunt across **every** community it runs in, public and private, while the feed shows one at a time, so a multi-community hunt lists fewer rows than the badge reports) and `--max-results` (stop after N; unset means every page, as before). Those two need the paired SDK and are forwarded only when passed, so every pre-existing invocation is unchanged on the pin's floor — see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | | `historical` (`historical.py`) | Historical hunts: CRUD + results | `historical_{get,create,update,list}`, `historical_delete_multiple`, `historical_delete_list`, `historical_results_multiple`, `historical_result`, `historical_results_delete` | | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index 49108739..48665cf0 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -39,6 +39,8 @@ def live_stop(ctx, ruleset_id): 'Pass 0 for no time filter at all.') @click.option('-i', '--livescan-id', type=click.INT, help="Scope the feed to one live hunt (a ruleset's Live Hunt Id). " + 'Shows one community at a time, while the badge counts all ' + 'of them, so the counts need not match. ' 'Ids are 17-digit numbers; click.INT matches every other id ' 'option in the CLI and rejects a typo before it reaches the ' 'server.') @@ -56,10 +58,15 @@ def live_results(ctx, since, livescan_id, max_results, rule_name, family, polyscore_lower, polyscore_upper, private): """Show live-hunt results. - `--livescan-id` is the drill-down for the per-ruleset new-results badge - that `rules list` renders (the detail view deliberately does not carry - it): the badge counts a hunt's recent results, and this is how you list - them. + `--livescan-id` scopes the feed to one live hunt — the drill-down for the + per-ruleset new-results badge that `rules list` renders (the detail view + deliberately does not carry it). + + The two do not have to agree, and a smaller feed is not a bug. The badge + counts the hunt across EVERY community it runs in, public and private + together; the feed shows one community at a time (this command always + sends one — `--private` selects it). A hunt spanning both will show fewer + rows here than the badge reports. """ api = ctx.obj['api'] output = ctx.obj['output'] From cb7e91f21fd6177c8b0a697e5700bbb7aba88126 Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 28 Aug 2026 15:00:31 -0300 Subject: [PATCH 23/54] fix(tests): key each floor guard on every parameter its test passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `needs_live_feed_options` checked only `max_results` while gating a test that passes `--livescan-id` too, and `needs_ruleset_list_filters` checked only `name` while gating a test that passes all four filters. An SDK carrying the subset would satisfy the guard, then `require_sdk_kwargs` would refuse the invocation at exit 2 and the test would FAIL instead of skipping — the exact failure mode these guards were added to prevent, reintroduced by keying them too narrowly. `_accepts` now takes several names and requires all of them. Verified it discriminates: True against the paired signatures, False against a partial SDK carrying only `max_results`. --- tests/_sdk_guards.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/_sdk_guards.py b/tests/_sdk_guards.py index 94ed202a..33396b7e 100644 --- a/tests/_sdk_guards.py +++ b/tests/_sdk_guards.py @@ -48,8 +48,16 @@ 'paired SDK does not parse the hunt provenance fields') -def _accepts(method, param): - return param in inspect.signature(method).parameters +def _accepts(method, *params): + """True only when the installed signature takes EVERY named parameter. + + All of them, because a guard protects one test and that test passes every + option it names: keying on a subset lets a partial SDK satisfy the guard + while the invocation still refuses at exit 2, which is the fail-instead-of- + skip this whole module exists to prevent. + """ + sig = inspect.signature(method).parameters + return all(param in sig for param in params) # Keyed on the PARAMETER, not the method: both methods exist on the floor and @@ -58,8 +66,9 @@ def _accepts(method, param): # the option-passing tests take these; the plain-invocation ones must stay # unguarded, since the floor supports them. needs_ruleset_list_filters = unittest.skipUnless( - _accepts(PolyswarmAPI.ruleset_list, 'name'), + _accepts(PolyswarmAPI.ruleset_list, 'name', 'status', 'favorites_only', + 'has_new_results'), 'paired SDK ruleset_list does not accept the filter keywords') needs_live_feed_options = unittest.skipUnless( - _accepts(PolyswarmAPI.live_feed, 'max_results'), + _accepts(PolyswarmAPI.live_feed, 'livescan_id', 'max_results'), 'paired SDK live_feed does not accept livescan_id/max_results') From af2a1300022b04bf995e4e902368fff6e5f56d84 Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 28 Aug 2026 15:12:56 -0300 Subject: [PATCH 24/54] fix(tests): keep the favorite command tests off the resource guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_favorite_calls_the_sdk_and_renders_the_budget` and `test_unfavorite_flag_flips_the_boolean` carried @_needs_favorite_resource only because the shared `_response()` built a real YaraRulesetFavorite. A rename of that class would therefore have skipped the only two tests that assert `rules favorite` calls the SDK at all — the failure the guard module's own docstring says it exists to prevent. TextOutput.ruleset_favorite reads `.id` plus getattrs, so a SimpleNamespace serves and the command tests now depend on the METHOD alone. The two fixture tests that genuinely instantiate the resource keep the guard. Also pins the exception hierarchy the exit-code mapping silently rests on: non-limit refusals exit 2 only because the SDK's RequestException subclasses PolyswarmException and the handler catches that base before the transport branch, which matches the bare name 'RequestException' against the MRO and exits 1 with "contact support". A reparent in the SDK would turn every fixable 4xx into that advice; the new test fails loudly instead. specs/05 records the two server-owned behaviours the CLI relies on and cannot enforce — `--since` being seconds, and `--since 0` meaning no filter — naming the server-side tests that pin each. --- specs/05-sdk-contract.md | 14 ++++++++++++++ tests/formatter_hunt_fields_test.py | 27 +++++++++++++++++++++------ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index 025924f9..1bed055a 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -91,6 +91,20 @@ The known-good rendering attributes (`ArtifactInstance.state`, `.known_good`/`.k | `rules list --name/--status/--favorites-only/--has-new-results` | `ruleset_list(**filters)` | `client/utils.py`’s `require_sdk_kwargs` | | `live feed --livescan-id/--max-results` | `live_feed(livescan_id=, max_results=)` | `client/utils.py`’s `require_sdk_kwargs` | +**Two behaviours the CLI relies on and cannot itself enforce**, both owned by the +server and pinned there rather than here: + +- **`live feed --since` is SECONDS on the wire.** The CLI's `86400` default is + only correct under that reading; nothing in this repo can distinguish the unit + from a recorded query string. Pinned server-side by + `test_since_is_seconds_not_minutes`. +- **`--since 0` means no time filter at all**, which the help text promises. The + CLI forwards `0` positionally and does nothing to make it mean "unfiltered"; + the server applies the window on a truthiness test. Pinned server-side by + `test_since_zero_and_absent_both_mean_no_time_filter`. If that ever tightened + to `is not None`, `live feed --since 0` would silently return nothing while the + help says it returns everything. + The distinction that keeps the floor where it is: a new **option** may require the newer SDK, but an existing **invocation** may not. So the guards fire only when the caller actually uses the new surface — an unfiltered `rules list` and a plain `live feed` still reach the floor's own signatures untouched — and a floor install gets a clean upgrade message at exit 2 rather than the `TypeError`/`AttributeError` traceback a bare call would raise. That is why the floor itself does not move; moving it has the two preconditions above, and neither holds until the SDK releases. `require_sdk_kwargs` inspects the installed signature rather than catching `TypeError`, so a genuine argument error inside the SDK is never mistaken for a version mismatch. When the SDK release lands on PyPI, bumping the floor and dropping all three guards is the follow-up. ## Worked example — the httpx SDK migration diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 809d0589..9d562c45 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -338,13 +338,16 @@ def _invoke(self, args, side_effect=None, return_value=None): @staticmethod def _response(favorite): - return resources.YaraRulesetFavorite( - {'id': '5', 'favorite': favorite, - 'favorited_at': '2026-08-25T12:00:00+00:00' if favorite else None, - 'favorites_used': 1, 'favorites_limit': 5}, api=None) + # A namespace, not the SDK resource: TextOutput.ruleset_favorite reads + # `.id` plus getattrs, so building the real class would make these + # command tests depend on the RESOURCE and a rename would silently skip + # the only coverage that `rules favorite` calls the SDK at all. + return types.SimpleNamespace( + id='5', favorite=favorite, + favorited_at='2026-08-25T12:00:00+00:00' if favorite else None, + favorites_used=1, favorites_limit=5) @_needs_favorite_method - @_needs_favorite_resource def test_favorite_calls_the_sdk_and_renders_the_budget(self): result, toggle = self._invoke(['5'], return_value=self._response(True)) assert result.exit_code == 0, result.output @@ -353,7 +356,6 @@ def test_favorite_calls_the_sdk_and_renders_the_budget(self): assert 'Favorites used: 1 of 5' in result.output @_needs_favorite_method - @_needs_favorite_resource def test_unfavorite_flag_flips_the_boolean(self): result, toggle = self._invoke(['5', '--unfavorite'], return_value=self._response(False)) @@ -452,6 +454,19 @@ def test_other_refusals_still_raise(self): assert result.exit_code == 2 # PolyswarmException family assert 'FAVORITE_LIMIT' not in result.output +class ExitCodeHierarchyTest(TestCase): + """`rules favorite`'s non-limit refusals exit 2, and that holds only because + the SDK's RequestException is a PolyswarmException — the handler catches + that base BEFORE the transport branch, which matches the bare name + 'RequestException' against the MRO and would exit 1 with "contact support". + Reparent it in the SDK and every fixable 4xx starts giving that advice, so + the dependency is pinned here rather than inferred.""" + + def test_request_exception_is_caught_as_a_polyswarm_exception(self): + assert issubclass(exceptions.RequestException, + exceptions.PolyswarmException) + + class SdkFloorConstantTest(TestCase): """``utils.SDK_FLOOR`` must equal the lower bound in ``pyproject.toml``. From 65a54ea55fd500d6b8f2c853bcf318d8ec441374 Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 28 Aug 2026 15:48:41 -0300 Subject: [PATCH 25/54] fix(tests): guard the favorite cassette renders on the parsed resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_ruleset_favorite_text` / `test_ruleset_unfavorite_text` assert rendered lines ("Favorites used: N of M", "Favorited at:") that appear only when the SDK parses those keys off the response. specs/04 row 3 says a render assertion guards on the built resource attribute, not the method — otherwise an absence-asserting test passes vacuously. Method and resource ship together today, so this is the drift that row exists to stop rather than a live break. Records the published 4.3.0 signatures in specs/05, read off the wheel rather than inferred: `ruleset_list(self)` and `live_feed(self, since, rule_name, family, polyscore_lower, polyscore_upper, community)`. Neither declares **kwargs, so `require_sdk_kwargs`'s fail-open branch is unreachable against the real floor and the floor tests' stand-ins match reality. That mattered: had either taken **kwargs, the new options would have been forwarded to an SDK that drops them and the caller would get an unfiltered list at exit 0. specs/02 now also names `download stream --since` — a third --since that is genuinely minutes with a 1440 default, the likeliest origin of the original mistake. And drops a review-bookkeeping aside from rules.py. --- specs/02-commands.md | 9 ++++++++- specs/05-sdk-contract.md | 13 ++++++++++++- src/polyswarm/client/rules.py | 2 +- tests/cli_test.py | 3 +++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/specs/02-commands.md b/specs/02-commands.md index 0b5b52e7..505f0793 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -45,7 +45,14 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` > hours. The fix is here rather than on the wire: the endpoint takes ~197k > requests per 30 days carrying `since` from clients outside our control, and > re-basing the server to minutes would widen every one of them 60x with no -> error. `historical list --since` is seconds too — the two agree. +> error. `historical list --since` is seconds too — those two agree. +> +> **A third `--since` is genuinely MINUTES and must stay that way:** +> `download stream --since` (`client/download.py`, `IntRange(1, 2880)`, +> default `1440`) hits a different endpoint that really does read minutes. That +> `1440` is the same literal `live feed` is being corrected away from, and is the +> likeliest origin of the original mistake — check which endpoint you are on +> before copying a default between them. ## Adding to the catalogue diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index 1bed055a..c45e8ae2 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -105,7 +105,18 @@ server and pinned there rather than here: to `is not None`, `live feed --since 0` would silently return nothing while the help says it returns everything. -The distinction that keeps the floor where it is: a new **option** may require the newer SDK, but an existing **invocation** may not. So the guards fire only when the caller actually uses the new surface — an unfiltered `rules list` and a plain `live feed` still reach the floor's own signatures untouched — and a floor install gets a clean upgrade message at exit 2 rather than the `TypeError`/`AttributeError` traceback a bare call would raise. That is why the floor itself does not move; moving it has the two preconditions above, and neither holds until the SDK releases. `require_sdk_kwargs` inspects the installed signature rather than catching `TypeError`, so a genuine argument error inside the SDK is never mistaken for a version mismatch. When the SDK release lands on PyPI, bumping the floor and dropping all three guards is the follow-up. +The distinction that keeps the floor where it is: a new **option** may require the newer SDK, but an existing **invocation** may not. So the guards fire only when the caller actually uses the new surface — an unfiltered `rules list` and a plain `live feed` still reach the floor's own signatures untouched — and a floor install gets a clean upgrade message at exit 2 rather than the `TypeError`/`AttributeError` traceback a bare call would raise. That is why the floor itself does not move; moving it has the two preconditions above, and neither holds until the SDK releases. `require_sdk_kwargs` inspects the installed signature rather than catching `TypeError`, so a genuine argument error inside the SDK is never mistaken for a version mismatch. + +**The floor's real signatures, read off the published 4.3.0 wheel** (not inferred — the guard fails *open* on `**kwargs`, so a floor that declared one would silently forward the new options to an SDK that drops them, and the caller would get an unfiltered list at exit 0): + +```python +def ruleset_list(self) # no filters, no **kwargs +def live_feed(self, since=None, rule_name=None, family=None, + polyscore_lower=None, polyscore_upper=None, + community=None) # no livescan_id, no max_results, no **kwargs +``` + +Neither declares `VAR_KEYWORD`, so the fail-open branch is unreachable against the real floor and the guards refuse as designed. The stand-ins in the floor tests match these exactly. Re-read them off the wheel — `pip download polyswarm_api== --no-deps` — whenever the floor moves; a signature is not something to take on trust from the branch you happen to have checked out. When the SDK release lands on PyPI, bumping the floor and dropping all three guards is the follow-up. ## Worked example — the httpx SDK migration diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index 71d44cac..868106e1 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -91,7 +91,7 @@ def favorite(ctx, rule_id, unfavorite): output.ruleset_favorite(toggle(rule_id, not unfavorite)) except api_exceptions.RequestException as exc: # `exc.request` needs no guard: __init__ always assigns it, and a None - # request flows safely through the getattr. (Raised twice in review.) + # request flows safely through the getattr. errors = getattr(exc.request, 'errors', None) or {} if isinstance(errors, dict) and errors.get('code') == 'FAVORITE_LIMIT': used = errors.get('favorites_used') diff --git a/tests/cli_test.py b/tests/cli_test.py index e662bd7b..9ab4e817 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -13,6 +13,7 @@ from tests._sdk_guards import ( needs_favorite_method as _needs_favorite_method, + needs_favorite_resource as _needs_favorite_resource, needs_tracking_fields as _needs_tracking_fields, ) import click @@ -279,6 +280,7 @@ def test_ruleset_list_json(self): self._assert_json_result(result, self.click_vcr(result)) @_needs_favorite_method + @_needs_favorite_resource @vcr.use_cassette() def test_ruleset_favorite_text(self): result = self._run_cli([ @@ -286,6 +288,7 @@ def test_ruleset_favorite_text(self): self._assert_text_result(result, self.click_vcr(result)) @_needs_favorite_method + @_needs_favorite_resource @vcr.use_cassette() def test_ruleset_unfavorite_text(self): result = self._run_cli([ From b69d902af1788a66559af5d6a93f63a7cbb58520 Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 28 Aug 2026 15:57:17 -0300 Subject: [PATCH 26/54] fix(live): pin that --since 0 reaches the SDK, and trim the help text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --max-results 0 is deliberately dropped before the SDK while --since 0 must reach it, because 0 is how the server is told to apply no time filter. The two zeros mean the same thing to a user and take opposite paths in the code, and only one was pinned. Verified the new assertion catches the refactor it names: folding `since` into the conditional-kwargs block makes it arrive as None and fails that test alone. `live feed --help` was carrying the rationale for the option TYPES — why click.INT, why a negative is refused — which is reviewer context, not behaviour a user needs at the prompt. Moved to a comment; the help lines now read like every other option in the group. specs/05 also now documents `exc.request.result`, the second attribute `rules favorite` reaches for on the SDK's request object. The table exists to enumerate exactly that, and only `.errors['code']` was listed. --- specs/05-sdk-contract.md | 2 +- src/polyswarm/client/live.py | 15 +++++++-------- tests/formatter_hunt_fields_test.py | 9 +++++++++ 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index c45e8ae2..a820fba3 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -18,7 +18,7 @@ How the CLI depends on the `polyswarm-api` SDK: which parts of the SDK's public | `from polyswarm_api.api import PolyswarmAPI` | Base class of the `Polyswarm` wrapper (`src/polyswarm/polyswarm.py`). | | `from polyswarm_api import settings` | Defaults: `DEFAULT_SCAN_TIMEOUT`, `DEFAULT_REPORT_TIMEOUT`, etc. | | `from polyswarm_api import resources` | Result-parser classes for power-user calls (e.g. `resources.ArtifactInstance`); resource attributes the formatters read. | -| `from polyswarm_api import exceptions as api_exceptions` | Caught in `ExceptionHandlingGroup` and `utils.parallel_executor` (`NoResultsException`, `NotFoundException`, `FailedInstanceException`, `PolyswarmException`). Also `RequestException`, caught by `rules favorite` (`client/rules.py`) to read the machine-readable `FAVORITE_LIMIT` refusal off `exc.request.errors['code']`. The SDK does not raise a typed exception for that refusal by design: `.request.errors` is a plain dict the server's error envelope populates, pinned end-to-end by `tests/cli_test.py::test_ruleset_favorite_limit_text` against a real recorded 400 (not a hand-built mock), so a rename on either side fails that cassette. | +| `from polyswarm_api import exceptions as api_exceptions` | Caught in `ExceptionHandlingGroup` and `utils.parallel_executor` (`NoResultsException`, `NotFoundException`, `FailedInstanceException`, `PolyswarmException`). Also `RequestException`, caught by `rules favorite` (`client/rules.py`) to read the machine-readable `FAVORITE_LIMIT` refusal off `exc.request.errors['code']` — and, when the envelope carries no counters, `exc.request.result` as the server's own message (used only when it is a `str`; the parsed body is a dict on every other path). The SDK does not raise a typed exception for that refusal by design: `.request.errors` is a plain dict the server's error envelope populates, pinned end-to-end by `tests/cli_test.py::test_ruleset_favorite_limit_text` against a real recorded 400 (not a hand-built mock), so a rename on either side fails that cassette. | | `from polyswarm_api.core import parse_isoformat` | Date rendering in `formatters/text.py`. | | `import polyswarm_api` (`__version__`) | `--api-version`. | diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index 48665cf0..dccd6092 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -37,17 +37,16 @@ def live_stop(ctx, ruleset_id): help='How far back in SECONDS to request results ' '(default: 86400 — 24h, the window the ruleset badge counts). ' 'Pass 0 for no time filter at all.') +# click.INT matches every other id option in the CLI and rejects a typo before +# it reaches the server; IntRange(min=0) refuses a negative here rather than +# letting it silently mean unbounded. @click.option('-i', '--livescan-id', type=click.INT, - help="Scope the feed to one live hunt (a ruleset's Live Hunt Id). " - 'Shows one community at a time, while the badge counts all ' - 'of them, so the counts need not match. ' - 'Ids are 17-digit numbers; click.INT matches every other id ' - 'option in the CLI and rejects a typo before it reaches the ' - 'server.') + help="Scope the feed to one live hunt (a ruleset's Live Hunt Id, " + 'a 17-digit number). Shows one community at a time, while ' + 'the badge counts all of them, so the counts need not match.') @click.option('-m', '--max-results', type=click.IntRange(min=0), help='Stop after this many results. Unset or 0 means no bound — ' - 'every page, as before. A negative is refused here rather ' - 'than silently meaning unbounded.') + 'every page, as before.') @click.option('-r', '--rule-name', help='Filter results on this rule name.') @click.option('-f', '--family', help='Filter hunt results based on the family name.') @click.option('-l', '--polyscore-lower', help='Polyscore lower bound for the hunt results.') diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 9d562c45..fe65fbcb 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -274,6 +274,15 @@ def test_zero_max_results_is_unbounded_and_never_reaches_the_sdk(self): _, kwargs = live_feed.call_args assert 'max_results' not in kwargs + def test_zero_since_IS_forwarded_unlike_zero_max_results(self): + """Both zeros mean "no bound" to the user and take OPPOSITE paths: + --max-results 0 is dropped before the SDK (above), while --since 0 must + reach it, because 0 is how the server is told to apply no time filter. + Fold `since` into the conditional-kwargs block and this breaks.""" + result, live_feed = self._invoke('--since', '0') + assert result.exit_code == 0, result.output + assert live_feed.call_args[0][1] == 0 + def test_zero_max_results_does_not_trip_the_floor_guard(self): def floor_live_feed(self, since=None, rule_name=None, family=None, polyscore_lower=None, polyscore_upper=None, From fcdd02af2ec763924a02103eb1b958bd4e094ac7 Mon Sep 17 00:00:00 2001 From: Samuel Date: Fri, 28 Aug 2026 16:06:30 -0300 Subject: [PATCH 27/54] test(cli): pin the floor guard's fail-open branch, and fix specs/01 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `require_sdk_kwargs` returns early when the installed method declares **kwargs, forwarding rather than false-refusing. Published 4.3.0 declares none, so the branch is unreachable today — but it is product code, and an SDK that grew one would take it and silently forward options the SDK drops. Verified the test discriminates: deleting the branch makes it fail alone. specs/01 §Support was the last spec still attributing `parse_hashes` to the top-level `utils.py`; it lives in `client/utils.py`, which is also where this change adds `require_sdk_kwargs` and `SDK_FLOOR`. specs/04 and 05 were corrected earlier in this PR, 01 was missed. --- specs/01-architecture.md | 3 ++- tests/formatter_hunt_fields_test.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/specs/01-architecture.md b/specs/01-architecture.md index 5ca2673b..0b8a3ce6 100644 --- a/specs/01-architecture.md +++ b/specs/01-architecture.md @@ -74,7 +74,8 @@ The catalogue of groups and the SDK methods each wraps is in [`02-commands.md`]( ## Support — `utils.py`, `exceptions.py` -- **`utils.py`** — `parallelize`/`parallel_executor` (thread-pool fan-out with per-item exception aggregation: collects results, logs per-item no-results, raises an aggregate `NoResultsException`/`NotFoundException`/`InternalFailureException` at the end), `parallel_executor_iterable_results` (the same, for SDK methods that return generators — it materialises each generator inside the worker so per-item exception handling still fires), and input parsing/validation (`parse_hashes`, hash/IP detection). +- **`utils.py`** — `parallelize`/`parallel_executor` (thread-pool fan-out with per-item exception aggregation: collects results, logs per-item no-results, raises an aggregate `NoResultsException`/`NotFoundException`/`InternalFailureException` at the end) and `parallel_executor_iterable_results` (the same, for SDK methods that return generators — it materialises each generator inside the worker so per-item exception handling still fires). +- **`client/utils.py`** — input parsing/validation (`parse_hashes`, hash/IP detection) and `require_sdk_kwargs`, the floor guard that refuses an option the installed SDK's signature does not accept (`SDK_FLOOR` lives here too; see [`05-sdk-contract.md`](./05-sdk-contract.md) §Current floor). Note the module is `client/utils.py`, not the top-level `utils.py` above — the two are distinct and this section named the wrong one until the hunt-page change. - **`exceptions.py`** — the CLI's own hierarchy, **distinct from the SDK's**: `PolyswarmException` → `NoResultsException`, `NotFoundException`, `InternalFailureException`, `PartialResultsException`. `ExceptionHandlingGroup` catches both these and the SDK's `api_exceptions.*`. ## Lifecycle of a command (end to end) diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index fe65fbcb..1f080475 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -311,6 +311,23 @@ def test_a_non_numeric_livescan_id_is_refused_before_the_server(self): 'live', 'feed', '--livescan-id', 'not-an-id']) assert result.exit_code != 0 + def test_a_kwargs_sdk_fails_open_rather_than_false_refusing(self): + """The guard cannot see through **kwargs, so it forwards rather than + refusing an SDK that may well accept the name. Published 4.3.0 declares + no **kwargs (specs/05 §Current floor records the signatures), so this + branch is unreachable today — but it is product code, and an SDK that + grew one would take it.""" + def kwargs_ruleset_list(self, **kwargs): + return iter(()) + + with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_list', + kwargs_ruleset_list): + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'rules', 'list', '--favorites-only']) + assert result.exit_code == 0, result.output + def test_new_options_on_a_floor_sdk_are_a_clean_message(self): def floor_live_feed(self, since=None, rule_name=None, family=None, polyscore_lower=None, polyscore_upper=None, From 6cb030bdde5b6923598cf6978b4cfad8cd21cae0 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 13:22:54 -0300 Subject: [PATCH 28/54] refactor: pin the SDK floor instead of probing it at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI needed surfaces the published SDK did not have, and expressed that as runtime probes: `require_sdk_kwargs` inspecting signatures, a `getattr` on `ruleset_favorite`, and four per-test skip guards. The pin stayed behind at 4.3.0 so the probes had something to protect against. That put the same fact in two places — the pin, and each probe — with nothing keeping them in sync. Every probe was one edit from disagreeing with the code it guarded, in either direction: check less than the test uses and it FAILS where it should skip; check more and it SKIPS a test that would have passed, dropping coverage while CI stays green. Five defects came out of that in review, each a different way of getting the same mapping wrong. And a green run against the paired SDK never verified the floor install the guards existed for. The floor now names 4.4.0, the version that introduces those surfaces, and pip enforces it at install time before any code runs. Deleted: `_sdk_guards.py`, all 22 guard decorators, `require_sdk_kwargs`, `SDK_FLOOR`, the favorite getattr dance, and the six tests that only exercised the guards. Coverage goes UP: 146 tests, none skipped. Every test that could previously skip itself now always runs. specs/04 replaces the guard convention with the version contract and says why it is gone; specs/05 documents raising the floor as the procedure, the release ordering it forces, and the PEP 440 dev-suffix trap. specs/01 and specs/02 drop the helper and the degradation language. --- pyproject.toml | 2 +- specs/01-architecture.md | 2 +- specs/02-commands.md | 2 +- specs/04-testing.md | 60 +++++++----- specs/05-sdk-contract.md | 73 +++++++------- src/polyswarm/client/live.py | 7 +- src/polyswarm/client/rules.py | 20 +--- src/polyswarm/client/utils.py | 21 ---- tests/_sdk_guards.py | 74 -------------- tests/cli_test.py | 25 ----- tests/formatter_hunt_fields_test.py | 144 +--------------------------- 11 files changed, 85 insertions(+), 345 deletions(-) delete mode 100644 tests/_sdk_guards.py diff --git a/pyproject.toml b/pyproject.toml index 03e289ba..ae96b77e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ classifiers = [ ] dependencies = [ - "polyswarm_api>=4.3.0,<5.0.0", + "polyswarm_api>=4.4.0,<5.0.0", "click>=7.1", "colorama>=0.4.6", "click-log>=0.4.0", diff --git a/specs/01-architecture.md b/specs/01-architecture.md index 0b8a3ce6..fc55e823 100644 --- a/specs/01-architecture.md +++ b/specs/01-architecture.md @@ -75,7 +75,7 @@ The catalogue of groups and the SDK methods each wraps is in [`02-commands.md`]( ## Support — `utils.py`, `exceptions.py` - **`utils.py`** — `parallelize`/`parallel_executor` (thread-pool fan-out with per-item exception aggregation: collects results, logs per-item no-results, raises an aggregate `NoResultsException`/`NotFoundException`/`InternalFailureException` at the end) and `parallel_executor_iterable_results` (the same, for SDK methods that return generators — it materialises each generator inside the worker so per-item exception handling still fires). -- **`client/utils.py`** — input parsing/validation (`parse_hashes`, hash/IP detection) and `require_sdk_kwargs`, the floor guard that refuses an option the installed SDK's signature does not accept (`SDK_FLOOR` lives here too; see [`05-sdk-contract.md`](./05-sdk-contract.md) §Current floor). Note the module is `client/utils.py`, not the top-level `utils.py` above — the two are distinct and this section named the wrong one until the hunt-page change. +- **`client/utils.py`** — input parsing/validation (`parse_hashes`, hash/IP detection) and the click parameter validators. Note the module is `client/utils.py`, not the top-level `utils.py` above — the two are distinct and this section named the wrong one until the hunt-page change. - **`exceptions.py`** — the CLI's own hierarchy, **distinct from the SDK's**: `PolyswarmException` → `NoResultsException`, `NotFoundException`, `InternalFailureException`, `PartialResultsException`. `ExceptionHandlingGroup` catches both these and the SDK's `api_exceptions.*`. ## Lifecycle of a command (end to end) diff --git a/specs/02-commands.md b/specs/02-commands.md index 505f0793..5ceec4ac 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -30,7 +30,7 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | | `family` (`families.py`) | Malware-family CRUD | `family_{create,update,delete,get,list}` | -| `rules` (`rules.py`) | YARA ruleset CRUD plus `favorite [--unfavorite]` (the star toggle: renders the new state + the server-owned "N of M used" budget, and converts the machine-readable `FAVORITE_LIMIT` refusal into a clean actionable message at exit 2, never 1 — 1 is reserved for no-results/not-found; 2 is the broad bucket `ExceptionHandlingGroup` maps the PolyswarmException hierarchies to). `list` takes the server-side filters `--name` / `--status active` / `--favorites-only` / `--has-new-results` (conjunctive; the list is keyset-paginated, so filtering locally would mean walking every page). Every pre-existing INVOCATION works unchanged on the pin's floor: an unfiltered `rules list` still calls a zero-argument `ruleset_list()`, and the hunt-page fields arrive as plain response fields the formatters getattr-guard. What needs the paired SDK is `rules favorite` and the new `rules list` filters; each degrades to a clean upgrade message on the floor (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor) | `ruleset_{create,delete,update,get,list,favorite}` | +| `rules` (`rules.py`) | YARA ruleset CRUD plus `favorite [--unfavorite]` (the star toggle: renders the new state + the server-owned "N of M used" budget, and converts the machine-readable `FAVORITE_LIMIT` refusal into a clean actionable message at exit 2, never 1 — 1 is reserved for no-results/not-found; 2 is the broad bucket `ExceptionHandlingGroup` maps the PolyswarmException hierarchies to). `list` takes the server-side filters `--name` / `--status active` / `--favorites-only` / `--has-new-results` (conjunctive; the list is keyset-paginated, so filtering locally would mean walking every page). `rules favorite` and the `rules list` filters need SDK 4.4.0, which the pin requires (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor), so they are called directly. The formatters still `getattr`-guard the hunt-page fields — that is about a *server* that has not populated them, not about an older SDK | `ruleset_{create,delete,update,get,list,favorite}` | | `metadata` (`metadata.py`) | Rerun metadata; scan lookup; IP/URL analysis | `rerun_metadata`, `scan_lookup`, `submit_url` | | `activity` (`event.py`) | List account activity/events | `event_list` | | `account` (`account.py`) | Account whois / features | `account_whois`, `account_features` | diff --git a/specs/04-testing.md b/specs/04-testing.md index 613d9e33..a4469901 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -62,26 +62,40 @@ Use it **only** for that. Argument parsing, SDK calls, generator consumption, `c This spec describes the harness as it stands. Not yet documented (add as the suite grows): a per-command coverage matrix, a documented "VCR-off against live e2e" CI job, and conventions for fixture/`.click` generation. See [`99-open-questions.md`](./99-open-questions.md). -## Staying honest on both installs the pin permits - -`pyproject.toml` pins a **floor**, not an exact SDK, so the suite can run against -either the floor or a newer paired SDK. A test that needs a surface the floor does -not have must **skip** there — not fail, and above all not pass vacuously. - -Guard on the **narrowest dependency the test actually has**, because the failure -modes differ by level: - -| The test needs | Guard on | Why not something broader | -|---|---|---| -| an API **method** (`rules favorite` → `ruleset_favorite`) | `hasattr(PolyswarmAPI, '')` | keying it on the resource class too would let a resource rename silently skip the whole command suite while CI stays green | -| a **keyword** on an existing method (`rules list --name`, `live feed --livescan-id`) | the parameter's presence in the installed signature — `inspect.signature().parameters`. Guard **only the test that passes the option**; leave the plain-invocation test unguarded | a class- or module-level skip would drop coverage of the unfiltered call, which the floor does support. Do **not** reach for `client/utils.py`’s `require_sdk_kwargs` here: that is product code, so on the floor it refuses and the command exits 2 — the test FAILS instead of skipping | -| a **parsed attribute** on a resource (`rule_count`, `source_rule_changed`) | `hasattr(, '')` | the resource CLASS exists on the floor and simply does not parse the key, so a class-level guard does not skip — the render tests FAIL and an absence-asserting test passes **vacuously**, which looks like coverage while pinning nothing | - -That last row is the one that bites: `getattr`-guarded formatter legs turn a missing -attribute into silent omission, so a test asserting a line is *absent* cannot tell -"correctly omitted" from "the SDK never parsed it". Build the resource and check the -attribute. - -Whatever names the floor, name it **once** — `client/utils.py`’s `SDK_FLOOR`, which a test ties to -the pin in `pyproject.toml`. The version is otherwise easy to drift: nothing fails if -a hardcoded literal in a guard message goes stale. +## The SDK floor is a version pin, not a runtime probe + +**A test never asks the installed SDK whether it has a feature. The pin guarantees +it.** When this repo needs a surface the SDK does not yet publish, the SDK bumps its +version and `pyproject.toml` raises `polyswarm_api>=` to it. `pip` then refuses the +combination that would fail, at install time, before a single test runs — so a test +can simply use the surface. + +This replaces an earlier convention of per-test skip guards (`hasattr` on a method, a +built resource's attribute, a parameter in the installed signature). They are gone, and +should not come back. What was wrong with them: + +- **The fact lived twice.** The pin said one thing; each guard re-derived the same + thing at runtime. Nothing kept them in sync, so every guard was one edit away from + disagreeing with the tests it gated. +- **Both ways of disagreeing are defects.** A guard that checks *less* than its test + uses lets the test run and **fail** where it should have skipped. One that checks + *more* **skips** a test that would have passed — silently dropping coverage while CI + stays green. The second is the dangerous one, because nothing reports it. +- **It never verified the thing it claimed to protect.** A green run against the paired + SDK said nothing about the floor install the guards existed for. + +The version contract has none of that: the claim is checked once, by a tool, against +the artifact that will actually be installed. + +**When you need a new SDK surface**, in order: add it in `polyswarm-api` → bump that +repo's version (minor, for an additive surface) **in the same PR**, because this repo's +floor cannot name a version the SDK has not declared → raise the floor here → use the +surface in code and tests with no guard. CI installs the SDK from git by branch name, +so an unreleased version is not an obstacle; see +[`05-sdk-contract.md`](./05-sdk-contract.md) §Current floor for the ordering that +forces at release time. + +**The failure mode to expect**, and it is a good one: if the paired SDK branch is +missing, CI falls back to the SDK's `develop`, whose version does not satisfy the new +floor, and `pip install .[tests]` fails loudly. Before, that fallback silently tested +against the wrong SDK. diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index a820fba3..1f2a2aa3 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -74,49 +74,50 @@ When a CLI feature needs an SDK surface that doesn't exist yet: **Read the declared version off the archive's own tree, and mind pre-release suffixes.** PEP 440 orders `4.2.0.dev1 < 4.2.0`, so a `develop` head carrying a dev suffix (the SDK's `pyproject.toml` has a `[tool.bumpversion.parts.dev]`) would *not* satisfy a `>=4.2.0` floor even though it looks like 4.2.0 — and the archive build would be silently replaced from PyPI. Check the version string in the SDK branch's `pyproject.toml` / `__init__.py`, not the last release tag. When the floor was last verified this way both were read from `origin/develop` as `4.2.0`, no suffix; the pin has since moved to 4.3.0 (§Current floor), and a future bump should be re-checked the same way. -### Current floor — `polyswarm_api>=4.3.0` +### Current floor — `polyswarm_api>=4.4.0` -The floor moved to **4.3.0** with #264 (`pyproject.toml` has said `>=4.3.0` since then; this header lagged at 4.2.0 — the drift itself is why the floor lives in ONE authoritative place, the pin, and this doc must follow it). The 4.2.0 rationale below still holds transitively; on 4.1.0 both behaviours fail *silently*, which is why the floor is a hard requirement rather than a preference: +The floor moved to **4.4.0** with the hunt-page change set; before that **4.3.0** with #264 (`pyproject.toml` has said `>=4.3.0` since then; this header lagged at 4.2.0 — the drift itself is why the floor lives in ONE authoritative place, the pin, and this doc must follow it). The 4.2.0 rationale below still holds transitively; on 4.1.0 both behaviours fail *silently*, which is why the floor is a hard requirement rather than a preference: 1. **`llm_report_create` sends the client's community.** 4.2.0 passes `community=self.community` when it builds the report resource; 4.1.0 omits it. `report llm-create` (`client/report.py`) supplies no community of its own — it relies entirely on the client's — so on 4.1.0 a report requested for a sample in a private community is created without one. No error, wrong resource. 2. **A streaming download answered `204 No Content` raises `NoResultsException`.** The streaming path bypasses `parse_response`, so the 204 has to be raised by the session itself; 4.2.0 does that, 4.1.0 has no such raise anywhere in its session. The CLI's `download` commands depend on it for the no-results **exit code `1`** (§No-results signalling); against 4.1.0 an empty response reads as a successful download and exits `0`. The known-good rendering attributes (`ArtifactInstance.state`, `.known_good`/`.known_good_sources`, read by `formatters/text.py` — see [`03-formatters.md`](./03-formatters.md) §Known-good artifact instances) ship in **4.1.0**, so they are *not* what sets the floor; they are simply covered by it. -**Three surfaces exceed the floor, by design, each with a guarded degradation.** All ship in the paired SDK change and reach PyPI with the next SDK release: - -| Surface | Needs from the SDK | Guard | -|---|---|---| -| `rules favorite` | `ruleset_favorite` | `getattr` on the method | -| `rules list --name/--status/--favorites-only/--has-new-results` | `ruleset_list(**filters)` | `client/utils.py`’s `require_sdk_kwargs` | -| `live feed --livescan-id/--max-results` | `live_feed(livescan_id=, max_results=)` | `client/utils.py`’s `require_sdk_kwargs` | - -**Two behaviours the CLI relies on and cannot itself enforce**, both owned by the -server and pinned there rather than here: - -- **`live feed --since` is SECONDS on the wire.** The CLI's `86400` default is - only correct under that reading; nothing in this repo can distinguish the unit - from a recorded query string. Pinned server-side by - `test_since_is_seconds_not_minutes`. -- **`--since 0` means no time filter at all**, which the help text promises. The - CLI forwards `0` positionally and does nothing to make it mean "unfiltered"; - the server applies the window on a truthiness test. Pinned server-side by - `test_since_zero_and_absent_both_mean_no_time_filter`. If that ever tightened - to `is not None`, `live feed --since 0` would silently return nothing while the - help says it returns everything. - -The distinction that keeps the floor where it is: a new **option** may require the newer SDK, but an existing **invocation** may not. So the guards fire only when the caller actually uses the new surface — an unfiltered `rules list` and a plain `live feed` still reach the floor's own signatures untouched — and a floor install gets a clean upgrade message at exit 2 rather than the `TypeError`/`AttributeError` traceback a bare call would raise. That is why the floor itself does not move; moving it has the two preconditions above, and neither holds until the SDK releases. `require_sdk_kwargs` inspects the installed signature rather than catching `TypeError`, so a genuine argument error inside the SDK is never mistaken for a version mismatch. - -**The floor's real signatures, read off the published 4.3.0 wheel** (not inferred — the guard fails *open* on `**kwargs`, so a floor that declared one would silently forward the new options to an SDK that drops them, and the caller would get an unfiltered list at exit 0): - -```python -def ruleset_list(self) # no filters, no **kwargs -def live_feed(self, since=None, rule_name=None, family=None, - polyscore_lower=None, polyscore_upper=None, - community=None) # no livescan_id, no max_results, no **kwargs -``` - -Neither declares `VAR_KEYWORD`, so the fail-open branch is unreachable against the real floor and the guards refuse as designed. The stand-ins in the floor tests match these exactly. Re-read them off the wheel — `pip download polyswarm_api== --no-deps` — whenever the floor moves; a signature is not something to take on trust from the branch you happen to have checked out. When the SDK release lands on PyPI, bumping the floor and dropping all three guards is the follow-up. +**The floor is how this repo expresses every SDK dependency.** There are no runtime +probes and no per-test skip guards: if the CLI uses an SDK surface, the floor names a +version that has it, and `pip` enforces that at install time. The hunt-page surfaces — +`ruleset_favorite` and the `YaraRulesetFavorite` resource, the `ruleset_list` filters, +`live_feed(livescan_id=, max_results=)`, and the tracking/provenance fields the +formatters render — are what moved the floor to 4.4.0. Code and tests use them +directly. + +**Raising the floor is the whole procedure** when this repo needs something new from +the SDK: + +1. Add the surface in `polyswarm-api`. +2. Bump the SDK's version **in that same PR** — minor for an additive surface. The + floor here cannot name a version the SDK has not declared, so this is the one case + where a feature PR carries the bump rather than the release step. +3. Raise `polyswarm_api>=` here to that version. +4. Use it. No guard, no `getattr`, no signature inspection. + +**Two consequences, both worth knowing before you do it.** + +*Release order is forced.* This repo cannot be released to PyPI until the SDK version +its floor names is on PyPI — so the SDK's `develop → master` must merge and release +first. CI is unaffected: `.gitlab-ci.yml` installs the SDK from git by branch name +(`$CI_COMMIT_BRANCH.zip`, falling back to `develop.zip`), so an unreleased version +tests fine. + +*A missing paired branch now fails loudly.* If the SDK branch does not exist, CI falls +back to the SDK's `develop`, whose version does not satisfy the new floor, and +`pip install .[tests]` fails. That is the intended behaviour and an improvement: the +old fallback silently tested against an SDK that lacked the surfaces. + +*Version strings must be clean.* PEP 440 orders `4.4.0.dev0` **below** `4.4.0`, so a +dev-suffixed SDK build does not satisfy `>=4.4.0` — CI then goes to PyPI for a version +that does not exist yet. The SDK's `bump-my-version` config can emit that form; its +`AGENTS.md` carries the check. ## Worked example — the httpx SDK migration diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index dccd6092..616d93de 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -69,17 +69,12 @@ def live_results(ctx, since, livescan_id, max_results, rule_name, family, """ api = ctx.obj['api'] output = ctx.obj['output'] - # Both new options share one floor guard; existing invocations are untouched. kwargs = {} if livescan_id is not None: kwargs['livescan_id'] = livescan_id - # Truthiness: 0 is the pre-existing unbounded behaviour, so it must not - # reach the SDK or trip the floor guard (specs/05 §Current floor). + # Truthiness: 0 means unbounded, which is what omitting it already does. if max_results: kwargs['max_results'] = max_results - if kwargs: - utils.require_sdk_kwargs(api.live_feed, sorted(kwargs), 'live feed ' + - ' and '.join('--' + k.replace('_', '-') for k in sorted(kwargs))) for result in api.live_feed( since, rule_name=rule_name, family=family, polyscore_lower=polyscore_lower, polyscore_upper=polyscore_upper, diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index 868106e1..69d28076 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -47,14 +47,11 @@ def list_rules(ctx, name, status, favorites_only, has_new_results): """ api = ctx.obj['api'] output = ctx.obj['output'] - # Unfiltered stays a zero-argument call, so it keeps working on the floor; - # only a caller passing a filter needs the paired SDK. + # A False flag is not a filter: send only what the caller actually asked for. kwargs = {k: v for k, v in (('name', name), ('status', status), ('favorites_only', favorites_only or None), ('has_new_results', has_new_results or None)) if v is not None} - if kwargs: - utils.require_sdk_kwargs(api.ruleset_list, sorted(kwargs), 'rules list filtering') for ruleset in api.ruleset_list(**kwargs): output.ruleset(ruleset) @@ -74,21 +71,8 @@ def favorite(ctx, rule_id, unfavorite): """ api = ctx.obj['api'] output = ctx.obj['output'] - toggle = getattr(api, 'ruleset_favorite', None) - if toggle is None: - # The declared floor (published polyswarm-api 4.3.0) predates the - # favorite surface — it ships in the paired SDK change. Every OTHER - # command keeps working on the floor (an unfiltered list still is); - # only this command needs the newer SDK, and on the floor it must - # fail with a clean upgrade message, never an AttributeError - # traceback. The principle: a new OPTION may require the newer SDK; an - # existing INVOCATION may not. - raise exceptions.PolyswarmException( - f'rules favorite requires a polyswarm-api release newer than ' - f'{utils.SDK_FLOOR} (the paired SDK change adds ruleset_favorite). ' - f'Upgrade polyswarm-api to use this command.') try: - output.ruleset_favorite(toggle(rule_id, not unfavorite)) + output.ruleset_favorite(api.ruleset_favorite(rule_id, not unfavorite)) except api_exceptions.RequestException as exc: # `exc.request` needs no guard: __init__ always assigns it, and a None # request flows safely through the getattr. diff --git a/src/polyswarm/client/utils.py b/src/polyswarm/client/utils.py index c11b831a..e55fa62c 100644 --- a/src/polyswarm/client/utils.py +++ b/src/polyswarm/client/utils.py @@ -1,6 +1,5 @@ import logging import functools -import inspect import sys import click @@ -12,9 +11,6 @@ logger = logging.getLogger(__name__) HASH_VALIDATORS = resources.Hash.SUPPORTED_HASH_TYPES -# The published SDK floor; tracks pyproject.toml's pin (SdkFloorConstantTest). -SDK_FLOOR = '4.3.0' - #################################################### # Input parsers #################################################### @@ -33,23 +29,6 @@ def parse_hashes(hashes, hash_file=None): #################################################### -def require_sdk_kwargs(method, names, what): - """Refuse cleanly when the installed SDK predates a keyword this command needs. - - Called only when the caller actually uses the option, so existing - invocations keep working on the floor. See specs/05-sdk-contract.md. - """ - parameters = inspect.signature(method).parameters - if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters.values()): - return # **kwargs accepts every name; fail open rather than false-refuse - missing = [n for n in names if n not in parameters] - if missing: - raise exceptions.PolyswarmException( - f'{what} requires a polyswarm-api release newer than {SDK_FLOOR} ' - f'(the paired SDK change adds {", ".join(missing)}). ' - f'Upgrade polyswarm-api to use it.') - - #################################################### # Click parameters validators #################################################### diff --git a/tests/_sdk_guards.py b/tests/_sdk_guards.py deleted file mode 100644 index 33396b7e..00000000 --- a/tests/_sdk_guards.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Skip guards keyed on the SDK surface a test actually needs. - -``pyproject.toml`` pins a FLOOR, not an exact SDK, so the suite runs against -either the floor or a newer paired SDK and a test needing a surface the floor -lacks must SKIP there — not fail, and above all not pass vacuously. The full -convention, including why the guard is keyed on the narrowest dependency, is in -``specs/04-testing.md`` §Staying honest on both installs the pin permits. - -Shared because two modules need the same guards: the formatter unit tests build -resources directly, and the cassette tests render CLI output whose lines only -appear when the SDK parses the underlying attribute.""" -import inspect -import unittest - -from polyswarm_api import resources -from polyswarm_api.api import PolyswarmAPI - -_RULESET = {'id': '0', 'livescan_id': None, 'livescan_created': None, - 'name': 'n', 'description': 'd', 'deleted': False, - 'created': '2026-08-20T00:00:00+00:00', - 'modified': '2026-08-20T00:00:00+00:00', 'yara': None, - # the probed keys are present so the guard does not depend on the - # SDK assigning absent ones - 'rule_count': 1, 'favorite': False, 'historical_hunt_count': 0} -_HUNT = {'id': '0', 'status': 'PENDING', 'progress': 0.0, 'active': None, - 'created': '2026-08-20T00:00:00+00:00', 'summary': None, - 'results_csv_uri': None, 'ruleset_name': 'n', 'yara': None, - 'rule_id': '1', 'rule_modified': None, 'source_rule_changed': False} - -# Keyed on the METHOD, never also on the resource class: that would let a -# resource rename silently skip the whole command suite while CI stays green. -needs_favorite_method = unittest.skipUnless( - hasattr(PolyswarmAPI, 'ruleset_favorite'), - 'paired SDK method (ruleset_favorite) not installed') -needs_favorite_resource = unittest.skipUnless( - hasattr(resources, 'YaraRulesetFavorite'), - 'paired SDK resource (YaraRulesetFavorite) not installed') - -# Keyed on the ATTRIBUTE, not the class: YaraRuleset and HistoricalHunt exist on -# the floor and simply do not parse these keys, so a class-level guard does not -# skip — the render assertions fail, and an absence-asserting test passes -# vacuously, which looks like coverage while pinning nothing. -needs_tracking_fields = unittest.skipUnless( - hasattr(resources.YaraRuleset(_RULESET, api=None), 'rule_count'), - 'paired SDK does not parse the ruleset tracking fields') -needs_provenance_fields = unittest.skipUnless( - hasattr(resources.HistoricalHunt(_HUNT, api=None), 'source_rule_changed'), - 'paired SDK does not parse the hunt provenance fields') - - -def _accepts(method, *params): - """True only when the installed signature takes EVERY named parameter. - - All of them, because a guard protects one test and that test passes every - option it names: keying on a subset lets a partial SDK satisfy the guard - while the invocation still refuses at exit 2, which is the fail-instead-of- - skip this whole module exists to prevent. - """ - sig = inspect.signature(method).parameters - return all(param in sig for param in params) - - -# Keyed on the PARAMETER, not the method: both methods exist on the floor and -# simply reject the keyword, so `require_sdk_kwargs` refuses and the command -# exits 2 — a test passing the option would FAIL there rather than skip. Only -# the option-passing tests take these; the plain-invocation ones must stay -# unguarded, since the floor supports them. -needs_ruleset_list_filters = unittest.skipUnless( - _accepts(PolyswarmAPI.ruleset_list, 'name', 'status', 'favorites_only', - 'has_new_results'), - 'paired SDK ruleset_list does not accept the filter keywords') -needs_live_feed_options = unittest.skipUnless( - _accepts(PolyswarmAPI.live_feed, 'livescan_id', 'max_results'), - 'paired SDK live_feed does not accept livescan_id/max_results') diff --git a/tests/cli_test.py b/tests/cli_test.py index 9ab4e817..ec043957 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -11,11 +11,6 @@ import vcr as vcr_ -from tests._sdk_guards import ( - needs_favorite_method as _needs_favorite_method, - needs_favorite_resource as _needs_favorite_resource, - needs_tracking_fields as _needs_tracking_fields, -) import click from click.testing import CliRunner @@ -185,11 +180,6 @@ def test_live_hunt_start_json(self): '--output-format', 'json', 'live', 'start', '44051669277897879']) self._assert_json_result(result, self.click_vcr(result)) - # The re-recorded .click expects 'Rules in ruleset' / 'Historical hunts - # triggered', which render only when the SDK PARSES those attributes — - # on the floor the formatter's getattr guard omits them and this fails - # rather than skips (specs/04 §Staying honest on both installs). - @_needs_tracking_fields @vcr.use_cassette() def test_live_hunt_start_text(self): result = self._run_cli([ @@ -201,11 +191,6 @@ def test_live_hunt_stop_json(self): result = self._run_cli(['--output-format', 'json', 'live', 'stop', '44051669277897879']) self._assert_json_result(result, self.click_vcr(result)) - # The re-recorded .click expects 'Rules in ruleset' / 'Historical hunts - # triggered', which render only when the SDK PARSES those attributes — - # on the floor the formatter's getattr guard omits them and this fails - # rather than skips (specs/04 §Staying honest on both installs). - @_needs_tracking_fields @vcr.use_cassette() def test_live_hunt_stop_text(self): result = self._run_cli(['--output-format', 'text', 'live', 'stop', '44051669277897879']) @@ -278,32 +263,22 @@ def test_ruleset_list_json(self): result = self._run_cli([ '--output-format', 'json', 'rules', 'list']) self._assert_json_result(result, self.click_vcr(result)) - - @_needs_favorite_method - @_needs_favorite_resource @vcr.use_cassette() def test_ruleset_favorite_text(self): result = self._run_cli([ '--output-format', 'text', 'rules', 'favorite', '96652060989160147']) self._assert_text_result(result, self.click_vcr(result)) - - @_needs_favorite_method - @_needs_favorite_resource @vcr.use_cassette() def test_ruleset_unfavorite_text(self): result = self._run_cli([ '--output-format', 'text', 'rules', 'favorite', '96652060989160147', '--unfavorite']) self._assert_text_result(result, self.click_vcr(result)) - - @_needs_favorite_method @vcr.use_cassette() def test_ruleset_favorite_json(self): result = self._run_cli([ '--output-format', 'json', 'rules', 'favorite', '14883307518120680']) self._assert_json_result(result, self.click_vcr(result)) - - @_needs_favorite_method @vcr.use_cassette() def test_ruleset_favorite_limit_text(self): # The server's machine-readable FAVORITE_LIMIT refusal, recorded off diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 1f080475..d92faacb 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -31,7 +31,6 @@ from click.testing import CliRunner from polyswarm.client import polyswarm as client -from polyswarm.client import utils from polyswarm.formatters import text from polyswarm_api import exceptions, resources @@ -44,14 +43,6 @@ # need only the METHOD (keying them on the resource too would let a resource # rename silently skip the whole command suite while CI stays green), and the # formatter fixture tests need only the RESOURCE class they instantiate. -from tests._sdk_guards import ( # noqa: E402 - needs_favorite_method as _needs_favorite_method, - needs_favorite_resource as _needs_favorite_resource, - needs_live_feed_options as _needs_live_feed_options, - needs_provenance_fields as _needs_provenance_fields, - needs_ruleset_list_filters as _needs_ruleset_list_filters, - needs_tracking_fields as _needs_tracking_fields, -) def _ruleset(**overrides): @@ -90,8 +81,6 @@ def _render(self, method, result, **kwargs): out = io.StringIO() getattr(text.TextOutput(color=False, output=out), method)(result, **kwargs) return out.getvalue() - - @_needs_tracking_fields def test_ruleset_tracking_fields_render_with_zero_distinct_from_absent(self): rendered = self._render('ruleset', _ruleset( favorite=True, favorited_at='2026-08-20T12:00:00+00:00', rule_count=0, @@ -102,8 +91,6 @@ def test_ruleset_tracking_fields_render_with_zero_distinct_from_absent(self): assert 'Rules in ruleset: 0' in rendered assert 'Historical hunts triggered: 0' in rendered assert 'New live results (last 24h): 3' in rendered - - @_needs_tracking_fields def test_ruleset_staleness_marker_renders_beside_the_count(self): # The stored badge's marker: how fresh the number is. Rendered only # with a count (the server sends them together). @@ -112,8 +99,6 @@ def test_ruleset_staleness_marker_renders_beside_the_count(self): new_results_counted_at='2026-08-25T12:00:00+00:00')) assert 'New live results (last 24h): 0' in rendered assert 'New-results count refreshed at: 2026-08-25 12:00:00+00:00' in rendered - - @_needs_favorite_resource def test_ruleset_favorite_response_renders_state_and_budget(self): rendered = self._render('ruleset_favorite', resources.YaraRulesetFavorite( {'id': '5', 'favorite': True, @@ -123,8 +108,6 @@ def test_ruleset_favorite_response_renders_state_and_budget(self): assert 'Favorite: yes' in rendered assert 'Favorited at: 2026-08-25 12:00:00+00:00' in rendered assert 'Favorites used: 3 of 5' in rendered - - @_needs_favorite_resource def test_ruleset_unfavorite_response_renders_no_state(self): rendered = self._render('ruleset_favorite', resources.YaraRulesetFavorite( {'id': '5', 'favorite': False, 'favorited_at': None, @@ -132,8 +115,6 @@ def test_ruleset_unfavorite_response_renders_no_state(self): assert 'Favorite: no' in rendered assert 'Favorited at' not in rendered assert 'Favorites used: 2 of 5' in rendered - - @_needs_tracking_fields def test_ruleset_none_and_false_fields_are_omitted(self): rendered = self._render('ruleset', _ruleset( favorite=False, favorited_at=None, rule_count=None, @@ -147,8 +128,6 @@ def test_old_sdk_ruleset_without_the_attributes_renders(self): rendered = self._render('ruleset', _old_sdk_ruleset()) assert 'Ruleset Id: 5' in rendered assert 'Favorite' not in rendered - - @_needs_provenance_fields def test_hunt_provenance_fields_render_with_the_reference_point(self): rendered = self._render('hunt', _hunt( rule_id='5', rule_modified='2026-08-20T12:00:00+00:00', @@ -184,8 +163,6 @@ def test_list_passes_no_kwargs_at_all(self): catch_exceptions=False) assert result.exit_code == 0, result.output ruleset_list.assert_called_once_with(mock.ANY) - - @_needs_ruleset_list_filters def test_filters_are_forwarded_only_when_given(self): """A filtered list forwards exactly the filters passed and nothing else — the flags default to False, and a False flag must not become @@ -200,33 +177,13 @@ def test_filters_are_forwarded_only_when_given(self): '--status', 'active', '--has-new-results'], catch_exceptions=False) assert result.exit_code == 0, result.output - # All four filters, because autospec is what turns this into a - # SIGNATURE check against the installed SDK: a kwarg name that only - # this side renamed would otherwise ship as require_sdk_kwargs - # refusing on an SDK that does have the surface. + # All four, and autospec makes this a SIGNATURE check against the + # installed SDK: a kwarg only this side renamed fails here rather than + # reaching the server as a filter it silently ignores. ruleset_list.assert_called_once_with( mock.ANY, name='alpha', status='active', favorites_only=True, has_new_results=True) - def test_filtering_on_a_floor_sdk_is_a_clean_message_not_a_traceback(self): - """The published floor's ``ruleset_list()`` takes no filters. Using one - there must produce the upgrade message at exit 2 (the server-refusal - code), never the TypeError traceback a bare kwarg would raise. - - The floor is simulated by a stand-in with the FLOOR signature, which is - what the guard inspects.""" - def floor_ruleset_list(self): - return iter(()) - - with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_list', - floor_ruleset_list): - result = CliRunner().invoke( - client.polyswarm_cli, - ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', - 'rules', 'list', '--name', 'alpha']) - assert result.exit_code == 2, result.output - assert f'newer than {utils.SDK_FLOOR}' in result.output - assert 'Traceback' not in result.output class LiveFeedOptionsTest(TestCase): @@ -252,8 +209,6 @@ def test_plain_feed_forwards_neither_new_kwarg(self): # the default window is 86400 SECONDS (24h), passed positionally — the # wire is seconds and stays seconds, so the CLI default carries the 24h assert live_feed.call_args[0][1] == 86400 - - @_needs_live_feed_options def test_livescan_id_and_max_results_are_forwarded(self): result, live_feed = self._invoke( '--livescan-id', '72927285313305230', '--max-results', '5') @@ -266,9 +221,8 @@ def test_livescan_id_and_max_results_are_forwarded(self): assert kwargs['max_results'] == 5 def test_zero_max_results_is_unbounded_and_never_reaches_the_sdk(self): - """--max-results 0 is documented as the pre-existing unbounded - behaviour, so it must not be forwarded — and therefore must not trip the - floor guard for an invocation the floor already serves.""" + """--max-results 0 is the pre-existing unbounded behaviour, so it must not + be forwarded — omitting it is what already means "no bound".""" result, live_feed = self._invoke('--max-results', '0') assert result.exit_code == 0, result.output _, kwargs = live_feed.call_args @@ -283,19 +237,6 @@ def test_zero_since_IS_forwarded_unlike_zero_max_results(self): assert result.exit_code == 0, result.output assert live_feed.call_args[0][1] == 0 - def test_zero_max_results_does_not_trip_the_floor_guard(self): - def floor_live_feed(self, since=None, rule_name=None, family=None, - polyscore_lower=None, polyscore_upper=None, - community=None): - return iter(()) - - with mock.patch('polyswarm_api.api.PolyswarmAPI.live_feed', - floor_live_feed): - result = CliRunner().invoke( - client.polyswarm_cli, - ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', - 'live', 'feed', '--max-results', '0']) - assert result.exit_code == 0, result.output def test_a_negative_max_results_is_refused_at_the_interface(self): result = CliRunner().invoke( @@ -311,38 +252,7 @@ def test_a_non_numeric_livescan_id_is_refused_before_the_server(self): 'live', 'feed', '--livescan-id', 'not-an-id']) assert result.exit_code != 0 - def test_a_kwargs_sdk_fails_open_rather_than_false_refusing(self): - """The guard cannot see through **kwargs, so it forwards rather than - refusing an SDK that may well accept the name. Published 4.3.0 declares - no **kwargs (specs/05 §Current floor records the signatures), so this - branch is unreachable today — but it is product code, and an SDK that - grew one would take it.""" - def kwargs_ruleset_list(self, **kwargs): - return iter(()) - - with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_list', - kwargs_ruleset_list): - result = CliRunner().invoke( - client.polyswarm_cli, - ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', - 'rules', 'list', '--favorites-only']) - assert result.exit_code == 0, result.output - - def test_new_options_on_a_floor_sdk_are_a_clean_message(self): - def floor_live_feed(self, since=None, rule_name=None, family=None, - polyscore_lower=None, polyscore_upper=None, - community=None): - return iter(()) - with mock.patch('polyswarm_api.api.PolyswarmAPI.live_feed', - floor_live_feed): - result = CliRunner().invoke( - client.polyswarm_cli, - ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', - 'live', 'feed', '--livescan-id', '7']) - assert result.exit_code == 2, result.output - assert f'newer than {utils.SDK_FLOOR}' in result.output - assert 'Traceback' not in result.output class RulesFavoriteCommandTest(TestCase): @@ -372,24 +282,18 @@ def _response(favorite): id='5', favorite=favorite, favorited_at='2026-08-25T12:00:00+00:00' if favorite else None, favorites_used=1, favorites_limit=5) - - @_needs_favorite_method def test_favorite_calls_the_sdk_and_renders_the_budget(self): result, toggle = self._invoke(['5'], return_value=self._response(True)) assert result.exit_code == 0, result.output toggle.assert_called_once_with(mock.ANY, 5, True) assert 'Favorite: yes' in result.output assert 'Favorites used: 1 of 5' in result.output - - @_needs_favorite_method def test_unfavorite_flag_flips_the_boolean(self): result, toggle = self._invoke(['5', '--unfavorite'], return_value=self._response(False)) assert result.exit_code == 0, result.output toggle.assert_called_once_with(mock.ANY, 5, False) assert 'Favorite: no' in result.output - - @_needs_favorite_method def test_favorite_limit_refusal_is_a_clean_message_at_exit_2(self): # Exit 2 is the central mapping's code for this, never 1; exit 1 is # reserved for no-results/not-found. The friendly message rides a CLI @@ -408,8 +312,6 @@ def test_favorite_limit_refusal_is_a_clean_message_at_exit_2(self): assert 'Favorite limit reached (5 of 5 used)' in result.output assert '--unfavorite' in result.output # names the way out assert 'Traceback' not in result.output - - @_needs_favorite_method def test_favorite_limit_without_counters_uses_the_server_message(self): # The counters are advisory; an envelope can carry the code without # them. Interpolating them unguarded rendered "(None of None used)" at @@ -428,8 +330,6 @@ def test_favorite_limit_without_counters_uses_the_server_message(self): assert 'None of None' not in result.output assert 'Favorite limit reached (5 of 5 used).' in result.output assert '--unfavorite' in result.output - - @_needs_favorite_method def test_favorite_limit_on_a_request_without_result_still_has_no_traceback(self): # A Mock has every attribute, so the test above cannot fail on a missing # `.result`. This one uses a real object that genuinely lacks it — the @@ -449,24 +349,6 @@ class BareRequest: assert 'Traceback' not in result.output assert 'None' not in result.output assert '--unfavorite' in result.output - - def test_favorite_on_the_floor_sdk_degrades_cleanly(self): - # The declared floor (published 4.3.0) has no ruleset_favorite: the - # command must fail with a clean upgrade message at exit 2, never an - # AttributeError traceback — CI's branch-name SDK install can never - # surface this, so the test simulates the floor by nulling the method. - with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_favorite', - new=None, create=True): - result = CliRunner().invoke( - client.polyswarm_cli, - ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', - 'rules', 'favorite', '5']) - assert result.exit_code == 2, result.output - assert (f'requires a polyswarm-api release newer than {utils.SDK_FLOOR}' - in result.output) - assert 'AttributeError' not in result.output - - @_needs_favorite_method def test_other_refusals_still_raise(self): request = mock.Mock() request.errors = None @@ -493,19 +375,3 @@ def test_request_exception_is_caught_as_a_polyswarm_exception(self): exceptions.PolyswarmException) -class SdkFloorConstantTest(TestCase): - """``utils.SDK_FLOOR`` must equal the lower bound in ``pyproject.toml``. - - specs/05-sdk-contract.md makes the pin the one authoritative floor, and the - constant only exists so the guard messages can name it. Nothing else ties - the two together: the follow-up bump edits the pin, and a stale constant - would leave every upgrade message naming the wrong version while the suite - stayed green — the exact drift specs/05 says the pin exists to prevent.""" - - def test_the_constant_matches_the_pin(self): - import re - pyproject = (pathlib.Path(__file__).resolve().parent.parent - / 'pyproject.toml').read_text() - match = re.search(r'polyswarm_api>=([0-9]+\.[0-9]+\.[0-9]+)', pyproject) - assert match, 'polyswarm_api pin not found in pyproject.toml' - assert utils.SDK_FLOOR == match.group(1) From d705bf59154f1f71c3b83519d8cf66b8c34d6c3e Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 13:33:36 -0300 Subject: [PATCH 29/54] refactor: read the tracking fields directly; the pin makes getattr dead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The formatters guarded every hunt-page field with `getattr(result, x, None)` and said in three places it was so an SDK predating the fields could still render. The floor now forbids that SDK, and 4.4.0 assigns each attribute unconditionally — verified: a resource built from a payload carrying none of the keys still answers hasattr() for all six, with value None. So the getattr DEFAULT was unreachable and only `is not None` was ever doing work. Read the attributes directly in `hunt()` and `ruleset()`, and say what None now means: the SERVER had no answer. `ruleset_favorite()` keeps its getattrs — its budget counters are genuinely optional server-side — and `artifact_instance()` is untouched, pre-existing and outside this change. Removes the two tests that rendered a SimpleNamespace missing the attributes entirely: they pinned an install the pin forbids, which is the same fiction the deleted skip guards traded in. Also clears what the previous commit left behind: an empty `# SDK-surface guards` banner in client/utils.py, a test module docstring and comment block describing guards and `create=True` that no longer exist, a class docstring justifying the zero-arg call by the old floor's signature, and specs/03 both lagging at 4.3.0 and still describing the getattrs as old-SDK protection. --- specs/03-formatters.md | 17 +++++----- src/polyswarm/client/utils.py | 5 --- src/polyswarm/formatters/text.py | 26 ++++++++-------- tests/formatter_hunt_fields_test.py | 48 ++++++----------------------- 4 files changed, 32 insertions(+), 64 deletions(-) diff --git a/specs/03-formatters.md b/specs/03-formatters.md index f432c531..ae4c507b 100644 --- a/specs/03-formatters.md +++ b/specs/03-formatters.md @@ -142,8 +142,8 @@ either field) never raises `AttributeError`; an SDK without `.state` simply neve the known-good branch, which is the safe fallback — the pre-known-good rendering. That degradation is belt-and-braces, not a supported configuration: `.state` is load-bearing here with no substitute. Both attributes ship in SDK **4.1.0**, but the dependency floor is -`polyswarm_api>=4.3.0` — the pin's current value (moved there by the #264 release -bump); its *rationale* is two behaviours that landed in 4.2.0 and still hold +`polyswarm_api>=4.4.0` — the pin's current value (moved there by the hunt-page change +set; 4.3.0 before it, by the #264 release bump); its *rationale* is two behaviours that landed in 4.2.0 and still hold transitively, and [05-sdk-contract.md](./05-sdk-contract.md) §Current floor is authoritative. Those two fail silently on 4.1.0 (see [`05-sdk-contract.md`](./05-sdk-contract.md) §Version pin) — so @@ -152,18 +152,21 @@ every supported install has them. `JSONOutput` needs no change — it dumps the ## Hunt-page tracking fields (rulesets + historical hunts) -Rendering rules that are deliberate, not incidental — all getattr-guarded so -an SDK release predating the fields renders without the lines: +Rendering rules that are deliberate, not incidental. Every one of these fields is +parsed by the pinned SDK, so the attribute always exists and `None` means the +**server** had no answer — never an older SDK (the floor forbids one; see +[`05-sdk-contract.md`](./05-sdk-contract.md) §Current floor). The formatters read +the attributes directly: - `rule_count` / `historical_hunt_count`: `0` renders as a real zero; `None` (the server had no answer) omits the line — never shown as 0. -- `favorite` is truthy-only ("Favorite: yes"): False and old-SDK-absent both - print nothing, deliberately indistinguishable. +- `favorite` is truthy-only ("Favorite: yes"): False and None both print + nothing, deliberately indistinguishable. - `new_results_count` is the server's STORED badge (refreshed by its scheduled job — the window is the fixed 24 h product window, which the label names, since a caller cannot choose it): a number renders with its `new_results_counted_at` staleness marker beside it; `None` (never - refreshed / no live hunt / old SDK) omits both lines. + refreshed / no live hunt) omits both lines. - `ruleset_favorite` renders the toggle response: `Favorite: yes/no`, the `favorited_at` timestamp when starred, and the server-owned budget as "Favorites used: N of M" — the client never counts. diff --git a/src/polyswarm/client/utils.py b/src/polyswarm/client/utils.py index e55fa62c..20504e34 100644 --- a/src/polyswarm/client/utils.py +++ b/src/polyswarm/client/utils.py @@ -24,11 +24,6 @@ def parse_hashes(hashes, hash_file=None): return [h.strip('\n') for h in hashes] -#################################################### -# SDK-surface guards -#################################################### - - #################################################### # Click parameters validators #################################################### diff --git a/src/polyswarm/formatters/text.py b/src/polyswarm/formatters/text.py index 0eac9f60..2adb3f0e 100644 --- a/src/polyswarm/formatters/text.py +++ b/src/polyswarm/formatters/text.py @@ -201,13 +201,13 @@ def hunt(self, result, write=True): self._close_group() if result.ruleset_name is not None: output.append(self._white(f'Ruleset Name: {result.ruleset_name}')) - # Source-rule provenance — getattr-guarded so the formatter also - # renders results parsed by an SDK release that predates the fields. - if getattr(result, 'rule_id', None) is not None: + # Source-rule provenance. The pin guarantees the SDK parses these, + # so None means the SERVER had no answer (specs/03). + if result.rule_id is not None: output.append(self._white(f'Source Ruleset Id: {result.rule_id}')) - if getattr(result, 'rule_modified', None) is not None: + if result.rule_modified is not None: output.append(self._white(f'Source ruleset last modified at freeze: {result.rule_modified}')) - if getattr(result, 'source_rule_changed', None) is not None: + if result.source_rule_changed is not None: # Tri-state upstream: None (unknown) prints nothing; the label # names the reference point so it can't read as "edited recently". changed = 'yes' if result.source_rule_changed else 'no' @@ -295,23 +295,23 @@ def ruleset(self, result, write=True, contents=False): output.append(self._white(f'Description: {result.description}')) output.append(self._white(f'Created at: {result.created}')) output.append(self._white(f'Modified at: {result.modified}')) - # Tracking fields are guarded with getattr so this formatter also - # renders results parsed by an SDK release that predates them. - if getattr(result, 'favorite', None): + # The pin guarantees the SDK parses these, so None means the SERVER + # had no answer — never an older SDK (specs/03). + if result.favorite: output.append(self._yellow('Favorite: yes')) - if getattr(result, 'favorited_at', None) is not None: + if result.favorited_at is not None: output.append(self._white(f'Favorited at: {result.favorited_at}')) - if getattr(result, 'rule_count', None) is not None: + if result.rule_count is not None: output.append(self._white(f'Rules in ruleset: {result.rule_count}')) - if getattr(result, 'historical_hunt_count', None) is not None: + if result.historical_hunt_count is not None: output.append(self._white(f'Historical hunts triggered: {result.historical_hunt_count}')) - if getattr(result, 'new_results_count', None) is not None: + if result.new_results_count is not None: # The window is the server's fixed 24 h product window (the badge # is a stored counter its scheduled refresh maintains — a caller # cannot choose the window, so the label must not imply one), and # the marker says how fresh the stored number is. output.append(self._white(f'New live results (last 24h): {result.new_results_count}')) - if getattr(result, 'new_results_counted_at', None) is not None: + if result.new_results_counted_at is not None: output.append(self._white(f'New-results count refreshed at: {result.new_results_counted_at}')) if contents: output.append(self._white(f'Ruleset Contents:\n{result.yara}')) diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index d92faacb..3854e9be 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -13,15 +13,13 @@ no such attributes to build from) renders without raising and simply omits the new lines; and * the command plumbing: an UNFILTERED ``rules list`` still calls a - zero-argument ``ruleset_list()`` (the pin's floor, 4.3.0, has exactly - that signature, so the common invocation needs no new SDK behaviour), - a FILTERED one forwards exactly the filters given, ``live feed`` - forwards ``--livescan-id`` / ``--max-results`` only when passed, and - ``rules favorite`` renders the toggle response and converts the - machine-readable FAVORITE_LIMIT refusal into a clean message. All are - asserted through autospec'd mocks so every call is signature-checked - against the installed SDK; the options that DO need the paired SDK are - pinned to degrade with a clean upgrade message on the floor. + zero-argument ``ruleset_list()`` (a False flag is not a filter), a + FILTERED one forwards exactly the filters given, ``live feed`` forwards + ``--livescan-id`` / ``--max-results`` only when passed, and ``rules + favorite`` renders the toggle response and converts the machine-readable + FAVORITE_LIMIT refusal into a clean message. All are asserted through + autospec'd mocks, so every call is signature-checked against the SDK the + pin actually installs. """ import io import pathlib @@ -34,15 +32,6 @@ from polyswarm.formatters import text from polyswarm_api import exceptions, resources -# The favorite surface ships in the paired SDK change; the pin's floor -# (published 4.3.0) has neither the method nor the resource. These tests must -# stay honest on BOTH installs: everything that needs the new surface skips -# on the floor (where `rules favorite` itself degrades to the clean upgrade -# message its own floor test pins with create=True). -# Two guards, deliberately as NARROW as each dependency: the command tests -# need only the METHOD (keying them on the resource too would let a resource -# rename silently skip the whole command suite while CI stays green), and the -# formatter fixture tests need only the RESOURCE class they instantiate. def _ruleset(**overrides): @@ -61,19 +50,8 @@ def _hunt(**overrides): return resources.HistoricalHunt(content, api=None) -def _old_sdk_ruleset(): - """A result parsed by an SDK release that predates the tracking fields: - the attributes are ABSENT, not None — SimpleNamespace is deliberate, since - the installed (new) SDK cannot build such an object.""" - return types.SimpleNamespace( - id='5', livescan_id=None, livescan_created=None, name='n', - description='d', created='c', modified='m', yara=None) -def _old_sdk_hunt(): - return types.SimpleNamespace( - id='9', status='PENDING', progress=None, active=None, created='c', - summary=None, results_csv_uri=None, ruleset_name='n', yara=None) class FormatterHuntFieldsTest(TestCase): @@ -124,10 +102,6 @@ def test_ruleset_none_and_false_fields_are_omitted(self): assert 'Historical hunts triggered' not in rendered assert 'New live results' not in rendered - def test_old_sdk_ruleset_without_the_attributes_renders(self): - rendered = self._render('ruleset', _old_sdk_ruleset()) - assert 'Ruleset Id: 5' in rendered - assert 'Favorite' not in rendered def test_hunt_provenance_fields_render_with_the_reference_point(self): rendered = self._render('hunt', _hunt( rule_id='5', rule_modified='2026-08-20T12:00:00+00:00', @@ -141,15 +115,11 @@ def test_hunt_unknown_tri_state_prints_nothing(self): rule_id=None, rule_modified=None, source_rule_changed=None)) assert 'Source' not in rendered - def test_old_sdk_hunt_without_the_attributes_renders(self): - rendered = self._render('hunt', _old_sdk_hunt()) - assert 'Hunt Id: 9' in rendered - assert 'Source' not in rendered class RulesListZeroArgTest(TestCase): - """`rules list` calls a zero-argument ``ruleset_list()`` — the pin's - floor (4.3.0) has exactly that signature, so the command needs no new SDK + """`rules list` calls a zero-argument ``ruleset_list()`` — a False flag + is not a filter, so an unfiltered list forwards no behaviour at all. autospec makes the assertion a signature check against the installed SDK.""" From 9219e5bddc659b2b80d7597129322dbe0ee148dd Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 13:45:53 -0300 Subject: [PATCH 30/54] refactor: finish the getattr removal, and stop describing deleted machinery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TextOutput.ruleset_favorite` was the one leg still guarding, and it failed in the worst direction: a missing `favorite` made `starred` None and the else branch printed "Favorite: no" — a wrong state after a successful star, not an omission. `YaraRulesetFavorite` assigns all four attributes unconditionally (verified: a resource built from `{'id': '5'}` alone answers hasattr for every one), so the defaults were unreachable. Read them directly, and let the command tests build the real resource again — the SimpleNamespace existed to decouple them from a skip guard that no longer exists. Prose that outlived the code: specs/02 still said the formatters getattr-guard, and the test module docstring still advertised "the getattr guards convert an attribute-name mismatch into silent omission" and an old-SDK SimpleNamespace path whose tests are gone. specs/04 no longer frames the rule as replacing something. The guards were created and deleted inside this branch, so `develop` never had them and a reader grepping history for the removal finds nothing; the forward-looking "do not reintroduce" rule and its reasons are what carry over. Also drops a dead `utils` import in live.py and moves the IntRange comment onto the option it actually describes. --- specs/02-commands.md | 2 +- specs/04-testing.md | 6 +++--- src/polyswarm/client/live.py | 6 ++---- src/polyswarm/formatters/text.py | 8 ++++---- tests/formatter_hunt_fields_test.py | 31 ++++++++++------------------- 5 files changed, 21 insertions(+), 32 deletions(-) diff --git a/specs/02-commands.md b/specs/02-commands.md index 5ceec4ac..4c3654f1 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -30,7 +30,7 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | | `family` (`families.py`) | Malware-family CRUD | `family_{create,update,delete,get,list}` | -| `rules` (`rules.py`) | YARA ruleset CRUD plus `favorite [--unfavorite]` (the star toggle: renders the new state + the server-owned "N of M used" budget, and converts the machine-readable `FAVORITE_LIMIT` refusal into a clean actionable message at exit 2, never 1 — 1 is reserved for no-results/not-found; 2 is the broad bucket `ExceptionHandlingGroup` maps the PolyswarmException hierarchies to). `list` takes the server-side filters `--name` / `--status active` / `--favorites-only` / `--has-new-results` (conjunctive; the list is keyset-paginated, so filtering locally would mean walking every page). `rules favorite` and the `rules list` filters need SDK 4.4.0, which the pin requires (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor), so they are called directly. The formatters still `getattr`-guard the hunt-page fields — that is about a *server* that has not populated them, not about an older SDK | `ruleset_{create,delete,update,get,list,favorite}` | +| `rules` (`rules.py`) | YARA ruleset CRUD plus `favorite [--unfavorite]` (the star toggle: renders the new state + the server-owned "N of M used" budget, and converts the machine-readable `FAVORITE_LIMIT` refusal into a clean actionable message at exit 2, never 1 — 1 is reserved for no-results/not-found; 2 is the broad bucket `ExceptionHandlingGroup` maps the PolyswarmException hierarchies to). `list` takes the server-side filters `--name` / `--status active` / `--favorites-only` / `--has-new-results` (conjunctive; the list is keyset-paginated, so filtering locally would mean walking every page). `rules favorite` and the `rules list` filters need SDK 4.4.0, which the pin requires (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor), so they are called directly. The formatters read the hunt-page fields directly: the pin guarantees the SDK parses them, so `None` means the *server* had no answer | `ruleset_{create,delete,update,get,list,favorite}` | | `metadata` (`metadata.py`) | Rerun metadata; scan lookup; IP/URL analysis | `rerun_metadata`, `scan_lookup`, `submit_url` | | `activity` (`event.py`) | List account activity/events | `event_list` | | `account` (`account.py`) | Account whois / features | `account_whois`, `account_features` | diff --git a/specs/04-testing.md b/specs/04-testing.md index a4469901..4f74f3f3 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -70,9 +70,9 @@ version and `pyproject.toml` raises `polyswarm_api>=` to it. `pip` then refuses combination that would fail, at install time, before a single test runs — so a test can simply use the surface. -This replaces an earlier convention of per-test skip guards (`hasattr` on a method, a -built resource's attribute, a parameter in the installed signature). They are gone, and -should not come back. What was wrong with them: +**Do not reintroduce per-test skip guards** — `hasattr` on a method, a built resource's +attribute, a parameter in the installed signature. They are the shape this rule exists to +exclude, and the reasons are worth keeping written down: - **The fact lived twice.** The pin said one thing; each guard re-derived the same thing at runtime. Nothing kept them in sync, so every guard was one edit away from diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index 616d93de..35b3ad41 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -2,8 +2,6 @@ import click -from polyswarm.client import utils - logger = logging.getLogger(__name__) @@ -38,12 +36,12 @@ def live_stop(ctx, ruleset_id): '(default: 86400 — 24h, the window the ruleset badge counts). ' 'Pass 0 for no time filter at all.') # click.INT matches every other id option in the CLI and rejects a typo before -# it reaches the server; IntRange(min=0) refuses a negative here rather than -# letting it silently mean unbounded. +# it reaches the server. @click.option('-i', '--livescan-id', type=click.INT, help="Scope the feed to one live hunt (a ruleset's Live Hunt Id, " 'a 17-digit number). Shows one community at a time, while ' 'the badge counts all of them, so the counts need not match.') +# IntRange(min=0) refuses a negative rather than letting it silently mean unbounded. @click.option('-m', '--max-results', type=click.IntRange(min=0), help='Stop after this many results. Unset or 0 means no bound — ' 'every page, as before.') diff --git a/src/polyswarm/formatters/text.py b/src/polyswarm/formatters/text.py index 2adb3f0e..855f9051 100644 --- a/src/polyswarm/formatters/text.py +++ b/src/polyswarm/formatters/text.py @@ -320,13 +320,13 @@ def ruleset(self, result, write=True, contents=False): def ruleset_favorite(self, result, write=True): output = [] output.append(self._blue(f'Ruleset Id: {result.id}')) - starred = getattr(result, 'favorite', None) + starred = result.favorite output.append(self._yellow('Favorite: yes') if starred else self._white('Favorite: no')) - if getattr(result, 'favorited_at', None) is not None: + if result.favorited_at is not None: output.append(self._white(f'Favorited at: {result.favorited_at}')) - used = getattr(result, 'favorites_used', None) - limit = getattr(result, 'favorites_limit', None) + used = result.favorites_used + limit = result.favorites_limit if used is not None and limit is not None: # server-owned budget counters — the client never counts output.append(self._white(f'Favorites used: {used} of {limit}')) diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 3854e9be..db5bfab7 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -1,17 +1,13 @@ """The hunt-page tracking legs of the text formatter, and the flag that reaches them. -Pins three contracts: - -* the rendering legs against REAL SDK resources built from literal dicts (not - hand-built namespaces): the getattr guards convert an attribute-name - mismatch into silent omission, so only real resources couple these tests to - the SDK's actual attribute names — and they additionally pin that - ``favorited_at`` / ``rule_modified`` arrive as parsed datetimes; -* the old-SDK degradation path — a result object without the attributes at - all (SimpleNamespace on purpose: an installed SDK predating the fields has - no such attributes to build from) renders without raising and simply omits - the new lines; and +Pins two contracts: + +* the rendering legs against REAL SDK resources built from literal dicts, so + the tests are coupled to the SDK's actual attribute names — and they + additionally pin that ``favorited_at`` / ``rule_modified`` arrive as parsed + datetimes. The pin guarantees those attributes exist, so ``None`` here means + the SERVER had no answer; and * the command plumbing: an UNFILTERED ``rules list`` still calls a zero-argument ``ruleset_list()`` (a False flag is not a filter), a FILTERED one forwards exactly the filters given, ``live feed`` forwards @@ -23,7 +19,6 @@ """ import io import pathlib -import types from unittest import TestCase, mock from click.testing import CliRunner @@ -244,14 +239,10 @@ def _invoke(self, args, side_effect=None, return_value=None): @staticmethod def _response(favorite): - # A namespace, not the SDK resource: TextOutput.ruleset_favorite reads - # `.id` plus getattrs, so building the real class would make these - # command tests depend on the RESOURCE and a rename would silently skip - # the only coverage that `rules favorite` calls the SDK at all. - return types.SimpleNamespace( - id='5', favorite=favorite, - favorited_at='2026-08-25T12:00:00+00:00' if favorite else None, - favorites_used=1, favorites_limit=5) + return resources.YaraRulesetFavorite( + {'id': '5', 'favorite': favorite, + 'favorited_at': '2026-08-25T12:00:00+00:00' if favorite else None, + 'favorites_used': 1, 'favorites_limit': 5}, api=None) def test_favorite_calls_the_sdk_and_renders_the_budget(self): result, toggle = self._invoke(['5'], return_value=self._response(True)) assert result.exit_code == 0, result.output From 3262e712f249cffe0ca26ad0a7db58096130906e Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 13:47:24 -0300 Subject: [PATCH 31/54] docs(specs): state the develop-merge hazard, not just release order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec said CI was unaffected by the floor bump because it installs the SDK by branch name. True on a feature branch, which resolves the paired SDK branch — but once this repo merges, its `develop` CI asks for the SDK's `develop.zip`. If the SDK has not merged to `develop` by then, that archive still declares the old version, `pip install .[tests]` cannot satisfy the new floor from it or from PyPI, and develop CI breaks for every subsequent PR. So there are two orderings, and only one was written down: the SDK merges to `develop` before this repo does, and separately releases before this repo can. --- specs/05-sdk-contract.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index 1f2a2aa3..67ee2da4 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -103,11 +103,18 @@ the SDK: **Two consequences, both worth knowing before you do it.** -*Release order is forced.* This repo cannot be released to PyPI until the SDK version -its floor names is on PyPI — so the SDK's `develop → master` must merge and release -first. CI is unaffected: `.gitlab-ci.yml` installs the SDK from git by branch name -(`$CI_COMMIT_BRANCH.zip`, falling back to `develop.zip`), so an unreleased version -tests fine. +*Merge order is forced too, and it bites earlier than release.* CI installs the SDK by +branch name — `$CI_COMMIT_BRANCH.zip`, falling back to `develop.zip`. On a feature +branch that resolves to the paired SDK branch, so an unreleased version tests fine. But +**once this repo merges, its `develop` CI asks for the SDK's `develop.zip`** — and if the +SDK has not merged to `develop` yet, that archive still declares the old version, `pip +install .[tests]` cannot satisfy the new floor from it or from PyPI, and **`develop` CI +breaks for every subsequent PR**, not just this one. So the SDK merges to `develop` +first, then this repo. + +*Release order is forced separately.* This repo cannot be released to PyPI until the SDK +version its floor names is on PyPI, so the SDK's `develop → master` must merge and +release before this repo's does. Merging to `develop` publishes nothing. *A missing paired branch now fails loudly.* If the SDK branch does not exist, CI falls back to the SDK's `develop`, whose version does not satisfy the new floor, and From a48111147e5882e0adc5587602efd0e64f2f2ee0 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 13:56:30 -0300 Subject: [PATCH 32/54] docs(specs): split the floor's merge and release preconditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit specs/05 said a floor bump has two preconditions — on PyPI, and declared by the SDK's develop — and then, a few lines below, that merging before the PyPI release is fine. Both readings sat in the same section. The old bullet conflated "safe to merge" with "safe to release"; they fall due at different moments, and a floor naming a version declared on the SDK's develop but not yet released is the normal state of a paired change between the two merges. Two more places where a claim outran the code: - "There are no runtime probes" is absolute, but the known-good render legs in formatters/text.py still getattr — deliberately, and specs/03 documents it as belt-and-braces against an unsupported configuration rather than a version the floor permits. Scoped the claim to the surfaces the floor names and cross-referenced the carve-out. - specs/01 overcorrected: `parse_hashes` does live in client/utils.py, but the detection helpers and collect_files are still top-level. Fixing the module for one moved the other to the wrong place. Also records the --since migration where a user actually meets it. The default widens ~60x, and since --max-results is unset by default a bare `live feed` pages through all of it — the sharper consequence of the two. Pass --since 1440 for the old window. There is no CHANGELOG here, so a release-time note had no artifact to live in. --- specs/01-architecture.md | 4 ++-- specs/02-commands.md | 8 ++++++++ specs/04-testing.md | 3 ++- specs/05-sdk-contract.md | 16 ++++++++++++---- src/polyswarm/client/live.py | 7 +++++++ 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/specs/01-architecture.md b/specs/01-architecture.md index fc55e823..f270b451 100644 --- a/specs/01-architecture.md +++ b/specs/01-architecture.md @@ -74,8 +74,8 @@ The catalogue of groups and the SDK methods each wraps is in [`02-commands.md`]( ## Support — `utils.py`, `exceptions.py` -- **`utils.py`** — `parallelize`/`parallel_executor` (thread-pool fan-out with per-item exception aggregation: collects results, logs per-item no-results, raises an aggregate `NoResultsException`/`NotFoundException`/`InternalFailureException` at the end) and `parallel_executor_iterable_results` (the same, for SDK methods that return generators — it materialises each generator inside the worker so per-item exception handling still fires). -- **`client/utils.py`** — input parsing/validation (`parse_hashes`, hash/IP detection) and the click parameter validators. Note the module is `client/utils.py`, not the top-level `utils.py` above — the two are distinct and this section named the wrong one until the hunt-page change. +- **`utils.py`** — `parallelize`/`parallel_executor` (thread-pool fan-out with per-item exception aggregation: collects results, logs per-item no-results, raises an aggregate `NoResultsException`/`NotFoundException`/`InternalFailureException` at the end) and `parallel_executor_iterable_results` (the same, for SDK methods that return generators — it materialises each generator inside the worker so per-item exception handling still fires), plus `collect_files` and the detection helpers (`is_valid_id`, `is_ip`, `is_domain`, `is_url`). +- **`client/utils.py`** — `parse_hashes` and the click parameter validators (`validate_id`, `validate_hash(es)`, `validate_key`, …). Note the module is `client/utils.py`, not the top-level `utils.py` above — the two are distinct and this section named the wrong one until the hunt-page change. - **`exceptions.py`** — the CLI's own hierarchy, **distinct from the SDK's**: `PolyswarmException` → `NoResultsException`, `NotFoundException`, `InternalFailureException`, `PartialResultsException`. `ExceptionHandlingGroup` catches both these and the SDK's `api_exceptions.*`. ## Lifecycle of a command (end to end) diff --git a/specs/02-commands.md b/specs/02-commands.md index 4c3654f1..daf76fb3 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -47,6 +47,14 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` > re-basing the server to minutes would widen every one of them 60x with no > error. `historical list --since` is seconds too — those two agree. > +> **Migration, for a caller who relied on the old behaviour:** pass +> `--since 1440` to get the 24-minute window back. Two effects compound and +> the second is the sharper one — the default window widens ~60x, and +> `--max-results` is unset by default, so a bare `live feed` pages through all +> of it rather than stopping. This repo has no CHANGELOG, so the note lives +> here and in the command's own `--help` rather than only in a release-time +> reminder. +> > **A third `--since` is genuinely MINUTES and must stay that way:** > `download stream --since` (`client/download.py`, `IntRange(1, 2880)`, > default `1440`) hits a different endpoint that really does read minutes. That diff --git a/specs/04-testing.md b/specs/04-testing.md index 4f74f3f3..293950a4 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -65,7 +65,8 @@ This spec describes the harness as it stands. Not yet documented (add as the sui ## The SDK floor is a version pin, not a runtime probe **A test never asks the installed SDK whether it has a feature. The pin guarantees -it.** When this repo needs a surface the SDK does not yet publish, the SDK bumps its +it.** (For the one pre-existing render guard this does not cover, see +[`03-formatters.md`](./03-formatters.md) §Known-good artifact instances.) When this repo needs a surface the SDK does not yet publish, the SDK bumps its version and `pyproject.toml` raises `polyswarm_api>=` to it. `pip` then refuses the combination that would fail, at install time, before a single test runs — so a test can simply use the surface. diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index 67ee2da4..92e41c02 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -70,7 +70,12 @@ When a CLI feature needs an SDK surface that doesn't exist yet: - The CLI is **sync-only** — it imports `polyswarm_api.api.PolyswarmAPI`, never `polyswarm_api.aio`. Don't add the `polyswarm_api[async]` extra. - Bumping the pin is a normal code change; bumping the CLI's *own* version is a release step (`AGENTS.md` §Gitflow). They're unrelated. - There is **no lock file / compiled requirements** to keep in step: `pyproject.toml` is the only place the SDK version is expressed, and CI installs the SDK straight from the SDK repo's branch archive (see §Coordinated changes). A pin change is a one-file change *in this repo*, but it is not free of interactions — see below. -- **The floor must be satisfied by the SDK archive CI installs, and by PyPI.** CI installs the archive build and *then* runs `pip install .[tests]`; if the archive's declared version is below the floor, that second install silently pulls a newer SDK from PyPI **over** the archive build, and CI stops testing the SDK branch at all — the mechanism §Coordinated changes rests on, defeated with no error. Symmetrically, a floor above the newest **published** version breaks `pip install polyswarm-cli` for every consumer the moment it reaches `master`. So a floor bump has two preconditions: the version is on PyPI, and the SDK's `develop` declares at least that version. +- **The floor must be satisfied by the SDK archive CI installs, and by PyPI.** CI installs the archive build and *then* runs `pip install .[tests]`; if the archive's declared version is below the floor, that second install silently pulls a newer SDK from PyPI **over** the archive build, and CI stops testing the SDK branch at all — the mechanism §Coordinated changes rests on, defeated with no error. Symmetrically, a floor above the newest **published** version breaks `pip install polyswarm-cli` for every consumer the moment it reaches `master`. So a floor bump has two preconditions, and they fall due at **different moments** — conflating them is what makes a correct bump look wrong: + + - **To merge here:** the SDK's `develop` must declare at least the floor. Nothing about PyPI applies yet; merging to `develop` publishes nothing. + - **To release here:** the floor version must be on PyPI, which needs the SDK's own `develop → master` first. + + A floor naming a version that is declared on the SDK's `develop` but not yet released is therefore correct and mergeable — that is the normal state of a paired change between the two merges. **Read the declared version off the archive's own tree, and mind pre-release suffixes.** PEP 440 orders `4.2.0.dev1 < 4.2.0`, so a `develop` head carrying a dev suffix (the SDK's `pyproject.toml` has a `[tool.bumpversion.parts.dev]`) would *not* satisfy a `>=4.2.0` floor even though it looks like 4.2.0 — and the archive build would be silently replaced from PyPI. Check the version string in the SDK branch's `pyproject.toml` / `__init__.py`, not the last release tag. When the floor was last verified this way both were read from `origin/develop` as `4.2.0`, no suffix; the pin has since moved to 4.3.0 (§Current floor), and a future bump should be re-checked the same way. @@ -83,9 +88,12 @@ The floor moved to **4.4.0** with the hunt-page change set; before that **4.3.0* The known-good rendering attributes (`ArtifactInstance.state`, `.known_good`/`.known_good_sources`, read by `formatters/text.py` — see [`03-formatters.md`](./03-formatters.md) §Known-good artifact instances) ship in **4.1.0**, so they are *not* what sets the floor; they are simply covered by it. -**The floor is how this repo expresses every SDK dependency.** There are no runtime -probes and no per-test skip guards: if the CLI uses an SDK surface, the floor names a -version that has it, and `pip` enforces that at install time. The hunt-page surfaces — +**The floor is how this repo expresses every SDK dependency.** No runtime probes for the +surfaces the floor names, and no per-test skip guards: if the CLI uses an SDK surface, the +floor names a version that has it, and `pip` enforces that at install time. (One carve-out +predates this and is documented where it lives: the known-good rendering attributes in +[`03-formatters.md`](./03-formatters.md), guarded belt-and-braces against a configuration +that is not supported rather than against a version the floor permits.) The hunt-page surfaces — `ruleset_favorite` and the `YaraRulesetFavorite` resource, the `ruleset_list` filters, `live_feed(livescan_id=, max_results=)`, and the tracking/provenance fields the formatters render — are what moved the floor to 4.4.0. Code and tests use them diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index 35b3ad41..62efcfc8 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -55,6 +55,13 @@ def live_results(ctx, since, livescan_id, max_results, rule_name, family, polyscore_lower, polyscore_upper, private): """Show live-hunt results. + `--since` is SECONDS and defaults to 86400 (24h). It used to default to + 1440, which was written as 24*60 believing the unit was minutes — so the + real window was 24 MINUTES. Pass `--since 1440` to get the old behaviour + back. The default now returns roughly 60x more, and `--max-results` is + unset by default, so a bare `live feed` pages through all of it; bound it + with `--max-results` if that matters. + `--livescan-id` scopes the feed to one live hunt — the drill-down for the per-ruleset new-results badge that `rules list` renders (the detail view deliberately does not carry it). From 210879599b3ebb446f524d1a2821066e7a9c58c5 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 14:06:31 -0300 Subject: [PATCH 33/54] docs(specs): one authoritative statement of the floor, and drop stale rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit specs/05's version-pin paragraph still ended "the pin has since moved to 4.3.0" — two lines above the `### Current floor — >=4.4.0` heading, in the very file that declares §Current floor authoritative. Fresh drift from this branch's own edit; the sentence now points at the heading instead of restating a value. specs/02 still justified the conditional forwarding with the withdrawn design's reasoning ("need the paired SDK ... unchanged on the pin's floor"). The pin guarantees both parameters now. The block itself is redundant and the comment now says so plainly: `livescan_id` defaults to None and `as_result_bound` maps 0/negative/None to "no bound", and I verified the request is byte-identical whether the kwargs are omitted, passed as None, or passed as 0. It stays only so a pre-existing invocation's call shape does not move — which the plain-feed test pins, alongside the `--since 0` refactor hazard beside it. Simplifying it away would cost that guard for no behavioural gain. --- specs/02-commands.md | 2 +- specs/05-sdk-contract.md | 2 +- src/polyswarm/client/live.py | 6 +++++- tests/cli_test.py | 3 --- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/specs/02-commands.md b/specs/02-commands.md index daf76fb3..e811d7d9 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -25,7 +25,7 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `report` (`report.py`) | Create/fetch/download reports; `prompt-config` subgroup; LLM reports | `report_create`, `report_wait_for`, `report_download`, `report_get`, `llm_report_{create,get,download}`, `prompt_config_{create,get,update,list}` | | `report-template` (`report_template.py`) | Manage report templates + logos | `report_template_{create,update,get,list}`, `report_template_logo_{download,upload}` | | `engine` → `votes` / `assertions` (`engine.py`) | Consolidated votes/assertions bundles per engine | `votes_{create,get,delete,list}`, `assertions_{create,get,delete,list}` | -| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **SECONDS** (default 86400 — 24h, matching the window the ruleset badge counts; `0` means no time filter at all), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry it; the badge counts the hunt across **every** community it runs in, public and private, while the feed shows one at a time, so a multi-community hunt lists fewer rows than the badge reports) and `--max-results` (stop after N; unset means every page, as before). Those two need the paired SDK and are forwarded only when passed, so every pre-existing invocation is unchanged on the pin's floor — see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | +| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **SECONDS** (default 86400 — 24h, matching the window the ruleset badge counts; `0` means no time filter at all), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry it; the badge counts the hunt across **every** community it runs in, public and private, while the feed shows one at a time, so a multi-community hunt lists fewer rows than the badge reports) and `--max-results` (stop after N; unset means every page, as before). Both are guaranteed by the pin (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor) and called directly; they are forwarded only when passed purely so a pre-existing invocation's call shape is unchanged — the request is identical either way | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | | `historical` (`historical.py`) | Historical hunts: CRUD + results | `historical_{get,create,update,list}`, `historical_delete_multiple`, `historical_delete_list`, `historical_results_multiple`, `historical_result`, `historical_results_delete` | | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index 92e41c02..99af908e 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -77,7 +77,7 @@ When a CLI feature needs an SDK surface that doesn't exist yet: A floor naming a version that is declared on the SDK's `develop` but not yet released is therefore correct and mergeable — that is the normal state of a paired change between the two merges. - **Read the declared version off the archive's own tree, and mind pre-release suffixes.** PEP 440 orders `4.2.0.dev1 < 4.2.0`, so a `develop` head carrying a dev suffix (the SDK's `pyproject.toml` has a `[tool.bumpversion.parts.dev]`) would *not* satisfy a `>=4.2.0` floor even though it looks like 4.2.0 — and the archive build would be silently replaced from PyPI. Check the version string in the SDK branch's `pyproject.toml` / `__init__.py`, not the last release tag. When the floor was last verified this way both were read from `origin/develop` as `4.2.0`, no suffix; the pin has since moved to 4.3.0 (§Current floor), and a future bump should be re-checked the same way. + **Read the declared version off the archive's own tree, and mind pre-release suffixes.** PEP 440 orders `4.2.0.dev1 < 4.2.0`, so a `develop` head carrying a dev suffix (the SDK's `pyproject.toml` has a `[tool.bumpversion.parts.dev]`) would *not* satisfy a `>=4.2.0` floor even though it looks like 4.2.0 — and the archive build would be silently replaced from PyPI. Check the version string in the SDK branch's `pyproject.toml` / `__init__.py`, not the last release tag. When the floor was last verified this way both were read from `origin/develop` as `4.2.0`, no suffix; the pin has since moved on (§Current floor is the one authoritative statement of its value), and every bump should be re-checked the same way. ### Current floor — `polyswarm_api>=4.4.0` diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index 62efcfc8..24be23e6 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -74,10 +74,14 @@ def live_results(ctx, since, livescan_id, max_results, rule_name, family, """ api = ctx.obj['api'] output = ctx.obj['output'] + # Both are redundant against the pinned SDK — `livescan_id` defaults to None + # and `as_result_bound` maps 0/negative/None to "no bound" — and the request + # is byte-identical either way. Kept so a pre-existing invocation's call + # shape does not move, which `test_plain_feed_forwards_neither_new_kwarg` + # pins alongside the `--since 0` refactor hazard beside it. kwargs = {} if livescan_id is not None: kwargs['livescan_id'] = livescan_id - # Truthiness: 0 means unbounded, which is what omitting it already does. if max_results: kwargs['max_results'] = max_results for result in api.live_feed( diff --git a/tests/cli_test.py b/tests/cli_test.py index ec043957..52a5ed73 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -8,15 +8,12 @@ from polyswarm_api import resources from pathlib import Path - import vcr as vcr_ - import click from click.testing import CliRunner from polyswarm.client import polyswarm as client - vcr = vcr_.VCR(cassette_library_dir='tests/vcr', path_transformer=vcr_.VCR.ensure_suffix('.vcr')) From c7996220752d655d495914ef68c04c2fb7b112e2 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 14:17:26 -0300 Subject: [PATCH 34/54] fix(formatters): an unstarred ruleset must not render a Favorited at line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ruleset_favorite` gated the timestamp on `is not None` outside the starred branch, so an unstar response still carrying `favorited_at` renders "Favorite: no" with a "Favorited at" line beneath it — a contradiction, and against what specs/03 documents ("the timestamp when starred"). The `ruleset` leg directly above already nests it correctly. No cassette catches this because the server sends null on unstar, and the existing unfavorite test passes null too, so the case is unit-pinned. Verified the new test discriminates: reverting the nesting fails it alone. Also drops an unused pathlib import and separates the four new cassette tests, which ran together as one block. --- src/polyswarm/formatters/text.py | 13 +++++++++---- tests/cli_test.py | 4 ++++ tests/formatter_hunt_fields_test.py | 12 +++++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/polyswarm/formatters/text.py b/src/polyswarm/formatters/text.py index 855f9051..d5c5ec8b 100644 --- a/src/polyswarm/formatters/text.py +++ b/src/polyswarm/formatters/text.py @@ -321,10 +321,15 @@ def ruleset_favorite(self, result, write=True): output = [] output.append(self._blue(f'Ruleset Id: {result.id}')) starred = result.favorite - output.append(self._yellow('Favorite: yes') if starred - else self._white('Favorite: no')) - if result.favorited_at is not None: - output.append(self._white(f'Favorited at: {result.favorited_at}')) + # Nested under `starred`, matching `ruleset` above: the timestamp + # describes the star, so an unstar response still carrying one must not + # render "Favorite: no" with a "Favorited at" beneath it. + if starred: + output.append(self._yellow('Favorite: yes')) + if result.favorited_at is not None: + output.append(self._white(f'Favorited at: {result.favorited_at}')) + else: + output.append(self._white('Favorite: no')) used = result.favorites_used limit = result.favorites_limit if used is not None and limit is not None: diff --git a/tests/cli_test.py b/tests/cli_test.py index 52a5ed73..f43a4046 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -260,22 +260,26 @@ def test_ruleset_list_json(self): result = self._run_cli([ '--output-format', 'json', 'rules', 'list']) self._assert_json_result(result, self.click_vcr(result)) + @vcr.use_cassette() def test_ruleset_favorite_text(self): result = self._run_cli([ '--output-format', 'text', 'rules', 'favorite', '96652060989160147']) self._assert_text_result(result, self.click_vcr(result)) + @vcr.use_cassette() def test_ruleset_unfavorite_text(self): result = self._run_cli([ '--output-format', 'text', 'rules', 'favorite', '96652060989160147', '--unfavorite']) self._assert_text_result(result, self.click_vcr(result)) + @vcr.use_cassette() def test_ruleset_favorite_json(self): result = self._run_cli([ '--output-format', 'json', 'rules', 'favorite', '14883307518120680']) self._assert_json_result(result, self.click_vcr(result)) + @vcr.use_cassette() def test_ruleset_favorite_limit_text(self): # The server's machine-readable FAVORITE_LIMIT refusal, recorded off diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index db5bfab7..d7102873 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -18,7 +18,6 @@ pin actually installs. """ import io -import pathlib from unittest import TestCase, mock from click.testing import CliRunner @@ -88,6 +87,17 @@ def test_ruleset_unfavorite_response_renders_no_state(self): assert 'Favorite: no' in rendered assert 'Favorited at' not in rendered assert 'Favorites used: 2 of 5' in rendered + def test_unfavorite_carrying_a_stale_timestamp_hides_it(self): + """An unstar response that still carries `favorited_at` must not render + it: the timestamp describes the star. No cassette produces this — the + server sends null — so it is unit-pinned.""" + rendered = self._render('ruleset_favorite', resources.YaraRulesetFavorite( + {'id': '5', 'favorite': False, + 'favorited_at': '2026-08-25T12:00:00+00:00', + 'favorites_used': 2, 'favorites_limit': 5}, api=None)) + assert 'Favorite: no' in rendered + assert 'Favorited at' not in rendered + def test_ruleset_none_and_false_fields_are_omitted(self): rendered = self._render('ruleset', _ruleset( favorite=False, favorited_at=None, rule_count=None, From 9f4a5c85ae9a2db37fe72f44fbb241191d35542b Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 14:29:58 -0300 Subject: [PATCH 35/54] fix(rules): read the server's refusal off the documented envelope path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FAVORITE_LIMIT fallback read `exc.request.result`. `PolyswarmRequest` has no such attribute — the dataclass field is `_result`, and the response envelope is `.json` — so `getattr(..., 'result', None)` was always None and the fallback was dead code. At the cap without counters the user got the generic "Favorite limit reached." instead of the server's own message. Nothing caught it. The test that exercises the fallback set `.result` on a mock.Mock, which fabricates whatever attribute it is asked for, so it passed against a spelling no real request has. Its companion test deliberately used a bare object, but asserted only that no traceback escaped — not that the message came through. Between them they covered everything except the one thing that was wrong. Now reads `(getattr(exc.request, 'json', None) or {}).get('result')` — the path the SDK's own comment documents — with getattr so a malformed request still reaches the clean message. The test builds a real PolyswarmRequest; verified it fails when the old spelling is restored. specs/05 also overstated the pin: the recorded 400 carries counters, so that cassette covers the `.errors` branch and never touches the fallback. Says so now, and names the spelling trap. --- specs/05-sdk-contract.md | 2 +- src/polyswarm/client/live.py | 2 +- src/polyswarm/client/rules.py | 10 ++++++++-- tests/formatter_hunt_fields_test.py | 13 +++++++++---- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index 99af908e..442065b2 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -18,7 +18,7 @@ How the CLI depends on the `polyswarm-api` SDK: which parts of the SDK's public | `from polyswarm_api.api import PolyswarmAPI` | Base class of the `Polyswarm` wrapper (`src/polyswarm/polyswarm.py`). | | `from polyswarm_api import settings` | Defaults: `DEFAULT_SCAN_TIMEOUT`, `DEFAULT_REPORT_TIMEOUT`, etc. | | `from polyswarm_api import resources` | Result-parser classes for power-user calls (e.g. `resources.ArtifactInstance`); resource attributes the formatters read. | -| `from polyswarm_api import exceptions as api_exceptions` | Caught in `ExceptionHandlingGroup` and `utils.parallel_executor` (`NoResultsException`, `NotFoundException`, `FailedInstanceException`, `PolyswarmException`). Also `RequestException`, caught by `rules favorite` (`client/rules.py`) to read the machine-readable `FAVORITE_LIMIT` refusal off `exc.request.errors['code']` — and, when the envelope carries no counters, `exc.request.result` as the server's own message (used only when it is a `str`; the parsed body is a dict on every other path). The SDK does not raise a typed exception for that refusal by design: `.request.errors` is a plain dict the server's error envelope populates, pinned end-to-end by `tests/cli_test.py::test_ruleset_favorite_limit_text` against a real recorded 400 (not a hand-built mock), so a rename on either side fails that cassette. | +| `from polyswarm_api import exceptions as api_exceptions` | Caught in `ExceptionHandlingGroup` and `utils.parallel_executor` (`NoResultsException`, `NotFoundException`, `FailedInstanceException`, `PolyswarmException`). Also `RequestException`, caught by `rules favorite` (`client/rules.py`) to read the machine-readable `FAVORITE_LIMIT` refusal off `exc.request.errors['code']` — and, when the envelope carries no counters, `exc.request.json['result']` as the server's own message. Note the spelling: the request object exposes the response envelope as `.json` and keeps only a private `._result`, so `exc.request.result` is not a thing — reading it yields `None` silently. The SDK does not raise a typed exception for that refusal by design: `.request.errors` is a plain dict the server's error envelope populates, pinned end-to-end by `tests/cli_test.py::test_ruleset_favorite_limit_text` against a real recorded 400 (not a hand-built mock). That cassette's envelope carries the counters, so it pins the `.errors` branch; the no-counters fallback is unit-pinned against a real `PolyswarmRequest` instead. | | `from polyswarm_api.core import parse_isoformat` | Date rendering in `formatters/text.py`. | | `import polyswarm_api` (`__version__`) | `--api-version`. | diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index 24be23e6..101c4a6a 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -64,7 +64,7 @@ def live_results(ctx, since, livescan_id, max_results, rule_name, family, `--livescan-id` scopes the feed to one live hunt — the drill-down for the per-ruleset new-results badge that `rules list` renders (the detail view - deliberately does not carry it). + deliberately does not carry the badge). The two do not have to agree, and a smaller feed is not a bug. The badge counts the hunt across EVERY community it runs in, public and private diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index 69d28076..d15db2da 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -81,8 +81,14 @@ def favorite(ctx, rule_id, unfavorite): used = errors.get('favorites_used') limit = errors.get('favorites_limit') # Counters are advisory; fall back rather than render "(None of None)". - server_msg = getattr(exc.request, 'result', None) - # `result` is the parsed body: only usable here if it is a string. + # The server's own message, off the DOCUMENTED path: the response + # envelope is `request.json`, and `result` is a key inside it. The + # request object has no `.result` attribute — only a private + # `._result` — so reading that spelling silently yielded None. + # getattr, not attribute access: a malformed/bare request must + # still reach the clean message rather than an AttributeError + # traceback (pinned by the bare-request test). + server_msg = (getattr(exc.request, 'json', None) or {}).get('result') budget = (f'Favorite limit reached ({used} of {limit} used).' if used is not None and limit is not None else (server_msg if isinstance(server_msg, str) diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index d7102873..277fd191 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -24,7 +24,7 @@ from polyswarm.client import polyswarm as client from polyswarm.formatters import text -from polyswarm_api import exceptions, resources +from polyswarm_api import core, exceptions, resources @@ -287,9 +287,14 @@ def test_favorite_limit_without_counters_uses_the_server_message(self): # The counters are advisory; an envelope can carry the code without # them. Interpolating them unguarded rendered "(None of None used)" at # the user, so the server's own message is the fallback. - request = mock.Mock() - request.errors = {'code': 'FAVORITE_LIMIT'} - request.result = 'Favorite limit reached (5 of 5 used).' + # A REAL request, not a Mock: a Mock fabricates whatever attribute it + # is asked for, so it passed even while the code read a spelling the + # request object does not have. + request = core.PolyswarmRequest(api=None, method='PUT', url='http://x') + request.json = {'errors': {'code': 'FAVORITE_LIMIT'}, + 'result': 'Favorite limit reached (5 of 5 used).', + 'status': 'error'} + request.errors = request.json['errors'] refusal = exceptions.RequestException(request) with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_favorite', autospec=True, side_effect=refusal): From 16f49849f12b2365f6861d25258c52cefd34eaf3 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 15:12:01 -0300 Subject: [PATCH 36/54] docs(specs): lockstep on develop is the design, not a hazard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ordering section read as a warning: merge this side first and "develop CI breaks for every subsequent PR". That describes the mechanism correctly and the intent backwards. The two develop branches are tested against each other on purpose — that is how both ends carry the latest features and stay exercised together without waiting on a release. A change spanning the pair is pushed and merged to both develops together, and a failure out of lockstep is the pairing being broken, not a trap to design around. It also collapsed two separate events. Merging to develop publishes nothing; PyPI only sees a version at develop -> master. So the floor a feature PR sets is a working value that develop integration validates, and the cutoff is where the SDK version gets confirmed — bumped if the repo files do not already carry it — and this repo's dependency set to the version actually being released. --- specs/05-sdk-contract.md | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index 442065b2..916e8a58 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -111,18 +111,23 @@ the SDK: **Two consequences, both worth knowing before you do it.** -*Merge order is forced too, and it bites earlier than release.* CI installs the SDK by -branch name — `$CI_COMMIT_BRANCH.zip`, falling back to `develop.zip`. On a feature -branch that resolves to the paired SDK branch, so an unreleased version tests fine. But -**once this repo merges, its `develop` CI asks for the SDK's `develop.zip`** — and if the -SDK has not merged to `develop` yet, that archive still declares the old version, `pip -install .[tests]` cannot satisfy the new floor from it or from PyPI, and **`develop` CI -breaks for every subsequent PR**, not just this one. So the SDK merges to `develop` -first, then this repo. - -*Release order is forced separately.* This repo cannot be released to PyPI until the SDK -version its floor names is on PyPI, so the SDK's `develop → master` must merge and -release before this repo's does. Merging to `develop` publishes nothing. +*The two `develop` branches move in lockstep, by design.* CI installs the SDK by branch +name — `$CI_COMMIT_BRANCH.zip`, falling back to `develop.zip` — so a paired feature +branch tests against its opposite number, and once merged, each repo's `develop` tests +against the other's. That is the point: both ends carry the latest features and are +exercised against each other continuously, without waiting on a release. A change that +spans the pair is pushed to both ends together and merged to both `develop`s together. + +Out of lockstep, the mechanism says so immediately: this repo's `develop` asks for the +SDK's `develop.zip`, and if that archive does not yet declare the floor, `pip install +.[tests]` fails. That is the pairing being broken, not a trap to design around — the fix +is to land the SDK side, not to loosen the floor. + +*Publication is a separate, later cutoff.* Merging to `develop` publishes nothing; PyPI +only sees a version when `develop → master` merges. So the floor a feature PR sets is a +working value that `develop` integration validates. **At cutoff:** bump the SDK version +if the repo files do not already carry it, then set this repo's dependency to the version +actually being released. This repo cannot be released before that SDK release exists. *A missing paired branch now fails loudly.* If the SDK branch does not exist, CI falls back to the SDK's `develop`, whose version does not satisfy the new floor, and From b5913147b5f408ab63a10f5b9e323cc6ffa495e9 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 15:30:40 -0300 Subject: [PATCH 37/54] fix(cli): stop asserting a badge window the payload does not carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `New live results (last 24h)` and the `--since` help both named 24h as the badge window. The response carries only the count and its refreshed-at marker, and the SDK contract deliberately declines to name a window — so the CLI was the only thing claiming it, with nothing in either repo failing if the server's refresh interval changed. Dropped from both; the refreshed-at line beside the count is what actually tells a reader how current it is. Three more from the same round: - The FAVORITE_LIMIT message appended "Unfavorite another ruleset first" in both directions, including when the refused invocation *was* --unfavorite. Only the star direction can hit the cap, and only it has a remedy. - A comment named `as_result_bound`; the SDK made it `_as_result_bound` and marks it private, so the name is gone rather than re-spelled. - The formatter test built a StringIO and asserted on the stream. specs/04 Style 3 says write=False and assert on the returned lines, which is what the spec's own cited example does — drift introduced in the PR that edits that spec. specs/03 no longer restates the floor value. specs/05 §Current floor is authoritative, and repeating the number in 03 is exactly what let that line sit at 4.2.0 while the pin said 4.3.0. --- specs/03-formatters.md | 5 +++-- src/polyswarm/client/live.py | 4 ++-- src/polyswarm/client/rules.py | 11 +++++++---- src/polyswarm/formatters/text.py | 2 +- tests/cli_test.py | 2 +- tests/formatter_hunt_fields_test.py | 11 ++++++----- 6 files changed, 20 insertions(+), 15 deletions(-) diff --git a/specs/03-formatters.md b/specs/03-formatters.md index ae4c507b..fcb91b29 100644 --- a/specs/03-formatters.md +++ b/specs/03-formatters.md @@ -142,8 +142,9 @@ either field) never raises `AttributeError`; an SDK without `.state` simply neve the known-good branch, which is the safe fallback — the pre-known-good rendering. That degradation is belt-and-braces, not a supported configuration: `.state` is load-bearing here with no substitute. Both attributes ship in SDK **4.1.0**, but the dependency floor is -`polyswarm_api>=4.4.0` — the pin's current value (moved there by the hunt-page change -set; 4.3.0 before it, by the #264 release bump); its *rationale* is two behaviours that landed in 4.2.0 and still hold +the value in `pyproject.toml` (see [05-sdk-contract.md](./05-sdk-contract.md) +§Current floor, which is authoritative — repeating the number here is what let this +line go stale before); its *rationale* is two behaviours that landed in 4.2.0 and still hold transitively, and [05-sdk-contract.md](./05-sdk-contract.md) §Current floor is authoritative. Those two fail silently on 4.1.0 (see [`05-sdk-contract.md`](./05-sdk-contract.md) §Version pin) — so diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index 101c4a6a..f175b889 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -33,7 +33,7 @@ def live_stop(ctx, ruleset_id): @live.command('feed', short_help='Get results from live hunt.') @click.option('-s', '--since', type=click.INT, default=86400, help='How far back in SECONDS to request results ' - '(default: 86400 — 24h, the window the ruleset badge counts). ' + '(default: 86400 — 24h). ' 'Pass 0 for no time filter at all.') # click.INT matches every other id option in the CLI and rejects a typo before # it reaches the server. @@ -75,7 +75,7 @@ def live_results(ctx, since, livescan_id, max_results, rule_name, family, api = ctx.obj['api'] output = ctx.obj['output'] # Both are redundant against the pinned SDK — `livescan_id` defaults to None - # and `as_result_bound` maps 0/negative/None to "no bound" — and the request + # and the SDK maps 0/negative/None to "no bound" — and the request # is byte-identical either way. Kept so a pre-existing invocation's call # shape does not move, which `test_plain_feed_forwards_neither_new_kwarg` # pins alongside the `--since 0` refactor hazard beside it. diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index d15db2da..1a2f50d1 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -95,10 +95,13 @@ def favorite(ctx, rule_id, unfavorite): else 'Favorite limit reached.')) # PolyswarmException exits 2; ClickException would exit 1, reserved # for no-results/not-found. - raise exceptions.PolyswarmException( - f'{budget} Unfavorite another ruleset first: ' - f'`polyswarm rules favorite --unfavorite`.' - ) from exc + # Only the star direction can hit the cap, and only it has a + # remedy — telling someone unstarring to unstar something else is + # advice that cannot help. + remedy = ('' if unfavorite else + ' Unfavorite another ruleset first: ' + '`polyswarm rules favorite --unfavorite`.') + raise exceptions.PolyswarmException(f'{budget}{remedy}') from exc raise diff --git a/src/polyswarm/formatters/text.py b/src/polyswarm/formatters/text.py index d5c5ec8b..cd87ea6e 100644 --- a/src/polyswarm/formatters/text.py +++ b/src/polyswarm/formatters/text.py @@ -310,7 +310,7 @@ def ruleset(self, result, write=True, contents=False): # is a stored counter its scheduled refresh maintains — a caller # cannot choose the window, so the label must not imply one), and # the marker says how fresh the stored number is. - output.append(self._white(f'New live results (last 24h): {result.new_results_count}')) + output.append(self._white(f'New live results: {result.new_results_count}')) if result.new_results_counted_at is not None: output.append(self._white(f'New-results count refreshed at: {result.new_results_counted_at}')) if contents: diff --git a/tests/cli_test.py b/tests/cli_test.py index f43a4046..b0ecc772 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -14,11 +14,11 @@ from polyswarm.client import polyswarm as client + vcr = vcr_.VCR(cassette_library_dir='tests/vcr', path_transformer=vcr_.VCR.ensure_suffix('.vcr')) - class BaseTestCase(TestCase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 277fd191..9a6218ef 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -50,9 +50,10 @@ def _hunt(**overrides): class FormatterHuntFieldsTest(TestCase): def _render(self, method, result, **kwargs): - out = io.StringIO() - getattr(text.TextOutput(color=False, output=out), method)(result, **kwargs) - return out.getvalue() + # write=False and join the returned lines, per specs/04 Style 3 — no + # stream, matching known_good_field_test.py. + return '\n'.join( + getattr(text.TextOutput(color=False), method)(result, write=False, **kwargs)) def test_ruleset_tracking_fields_render_with_zero_distinct_from_absent(self): rendered = self._render('ruleset', _ruleset( favorite=True, favorited_at='2026-08-20T12:00:00+00:00', rule_count=0, @@ -62,14 +63,14 @@ def test_ruleset_tracking_fields_render_with_zero_distinct_from_absent(self): assert 'Favorited at: 2026-08-20 12:00:00+00:00' in rendered assert 'Rules in ruleset: 0' in rendered assert 'Historical hunts triggered: 0' in rendered - assert 'New live results (last 24h): 3' in rendered + assert 'New live results: 3' in rendered def test_ruleset_staleness_marker_renders_beside_the_count(self): # The stored badge's marker: how fresh the number is. Rendered only # with a count (the server sends them together). rendered = self._render('ruleset', _ruleset( new_results_count=0, new_results_counted_at='2026-08-25T12:00:00+00:00')) - assert 'New live results (last 24h): 0' in rendered + assert 'New live results: 0' in rendered assert 'New-results count refreshed at: 2026-08-25 12:00:00+00:00' in rendered def test_ruleset_favorite_response_renders_state_and_budget(self): rendered = self._render('ruleset_favorite', resources.YaraRulesetFavorite( From d8905438bf9eef19031b13dc5d723cf0db0d4954 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 15:44:03 -0300 Subject: [PATCH 38/54] docs(specs): the badge label names no window, and the specs now agree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code stopped claiming a 24h window; two specs and the PR body did not. specs/03 still said the window is "the fixed 24 h product window, which the label names" — the label names nothing now, and the response carries no window field, so there was nothing left to name it. specs/02 justified the --since default as "matching the window the ruleset badge counts", which asserts a server constant neither repo pins. Also fixes the referent specs/02 shared with the help text before it: "the detail view deliberately does not carry it" reads as --livescan-id, and `rules view` does render Live Hunt Id. It means the badge. Adds the missing case for the list-shaped `errors` envelope. The guard is deliberate — only the mapping shape carries a machine-readable code, so a list-shaped refusal cannot be FAVORITE_LIMIT and must fall through to the generic path rather than raising on `.get`. Nothing drove that branch; now something does. And drops an `import io` left over from the write=False refactor. --- specs/02-commands.md | 2 +- specs/03-formatters.md | 4 ++-- tests/formatter_hunt_fields_test.py | 19 ++++++++++++++++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/specs/02-commands.md b/specs/02-commands.md index e811d7d9..861ef8a0 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -25,7 +25,7 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `report` (`report.py`) | Create/fetch/download reports; `prompt-config` subgroup; LLM reports | `report_create`, `report_wait_for`, `report_download`, `report_get`, `llm_report_{create,get,download}`, `prompt_config_{create,get,update,list}` | | `report-template` (`report_template.py`) | Manage report templates + logos | `report_template_{create,update,get,list}`, `report_template_logo_{download,upload}` | | `engine` → `votes` / `assertions` (`engine.py`) | Consolidated votes/assertions bundles per engine | `votes_{create,get,delete,list}`, `assertions_{create,get,delete,list}` | -| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **SECONDS** (default 86400 — 24h, matching the window the ruleset badge counts; `0` means no time filter at all), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry it; the badge counts the hunt across **every** community it runs in, public and private, while the feed shows one at a time, so a multi-community hunt lists fewer rows than the badge reports) and `--max-results` (stop after N; unset means every page, as before). Both are guaranteed by the pin (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor) and called directly; they are forwarded only when passed purely so a pre-existing invocation's call shape is unchanged — the request is identical either way | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | +| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **SECONDS** (default 86400 — 24h; `0` means no time filter at all), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry the badge; the badge counts the hunt across **every** community it runs in, public and private, while the feed shows one at a time, so a multi-community hunt lists fewer rows than the badge reports) and `--max-results` (stop after N; unset means every page, as before). Both are guaranteed by the pin (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor) and called directly; they are forwarded only when passed purely so a pre-existing invocation's call shape is unchanged — the request is identical either way | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | | `historical` (`historical.py`) | Historical hunts: CRUD + results | `historical_{get,create,update,list}`, `historical_delete_multiple`, `historical_delete_list`, `historical_results_multiple`, `historical_result`, `historical_results_delete` | | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | diff --git a/specs/03-formatters.md b/specs/03-formatters.md index fcb91b29..b72d2bf5 100644 --- a/specs/03-formatters.md +++ b/specs/03-formatters.md @@ -164,8 +164,8 @@ the attributes directly: - `favorite` is truthy-only ("Favorite: yes"): False and None both print nothing, deliberately indistinguishable. - `new_results_count` is the server's STORED badge (refreshed by its - scheduled job — the window is the fixed 24 h product window, which the - label names, since a caller cannot choose it): a number renders with its + scheduled job; the window is the server's and the response does not carry + it, so the label deliberately does not name one): a number renders with its `new_results_counted_at` staleness marker beside it; `None` (never refreshed / no live hunt) omits both lines. - `ruleset_favorite` renders the toggle response: `Favorite: yes/no`, the diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 9a6218ef..d7a87042 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -17,7 +17,6 @@ autospec'd mocks, so every call is signature-checked against the SDK the pin actually installs. """ -import io from unittest import TestCase, mock from click.testing import CliRunner @@ -307,6 +306,24 @@ def test_favorite_limit_without_counters_uses_the_server_message(self): assert 'None of None' not in result.output assert 'Favorite limit reached (5 of 5 used).' in result.output assert '--unfavorite' in result.output + def test_a_list_shaped_errors_envelope_does_not_crash_the_handler(self): + """The SDK carries a legacy LIST shape for `errors` alongside the + mapping. Only the mapping carries a machine-readable code, so a + list-shaped refusal cannot be FAVORITE_LIMIT — the `isinstance` guard + exists so such a refusal falls through to the generic path instead of + raising on `.get`. Still exit 2, still no traceback.""" + request = core.PolyswarmRequest(api=None, method='PUT', url='http://x') + request.errors = [{'code': 'FAVORITE_LIMIT', 'favorites_used': 5}] + refusal = exceptions.RequestException(request) + with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_favorite', + autospec=True, side_effect=refusal): + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'rules', 'favorite', '5']) + assert result.exit_code == 2, result.output + assert 'Traceback' not in result.output + def test_favorite_limit_on_a_request_without_result_still_has_no_traceback(self): # A Mock has every attribute, so the test above cannot fail on a missing # `.result`. This one uses a real object that genuinely lacks it — the From 00b632cfd721fc5170f54120f23b13998dbcd0de Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 15:44:48 -0300 Subject: [PATCH 39/54] docs: the comment asserted the window the label just stopped naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Left over from dropping the parenthetical: the code comment still stated the window as a fact the response does not carry. Says what is actually true now — the badge is a stored counter, the window is the server's and unstated, and the staleness marker is what tells a reader how current the number is. --- src/polyswarm/formatters/text.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/polyswarm/formatters/text.py b/src/polyswarm/formatters/text.py index cd87ea6e..140d4c1f 100644 --- a/src/polyswarm/formatters/text.py +++ b/src/polyswarm/formatters/text.py @@ -306,10 +306,10 @@ def ruleset(self, result, write=True, contents=False): if result.historical_hunt_count is not None: output.append(self._white(f'Historical hunts triggered: {result.historical_hunt_count}')) if result.new_results_count is not None: - # The window is the server's fixed 24 h product window (the badge - # is a stored counter its scheduled refresh maintains — a caller - # cannot choose the window, so the label must not imply one), and - # the marker says how fresh the stored number is. + # The badge is a stored counter the server's scheduled refresh + # maintains. The response carries no window and a caller cannot + # choose one, so the label must not imply a window; the marker + # below is what says how fresh the number is. output.append(self._white(f'New live results: {result.new_results_count}')) if result.new_results_counted_at is not None: output.append(self._white(f'New-results count refreshed at: {result.new_results_counted_at}')) From 2e3ba9c2476ee8143e2cf22a6eb2cf978a707aea Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 15:53:19 -0300 Subject: [PATCH 40/54] fix(rules): pass the favorite toggle by keyword so autospec can see it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `api.ruleset_favorite(rule_id, not unfavorite)` binds the boolean positionally, and so does the test's `assert_called_once_with(mock.ANY, 5, True)`. autospec checks the signature, but positional binding means a parameter inserted between `ruleset_id` and `favorite` would silently misroute the toggle with both the command and its test still green. Passing `favorite=` is what makes the signature check load-bearing, which is the stated reason autospec is used throughout this file. Docs from the same round: - specs/04 carried "never mock and replay the same path", which this PR deliberately crosses for FAVORITE_LIMIT. The duplication is worth keeping — the mock pins the message and exit code, the cassette pins where in the envelope the code lives — so the carve-out is now written down with the test that each half has to name. - The re-record steps did not mention that `test_ruleset_favorite_limit_text` needs the favorite budget already saturated. Re-recording it against a fresh stack yields a 200 and a cassette that tests nothing. - specs/05 says why the no-counters fallback is unit-pinned and not recorded: the server sends counters on every refusal, so no cassette can produce the envelope the fallback exists for. - specs/03's floor sentence claimed authoritativeness twice and left a dangling clause — an artifact of my own previous edit. - A `live.py` comment described the SDK mapping a negative bound, which `IntRange(min=0)` refuses before the SDK ever sees it. --- specs/03-formatters.md | 9 ++++----- specs/04-testing.md | 8 ++++++++ specs/05-sdk-contract.md | 2 +- src/polyswarm/client/live.py | 3 ++- src/polyswarm/client/rules.py | 2 +- tests/formatter_hunt_fields_test.py | 2 +- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/specs/03-formatters.md b/specs/03-formatters.md index b72d2bf5..adb49df8 100644 --- a/specs/03-formatters.md +++ b/specs/03-formatters.md @@ -142,11 +142,10 @@ either field) never raises `AttributeError`; an SDK without `.state` simply neve the known-good branch, which is the safe fallback — the pre-known-good rendering. That degradation is belt-and-braces, not a supported configuration: `.state` is load-bearing here with no substitute. Both attributes ship in SDK **4.1.0**, but the dependency floor is -the value in `pyproject.toml` (see [05-sdk-contract.md](./05-sdk-contract.md) -§Current floor, which is authoritative — repeating the number here is what let this -line go stale before); its *rationale* is two behaviours that landed in 4.2.0 and still hold -transitively, and [05-sdk-contract.md](./05-sdk-contract.md) §Current floor is -authoritative. Those two +the value in `pyproject.toml` — see [05-sdk-contract.md](./05-sdk-contract.md) +§Current floor, which is authoritative, since repeating the number here is what let this +line go stale before. Its *rationale* is two behaviours that landed in 4.2.0 and still hold +transitively; those two fail silently on 4.1.0 (see [`05-sdk-contract.md`](./05-sdk-contract.md) §Version pin) — so every supported install has them. `JSONOutput` needs no change — it dumps the resource's `.json`, which already carries the raw `state` and `known_good` keys. diff --git a/specs/04-testing.md b/specs/04-testing.md index 293950a4..c8ee6cc9 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -8,6 +8,8 @@ How the CLI is tested: the `CliRunner` harness, the two mocking styles (SDK-boun - **Anything that is command behaviour is driven through `click.testing.CliRunner`** — argument parsing, the SDK call, the wiring, the exit code: exercise the real command tree, never an internal function standing in for it. No live PolySwarm stack is required. The one sanctioned exception is pure rendering logic — see [Style 3](#style-3--formatter-unit-tests). - **Mock at the SDK boundary, or replay HTTP with VCR — never both for the same path.** A test either patches `polyswarm_api.api.PolyswarmAPI.` (unit-style) or lets VCR replay recorded HTTP (end-to-end). The CLI's own code is exercised either way. + + **One sanctioned exception, where the two pin different things:** a refusal whose *rendering* is a CLI decision and whose *envelope shape* is an SDK contract. `FAVORITE_LIMIT` is covered both ways deliberately — the SDK-boundary mock pins the message and the exit code, the recorded 400 pins where in the envelope the machine-readable code actually lives. Neither substitutes for the other, and dropping the cassette would leave the CLI asserting a shape nothing checks. Use this only when you can name what each half pins. - **VCR is an efficiency cache, not a load-bearing requirement.** The suite must pass against a live e2e stack with VCR off. Don't hardcode `record_mode='none'`; if a test only works against its recorded cassette, that's a bug in the test. - **Never `cp` a cassette from a sibling test, never hand-edit cassette bytes.** Re-record against a live stack. @@ -39,6 +41,12 @@ Helpers in `cli_test.py`: `_run_cli(args)` invokes the command tree under a cass ### Re-recording a cassette +Some cassettes need a **stack state**, not just a live stack, and the steps below will +not produce it on their own. `test_ruleset_favorite_limit_text` needs the team's favorite +budget already saturated, because it records the server's refusal; re-recording it against +a fresh stack yields a 200 and a cassette that no longer tests anything. Set the state +first, then record. + ```bash rm tests/vcr/.vcr # (and regenerate .click from the new run) pytest tests/cli_test.py:::: # records against whatever stack your env points at diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index 916e8a58..901a2a1f 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -18,7 +18,7 @@ How the CLI depends on the `polyswarm-api` SDK: which parts of the SDK's public | `from polyswarm_api.api import PolyswarmAPI` | Base class of the `Polyswarm` wrapper (`src/polyswarm/polyswarm.py`). | | `from polyswarm_api import settings` | Defaults: `DEFAULT_SCAN_TIMEOUT`, `DEFAULT_REPORT_TIMEOUT`, etc. | | `from polyswarm_api import resources` | Result-parser classes for power-user calls (e.g. `resources.ArtifactInstance`); resource attributes the formatters read. | -| `from polyswarm_api import exceptions as api_exceptions` | Caught in `ExceptionHandlingGroup` and `utils.parallel_executor` (`NoResultsException`, `NotFoundException`, `FailedInstanceException`, `PolyswarmException`). Also `RequestException`, caught by `rules favorite` (`client/rules.py`) to read the machine-readable `FAVORITE_LIMIT` refusal off `exc.request.errors['code']` — and, when the envelope carries no counters, `exc.request.json['result']` as the server's own message. Note the spelling: the request object exposes the response envelope as `.json` and keeps only a private `._result`, so `exc.request.result` is not a thing — reading it yields `None` silently. The SDK does not raise a typed exception for that refusal by design: `.request.errors` is a plain dict the server's error envelope populates, pinned end-to-end by `tests/cli_test.py::test_ruleset_favorite_limit_text` against a real recorded 400 (not a hand-built mock). That cassette's envelope carries the counters, so it pins the `.errors` branch; the no-counters fallback is unit-pinned against a real `PolyswarmRequest` instead. | +| `from polyswarm_api import exceptions as api_exceptions` | Caught in `ExceptionHandlingGroup` and `utils.parallel_executor` (`NoResultsException`, `NotFoundException`, `FailedInstanceException`, `PolyswarmException`). Also `RequestException`, caught by `rules favorite` (`client/rules.py`) to read the machine-readable `FAVORITE_LIMIT` refusal off `exc.request.errors['code']` — and, when the envelope carries no counters, `exc.request.json['result']` as the server's own message. Note the spelling: the request object exposes the response envelope as `.json` and keeps only a private `._result`, so `exc.request.result` is not a thing — reading it yields `None` silently. The SDK does not raise a typed exception for that refusal by design: `.request.errors` is a plain dict the server's error envelope populates, pinned end-to-end by `tests/cli_test.py::test_ruleset_favorite_limit_text` against a real recorded 400 (not a hand-built mock). That cassette's envelope carries the counters, so it pins the `.errors` branch; the no-counters fallback is unit-pinned against a real `PolyswarmRequest` instead. It cannot be cassette-pinned: the server sends the counters on every `FAVORITE_LIMIT`, so the envelope the fallback exists for is one no recording can produce. The fallback is defensive against a server that omits them, and the unit test is what fixes the spelling it reads. | | `from polyswarm_api.core import parse_isoformat` | Date rendering in `formatters/text.py`. | | `import polyswarm_api` (`__version__`) | `--api-version`. | diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index f175b889..38dd0292 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -75,7 +75,8 @@ def live_results(ctx, since, livescan_id, max_results, rule_name, family, api = ctx.obj['api'] output = ctx.obj['output'] # Both are redundant against the pinned SDK — `livescan_id` defaults to None - # and the SDK maps 0/negative/None to "no bound" — and the request + # and the SDK maps 0/None to "no bound" (a negative never reaches it — + # IntRange(min=0) refuses one at the interface) — and the request # is byte-identical either way. Kept so a pre-existing invocation's call # shape does not move, which `test_plain_feed_forwards_neither_new_kwarg` # pins alongside the `--since 0` refactor hazard beside it. diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index 1a2f50d1..081b21fe 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -72,7 +72,7 @@ def favorite(ctx, rule_id, unfavorite): api = ctx.obj['api'] output = ctx.obj['output'] try: - output.ruleset_favorite(api.ruleset_favorite(rule_id, not unfavorite)) + output.ruleset_favorite(api.ruleset_favorite(rule_id, favorite=not unfavorite)) except api_exceptions.RequestException as exc: # `exc.request` needs no guard: __init__ always assigns it, and a None # request flows safely through the getattr. diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index d7a87042..dd2ec2f1 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -256,7 +256,7 @@ def _response(favorite): def test_favorite_calls_the_sdk_and_renders_the_budget(self): result, toggle = self._invoke(['5'], return_value=self._response(True)) assert result.exit_code == 0, result.output - toggle.assert_called_once_with(mock.ANY, 5, True) + toggle.assert_called_once_with(mock.ANY, 5, favorite=True) assert 'Favorite: yes' in result.output assert 'Favorites used: 1 of 5' in result.output def test_unfavorite_flag_flips_the_boolean(self): From f4cc948a5aaac200251ab62b4ff45d0338589517 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 16:01:28 -0300 Subject: [PATCH 41/54] docs(specs): resolve the VCR-off invariant against the one test that cannot meet it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit specs/04 says the suite must pass against a live stack with VCR off, and "if a test only works against its recorded cassette, that's a bug in the test". The note I added last commit conceded the opposite for the favorite-limit cassette and put the concession in the re-recording section — which documents how to record, not how a VCR-off run is meant to pass. Both statements could not be true. Resolved on the invariant itself, with the mechanism named: the assertion needs a stack STATE the suite cannot create, because saturating the favorite budget on a shared stack consumes every slot the other favorite tests need. The paired SDK reached the same conclusion for the same refusal and used a stubbed transport rather than a recording. The carve-out also names what covers the ground when VCR is off — the SDK-boundary mock pins the message and exit code, the SDK's respx suite pins the envelope — so the exception is bounded rather than open. Also trims spec prose that narrated this PR instead of stating the contract: a line in specs/01 explaining that the section used to name the wrong module, and a specs/05 clause recounting which release moved the floor and how far the header lagged. A reader six months out wants the current contract. --- specs/01-architecture.md | 2 +- specs/04-testing.md | 12 +++++++----- specs/05-sdk-contract.md | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/specs/01-architecture.md b/specs/01-architecture.md index f270b451..cbd2d6ec 100644 --- a/specs/01-architecture.md +++ b/specs/01-architecture.md @@ -75,7 +75,7 @@ The catalogue of groups and the SDK methods each wraps is in [`02-commands.md`]( ## Support — `utils.py`, `exceptions.py` - **`utils.py`** — `parallelize`/`parallel_executor` (thread-pool fan-out with per-item exception aggregation: collects results, logs per-item no-results, raises an aggregate `NoResultsException`/`NotFoundException`/`InternalFailureException` at the end) and `parallel_executor_iterable_results` (the same, for SDK methods that return generators — it materialises each generator inside the worker so per-item exception handling still fires), plus `collect_files` and the detection helpers (`is_valid_id`, `is_ip`, `is_domain`, `is_url`). -- **`client/utils.py`** — `parse_hashes` and the click parameter validators (`validate_id`, `validate_hash(es)`, `validate_key`, …). Note the module is `client/utils.py`, not the top-level `utils.py` above — the two are distinct and this section named the wrong one until the hunt-page change. +- **`client/utils.py`** — `parse_hashes` and the click parameter validators (`validate_id`, `validate_hash(es)`, `validate_key`, …). Note the module is `client/utils.py`, not the top-level `utils.py` above — the two are distinct and easily confused. - **`exceptions.py`** — the CLI's own hierarchy, **distinct from the SDK's**: `PolyswarmException` → `NoResultsException`, `NotFoundException`, `InternalFailureException`, `PartialResultsException`. `ExceptionHandlingGroup` catches both these and the SDK's `api_exceptions.*`. ## Lifecycle of a command (end to end) diff --git a/specs/04-testing.md b/specs/04-testing.md index c8ee6cc9..42bf5a2d 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -11,6 +11,9 @@ How the CLI is tested: the `CliRunner` harness, the two mocking styles (SDK-boun **One sanctioned exception, where the two pin different things:** a refusal whose *rendering* is a CLI decision and whose *envelope shape* is an SDK contract. `FAVORITE_LIMIT` is covered both ways deliberately — the SDK-boundary mock pins the message and the exit code, the recorded 400 pins where in the envelope the machine-readable code actually lives. Neither substitutes for the other, and dropping the cassette would leave the CLI asserting a shape nothing checks. Use this only when you can name what each half pins. - **VCR is an efficiency cache, not a load-bearing requirement.** The suite must pass against a live e2e stack with VCR off. Don't hardcode `record_mode='none'`; if a test only works against its recorded cassette, that's a bug in the test. + + **The one exception is an assertion that needs a stack STATE the suite cannot create**, and it must be named here rather than left implicit. `test_ruleset_favorite_limit_text` records the server refusing at the favorite cap; producing a genuinely full budget on a shared stack would consume every slot the other favorite tests need, so a VCR-off run gets a 200 and the assertions fail. The paired SDK reached the same conclusion for the same refusal and pinned it with a stubbed transport rather than a recording. + What covers this ground when VCR is off: the SDK-boundary mock pins the message and the exit code, and the SDK's own respx suite pins the envelope shape. The cassette adds the end-to-end seam between them — worth having, but it is the one test that cannot stand alone against a live stack. - **Never `cp` a cassette from a sibling test, never hand-edit cassette bytes.** Re-record against a live stack. ## Running the suite @@ -41,11 +44,10 @@ Helpers in `cli_test.py`: `_run_cli(args)` invokes the command tree under a cass ### Re-recording a cassette -Some cassettes need a **stack state**, not just a live stack, and the steps below will -not produce it on their own. `test_ruleset_favorite_limit_text` needs the team's favorite -budget already saturated, because it records the server's refusal; re-recording it against -a fresh stack yields a 200 and a cassette that no longer tests anything. Set the state -first, then record. +Some cassettes need a **stack state**, not just a live stack (see the VCR invariant +above). `test_ruleset_favorite_limit_text` needs the team's favorite budget already +saturated; re-recording it against a fresh stack yields a 200 and a cassette that no +longer tests anything. Set the state first, then record. ```bash rm tests/vcr/.vcr # (and regenerate .click from the new run) diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index 901a2a1f..f3910a8f 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -81,7 +81,7 @@ When a CLI feature needs an SDK surface that doesn't exist yet: ### Current floor — `polyswarm_api>=4.4.0` -The floor moved to **4.4.0** with the hunt-page change set; before that **4.3.0** with #264 (`pyproject.toml` has said `>=4.3.0` since then; this header lagged at 4.2.0 — the drift itself is why the floor lives in ONE authoritative place, the pin, and this doc must follow it). The 4.2.0 rationale below still holds transitively; on 4.1.0 both behaviours fail *silently*, which is why the floor is a hard requirement rather than a preference: +The floor is whatever `pyproject.toml` pins; this header follows it. It lives in ONE authoritative place for a reason — a copy here drifted behind the pin once already. The 4.2.0 rationale below still holds transitively; on 4.1.0 both behaviours fail *silently*, which is why the floor is a hard requirement rather than a preference: 1. **`llm_report_create` sends the client's community.** 4.2.0 passes `community=self.community` when it builds the report resource; 4.1.0 omits it. `report llm-create` (`client/report.py`) supplies no community of its own — it relies entirely on the client's — so on 4.1.0 a report requested for a sample in a private community is created without one. No error, wrong resource. 2. **A streaming download answered `204 No Content` raises `NoResultsException`.** The streaming path bypasses `parse_response`, so the 204 has to be raised by the session itself; 4.2.0 does that, 4.1.0 has no such raise anywhere in its session. The CLI's `download` commands depend on it for the no-results **exit code `1`** (§No-results signalling); against 4.1.0 an empty response reads as a successful download and exits `0`. From 1e3d6acce2f94aa03ff6af93be8b8ef44f50fde1 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 16:14:04 -0300 Subject: [PATCH 42/54] docs: reconcile AGENTS.md with the spec, and record what the exit code rests on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three documentation gaps, each one a thing a future editor could get wrong without the code telling them. AGENTS.md asserted the VCR-off invariant unconditionally while specs/04 now carves out one named exception to it. AGENTS.md is the first file a contributor reads, so the contradiction resolves the wrong way by default. It now points at the argued exception and says that adding a second one means writing it down there too. specs/01's exit-code table describes the transport branch as matching legacy `requests`. It also matches the SDK's own RequestException, which shares that bare name and therefore satisfies the ancestry-name test. That exception exits 2 rather than 1-with-"contact support" purely because the PolyswarmException clause is matched BEFORE the transport branch — an ordering the table did not mention, so reordering the clauses looked free. It is not: it silently changes the exit code of every SDK request refusal. specs/03's formatter inventory listed the ruleset methods but not ruleset_favorite, which the same spec documents in detail further down. --- AGENTS.md | 2 +- specs/01-architecture.md | 8 ++++++++ specs/03-formatters.md | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eec96c6a..151a4fba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ Details in [`specs/04-testing.md`](./specs/04-testing.md). The shape: - Tests live in `tests/` and drive command behaviour through `click.testing.CliRunner` — no live PolySwarm stack needed. - Two styles for that: **SDK-boundary mocks** (`mock.patch('polyswarm_api.api.PolyswarmAPI.')`, e.g. `tests/field_property_test.py`) and **VCR cassettes** (`tests/cli_test.py`) that replay recorded HTTP for end-to-end CLI runs. Cassettes live in `tests/vcr/` (`.vcr` for the HTTP interactions, `.click` for the expected rendered output). - Pure rendering logic — which line a given field set produces, no command-tree behaviour — is instead unit-tested against the formatter directly (e.g. `tests/known_good_field_test.py`); see the spec's *Style 3* for when that's the right choice. -- VCR is an **efficiency cache, not a requirement** — the suite must pass against a live e2e stack with VCR off. Re-record a cassette by deleting it and re-running the test against a live stack; never hand-edit a cassette or `cp` one from a sibling test. +- VCR is an **efficiency cache, not a requirement** — the suite must pass against a live e2e stack with VCR off, with one named exception (an assertion needing a stack state the suite cannot create) argued in [`specs/04-testing.md`](./specs/04-testing.md) §Invariants. Adding a second one means writing it down there too. Re-record a cassette by deleting it and re-running the test against a live stack; never hand-edit a cassette or `cp` one from a sibling test. ## Commit + PR hygiene diff --git a/specs/01-architecture.md b/specs/01-architecture.md index cbd2d6ec..b8880793 100644 --- a/specs/01-architecture.md +++ b/specs/01-architecture.md @@ -38,6 +38,14 @@ The console-script entry (`__main__.py`) calls `polyswarm_cli(prog_name='polyswa The transport-error branch matches by **ancestry class name** — it intersects `{c.__name__ for c in type(e).__mro__}` with `{'HTTPError', 'RequestException', 'ConnectionError', 'SSLError'}` — because those classes come from the SDK's HTTP dependency (`httpx`; `requests` historically) and shouldn't be imported here directly. `httpx` roots every request/transport/status error at `HTTPError`, so ancestry matching covers all its leaf classes (`ConnectError`, `ReadTimeout`, `RemoteProtocolError`, `ProxyError`, …) without enumerating them. +**The order of the `except` clauses is load-bearing, not stylistic.** The SDK has its own +`api_exceptions.RequestException`, which subclasses `PolyswarmException` but shares the bare +name `requests` used — so it satisfies the ancestry-name test above and would take the +transport branch (exit `1`, "contact support") if it ever reached it. It exits `2` only +because the `PolyswarmException` clause is matched **before** the transport branch. Reordering +those clauses silently changes the exit code of every SDK request refusal, `FAVORITE_LIMIT` +included; `ExitCodeHierarchyTest` pins the subclass relation the ordering rests on. + ## The SDK wrapper — `polyswarm.py` `class Polyswarm(PolyswarmAPI)` subclasses the SDK's sync client to add **CLI-only** behaviour the SDK has no reason to ship: diff --git a/specs/03-formatters.md b/specs/03-formatters.md index adb49df8..e3ac6e51 100644 --- a/specs/03-formatters.md +++ b/specs/03-formatters.md @@ -14,7 +14,7 @@ How command output is rendered: the `BaseOutput` interface, the concrete formatt ## The interface — `base.py` -`BaseOutput(output, **kwargs)` holds the output stream and exposes a method per resource type, each raising `NotImplementedError`. The set includes (non-exhaustive): `artifact_instance`, `historical_result`, `hunt`, `hunt_deletion`, `local_artifact`, `ruleset`, `ioc`, `iocs`, `known_host`, `metadata`, `artifact_metadata`, `tag_link`, `family`, `tag`, `known_good`, `sandbox_list`, `sandbox_task`, `sandbox_tasks`, `bundle_task`, `sample`. Concrete formatters add further methods as command families grow (e.g. `report_task`, `webhook`, `llm_prompt_config`, `metadata_field_properties`); keep `text` and `json` in sync. +`BaseOutput(output, **kwargs)` holds the output stream and exposes a method per resource type, each raising `NotImplementedError`. The set includes (non-exhaustive): `artifact_instance`, `historical_result`, `hunt`, `hunt_deletion`, `local_artifact`, `ruleset`, `ruleset_favorite`, `ioc`, `iocs`, `known_host`, `metadata`, `artifact_metadata`, `tag_link`, `family`, `tag`, `known_good`, `sandbox_list`, `sandbox_task`, `sandbox_tasks`, `bundle_task`, `sample`. Concrete formatters add further methods as command families grow (e.g. `report_task`, `webhook`, `llm_prompt_config`, `metadata_field_properties`); keep `text` and `json` in sync. ## Concrete formatters From f70ab8fedd5d6f419cfa232d5c1a7c47a76388ac Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 16:14:05 -0300 Subject: [PATCH 43/54] fix(live): give --since the same negative guard as --max-results, and make the guards testable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --max-results carried IntRange(min=0) with a stated rationale — 0 is meaningful, a negative is not, and bare INT forwards it to the server. --since two lines up had the identical property (0 means "no time filter" and is asserted as such) and stayed bare INT, so `live feed --since -1` was forwarded verbatim. Adding the guard surfaced a worse problem in the tests that were supposed to cover it. All three interface-refusal tests asserted only `exit_code != 0`. An unvalidated value does not stop at the interface — it reaches the network, the connection fails on its own, and the process exits non-zero for that reason instead. Verified by reverting each guard: every one of the three tests still passed with its guard removed. They were pinning nothing. They now assert the refusal happened at parse time: exit 2 with a usage error naming the option, which cannot be produced by a request that was never supposed to be made. Re-verified the same way — with the guards reverted all three fail, and with them restored the suite is green. --- src/polyswarm/client/live.py | 4 +++- tests/formatter_hunt_fields_test.py | 26 ++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index 38dd0292..f400dd70 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -31,7 +31,9 @@ def live_stop(ctx, ruleset_id): @live.command('feed', short_help='Get results from live hunt.') -@click.option('-s', '--since', type=click.INT, default=86400, +# IntRange(min=0) for the same reason as --max-results below: 0 is meaningful +# (no time filter), a negative is not, and bare INT would forward it. +@click.option('-s', '--since', type=click.IntRange(min=0), default=86400, help='How far back in SECONDS to request results ' '(default: 86400 — 24h). ' 'Pass 0 for no time filter at all.') diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index dd2ec2f1..fd7cf41f 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -213,19 +213,41 @@ def test_zero_since_IS_forwarded_unlike_zero_max_results(self): assert live_feed.call_args[0][1] == 0 + @staticmethod + def _assert_refused_at_parse_time(result, option): + """A non-zero exit is NOT enough here. An unvalidated value is forwarded + and the request then fails on its own (no such host), which also exits + non-zero — so `exit_code != 0` passes whether or not the guard exists. + Click refuses a bad value with a UsageError before any request is made: + exit 2, and a message naming the option. Assert that instead. + """ + assert result.exit_code == 2, result.output + assert 'Invalid value' in result.output, result.output + assert option in result.output, result.output + def test_a_negative_max_results_is_refused_at_the_interface(self): result = CliRunner().invoke( client.polyswarm_cli, ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', 'live', 'feed', '--max-results', '-1']) - assert result.exit_code != 0 + self._assert_refused_at_parse_time(result, '--max-results') + + def test_a_negative_since_is_refused_at_the_interface(self): + """--since 0 is meaningful (no time filter, asserted above) but a + negative is not, and it would be forwarded verbatim to the server. + Same guard as --max-results, for the same reason.""" + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'live', 'feed', '--since', '-1']) + self._assert_refused_at_parse_time(result, '--since') def test_a_non_numeric_livescan_id_is_refused_before_the_server(self): result = CliRunner().invoke( client.polyswarm_cli, ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', 'live', 'feed', '--livescan-id', 'not-an-id']) - assert result.exit_code != 0 + self._assert_refused_at_parse_time(result, '--livescan-id') From b5b80c67cf22dba24e3abc0805c82d0836b02efc Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 16:18:06 -0300 Subject: [PATCH 44/54] test: make the non-limit refusal test assert the fall-through it is named for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_other_refusals_still_raise asserted exit 2 and that the raw FAVORITE_LIMIT code did not leak into the output. Neither fact distinguishes a refusal that fell through from one the handler swallowed: both paths exit 2, and the handler's own message says "Favorite limit reached", which does not contain the literal string the test looked for. Verified by forcing the branch to treat every refusal as the limit case — the test still passed. It now asserts the limit-specific message is absent, which does fail under that same injection, and keeps the raw-code and traceback assertions alongside it. Checked the neighbouring list-shaped test the same way before touching it: that one already discriminates (removing the isinstance guard makes a list .get() raise, and the traceback reaches the captured output), so it is left alone. --- tests/formatter_hunt_fields_test.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index fd7cf41f..54a70b6b 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -376,7 +376,15 @@ def test_other_refusals_still_raise(self): ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', 'rules', 'favorite', '5']) assert result.exit_code == 2 # PolyswarmException family - assert 'FAVORITE_LIMIT' not in result.output + # The claim is that a non-limit refusal FALLS THROUGH, so the thing to + # assert is the absence of the limit-specific message. Asserting only + # that the raw code doesn't leak did not test that: the fall-through + # message doesn't contain the literal 'FAVORITE_LIMIT' either, so the + # test passed with the branch forced to treat every refusal as the + # limit case. Verified by doing exactly that. + assert 'Favorite limit reached' not in result.output + assert 'FAVORITE_LIMIT' not in result.output # nor the raw code + assert 'Traceback' not in result.output class ExitCodeHierarchyTest(TestCase): """`rules favorite`'s non-limit refusals exit 2, and that holds only because From daa1d33659f462323d4289eba3d5dbcf11f1a2cc Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 16:19:29 -0300 Subject: [PATCH 45/54] test: build the non-limit refusal the way production raises it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test constructed RequestException with the request alone, which stringifies to empty — so the fall-through rendered a blank line and the only assertions available were negative ones about what was absent. Every raise site in the SDK passes a message alongside the request, so the test was not exercising a shape the CLI ever sees. With a message, the test can assert the positive fact it exists to establish: the server's own explanation reaches the user, unmodified, on the path that does not belong to the limit handler. --- tests/formatter_hunt_fields_test.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 54a70b6b..28b97220 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -368,7 +368,11 @@ class BareRequest: def test_other_refusals_still_raise(self): request = mock.Mock() request.errors = None - refusal = exceptions.RequestException(request) + # Every production raise site passes a message alongside the request + # (`RequestException(request, err_msg)`), so build it that way: with no + # message the refusal renders blank and there is nothing positive left + # to assert. + refusal = exceptions.RequestException(request, 'Ruleset not found.') with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_favorite', autospec=True, side_effect=refusal): result = CliRunner().invoke( @@ -376,12 +380,13 @@ def test_other_refusals_still_raise(self): ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', 'rules', 'favorite', '5']) assert result.exit_code == 2 # PolyswarmException family - # The claim is that a non-limit refusal FALLS THROUGH, so the thing to - # assert is the absence of the limit-specific message. Asserting only - # that the raw code doesn't leak did not test that: the fall-through - # message doesn't contain the literal 'FAVORITE_LIMIT' either, so the - # test passed with the branch forced to treat every refusal as the - # limit case. Verified by doing exactly that. + # The claim is that a non-limit refusal FALLS THROUGH, so assert the + # server's own message reaches the user and the limit-specific message + # does not. Asserting only that the raw code stayed out of the output + # tested neither: the handler's message doesn't contain the literal + # 'FAVORITE_LIMIT' either, so the test passed with the branch forced to + # treat every refusal as the limit case. Verified by doing exactly that. + assert 'Ruleset not found.' in result.output assert 'Favorite limit reached' not in result.output assert 'FAVORITE_LIMIT' not in result.output # nor the raw code assert 'Traceback' not in result.output From ae57555af0bd40fe6050e728af8eab89ef5795af Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 16:31:21 -0300 Subject: [PATCH 46/54] Revert "docs(specs): resolve the VCR-off invariant against the one test that cannot meet it" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The carve-out was wrong three ways, and reverting is cheaper than repairing it. Wrong on the count. It named ONE test that cannot pass with VCR off. Three other cassettes added here pin server-generated values a live stack will not reproduce (`Favorited at: 2026-08-26 ...`, `Favorites used: 2 of 5`), because .click snapshots are exact string equality. So is a cassette already on develop, which pins `Created at: 2022-05-26 ...`. The next contributor would have counted wrong. Wrong on the mechanism. It said a VCR-off run "gets a 200 and the assertions fail". The recorded ruleset id does not exist on a fresh stack, so such a run 404s and exits 1. Same conclusion, wrong reason — and the re-recording steps were written around that wrong reason. Wrong on the venue. The condition it described is not something this change introduces: exact-match cassettes have never been runnable against a fresh stack. Weakening a project-wide invariant to accommodate a pre-existing condition is a maintainer decision, not a line item in a feature PR. AGENTS.md goes back to develop's wording verbatim. The tension between the invariant and snapshot-style cassettes is real and stays on the record as-is, for a decision made on its own terms. Also drops the claim that dropping the cassette "would leave the CLI asserting a shape nothing checks" — the paired SDK's respx suite does check it. What the cassette actually pins is the seam between the two, which is what it now says. --- AGENTS.md | 2 +- specs/04-testing.md | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 151a4fba..eec96c6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ Details in [`specs/04-testing.md`](./specs/04-testing.md). The shape: - Tests live in `tests/` and drive command behaviour through `click.testing.CliRunner` — no live PolySwarm stack needed. - Two styles for that: **SDK-boundary mocks** (`mock.patch('polyswarm_api.api.PolyswarmAPI.')`, e.g. `tests/field_property_test.py`) and **VCR cassettes** (`tests/cli_test.py`) that replay recorded HTTP for end-to-end CLI runs. Cassettes live in `tests/vcr/` (`.vcr` for the HTTP interactions, `.click` for the expected rendered output). - Pure rendering logic — which line a given field set produces, no command-tree behaviour — is instead unit-tested against the formatter directly (e.g. `tests/known_good_field_test.py`); see the spec's *Style 3* for when that's the right choice. -- VCR is an **efficiency cache, not a requirement** — the suite must pass against a live e2e stack with VCR off, with one named exception (an assertion needing a stack state the suite cannot create) argued in [`specs/04-testing.md`](./specs/04-testing.md) §Invariants. Adding a second one means writing it down there too. Re-record a cassette by deleting it and re-running the test against a live stack; never hand-edit a cassette or `cp` one from a sibling test. +- VCR is an **efficiency cache, not a requirement** — the suite must pass against a live e2e stack with VCR off. Re-record a cassette by deleting it and re-running the test against a live stack; never hand-edit a cassette or `cp` one from a sibling test. ## Commit + PR hygiene diff --git a/specs/04-testing.md b/specs/04-testing.md index 42bf5a2d..1c708150 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -9,11 +9,9 @@ How the CLI is tested: the `CliRunner` harness, the two mocking styles (SDK-boun - **Anything that is command behaviour is driven through `click.testing.CliRunner`** — argument parsing, the SDK call, the wiring, the exit code: exercise the real command tree, never an internal function standing in for it. No live PolySwarm stack is required. The one sanctioned exception is pure rendering logic — see [Style 3](#style-3--formatter-unit-tests). - **Mock at the SDK boundary, or replay HTTP with VCR — never both for the same path.** A test either patches `polyswarm_api.api.PolyswarmAPI.` (unit-style) or lets VCR replay recorded HTTP (end-to-end). The CLI's own code is exercised either way. - **One sanctioned exception, where the two pin different things:** a refusal whose *rendering* is a CLI decision and whose *envelope shape* is an SDK contract. `FAVORITE_LIMIT` is covered both ways deliberately — the SDK-boundary mock pins the message and the exit code, the recorded 400 pins where in the envelope the machine-readable code actually lives. Neither substitutes for the other, and dropping the cassette would leave the CLI asserting a shape nothing checks. Use this only when you can name what each half pins. + **One sanctioned exception, where the two pin different things:** a refusal whose *rendering* is a CLI decision and whose *envelope shape* is an SDK contract. `FAVORITE_LIMIT` is covered both ways deliberately — the SDK-boundary mock pins the message and the exit code, the recorded 400 pins where in the envelope the machine-readable code actually lives. Neither substitutes for the other: the mock cannot notice either side renaming the envelope key, and the SDK's own respx suite checks that key without ever exercising this CLI's handler. The cassette is what pins the seam between them. Use this only when you can name what each half pins. - **VCR is an efficiency cache, not a load-bearing requirement.** The suite must pass against a live e2e stack with VCR off. Don't hardcode `record_mode='none'`; if a test only works against its recorded cassette, that's a bug in the test. - **The one exception is an assertion that needs a stack STATE the suite cannot create**, and it must be named here rather than left implicit. `test_ruleset_favorite_limit_text` records the server refusing at the favorite cap; producing a genuinely full budget on a shared stack would consume every slot the other favorite tests need, so a VCR-off run gets a 200 and the assertions fail. The paired SDK reached the same conclusion for the same refusal and pinned it with a stubbed transport rather than a recording. - What covers this ground when VCR is off: the SDK-boundary mock pins the message and the exit code, and the SDK's own respx suite pins the envelope shape. The cassette adds the end-to-end seam between them — worth having, but it is the one test that cannot stand alone against a live stack. - **Never `cp` a cassette from a sibling test, never hand-edit cassette bytes.** Re-record against a live stack. ## Running the suite From 41a7b6e4c8309efdf9abeaf89a465d44df439390 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 16:31:22 -0300 Subject: [PATCH 47/54] test: close two branches that no test reached, and stop asserting a digit count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The envelope-spelling test assigned request.json itself, so it could not detect the rename it was written to detect — its own comment explains that a real request was chosen over a Mock because "a Mock fabricates whatever attribute it is asked for", and assigning the attribute does the same thing. It now pins the spelling on the untouched request first, before assigning: .json present, the mistaken .result spelling absent. Verified by renaming the attribute across the SDK (the class annotation as well as the assignments) and watching it fail. The empty-remedy arm of the FAVORITE_LIMIT handler had never executed: every test reaching that branch invoked without --unfavorite, so inverting the conditional would not have failed one. Verified by inverting it. Now covered. The known-good getattr guard carried the rationale this branch spent its commits removing — "a CLI running against an older installed SDK won't have them". Those attributes ship three releases below the floor, so the pin already guarantees them; the guard survives as belt-and-braces for an unsupported configuration, which is what the comment now says. Left as-is it invites both mistakes: deleting the guard, or adding siblings for versions the floor covers. --livescan-id's help promised "a 17-digit number"; this repo's own cassettes carry 16-digit ids. specs/02 records that a negative --since is now refused at parse time rather than forwarded, and that exit 2 does not identify a server refusal — click uses it for usage errors too. specs/05's cutoff gains the release-note obligation for behaviour changes to existing invocations, since there is no CHANGELOG to carry them. --- specs/02-commands.md | 4 ++-- specs/05-sdk-contract.md | 6 ++++++ src/polyswarm/client/live.py | 4 ++-- src/polyswarm/formatters/text.py | 6 ++++-- tests/formatter_hunt_fields_test.py | 26 +++++++++++++++++++++++++- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/specs/02-commands.md b/specs/02-commands.md index 861ef8a0..98cf8d9d 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -25,12 +25,12 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `report` (`report.py`) | Create/fetch/download reports; `prompt-config` subgroup; LLM reports | `report_create`, `report_wait_for`, `report_download`, `report_get`, `llm_report_{create,get,download}`, `prompt_config_{create,get,update,list}` | | `report-template` (`report_template.py`) | Manage report templates + logos | `report_template_{create,update,get,list}`, `report_template_logo_{download,upload}` | | `engine` → `votes` / `assertions` (`engine.py`) | Consolidated votes/assertions bundles per engine | `votes_{create,get,delete,list}`, `assertions_{create,get,delete,list}` | -| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **SECONDS** (default 86400 — 24h; `0` means no time filter at all), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry the badge; the badge counts the hunt across **every** community it runs in, public and private, while the feed shows one at a time, so a multi-community hunt lists fewer rows than the badge reports) and `--max-results` (stop after N; unset means every page, as before). Both are guaranteed by the pin (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor) and called directly; they are forwarded only when passed purely so a pre-existing invocation's call shape is unchanged — the request is identical either way | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | +| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **SECONDS** (default 86400 — 24h; `0` means no time filter at all, and a negative is refused at parse time rather than forwarded), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry the badge; the badge counts the hunt across **every** community it runs in, public and private, while the feed shows one at a time, so a multi-community hunt lists fewer rows than the badge reports) and `--max-results` (stop after N; unset means every page, as before). Both are guaranteed by the pin (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor) and called directly; they are forwarded only when passed purely so a pre-existing invocation's call shape is unchanged — the request is identical either way | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | | `historical` (`historical.py`) | Historical hunts: CRUD + results | `historical_{get,create,update,list}`, `historical_delete_multiple`, `historical_delete_list`, `historical_results_multiple`, `historical_result`, `historical_results_delete` | | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | | `family` (`families.py`) | Malware-family CRUD | `family_{create,update,delete,get,list}` | -| `rules` (`rules.py`) | YARA ruleset CRUD plus `favorite [--unfavorite]` (the star toggle: renders the new state + the server-owned "N of M used" budget, and converts the machine-readable `FAVORITE_LIMIT` refusal into a clean actionable message at exit 2, never 1 — 1 is reserved for no-results/not-found; 2 is the broad bucket `ExceptionHandlingGroup` maps the PolyswarmException hierarchies to). `list` takes the server-side filters `--name` / `--status active` / `--favorites-only` / `--has-new-results` (conjunctive; the list is keyset-paginated, so filtering locally would mean walking every page). `rules favorite` and the `rules list` filters need SDK 4.4.0, which the pin requires (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor), so they are called directly. The formatters read the hunt-page fields directly: the pin guarantees the SDK parses them, so `None` means the *server* had no answer | `ruleset_{create,delete,update,get,list,favorite}` | +| `rules` (`rules.py`) | YARA ruleset CRUD plus `favorite [--unfavorite]` (the star toggle: renders the new state + the server-owned "N of M used" budget, and converts the machine-readable `FAVORITE_LIMIT` refusal into a clean actionable message at exit 2, never 1 — 1 is reserved for no-results/not-found; 2 is the broad bucket `ExceptionHandlingGroup` maps the PolyswarmException hierarchies to. **2 does not identify a server refusal:** click exits 2 for a `UsageError` too, so a scripted caller cannot tell “the favorite budget is full” from “you passed a bad flag” without reading the message). `list` takes the server-side filters `--name` / `--status active` / `--favorites-only` / `--has-new-results` (conjunctive; the list is keyset-paginated, so filtering locally would mean walking every page). `rules favorite` and the `rules list` filters need SDK 4.4.0, which the pin requires (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor), so they are called directly. The formatters read the hunt-page fields directly: the pin guarantees the SDK parses them, so `None` means the *server* had no answer | `ruleset_{create,delete,update,get,list,favorite}` | | `metadata` (`metadata.py`) | Rerun metadata; scan lookup; IP/URL analysis | `rerun_metadata`, `scan_lookup`, `submit_url` | | `activity` (`event.py`) | List account activity/events | `event_list` | | `account` (`account.py`) | Account whois / features | `account_whois`, `account_features` | diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index f3910a8f..3dd8b252 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -129,6 +129,12 @@ working value that `develop` integration validates. **At cutoff:** bump the SDK if the repo files do not already carry it, then set this repo's dependency to the version actually being released. This repo cannot be released before that SDK release exists. +*Behaviour changes to existing invocations are called out at the same cutoff.* This repo +has no CHANGELOG, so a change that alters what an unchanged command line does — a default +that moves, an argument that starts being rejected — is visible to users only if the +`develop → master` PR says so. List them there, and let the release be at least a minor. +A note that lives only in a spec is not a release note. + *A missing paired branch now fails loudly.* If the SDK branch does not exist, CI falls back to the SDK's `develop`, whose version does not satisfy the new floor, and `pip install .[tests]` fails. That is the intended behaviour and an improvement: the diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index f400dd70..8f75f9f7 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -40,8 +40,8 @@ def live_stop(ctx, ruleset_id): # click.INT matches every other id option in the CLI and rejects a typo before # it reaches the server. @click.option('-i', '--livescan-id', type=click.INT, - help="Scope the feed to one live hunt (a ruleset's Live Hunt Id, " - 'a 17-digit number). Shows one community at a time, while ' + help="Scope the feed to one live hunt (a ruleset's Live Hunt Id). " + 'Shows one community at a time, while ' 'the badge counts all of them, so the counts need not match.') # IntRange(min=0) refuses a negative rather than letting it silently mean unbounded. @click.option('-m', '--max-results', type=click.IntRange(min=0), diff --git a/src/polyswarm/formatters/text.py b/src/polyswarm/formatters/text.py index 140d4c1f..0b32d90d 100644 --- a/src/polyswarm/formatters/text.py +++ b/src/polyswarm/formatters/text.py @@ -76,8 +76,10 @@ def artifact_instance(self, instance, write=True, timeout=False): if not instance.failed: output.append(self._white(f'Scan permalink: {instance.permalink}')) - # Defensive getattr: these attributes ship in the paired SDK release, but a - # CLI running against an older installed SDK won't have them (no AttributeError). + # Defensive getattr, kept deliberately: these attributes ship in 4.1.0, well + # below the dependency floor, so the pin already guarantees them. This is + # belt-and-braces for an unsupported configuration, NOT the version-probing + # the floor replaced -- don't add siblings for a version the floor permits. # The bounty state (KNOWN_GOOD) is the only reliable signal that this artifact is # a known-good binary whose bytes are withheld — it alone decides. known_good_sources # (the flagging feeds) is emitted for any instance whose sha256 matches a known-good diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 28b97220..432442d0 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -311,8 +311,13 @@ def test_favorite_limit_without_counters_uses_the_server_message(self): # the user, so the server's own message is the fallback. # A REAL request, not a Mock: a Mock fabricates whatever attribute it # is asked for, so it passed even while the code read a spelling the - # request object does not have. + # request object does not have. Assigning `.json` below fabricates it + # just as effectively, so pin the spelling on the UNTOUCHED request + # first — that is the part a Mock could never have told us, and it is + # what fails if the SDK ever renames the envelope. request = core.PolyswarmRequest(api=None, method='PUT', url='http://x') + assert hasattr(request, 'json'), 'SDK renamed the response envelope' + assert not hasattr(request, 'result'), 'the wrong spelling became real' request.json = {'errors': {'code': 'FAVORITE_LIMIT'}, 'result': 'Favorite limit reached (5 of 5 used).', 'status': 'error'} @@ -365,6 +370,25 @@ class BareRequest: assert 'Traceback' not in result.output assert 'None' not in result.output assert '--unfavorite' in result.output + def test_the_unfavorite_direction_gets_no_remedy_sentence(self): + """Only the star direction can hit the cap and only it has a remedy — + telling someone unstarring to unstar something else cannot help. Every + other limit test invokes WITHOUT --unfavorite, so the empty-remedy arm + never ran and inverting the conditional would not have failed one.""" + request = mock.Mock() + request.errors = {'code': 'FAVORITE_LIMIT', + 'favorites_used': 5, 'favorites_limit': 5} + refusal = exceptions.RequestException(request) + with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_favorite', + autospec=True, side_effect=refusal): + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'rules', 'favorite', '5', '--unfavorite']) + assert result.exit_code == 2, result.output + assert 'Favorite limit reached (5 of 5 used).' in result.output + assert 'Unfavorite another ruleset first' not in result.output + def test_other_refusals_still_raise(self): request = mock.Mock() request.errors = None From 176f3a75156553a22d9ed6fe1d8aa45edfdef781 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 16:32:38 -0300 Subject: [PATCH 48/54] docs(specs): the re-record steps kept a pointer the revert removed, and the wrong failure The section pointed at 'the VCR invariant above' for an exception that no longer exists there, and repeated the mechanism the revert corrected: a fresh-stack recording does not yield a 200. The recorded ruleset id is not on a fresh stack at all, so that run 404s; the 200 is what a stack WITH the ruleset and an unsaturated budget returns. Both states have to be set up, and saturating the budget takes slots the sibling favorite tests need. --- specs/04-testing.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/specs/04-testing.md b/specs/04-testing.md index 1c708150..d04fae1d 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -42,10 +42,13 @@ Helpers in `cli_test.py`: `_run_cli(args)` invokes the command tree under a cass ### Re-recording a cassette -Some cassettes need a **stack state**, not just a live stack (see the VCR invariant -above). `test_ruleset_favorite_limit_text` needs the team's favorite budget already -saturated; re-recording it against a fresh stack yields a 200 and a cassette that no -longer tests anything. Set the state first, then record. +Some cassettes need a **stack state**, not just a live stack. `test_ruleset_favorite_limit_text` +records the server refusing at the favorite cap, so it needs a ruleset that exists *and* a +team whose budget is already saturated. Against a fresh stack the recorded id is simply +absent and the run 404s; against a stack where it exists but the budget is not full, the +server accepts the star and the cassette records a success that tests nothing. Set both up +first, then record — and note that saturating the budget consumes slots the other favorite +tests use, so do it deliberately rather than as a side effect of a full-suite recording. ```bash rm tests/vcr/.vcr # (and regenerate .click from the new run) From 0ef7a281209ee735319671d3a733e8e685178a02 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 16:40:29 -0300 Subject: [PATCH 49/54] test: assert the two omission arms in the ruleset leg, and pin what --since 0 rests on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both nested guards in the ruleset leg had only their True arm exercised. The tracking-fields test builds a count with no refresh marker — the inner guard's False arm — but never asserted the marker was absent, and no test rendered a favorite without a timestamp at all. Inverting either guard failed nothing. Verified by inverting both; both new assertions fail, and neither did before. --since 0's help text promises "no time filter at all", which is a claim about the server, not the CLI. Confirmed against the endpoint: the window is applied only when since is truthy, so 0 and an absent parameter are the same request. specs/02 now says so, and says what the CLI test can actually pin — that 0 is forwarded rather than dropped, which is the half that can regress in this repo. The re-record steps named one state-dependent cassette. All three ruleset-favorite cassettes are, because the rendered block is compared verbatim and the budget counter is inside it: one pins "Favorites used: 2 of 5", another "1 of 5". They have to be recorded together from a known starting state or the counters contradict each other. --- specs/02-commands.md | 2 +- specs/04-testing.md | 7 ++++++- tests/formatter_hunt_fields_test.py | 9 +++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/specs/02-commands.md b/specs/02-commands.md index 98cf8d9d..fd1aeb7e 100644 --- a/specs/02-commands.md +++ b/specs/02-commands.md @@ -25,7 +25,7 @@ The top-level command groups, what each is for, and the primary `polyswarm-api` | `report` (`report.py`) | Create/fetch/download reports; `prompt-config` subgroup; LLM reports | `report_create`, `report_wait_for`, `report_download`, `report_get`, `llm_report_{create,get,download}`, `prompt_config_{create,get,update,list}` | | `report-template` (`report_template.py`) | Manage report templates + logos | `report_template_{create,update,get,list}`, `report_template_logo_{download,upload}` | | `engine` → `votes` / `assertions` (`engine.py`) | Consolidated votes/assertions bundles per engine | `votes_{create,get,delete,list}`, `assertions_{create,get,delete,list}` | -| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **SECONDS** (default 86400 — 24h; `0` means no time filter at all, and a negative is refused at parse time rather than forwarded), plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry the badge; the badge counts the hunt across **every** community it runs in, public and private, while the feed shows one at a time, so a multi-community hunt lists fewer rows than the badge reports) and `--max-results` (stop after N; unset means every page, as before). Both are guaranteed by the pin (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor) and called directly; they are forwarded only when passed purely so a pre-existing invocation's call shape is unchanged — the request is identical either way | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | +| `live` (`live.py`) | Live YARA hunts: start/stop, feed, results. `feed` takes `--since` in **SECONDS** (default 86400 — 24h; `0` means no time filter at all, and a negative is refused at parse time rather than forwarded). **`0` is the API's contract, not a CLI convention:** the endpoint applies the window only when `since` is truthy, so `0` and an absent parameter behave identically. The CLI's own test can only pin that it forwards `0` rather than dropping it — which is the half that can regress here, plus `--livescan-id` (the drill-down for the per-ruleset new-results badge `rules list` renders — the detail view deliberately does not carry the badge; the badge counts the hunt across **every** community it runs in, public and private, while the feed shows one at a time, so a multi-community hunt lists fewer rows than the badge reports) and `--max-results` (stop after N; unset means every page, as before). Both are guaranteed by the pin (see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor) and called directly; they are forwarded only when passed purely so a pre-existing invocation's call shape is unchanged — the request is identical either way | `live_start`, `live_stop`, `live_feed`, `live_result`, `live_feed_delete` | | `historical` (`historical.py`) | Historical hunts: CRUD + results | `historical_{get,create,update,list}`, `historical_delete_multiple`, `historical_delete_list`, `historical_results_multiple`, `historical_result`, `historical_results_delete` | | `tag` (`tags.py`) | Tag CRUD | `tag_{create,delete,get,list}` | | `link` (`links.py`) | Tag/family links on artifacts | `tag_link_multiple`, `tag_link_get`, `tag_link_list` | diff --git a/specs/04-testing.md b/specs/04-testing.md index d04fae1d..41bbcff7 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -42,7 +42,12 @@ Helpers in `cli_test.py`: `_run_cli(args)` invokes the command tree under a cass ### Re-recording a cassette -Some cassettes need a **stack state**, not just a live stack. `test_ruleset_favorite_limit_text` +Some cassettes need a **stack state**, not just a live stack. All three ruleset-favorite +cassettes do, because `_assert_text_result` compares the whole rendered block verbatim and +the budget counter is part of it: `test_ruleset_favorite_text` pins `Favorites used: 2 of 5` +and `test_ruleset_unfavorite_text` pins `1 of 5`, so each needs the team holding exactly +that many stars at record time. Re-record them together, in a known starting state, or the +counters disagree with each other. `test_ruleset_favorite_limit_text` records the server refusing at the favorite cap, so it needs a ruleset that exists *and* a team whose budget is already saturated. Against a fresh stack the recorded id is simply absent and the run 404s; against a stack where it exists but the budget is not full, the diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 432442d0..19e7dcaf 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -63,6 +63,15 @@ def test_ruleset_tracking_fields_render_with_zero_distinct_from_absent(self): assert 'Rules in ruleset: 0' in rendered assert 'Historical hunts triggered: 0' in rendered assert 'New live results: 3' in rendered + # The count arrived without its marker: the inner guard's False arm. + # Without this the guard could be inverted and nothing would fail. + assert 'New-results count refreshed at' not in rendered + def test_a_favorite_without_a_timestamp_renders_the_state_alone(self): + # favorite=True carries favorited_at only when the server has one; the + # nested guard's False arm, unreached by the tests above. + rendered = self._render('ruleset', _ruleset(favorite=True, favorited_at=None)) + assert 'Favorite: yes' in rendered + assert 'Favorited at' not in rendered def test_ruleset_staleness_marker_renders_beside_the_count(self): # The stored badge's marker: how fresh the number is. Rendered only # with a count (the server sends them together). From 335b01dd1d4193ca76a77a9c91fc73a53ab31019 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 16:50:38 -0300 Subject: [PATCH 50/54] test: drop the favorite-cap cassette, which its own siblings make unsatisfiable The three ruleset-favorite cassettes describe one coherent sequence: star a ruleset (1 of 5), star another (2 of 5), unstar it (1 of 5). The cap refusal needs 5 of 5. With VCR off, saturating the budget to record or reproduce the refusal turns the other two into 400s, failing their expected exit 0; leaving it unsaturated turns the refusal into a 200. There is no stack state where all four pass, so the suite could not be run against a live stack at all. The previous commits tried to write that down instead of resolving it -- first as an exception to the VCR invariant (reverted), then as re-recording guidance. Documenting a test that cannot run is not the same as having one. Removing it costs little now, and less than it would have earlier in this branch. The message and exit code are pinned at the SDK boundary; the envelope spelling is pinned against a real PolyswarmRequest, which is the part a cassette was carrying until this branch added that pin; and the wire shape is pinned by the SDK's stubbed-transport suite -- which declined to record this same refusal, for this same reason, in the paired PR. Recording it on one side while stubbing it on the other was the inconsistency. --- specs/04-testing.md | 25 +++++----- specs/05-sdk-contract.md | 2 +- tests/cli_test.py | 16 ------- .../test_ruleset_favorite_limit_text.click | 4 -- .../vcr/test_ruleset_favorite_limit_text.vcr | 47 ------------------- 5 files changed, 14 insertions(+), 80 deletions(-) delete mode 100644 tests/vcr/test_ruleset_favorite_limit_text.click delete mode 100644 tests/vcr/test_ruleset_favorite_limit_text.vcr diff --git a/specs/04-testing.md b/specs/04-testing.md index 41bbcff7..1418063e 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -42,18 +42,19 @@ Helpers in `cli_test.py`: `_run_cli(args)` invokes the command tree under a cass ### Re-recording a cassette -Some cassettes need a **stack state**, not just a live stack. All three ruleset-favorite -cassettes do, because `_assert_text_result` compares the whole rendered block verbatim and -the budget counter is part of it: `test_ruleset_favorite_text` pins `Favorites used: 2 of 5` -and `test_ruleset_unfavorite_text` pins `1 of 5`, so each needs the team holding exactly -that many stars at record time. Re-record them together, in a known starting state, or the -counters disagree with each other. `test_ruleset_favorite_limit_text` -records the server refusing at the favorite cap, so it needs a ruleset that exists *and* a -team whose budget is already saturated. Against a fresh stack the recorded id is simply -absent and the run 404s; against a stack where it exists but the budget is not full, the -server accepts the star and the cassette records a success that tests nothing. Set both up -first, then record — and note that saturating the budget consumes slots the other favorite -tests use, so do it deliberately rather than as a side effect of a full-suite recording. +Some cassettes need a **stack state**, not just a live stack. The ruleset-favorite ones do, +because `_assert_text_result` compares the whole rendered block verbatim and the budget +counter is part of it: `test_ruleset_favorite_text` pins `Favorites used: 2 of 5` and +`test_ruleset_unfavorite_text` pins `1 of 5`. Re-record them **together**, from a known +starting state, in the order they run — they describe one sequence (star, star, unstar) and +recorded piecemeal their counters contradict each other. + +**A refusal at the favorite cap is deliberately not recorded here.** Saturating the budget +takes all five team slots, which is exactly the state the tests above must *not* be in, so +the cassette and its siblings cannot both be satisfiable in one VCR-off run. That refusal is +pinned without a stack instead: the CLI's message and exit code at the SDK boundary, the +envelope spelling against a real `PolyswarmRequest`, and the wire shape by the SDK's own +stubbed-transport suite, which declined a recording for the same reason. ```bash rm tests/vcr/.vcr # (and regenerate .click from the new run) diff --git a/specs/05-sdk-contract.md b/specs/05-sdk-contract.md index 3dd8b252..63630a49 100644 --- a/specs/05-sdk-contract.md +++ b/specs/05-sdk-contract.md @@ -18,7 +18,7 @@ How the CLI depends on the `polyswarm-api` SDK: which parts of the SDK's public | `from polyswarm_api.api import PolyswarmAPI` | Base class of the `Polyswarm` wrapper (`src/polyswarm/polyswarm.py`). | | `from polyswarm_api import settings` | Defaults: `DEFAULT_SCAN_TIMEOUT`, `DEFAULT_REPORT_TIMEOUT`, etc. | | `from polyswarm_api import resources` | Result-parser classes for power-user calls (e.g. `resources.ArtifactInstance`); resource attributes the formatters read. | -| `from polyswarm_api import exceptions as api_exceptions` | Caught in `ExceptionHandlingGroup` and `utils.parallel_executor` (`NoResultsException`, `NotFoundException`, `FailedInstanceException`, `PolyswarmException`). Also `RequestException`, caught by `rules favorite` (`client/rules.py`) to read the machine-readable `FAVORITE_LIMIT` refusal off `exc.request.errors['code']` — and, when the envelope carries no counters, `exc.request.json['result']` as the server's own message. Note the spelling: the request object exposes the response envelope as `.json` and keeps only a private `._result`, so `exc.request.result` is not a thing — reading it yields `None` silently. The SDK does not raise a typed exception for that refusal by design: `.request.errors` is a plain dict the server's error envelope populates, pinned end-to-end by `tests/cli_test.py::test_ruleset_favorite_limit_text` against a real recorded 400 (not a hand-built mock). That cassette's envelope carries the counters, so it pins the `.errors` branch; the no-counters fallback is unit-pinned against a real `PolyswarmRequest` instead. It cannot be cassette-pinned: the server sends the counters on every `FAVORITE_LIMIT`, so the envelope the fallback exists for is one no recording can produce. The fallback is defensive against a server that omits them, and the unit test is what fixes the spelling it reads. | +| `from polyswarm_api import exceptions as api_exceptions` | Caught in `ExceptionHandlingGroup` and `utils.parallel_executor` (`NoResultsException`, `NotFoundException`, `FailedInstanceException`, `PolyswarmException`). Also `RequestException`, caught by `rules favorite` (`client/rules.py`) to read the machine-readable `FAVORITE_LIMIT` refusal off `exc.request.errors['code']` — and, when the envelope carries no counters, `exc.request.json['result']` as the server's own message. Note the spelling: the request object exposes the response envelope as `.json` and keeps only a private `._result`, so `exc.request.result` is not a thing — reading it yields `None` silently. The SDK does not raise a typed exception for that refusal by design: `.request.errors` is a plain dict the server's error envelope populates. It is pinned without a recording — the SDK's stubbed-transport suite fixes the wire shape, and both CLI branches are unit-pinned against a real `PolyswarmRequest` (not a hand-built mock, which fabricates whatever attribute it is asked for and so cannot detect a rename). It cannot be cassette-pinned: the server sends the counters on every `FAVORITE_LIMIT`, so the envelope the fallback exists for is one no recording can produce. The fallback is defensive against a server that omits them, and the unit test is what fixes the spelling it reads. | | `from polyswarm_api.core import parse_isoformat` | Date rendering in `formatters/text.py`. | | `import polyswarm_api` (`__version__`) | `--api-version`. | diff --git a/tests/cli_test.py b/tests/cli_test.py index b0ecc772..2acb3bc4 100644 --- a/tests/cli_test.py +++ b/tests/cli_test.py @@ -280,22 +280,6 @@ def test_ruleset_favorite_json(self): '--output-format', 'json', 'rules', 'favorite', '14883307518120680']) self._assert_json_result(result, self.click_vcr(result)) - @vcr.use_cassette() - def test_ruleset_favorite_limit_text(self): - # The server's machine-readable FAVORITE_LIMIT refusal, recorded off - # the real wire (a stack with all five team slots held): pins where - # the error body actually lives (exc.request.errors, code string - # included) — the unit test's hand-built mock cannot notice either - # side renaming it — and the clean actionable message at exit 2, the - # central mapping's refusal path (exit 2, not 1). - result = self._run_cli([ - '--output-format', 'text', 'rules', 'favorite', '45874884769561543']) - expected = self.click_vcr(result) - self._assert_text_result(result, expected, expected_return_code=2) - assert 'Favorite limit reached (5 of 5 used)' in expected - assert '--unfavorite' in expected - - class SubmissionTest(BaseTestCase): @vcr.use_cassette() def test_submission_lookup_json(self): diff --git a/tests/vcr/test_ruleset_favorite_limit_text.click b/tests/vcr/test_ruleset_favorite_limit_text.click deleted file mode 100644 index 068d3427..00000000 --- a/tests/vcr/test_ruleset_favorite_limit_text.click +++ /dev/null @@ -1,4 +0,0 @@ -result: 'error [polyswarm.client.polyswarm]: Favorite limit reached (5 of 5 used). - Unfavorite another ruleset first: `polyswarm rules favorite --unfavorite`. - - ' diff --git a/tests/vcr/test_ruleset_favorite_limit_text.vcr b/tests/vcr/test_ruleset_favorite_limit_text.vcr deleted file mode 100644 index 3ed2b15a..00000000 --- a/tests/vcr/test_ruleset_favorite_limit_text.vcr +++ /dev/null @@ -1,47 +0,0 @@ -interactions: -- request: - body: '{"id":"45874884769561543","favorite":1}' - headers: - accept: - - '*/*' - accept-encoding: - - gzip, deflate - authorization: - - '11111111111111111111111111111111' - connection: - - keep-alive - content-length: - - '39' - content-type: - - application/json - host: - - artifact-index-e2e:9696 - user-agent: - - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) - method: PUT - uri: http://artifact-index-e2e:9696/v3/hunt/rule/favorite?community=gamma - response: - body: - string: '{"errors":{"code":"FAVORITE_LIMIT","favorites_limit":5,"favorites_used":5},"result":"Favorite - limit reached (5 of 5 used).","status":"error"} - - ' - headers: - access-control-allow-origin: - - '*' - access-control-expose-headers: - - Authorization - connection: - - keep-alive - content-length: - - '142' - content-type: - - application/json - date: - - Wed, 26 Aug 2026 15:02:21 GMT - server: - - gunicorn - status: - code: 400 - message: BAD REQUEST -version: 1 From e1b083032a9db86df94fab63dc822f901aa9dba7 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 17:01:28 -0300 Subject: [PATCH 51/54] docs: drop the dual-coverage carve-out for a cassette that no longer exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit removed the favorite-cap cassette but left specs/04's "one sanctioned exception" paragraph asserting it — "the recorded 400 pins where in the envelope the machine-readable code actually lives", "the cassette is what pins the seam between them". Both describe a file this branch deletes, and the same paragraph licensed mock-plus-VCR coverage of one path, which is the rule it was nested under as an exception. Nothing needs the exception now: the refusal is covered by unit tests on this side and a stubbed transport on the SDK's. Also moves development history out of user-facing help and out of comments that had grown into review transcripts. `live feed --help` no longer explains that an old default was written as 24*60 in the belief that the unit was minutes — it says what the default is and how to get the old window back. "The badge" had no referent for someone reading --help and is now named for what it is. The kwargs and FAVORITE_LIMIT comments state the fact at hand and point at the spec for the reasoning, instead of naming test functions that will drift. --- specs/04-testing.md | 1 - src/polyswarm/client/live.py | 38 +++++++++++++++-------------------- src/polyswarm/client/rules.py | 14 ++++--------- 3 files changed, 20 insertions(+), 33 deletions(-) diff --git a/specs/04-testing.md b/specs/04-testing.md index 1418063e..a4376525 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -9,7 +9,6 @@ How the CLI is tested: the `CliRunner` harness, the two mocking styles (SDK-boun - **Anything that is command behaviour is driven through `click.testing.CliRunner`** — argument parsing, the SDK call, the wiring, the exit code: exercise the real command tree, never an internal function standing in for it. No live PolySwarm stack is required. The one sanctioned exception is pure rendering logic — see [Style 3](#style-3--formatter-unit-tests). - **Mock at the SDK boundary, or replay HTTP with VCR — never both for the same path.** A test either patches `polyswarm_api.api.PolyswarmAPI.` (unit-style) or lets VCR replay recorded HTTP (end-to-end). The CLI's own code is exercised either way. - **One sanctioned exception, where the two pin different things:** a refusal whose *rendering* is a CLI decision and whose *envelope shape* is an SDK contract. `FAVORITE_LIMIT` is covered both ways deliberately — the SDK-boundary mock pins the message and the exit code, the recorded 400 pins where in the envelope the machine-readable code actually lives. Neither substitutes for the other: the mock cannot notice either side renaming the envelope key, and the SDK's own respx suite checks that key without ever exercising this CLI's handler. The cassette is what pins the seam between them. Use this only when you can name what each half pins. - **VCR is an efficiency cache, not a load-bearing requirement.** The suite must pass against a live e2e stack with VCR off. Don't hardcode `record_mode='none'`; if a test only works against its recorded cassette, that's a bug in the test. - **Never `cp` a cassette from a sibling test, never hand-edit cassette bytes.** Re-record against a live stack. diff --git a/src/polyswarm/client/live.py b/src/polyswarm/client/live.py index 8f75f9f7..f5cfbb4f 100644 --- a/src/polyswarm/client/live.py +++ b/src/polyswarm/client/live.py @@ -57,31 +57,25 @@ def live_results(ctx, since, livescan_id, max_results, rule_name, family, polyscore_lower, polyscore_upper, private): """Show live-hunt results. - `--since` is SECONDS and defaults to 86400 (24h). It used to default to - 1440, which was written as 24*60 believing the unit was minutes — so the - real window was 24 MINUTES. Pass `--since 1440` to get the old behaviour - back. The default now returns roughly 60x more, and `--max-results` is - unset by default, so a bare `live feed` pages through all of it; bound it - with `--max-results` if that matters. - - `--livescan-id` scopes the feed to one live hunt — the drill-down for the - per-ruleset new-results badge that `rules list` renders (the detail view - deliberately does not carry the badge). - - The two do not have to agree, and a smaller feed is not a bug. The badge - counts the hunt across EVERY community it runs in, public and private - together; the feed shows one community at a time (this command always - sends one — `--private` selects it). A hunt spanning both will show fewer - rows here than the badge reports. + `--since` is SECONDS and defaults to 86400 (24h). Earlier versions defaulted + to a 24-minute window; pass `--since 1440` for that. Because the default + window is much wider and `--max-results` is unset by default, a bare + `live feed` pages through everything in it — bound it with `--max-results` + if that matters. + + `--livescan-id` scopes the feed to one live hunt, the drill-down for the + per-ruleset new-results count that `rules list` shows. + + The two do not have to agree, and a smaller feed is not a bug: that count + covers EVERY community the hunt runs in, public and private together, while + the feed shows one at a time (`--private` selects it). A hunt spanning both + shows fewer rows here than the count reports. """ api = ctx.obj['api'] output = ctx.obj['output'] - # Both are redundant against the pinned SDK — `livescan_id` defaults to None - # and the SDK maps 0/None to "no bound" (a negative never reaches it — - # IntRange(min=0) refuses one at the interface) — and the request - # is byte-identical either way. Kept so a pre-existing invocation's call - # shape does not move, which `test_plain_feed_forwards_neither_new_kwarg` - # pins alongside the `--since 0` refactor hazard beside it. + # Sent only when passed. The request is byte-identical either way, so this + # exists to keep a pre-existing invocation's call shape unchanged. Note + # `since` is NOT folded in here: 0 must reach the SDK (specs/02). kwargs = {} if livescan_id is not None: kwargs['livescan_id'] = livescan_id diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index 081b21fe..c1712bc9 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -81,13 +81,9 @@ def favorite(ctx, rule_id, unfavorite): used = errors.get('favorites_used') limit = errors.get('favorites_limit') # Counters are advisory; fall back rather than render "(None of None)". - # The server's own message, off the DOCUMENTED path: the response - # envelope is `request.json`, and `result` is a key inside it. The - # request object has no `.result` attribute — only a private - # `._result` — so reading that spelling silently yielded None. - # getattr, not attribute access: a malformed/bare request must - # still reach the clean message rather than an AttributeError - # traceback (pinned by the bare-request test). + # `.json` is the response envelope and `result` a key inside it — + # the request has no `.result`, only a private `._result`. getattr + # so a bare request still reaches the message (specs/05). server_msg = (getattr(exc.request, 'json', None) or {}).get('result') budget = (f'Favorite limit reached ({used} of {limit} used).' if used is not None and limit is not None @@ -95,9 +91,7 @@ def favorite(ctx, rule_id, unfavorite): else 'Favorite limit reached.')) # PolyswarmException exits 2; ClickException would exit 1, reserved # for no-results/not-found. - # Only the star direction can hit the cap, and only it has a - # remedy — telling someone unstarring to unstar something else is - # advice that cannot help. + # Only starring can hit the cap, and only it has a remedy. remedy = ('' if unfavorite else ' Unfavorite another ruleset first: ' '`polyswarm rules favorite --unfavorite`.') From df925cfd0db0c70190fac1de20f362d9b1c2e9d2 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 17:18:46 -0300 Subject: [PATCH 52/54] fix(rules): guard the json envelope the way the errors mapping already is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `errors` is isinstance-guarded so a non-mapping envelope falls through to the generic path instead of raising on `.get`. The `.json` read two lines down had no such guard: `(getattr(...) or {}).get('result')` only substitutes `{}` for a falsy value, so a list envelope reaches `.get` and raises AttributeError — inside the handler whose entire purpose is turning this refusal into a clean message. Reproduced: full traceback plus "Unhandled exception happened. Please contact support." Needs three things at once (a dict `errors` carrying the code, no counters, and a non-dict `.json`), which is why no existing test caught it. One does now; verified by reverting the guard. specs/04 also names the mechanism the favorite cassettes' sequence rests on: unittest orders methods by sorted name, so renaming one breaks the recorded star/star/unstar sequence silently — replay keeps passing and only the next re-record surfaces it. And the VCR bullet now points at the re-recording section rather than appearing to contradict it: the invariant is about a test's logic, while a .click snapshot pinning server-generated values is a fixture concern. --- specs/04-testing.md | 7 +++++-- src/polyswarm/client/rules.py | 3 ++- tests/formatter_hunt_fields_test.py | 20 ++++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/specs/04-testing.md b/specs/04-testing.md index a4376525..bdd1fa8d 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -9,7 +9,7 @@ How the CLI is tested: the `CliRunner` harness, the two mocking styles (SDK-boun - **Anything that is command behaviour is driven through `click.testing.CliRunner`** — argument parsing, the SDK call, the wiring, the exit code: exercise the real command tree, never an internal function standing in for it. No live PolySwarm stack is required. The one sanctioned exception is pure rendering logic — see [Style 3](#style-3--formatter-unit-tests). - **Mock at the SDK boundary, or replay HTTP with VCR — never both for the same path.** A test either patches `polyswarm_api.api.PolyswarmAPI.` (unit-style) or lets VCR replay recorded HTTP (end-to-end). The CLI's own code is exercised either way. -- **VCR is an efficiency cache, not a load-bearing requirement.** The suite must pass against a live e2e stack with VCR off. Don't hardcode `record_mode='none'`; if a test only works against its recorded cassette, that's a bug in the test. +- **VCR is an efficiency cache, not a load-bearing requirement.** The suite must pass against a live e2e stack with VCR off. Don't hardcode `record_mode='none'`; if a test only works against its recorded cassette, that's a bug in the test. Note this is about a test's *logic*, not its fixtures: a `.click` snapshot pins server-generated ids and timestamps, so re-recording needs a stack in a particular state — see [Re-recording a cassette](#re-recording-a-cassette). - **Never `cp` a cassette from a sibling test, never hand-edit cassette bytes.** Re-record against a live stack. @@ -46,7 +46,10 @@ because `_assert_text_result` compares the whole rendered block verbatim and the counter is part of it: `test_ruleset_favorite_text` pins `Favorites used: 2 of 5` and `test_ruleset_unfavorite_text` pins `1 of 5`. Re-record them **together**, from a known starting state, in the order they run — they describe one sequence (star, star, unstar) and -recorded piecemeal their counters contradict each other. +recorded piecemeal their counters contradict each other. That order is `unittest`'s: method +names, sorted (`favorite_json` → `favorite_text` → `unfavorite_text`). Renaming or reordering +a method breaks the sequence **silently**, because replay keeps passing and only the next +re-record shows it — so if you rename one, re-record all of them. **A refusal at the favorite cap is deliberately not recorded here.** Saturating the budget takes all five team slots, which is exactly the state the tests above must *not* be in, so diff --git a/src/polyswarm/client/rules.py b/src/polyswarm/client/rules.py index c1712bc9..9f8fa7e2 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -84,7 +84,8 @@ def favorite(ctx, rule_id, unfavorite): # `.json` is the response envelope and `result` a key inside it — # the request has no `.result`, only a private `._result`. getattr # so a bare request still reaches the message (specs/05). - server_msg = (getattr(exc.request, 'json', None) or {}).get('result') + envelope = getattr(exc.request, 'json', None) + server_msg = envelope.get('result') if isinstance(envelope, dict) else None budget = (f'Favorite limit reached ({used} of {limit} used).' if used is not None and limit is not None else (server_msg if isinstance(server_msg, str) diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py index 19e7dcaf..1d0675da 100644 --- a/tests/formatter_hunt_fields_test.py +++ b/tests/formatter_hunt_fields_test.py @@ -379,6 +379,26 @@ class BareRequest: assert 'Traceback' not in result.output assert 'None' not in result.output assert '--unfavorite' in result.output + def test_a_list_shaped_json_envelope_does_not_crash_the_handler(self): + """The `errors` mapping is isinstance-guarded so a non-mapping falls + through; `.json` was not, so a non-dict envelope raised inside the very + handler that exists to avoid a traceback. Needs all three at once: a + dict `errors` carrying the code, no counters, and a non-dict `.json`.""" + request = core.PolyswarmRequest(api=None, method='PUT', url='http://x') + request.errors = {'code': 'FAVORITE_LIMIT'} + request.json = [{'result': 'a list, not the envelope'}] + refusal = exceptions.RequestException(request, 'refused') + with mock.patch('polyswarm_api.api.PolyswarmAPI.ruleset_favorite', + autospec=True, side_effect=refusal): + result = CliRunner().invoke( + client.polyswarm_cli, + ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma', + 'rules', 'favorite', '5']) + assert result.exit_code == 2, result.output + assert 'Traceback' not in result.output + assert 'contact support' not in result.output + assert 'Favorite limit reached.' in result.output + def test_the_unfavorite_direction_gets_no_remedy_sentence(self): """Only the star direction can hit the cap and only it has a remedy — telling someone unstarring to unstar something else cannot help. Every From ac67341d2feb56c6c9e8bd4247433d7478b35e1c Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 17:27:36 -0300 Subject: [PATCH 53/54] docs: write down what happens to a ticket-prefixed branch name at merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hygiene rule names commit messages, PR titles and PR descriptions. Branch names sit outside it, and reviewers have repeatedly and reasonably read that as an oversight — a branch ref is just as public and just as permanent. It isn't an oversight, but the reasoning lived nowhere in this repo. A change spanning this repo and the SDK has to use the identical branch name in both, because CI resolves the companion SDK by $CI_COMMIT_BRANCH; for a ticket-tracked change that name is usually the ticket. So the prefix is deliberate. What actually leaks is the default merge subject, which interpolates the head ref. Squash-merging with an explicit subject contains it, and that is now written down next to the rule it looks like an exception to, along with the preference for a descriptive shared name where one reads just as well. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index eec96c6a..e588d387 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,7 @@ Details in [`specs/04-testing.md`](./specs/04-testing.md). The shape: - Conventional commit prefixes (`feat:`, `fix:`, `refactor:`, `chore:`, `docs:`, `test:`). - Small, scoped commits — each one should be independently reviewable. - **Don't reference ticket IDs or internal project codes in commit messages, PR titles, or PR descriptions.** This repo is public; published artefacts shouldn't leak internal references. Track tickets in the internal tracker, not the git history. +- **Branch names are the exception, and the merge is where they're contained.** A change spanning this repo and the SDK must use the *identical* branch name in both, because CI resolves the companion SDK by `$CI_COMMIT_BRANCH` (`.gitlab-ci.yml`) — so a shared ticket-prefixed name is often the coordinating key and is deliberately allowed. It does reach public history, but only through the **default merge subject** (`Merge pull request #N from org/TICKET-…`). **Squash-merge with an explicit clean subject**, and it never lands. Prefer a shared descriptive name over a ticket prefix when one reads just as well. - **Don't name private companion repos in PR descriptions or commit messages on this repo.** Refer to internal services by category, not by repo name. - No AI-attribution trailers on commits (`Co-Authored-By: Claude …`, "Generated with Claude Code", etc.) — they're noise and they don't belong in project history. - PRs that depend on an unreleased `polyswarm-api` surface must link the SDK PR under a `## Requires` section (see `specs/05-sdk-contract.md`). From 6869b2daf0ee0e75d098f869de8f0c513c6eacb7 Mon Sep 17 00:00:00 2001 From: Samuel Date: Mon, 31 Aug 2026 17:38:00 -0300 Subject: [PATCH 54/54] docs(specs): the favorite re-record recipe described a run that never happened The recipe said to record the three favorite cassettes together, in sorted-name order, from a known starting state. Two things are wrong with it. It omits test_ruleset_list_json, which sorts between favorite_text and unfavorite_text and pins "favorite": false on every ruleset in its inventory. Anyone following the recipe records it while a ruleset is starred, so that snapshot changes as well. And the shipped cassettes were not made that way: favorite_text acts on 96652060989160147, which does not appear anywhere in list_json's inventory (77454540525125655, 44051669277897879, 78562964231669682). They were recorded against different stack states, so following the recipe would surface the contradiction rather than reproduce the fixtures. Now states the couplings instead of a sequence, keeps the silent-failure warning about renaming a method, and points at the escape hatch that exists already: _assert_text_result takes a replace= hook, so normalising the budget counter makes each cassette independent of what ran before it. Better to break the coupling than to document a wider one. --- specs/04-testing.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/specs/04-testing.md b/specs/04-testing.md index bdd1fa8d..f9ae2947 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -44,12 +44,25 @@ Helpers in `cli_test.py`: `_run_cli(args)` invokes the command tree under a cass Some cassettes need a **stack state**, not just a live stack. The ruleset-favorite ones do, because `_assert_text_result` compares the whole rendered block verbatim and the budget counter is part of it: `test_ruleset_favorite_text` pins `Favorites used: 2 of 5` and -`test_ruleset_unfavorite_text` pins `1 of 5`. Re-record them **together**, from a known -starting state, in the order they run — they describe one sequence (star, star, unstar) and -recorded piecemeal their counters contradict each other. That order is `unittest`'s: method -names, sorted (`favorite_json` → `favorite_text` → `unfavorite_text`). Renaming or reordering -a method breaks the sequence **silently**, because replay keeps passing and only the next -re-record shows it — so if you rename one, re-record all of them. +`test_ruleset_unfavorite_text` pins `1 of 5`. Their counters only make sense as a +sequence (star, star, unstar), so re-recording one alone produces a set that contradicts +itself. + +Re-recording them is harder than it looks, and the shipped set is **not** what a single +pass produces — `test_ruleset_favorite_text` acts on a ruleset that does not appear in +`test_ruleset_list_json`'s inventory at all, so the two were recorded against different +stack states. Two couplings to plan around before starting: + +- `unittest` runs methods in **sorted-name** order, and `test_ruleset_list_json` sorts + *between* `favorite_text` and `unfavorite_text`. Its snapshot pins `"favorite": false` on + every ruleset, so a full-suite recording captures it while a ruleset is starred and that + snapshot changes too. Re-record it with the others, or not at all. +- Renaming or reordering any of these methods changes the sequence **silently**: replay + keeps passing, and only the next re-record surfaces the contradiction. + +If keeping them consistent stops being worth it, break the coupling rather than documenting +a wider one — `_assert_text_result` already takes a `replace=` hook, and normalising the +budget counter there makes each cassette independent of what ran before it. **A refusal at the favorite cap is deliberately not recorded here.** Saturating the budget takes all five team slots, which is exactly the state the tests above must *not* be in, so