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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion specs/02-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
16 changes: 16 additions & 0 deletions specs/03-formatters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
13 changes: 11 additions & 2 deletions src/polyswarm/client/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
23 changes: 23 additions & 0 deletions src/polyswarm/formatters/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
136 changes: 136 additions & 0 deletions tests/formatter_hunt_fields_test.py
Original file line number Diff line number Diff line change
@@ -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)
Loading