Skip to content
Merged
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
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "polyswarm"
version = "4.6.0"
version = "4.7.0"
description = "CLI for using the PolySwarm Customer APIs"
readme = "README.md"
authors = [{ name = "PolySwarm Developers", email = "info@polyswarm.io" }]
Expand All @@ -22,7 +22,7 @@ classifiers = [
]

dependencies = [
"polyswarm_api>=4.7.0,<5.0.0",
"polyswarm_api>=4.8.0,<5.0.0",
"click>=7.1",
"colorama>=0.4.6",
"click-log>=0.4.0",
Expand Down Expand Up @@ -51,7 +51,7 @@ include-package-data = true
where = ["src"]

[tool.bumpversion]
current_version = "4.6.0"
current_version = "4.7.0"
commit = true
tag = false
sign_tags = true
Expand Down
2 changes: 1 addition & 1 deletion specs/02-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ 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 **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` |
| `historical` (`historical.py`) | Historical hunts: CRUD + results. `start` takes `--window-days {30,60,90,180}`, how many days back the hunt reaches (SDK 4.8.0, guaranteed by the pin — see [05-sdk-contract.md](./05-sdk-contract.md) §Current floor). The closed choice mirrors the server's enum, so a bad value is a usage error (exit 2) before any request; the value is forwarded as an int and only when passed, so a script that omits it makes the SDK call it always made and the hunt reaches the platform maximum, which the help does not put a day count on because the server owns it | `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}` |
Expand Down
8 changes: 8 additions & 0 deletions specs/03-formatters.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@ the attributes directly:
- `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".
- `window_days` renders as `Time Range:` right after `Created at:`, on every
historical hunt (`output.hunt` is only ever called for those): `Last N days`
for an int, `Platform maximum` for `None`. Unlike the fields above, `None` is
an answer rather than an absence — a hunt created without a window reaches
the platform maximum — so it prints a line instead of omitting one, and the
label names no day count because the server owns that maximum. The web UI
uses the same two labels. `JSONOutput` needs no change: it dumps the
resource's `.json`, which already carries the raw key (an int or `null`).

## Matched strings on hunt results

Expand Down
10 changes: 7 additions & 3 deletions specs/05-sdk-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ 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 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.7.0`
### Current floor — `polyswarm_api>=4.8.0`

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:

Expand Down Expand Up @@ -138,8 +138,12 @@ the refang inside the SDK's endpoint methods, which `--refang/--no-refang` relie
`refang_iocs=` and every command fails). `search ioc … --with-artifacts` forwards
`search_by_ioc(with_artifacts=True)`, a keyword 4.7.0 adds, and that is what moved the
floor to 4.7.0 (the autospec assertions in `tests/search_test.py` are its signature
check). Both are paired changes: the SDK branch of the same name declares the floor.
Code and tests use them directly.
check). `historical start --window-days` forwards `historical_create(window_days=)`, a
keyword 4.8.0 adds together with the `HistoricalHunt.window_days` attribute the text
formatter reads, and that is what moved the floor to 4.8.0 (the autospec assertions in
`tests/historical_window_test.py` are its signature check, and on 4.7.0 every hunt render
would raise on the missing attribute). Each is a paired change: the SDK branch of the
same name declares the floor. Code and tests use them directly.

**Raising the floor is the whole procedure** when this repo needs something new from
the SDK:
Expand Down
2 changes: 1 addition & 1 deletion src/polyswarm/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '4.6.0'
__version__ = '4.7.0'
6 changes: 5 additions & 1 deletion src/polyswarm/client/historical.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ def historical_view(ctx, hunt_id):
@click.argument('rule_file', type=click.File('r'), required=False)
@click.option('-r', '--rule-id', type=click.INT, help='If provided, create the historical hunt from the existing ruleset.')
@click.option('-n', '--name', help='Explicitly set the ruleset name for this hunt.')
@click.option('--window-days', type=click.Choice(['30', '60', '90', '180']),
help='How many days back the hunt reaches. Defaults to max')
@click.pass_context
@utils.any_provided('rule_file', 'rule_id')
def historical_start(ctx, rule_file, rule_id, name):
def historical_start(ctx, rule_file, rule_id, name, window_days):
api = ctx.obj['api']
output = ctx.obj['output']
params = {}
Expand All @@ -39,6 +41,8 @@ def historical_start(ctx, rule_file, rule_id, name):
rule = rule_id
if name:
params['ruleset_name'] = name
if window_days is not None:
params['window_days'] = int(window_days)
result = api.historical_create(rule, **params)
output.hunt(result)

Expand Down
5 changes: 5 additions & 0 deletions src/polyswarm/formatters/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,11 @@ def hunt(self, result, write=True):
if result.active is not None:
output.append(self._white(f'Active: {result.active}'))
output.append(self._white(f'Created at: {result.created}'))
# None: no window was requested, so the hunt reaches the platform maximum.
if result.window_days is None:
output.append(self._white('Time Range: Platform maximum'))
else:
output.append(self._white(f'Time Range: Last {result.window_days} days'))
if result.summary:
output.append(self._white(f'Total count: {result.summary["count"]}'))
self._open_group()
Expand Down
14 changes: 14 additions & 0 deletions tests/cli_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,20 @@ def test_historical_hunt_create_text(self):
'--output-format', 'text', 'historical', 'start', self.resource('eicar.yara')])
self._assert_text_result(result, self.click_vcr(result))

@vcr.use_cassette()
def test_historical_hunt_create_window_json(self):
result = self._run_cli([
'--output-format', 'json', 'historical', 'start', self.resource('eicar.yara'),
'--window-days', '30'])
self._assert_json_result(result, self.click_vcr(result))

@vcr.use_cassette()
def test_historical_hunt_create_window_text(self):
result = self._run_cli([
'--output-format', 'text', 'historical', 'start', self.resource('eicar.yara'),
'--window-days', '30'])
self._assert_text_result(result, self.click_vcr(result))

@vcr.use_cassette()
def test_historical_hunt_delete_json(self):
result = self._run_cli([
Expand Down
136 changes: 136 additions & 0 deletions tests/historical_window_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""``historical start --window-days`` and the ``Time Range`` line of the hunt text block.

The command is driven through autospec'd SDK-boundary mocks (specs/04-testing.md, Style 1),
so every call is also a signature check against the installed SDK: one without
``window_days`` fails at the mock. The line itself is a Style 3 formatter test on real SDK
resources built from dicts.
"""
import json
from pathlib import Path
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

_RULE_FILE = Path(__file__).resolve().parent / 'eicar.yara'
_GLOBALS = ['-a', '1' * 32, '-u', 'http://ai:9696/v3', '-c', 'gamma']


def _hunt(**overrides):
content = dict(id='9', status='PENDING', progress=0.0, active=None,
created='2026-10-01T12: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 _echo_window(_api, _rule, **kwargs):
"""Stand-in for the server: the created hunt carries the window it was asked for."""
return _hunt(window_days=kwargs.get('window_days'))


class HistoricalStartWindowTest(TestCase):
def _start(self, *args):
with mock.patch('polyswarm_api.api.PolyswarmAPI.historical_create',
autospec=True, return_value=_hunt()) as historical_create:
result = CliRunner().invoke(
client.polyswarm_cli, _GLOBALS + ['historical', 'start', *args],
catch_exceptions=False)
return result, historical_create

def test_each_window_is_forwarded_as_an_int(self):
for days in (30, 60, 90, 180):
with self.subTest(days=days):
result, historical_create = self._start(str(_RULE_FILE), '--window-days', str(days))
assert result.exit_code == 0, result.output
historical_create.assert_called_once_with(
mock.ANY, _RULE_FILE.read_text(), ruleset_name='eicar.yara',
window_days=days)
# 30 == '30' is False, but only the type says it is not a float or a bool.
assert type(historical_create.call_args.kwargs['window_days']) is int

def test_no_flag_forwards_no_window_at_all(self):
# A script written before the flag keeps making the SDK call it always made.
result, historical_create = self._start(str(_RULE_FILE))
assert result.exit_code == 0, result.output
historical_create.assert_called_once_with(
mock.ANY, _RULE_FILE.read_text(), ruleset_name='eicar.yara')
assert 'window_days' not in historical_create.call_args.kwargs

def test_window_composes_with_a_stored_rule(self):
result, historical_create = self._start('--rule-id', '5', '--window-days', '90')
assert result.exit_code == 0, result.output
historical_create.assert_called_once_with(mock.ANY, 5, window_days=90)

def test_a_window_outside_the_choices_is_a_usage_error(self):
result, historical_create = self._start(str(_RULE_FILE), '--window-days', '45')
assert result.exit_code == 2, result.output
# Names the refused value and what is allowed; click's wording varies by version.
for text in ('45', '30', '60', '90', '180'):
assert text in result.output, result.output
historical_create.assert_not_called()

def test_help_documents_the_choices_and_the_default(self):
result = CliRunner().invoke(
client.polyswarm_cli, _GLOBALS + ['historical', 'start', '--help'],
catch_exceptions=False)
assert result.exit_code == 0, result.output
helptext = ' '.join(result.output.split())
assert '--window-days [30|60|90|180]' in helptext, helptext
assert 'Defaults to max' in helptext, helptext


class HuntTimeRangeFormatterTest(TestCase):
def _lines(self, hunt, method='hunt'):
return getattr(text.TextOutput(color=False), method)(hunt, write=False)

def test_a_window_renders_as_the_last_n_days(self):
assert 'Time Range: Last 30 days' in self._lines(_hunt(window_days=30))

def test_no_window_renders_as_the_platform_maximum(self):
# An explicit null and a key the server never sent mean the same thing.
for hunt in (_hunt(window_days=None), _hunt()):
lines = self._lines(hunt)
assert 'Time Range: Platform maximum' in lines
assert not [line for line in lines if line.startswith('Time Range: Last')]

def test_the_line_follows_created_at(self):
lines = self._lines(_hunt(window_days=90))
created = next(i for i, line in enumerate(lines) if line.startswith('Created at:'))
assert lines[created + 1] == 'Time Range: Last 90 days'

def test_a_deleted_hunt_shows_it_too(self):
# hunt_deletion embeds the hunt block.
assert 'Time Range: Last 60 days' in self._lines(_hunt(window_days=60), 'hunt_deletion')


class HistoricalStartRendersTimeRangeTest(TestCase):
"""The command reaches the formatter: what the user asked for is what the hunt shows."""

def _start(self, *args, fmt='text', **patch):
with mock.patch('polyswarm_api.api.PolyswarmAPI.historical_create',
autospec=True, **patch):
return CliRunner().invoke(
client.polyswarm_cli,
_GLOBALS + ['--output-format', fmt, 'historical', 'start', '-r', '5', *args],
catch_exceptions=False)

def test_the_started_hunt_shows_the_window_it_was_started_with(self):
result = self._start('--window-days', '60', side_effect=_echo_window)
assert result.exit_code == 0, result.output
assert 'Time Range: Last 60 days' in result.output

def test_a_start_without_the_flag_shows_the_platform_maximum(self):
result = self._start(side_effect=_echo_window)
assert result.exit_code == 0, result.output
assert 'Time Range: Platform maximum' in result.output

def test_json_carries_the_raw_key_and_no_label(self):
# The JSON formatter dumps the resource's own JSON, so it needed no change.
result = self._start(fmt='json', return_value=_hunt(window_days=30))
assert result.exit_code == 0, result.output
assert json.loads(result.output)['window_days'] == 30
assert 'Time Range' not in result.output
27 changes: 14 additions & 13 deletions tests/vcr/test_historical_hunt_create_text.click
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
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\
\ : 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!\"\
Time Range: Platform maximum\nRuleset 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 : 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"
Loading
Loading