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 5a544b9..831501c 100644 --- a/src/polyswarm/client/rules.py +++ b/src/polyswarm/client/rules.py @@ -29,11 +29,20 @@ 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(): + # 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 1810eba..6be52d7 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): + 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) diff --git a/tests/formatter_hunt_fields_test.py b/tests/formatter_hunt_fields_test.py new file mode 100644 index 0000000..abc0eca --- /dev/null +++ b/tests/formatter_hunt_fields_test.py @@ -0,0 +1,136 @@ +"""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, 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): + 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): + 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): + 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-20T12:00:00+00:00', rule_count=0, + historical_hunt_count=0, new_results_count=3)) + assert 'Favorite: yes' 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 + + 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', _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', + source_rule_changed=False)) + assert 'Source Ruleset Id: 5' 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): + 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', _old_sdk_hunt()) + assert 'Hunt Id: 9' in rendered + 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.""" + + def _run(self, args): + 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, + 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(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)