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 1/5] 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 1810eba..bbdecc0 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 2/5] 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 0000000..96449b0 --- /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 3/5] 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 5a544b9..a9edc28 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 4/5] 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 96449b0..46ebbde 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 5/5] 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 fdf6149..11b933c 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 b717ae1..4dd91e7 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 a9edc28..831501c 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 bbdecc0..6be52d7 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 46ebbde..abc0eca 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)