diff --git a/AGENTS.md b/AGENTS.md index e4c2c055..29637b27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ feature/* ─┐ - **`develop → master` PRs are how `master` advances.** They're opened by a maintainer when a release-worthy batch of work is on `develop`. Most contributors never open one of these. - **Direct PRs to `master`** are wrong. If you opened one, close it, branch off `develop` instead, and re-open against `develop`. - **PyPI release happens automatically** when `pyproject.toml`'s `version` changes on `master`. Don't bump the version inside a feature PR unless the maintainer specifically asks — version bumps belong to the `develop → master` step. +- **The standing exception: a downstream floor that must point at this change.** (This is the SDK-side instance of a workspace-level standard — *cross-repo dependencies are a version pin, not a runtime probe* — not a rule this repo grants itself.) It applies on one condition: the sibling's CI resolves this repo **from source** by branch name, so it can test against an unpublished version. When that holds, the sibling raises its `polyswarm_api>=` floor to the version introducing the surface — and that floor cannot name a version this repo has not declared yet, so the bump lands **here, in the feature PR**, not at the release step. A consumer that installs only published artifacts has no such need: it adopts after the release, and this repo bumps at its own release step as usual. Bump with `bump-my-version` and **check the emitted string is a clean `X.Y.0`**: the config can serialize a `.devN+sha` form, and PEP 440 orders `4.4.0.dev0 < 4.4.0`, so a dev suffix silently fails the sibling's floor and sends its CI to PyPI for a version that does not exist yet. **Why this matters:** `master` is the published surface of the SDK. PyPI consumers see whatever shows up there. Skipping `develop` skips the integration soak that protects against accidentally shipping a half-baked change. @@ -87,7 +88,7 @@ No per-symbol codegen carve-outs: every method — including `engines`, a cached Mirror the existing patterns (`LLMPromptConfig`, `MetadataFieldProperties`, `YaraRuleset`): -1. `class FooBar(BaseJsonResource): RESOURCE_ENDPOINT = '/…'` in [`resources.py`](./src/polyswarm_api/resources.py). If the resource's identifier isn't `id`, set `RESOURCE_ID_KEYS = ['your_key']` so the base class routes it into the query string for `GET` / `DELETE` / `PUT`. Resources are transport-agnostic — only edit `resources.py`. +1. `class FooBar(BaseJsonResource): RESOURCE_ENDPOINT = '/…'` in [`resources.py`](./src/polyswarm_api/resources.py). `RESOURCE_ID_KEYS` (default `['id']`) names the keys the base class routes into the **query string** for `GET` / `DELETE` / `PUT` — everything else rides the body. Usually that is the identifier, when it isn't called `id`; but it is a routing list, not an identity declaration, so a resource may name a non-identifier key to keep `id` in the body (`YaraRulesetFavorite` does — see [`specs/02-resources.md`](./specs/02-resources.md)). Resources are transport-agnostic — only edit `resources.py`. 2. Add convenience methods on **[`PolySwarmAsyncAPI`](./src/polyswarm_api/aio/api.py)** (the canonical async source). For a single resource: `return await self._single(resources.FooBar.(self, …))`. For paginated: `async for item in self._paginate(...): yield item`. 3. Run `python scripts/regenerate_sync.py` (or rely on the pre-commit hook) to regenerate the sync mirror at `polyswarm_api/api.py`. 4. Add tests **e2e-first** (see [`specs/04-testing.md`](./specs/04-testing.md)): a live-e2e **VCR lifecycle test** against the real endpoint (sync body in `client_scan_test.py`, async in `async_client_test.py`; record the cassettes against a fresh e2e stack and commit them) plus **pure-unit builder tests** (assert the resulting `PolyswarmRequest`'s shape — body-vs-query routing, None-omission; no httpx fixtures needed). Reach for the respx `ClientTestCase` harness only when the scenario can't reasonably run on the e2e stack. diff --git a/pyproject.toml b/pyproject.toml index e1294158..2af7a2d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "polyswarm_api" -version = "4.3.0" +version = "4.4.0" description = "Client library to simplify interacting with the PolySwarm consumer API" readme = "README.md" requires-python = ">=3.10,<4" @@ -55,7 +55,7 @@ package-dir = { "" = "src" } where = ["src"] [tool.bumpversion] -current_version = "4.3.0" +current_version = "4.4.0" commit = true tag = false sign_tags = true diff --git a/specs/01-architecture.md b/specs/01-architecture.md index 8005ba7c..d9bea7a4 100644 --- a/specs/01-architecture.md +++ b/specs/01-architecture.md @@ -42,7 +42,7 @@ Hand-written. No I/O. Three sub-concerns: **Resource bases**: `BaseResource`, `BaseJsonResource`. The latter has classmethod builders (`create` / `get` / `head` / `update` / `delete` / `list`) that each return a `PolyswarmRequest` descriptor. Per-domain resources in `resources.py` inherit from `BaseJsonResource` and may add custom classmethods (`ArtifactInstance.search_hash`, `IOC.iocs_by_hash`, etc.) — all returning descriptors. -**Helpers**: `Hashable` + `Hash` + `is_valid_sha1/sha256/md5`, `parse_isoformat`, `_normalise_bool_params`, `RequestParamsEncoder`, `_raise_for_status` (the shared non-2xx → typed-exception mapper, used by both `parse_response` and the session's streaming-download path). Downloads stream straight to their destination from the session — there is no response adapter; see [`99-open-questions.md`](./99-open-questions.md) §"Streaming downloads". +**Helpers**: `Hashable` + `Hash` + `is_valid_sha1/sha256/md5`, `parse_isoformat`, `_normalise_bool_params`, `RequestParamsEncoder`, `_as_result_bound` (the one definition of a caller's result bound — private, like its sibling helpers: nothing outside this package calls it), `_raise_for_status` (the shared non-2xx → typed-exception mapper, used by both `parse_response` and the session's streaming-download path). Downloads stream straight to their destination from the session — there is no response adapter; see [`99-open-questions.md`](./99-open-questions.md) §"Streaming downloads". ### Layer 2 — transport (`session.py` / `aio/session.py`) diff --git a/specs/02-resources.md b/specs/02-resources.md index 181bbd81..9f1e6499 100644 --- a/specs/02-resources.md +++ b/specs/02-resources.md @@ -37,6 +37,7 @@ BaseResource # holds .api, ._content, .parse_result cla ├── HistoricalHuntResult / List # /hunt/historical/results (+ /results/list) ├── LiveHuntResult / List # /hunt/live (+ /hunt/live/list) ├── YaraRuleset # /hunt/rule + ├── YaraRulesetFavorite # /hunt/rule/favorite ├── Tag, MalwareFamily, TagLink # /tags/tag, /tags/family, /tags/link ├── AssertionsJob, VotesJob # /consumer/assertions-job, /consumer/votes-job ├── SandboxTask, SandboxProvider # /sandbox/sandboxtask, /sandbox/provider @@ -240,7 +241,7 @@ from .core import BaseJsonResource, Hashable, PolyswarmRequest class FooBar(BaseJsonResource): RESOURCE_ENDPOINT = '/foobar' - RESOURCE_ID_KEYS = ['foo_id'] # only needed if the identifier isn't 'id' + RESOURCE_ID_KEYS = ['foo_id'] # keys routed to the query string, not the body # Optional: parametrised path # RESOURCE_ENDPOINT = '/foobar/{foo_id}' @@ -343,7 +344,8 @@ Holds `handle`, `artifact_name`, `artifact_type`, `sha256`, `sha1`, `md5`. Also Several resources add domain-specific classmethods on top of the standard CRUD set: - `IOC` — `iocs_by_hash`, `ioc_search`, `check_known_hosts`, `create_known_good`, `create_known_bad`, `update_known_good`, `delete_known_good`. -- `LiveYaraRuleset` / `HistoricalHunt` / `YaraRuleset` — standard CRUD plus list/delete-batch variants. +- `LiveYaraRuleset` / `HistoricalHunt` / `YaraRuleset` — standard CRUD plus list/delete-batch variants. `YaraRuleset` also parses the hunt-page tracking fields (`favorite`, `favorited_at`, `rule_count` — `None` means the server had no answer, distinct from 0 — `historical_hunt_count`, and the stored `new_results_count` with its staleness marker `new_results_counted_at`: the server refreshes the counter on a schedule and the marker says when, so `None` means "not yet refreshed / no live hunt", never 0), and `HistoricalHunt` the source-rule provenance (`rule_id`, `rule_modified`, and the tri-state `source_rule_changed` — `None` is "unknown", never "unchanged"). All additive `.get()` parses; an older server leaves them `None`. +- `YaraRulesetFavorite` — `RESOURCE_ENDPOINT = '/hunt/rule/favorite'`, **`RESOURCE_ID_KEYS = ['community']`**: a deliberate deviation from the default `['id']`, splitting the request the way the server reads it — the toggle reads BOTH `id` and `favorite` from the PUT **body** (the default would move `id` into the query string and the server would 400 with "A valid rule id must be provided"), while `community` rides the **query string** to match where the ruleset GET/list calls send it. (The server's community middleware accepts the value from either the query or the body — but never both at once — so the placement here is about consistency and the id-400, not about a silently-ignored body value.) `favorite` serialises as `1`/`0` (the `_params` bool→int coercion), which the server's boolean parser accepts. Response carries the star state plus the team's `favorites_used`/`favorites_limit`. Pinned by `test/hunt_tracking_builder_test.py` and both transports of `test/ruleset_favorite_respx_test.py`. - `SandboxTask` — `create_file`, `update_file`, `latest`, `my_tasks` for the various sandbox-submission shapes. **No `upload_file` instance method** in 4.0. - `Sample` — `create` with `endpoint_fmt={'sha256': sha256}` for the URL-parametrised path. - `Webhook` — `test(api, webhook_id)` for the test-payload endpoint. diff --git a/specs/03-endpoints.md b/specs/03-endpoints.md index eea14fd5..c7762522 100644 --- a/specs/03-endpoints.md +++ b/specs/03-endpoints.md @@ -86,6 +86,7 @@ Internal-only CRUD for the `/known-good` binary resource (distinct from the IOC | `ruleset_create(name, rules, description=None)` | `YaraRuleset.create` | | `ruleset_get(ruleset_id=None)` | `YaraRuleset.get` | | `ruleset_update(ruleset_id, name=None, rules=None, description=None)` | `YaraRuleset.update` | +| `ruleset_favorite(ruleset_id, favorite=True)` | `YaraRulesetFavorite.update` — idempotent star/unstar; the response carries the team's `favorites_used`/`favorites_limit`, and an over-budget star is refused with a machine-readable `FAVORITE_LIMIT` error. The id and favorite ride the PUT **body**; `community` rides the query (see the `RESOURCE_ID_KEYS = ['community']` note in specs/02) | | `ruleset_delete(ruleset_id)` | `YaraRuleset.delete` | | `tag_link_get(sha256)` | `TagLink.get` | | `tag_link_update(sha256, tags=None, families=None, emerging=None, remove=False)` | `TagLink.update` | @@ -186,10 +187,10 @@ refusal. | `iocs_by_hash(hash_type, hash_value, hide_known_good=False, beta=False)` | `IOC.iocs_by_hash` | | `search_by_ioc(ip=None, domain=None, ttp=None, imphash=None)` | `IOC.ioc_search` | | `check_known_hosts(ips=[], domains=[])` | `IOC.check_known_hosts` | -| `live_feed(since=None, …)` | `LiveHuntResult.list` | +| `live_feed(since=None, …, livescan_id=None, max_results=None)` | `LiveHuntResult.list` — `livescan_id` scopes the feed to one live hunt (the hunt-page per-ruleset feed); `since` is in **SECONDS** (the server converts with `timedelta(seconds=since)`; the 3.x/4.x docstring said minutes and was wrong), and absent-or-`0` means no time filter at all — the server applies it on a truthiness test; `max_results` bounds how many results the generator yields — `None`/`0`/negative means no bound; it does not alter the request | | `historical_list(since=None)` | `HistoricalHunt.list` | | `historical_results(hunt=None, …)` | `HistoricalHuntResultList.get` | -| `ruleset_list()` | `YaraRuleset.list` | +| `ruleset_list(name=None, status=None, favorites_only=None, has_new_results=None)` | `YaraRuleset.list` — the hunt-page filters, conjunctive and optional; unset filters are omitted from the query so the no-filter request is byte-compatible with the old contract. `has_new_results` selects on the server's STORED counter (no window parameter — the window belongs to the server's scheduled refresh; rows carry `new_results_count` + `new_results_counted_at`) | | `tag_list()` | `Tag.list` | | `family_list()` | `MalwareFamily.list` | | `assertions_list(engine_id)` | `AssertionsJob.list` | diff --git a/specs/04-testing.md b/specs/04-testing.md index b81e5855..8228c689 100644 --- a/specs/04-testing.md +++ b/specs/04-testing.md @@ -26,7 +26,9 @@ How the test suite is organised. Three layers: pure unit tests (no HTTP at all - `test/async_client_test.py` — async, VCR-backed integration tests (not yet on the parametrised harness — follow-up work). - `test/jmespath_test.py` — unit tests for `BaseJsonResource.jmespath`. - `test/vcr/*.vcr` — recorded cassettes. -- `test/eicar.yara`, `test/malicious` — fixture files for upload tests. +- `test/malicious` — fixture file for upload tests (`test/eicar.yara` was retired when the rules tests moved to per-test `uid_yara` bodies). +- `test/hunt_tracking_builder_test.py` — pure-unit request-shape and parse tests for the hunt-page tracking builders/resources. +- `test/ruleset_favorite_respx_test.py` — dual-transport (`ClientTestCase`) respx suite for the favorite toggle: the `FAVORITE_LIMIT` refusal envelope and the query/body split. ## Three test layers @@ -55,7 +57,7 @@ A respx body for that same arm was written first and deleted once the live cover ## The parametrised `ClientTestCase` harness -Implemented in `test/_client_harness.py`, importable by any `respx` module that wants one body over both transports; `metadata_field_properties_test.py` is the canonical user, joined by `exists_probe_mapping_test.py` (the `exists()` `404`→`False` arm, which was async-only until the harness existed — the mapping is transport-independent, so one body covers both). The remaining `respx` bodies are the single-transport cases invariant 5 exempts. The shape: +Implemented in `test/_client_harness.py`, importable by any `respx` module that wants one body over both transports; `metadata_field_properties_test.py` is the canonical user, joined by `exists_probe_mapping_test.py` (the `exists()` `404`→`False` arm, which was async-only until the harness existed — the mapping is transport-independent, so one body covers both) and `ruleset_favorite_respx_test.py` (the favorite toggle's refusal envelope + query/body split). The remaining `respx` bodies are the single-transport cases invariant 5 exempts. The shape: ```python # test/_client_harness.py @@ -263,6 +265,10 @@ An earlier plan hedged that `-n 8` might need (a) poll windows scaled by `PYTEST The runner uses `--dist worksteal` (idle workers steal queued tests from busy ones — ≥ static `load` for tail balance; `loadscope` is avoided because it groups by module and would *concentrate* the heavy live tests on fewer workers). `conftest.py`'s `pytest_collection_modifyitems` then front-loads the long-pole live tests so they start at t=0 and the ~100 unit/respx tests backfill the tail. Both are deterministic (keyed on `nodeid`) so every xdist worker collects the same order. +`poll_equals` / `poll_equals_async` (in `_e2e_helpers.py`) is the read-after-write helper: poll a zero-arg `read` until it returns `want`. Use it for any assertion that reads back what the test just wrote through a replica-backed GET — on the e2e stack the replica *is* the primary so the first read usually wins, but a real-replica stack lags and the assertion flakes. The changed-since-freeze one flakes **silently** (a stale source body reads as "unchanged"), which is why it polls rather than sleeps. + +**`want` must never be `None`, and the helper refuses it.** Not-found during the lag window is treated as "not yet" and yields `None`, so `want=None` would compare equal and turn a *vanished* resource into a passing assertion. Poll a boolean instead — `read` returning `value is None`, `want=True`. Note the mirror-image limit: a `False` poll cannot ride out a stale `False`, so it only proves the value settled, not that it ever changed. + A few tests submit/dispatch several artifacts in one body; `run_concurrently` / `run_concurrently_async` (in `_e2e_helpers.py`) fan those out **only on the live run** and stay serial on replay — vcrpy patches the transport via `mock.patch`, which isn't thread-safe, and replay no-ops the sleeps anyway, so serial replay is both deterministic and instant (no cassette re-record needed). **These are correctness/scheduling/cleanup wins, not the wall-clock lever.** The suite's floor is the per-scan settle (`window_closed`), which every settle-bound test on a worker's serial chain pays in full. That floor is **not** the xdist scheduling or the job-phase cadence (lowering the e2e periodicity 10→3 provably didn't move it — a single-scan settle stayed ~32s). It is set deterministically at bounty creation by **`bounty_duration`** (the assertion-window length, ~25s of the ~32s) **+ `ARBITER_VOTE_DELAY`** (~1s). `bounty_duration` is an **artifact-index `ScanConfig` field** — the `default` config the suite submits with — so the lever lives in the e2e/artifact-index config, not in this repo. The full bounty lifecycle and these knobs are documented authoritatively in artifact-index `specs/02-bounty-scan-lifecycle.md`. diff --git a/specs/05-downstream-contract.md b/specs/05-downstream-contract.md index 2b40d018..77d0be8c 100644 --- a/specs/05-downstream-contract.md +++ b/specs/05-downstream-contract.md @@ -134,6 +134,7 @@ class Hashable: ... class Hash(Hashable): ... def is_valid_sha1 / _sha256 / _md5(value) -> bool def parse_isoformat(date_string) + ``` These are stable but less curated than the top-level exports. Downstream callers that subclass `BaseJsonResource` to add custom resource types are supported. Downstream callers that construct `PolyswarmRequest` manually and hand it to `session.execute` are supported. @@ -149,7 +150,7 @@ Metadata, MetadataMapping, MetadataFieldProperties IOC LiveYaraRuleset, LiveHuntResult, LiveHuntResultList HistoricalHunt, HistoricalHuntResult, HistoricalHuntResultList, HistoricalHuntList -YaraRuleset +YaraRuleset, YaraRulesetFavorite Tag, MalwareFamily, TagLink AssertionsJob, VotesJob SandboxTask, SandboxProvider @@ -180,6 +181,8 @@ class TimeoutException(PolyswarmException): ... `KnownGoodWithheldException` is the 404 raised when a download is refused because the artifact is a known-good binary — the platform never stores or serves those bytes. It **subclasses `NotFoundException`** precisely so invariant 3 holds for existing consumers: code that already does `except NotFoundException:` keeps catching the refusal with no change, and only callers that want to distinguish "withheld by design" from a plain miss catch the subclass. It adds one attribute, `.sources` — the known-good feeds that flagged the hash (e.g. `['nsrl']`), `[]` when the server named none. The contract is only this: **always a list of strings**, whatever the envelope carried, so `for feed in exc.sources` needs no shape check. Which wire shapes are coerced, and which are dropped and logged, is `exceptions._normalise_sources`' business rather than a promise to consumers — the server sends a list of strings today. Note it is **not** normalised the same way as `ArtifactInstance.known_good_sources`, which is the same concept reached from the instance response: that one is sorted and de-duplicated, while `.sources` preserves the order the envelope carried and can repeat a feed. Don't assume parity between the two. The raw envelope stays reachable at `exc.request.errors` (`{'code': 'KNOWN_GOOD', 'known_good': True, 'sources': [...]}`). The artifact's metadata — the flagging feeds plus any scan data already collected — remains readable through the search / instance endpoints; only the bytes are withheld, and the instance's `KNOWN_GOOD` state/status is the signal for the typed refusal (there is no separate "withheld" field; a `NOT_STORED` instance has no bytes either, but 404s plainly — see below). +`FAVORITE_LIMIT` (a refused ruleset star: the team's favorite budget is spent) has **no typed exception** — deliberately, since no pre-existing consumer needs re-routing the way `NotFoundException` did. It surfaces as the generic 400 `RequestException`, and the machine-readable path is the raw envelope: `exc.request.errors == {'code': 'FAVORITE_LIMIT', 'favorites_used': N, 'favorites_limit': M}`. The counters are the same pair a successful toggle returns, so a caller can render the "budget full" state from either outcome. Pinned by the dual-transport respx suite `test/ruleset_favorite_respx_test.py`. + **What "known-good" means on the server, as of artifact-index's two-predicate model** (its `specs/05`): the refusal fires on the server's *current understanding* — a catalogue entry exists for the sha256 **and** that entry's extension passes an executable allow-list — evaluated live on every request. Two consequences worth knowing as a consumer: the same download can start working again with no action on your part (the entry is deleted, or the policy narrows), and `ArtifactInstance.state` can report the new value **`NOT_STORED`** — a submission the server declined as known-good at the time whose hash is no longer currently known-good, so nothing was ever stored for it and a fresh submit of the same file works. `state` is a plain string here; the SDK does not enumerate it, so a new member needs no SDK release. Each `RequestException` subclass carries a `.request` attribute holding the originating `PolyswarmRequest` (set by `RequestException.__init__`). Callers can read `exc.request.status_code`, `exc.request.json` (the parsed response body after execution), `exc.request.input_json` (the body that was sent), `exc.request.request_parameters` (the request kwargs that built the call), etc. `InvalidValueException` and `TimeoutException` are client-side errors and don't carry a request descriptor. @@ -299,6 +302,44 @@ Migrating from 3.x to 4.0, callers retain: ## Backward compatibility — what changes +### Documentation corrections that read like behaviour changes + +- **`live_feed(since=)` was always SECONDS.** The 3.x/4.x docstring said + "minutes", but the server has always converted the parameter with + `timedelta(seconds=since)` — the docstring was corrected, not the wire. A + caller who read the old docstring and passed `60` meaning an hour was + already getting a minute; nothing changed underneath them. + + Moving the *wire* to minutes was considered and rejected. Every surface + around the parameter was written for minutes (the CLI's old `1440` default was + `24 * 60`), so re-basing the server looked like the fix that made all three + agree — but the endpoint carries substantial live traffic passing `since`, from + SDK and script clients outside our control, across a wide range of values. + Re-reading every one of those as minutes widens each window 60x and returns + more data with no error, silently. The wire stays seconds; + the one caller in our control (the CLI's default) was corrected instead. + +- **`since` absent or `0` means no time filter at all** — the feed pages over + everything. That is the server's contract (it applies the filter on a + truthiness test), and it is what a caller wanting the full history relies + on. Note the shape of the server-side test: it is truthiness, not + `is not None`, and tightening it would turn `since=0` into an empty window + rather than no filter. + +- **`live_feed(max_results=)` is new and additive.** It defaults to `None`, + which is the historical behaviour — every page. It bounds how many results + the generator yields and nothing else: the request is unchanged, so the + default call stays byte-compatible with every recorded cassette. + + **`max_results` is a total, not a page size — the two are independent.** The + page is the server's to choose (50 for the web service, capped by + `AI_MAX_QUERY_RESULTS`), and `_next_page` echoes the size it reports back on + each subsequent request; a bounded read keeps paginating in those same small, + API-friendly chunks and simply stops once it has enough. Sending + `limit=max_results` would conflate the two — and, since the cap is an + env var each deployment sets — and set well below a large bound — would have + turned that bound into a 400. + ### Required code changes - **File-upload module-level callables removed.** `polyswarm_api.aio.upload.async_upload_file` / `async_upload_logo` (and the bare-module aliases) are gone. The 3.x monkey-patch pattern doesn't carry forward. Customization is via subclassing `AsyncPolyswarmSession` and overriding `upload_file`. @@ -396,7 +437,8 @@ result = req._result # the parsed resource (or list); | **New exception class that subclasses an existing one** | **minor** — additive: every `except ` keeps catching it (invariant 3), so no consumer has to change. Raising the *base* class where a narrower one used to be raised is the major-bump direction | | **Narrowing which exception a status maps to** (same status code, more specific class) | **minor**, on the same reasoning — but only while the new class is a subclass of the old one. A sibling class is a behaviour change on a documented contract, i.e. major | | Bug fix in request/response handling | patch | -| Signature change on a public method | major | +| **New optional keyword argument on an existing method**, with a default that preserves the current behaviour | **minor** — additive on the same reasoning as the exception rows above: every existing call site keeps working untouched, so no consumer has to react | +| Signature change on a public method **that an existing caller must react to** — a parameter removed, reordered, renamed, or made required | major | | Rename / removal of a public symbol | major | | Behaviour change on a documented contract | major | | Internal refactor (private symbols, helper rewrites) | patch | @@ -412,6 +454,19 @@ result = req._result # the parsed resource (or list); No feature PR should touch `pyproject.toml`'s `version` unless the maintainer asks. See `AGENTS.md` §Gitflow. +**One case always asks: a consumer pinning a floor at this change.** A sibling repo that needs a +surface added here expresses that as a version requirement — `polyswarm_api>=X.Y.0` — because a +version pin is checkable by `pip` at install time, before any code runs. That floor cannot name a +version this repo has not declared, so the bump belongs in the feature PR and step 3's "bump at the +release step" does not apply. Consequences worth stating plainly: + +- **Order is forced.** This repo's `develop → master` must merge and release before the consumer can + be released, because the consumer's floor is unsatisfiable from PyPI until then. Consumer CI is + unaffected — it installs this repo straight from git by branch name. +- **The version string must be clean.** `4.4.0.dev0` does not satisfy `>=4.4.0` (PEP 440 orders + pre-releases below the release). Verify what `bump-my-version` emitted before pushing; the + serialize config here can produce a dev form. + ## Companion repos - **polyswarm-cli** — wraps these methods in CLI subcommands. CLI changes that need an SDK surface ship as a pair: SDK PR opens first, CLI PR depends on it via `## Requires` in the description. diff --git a/specs/99-open-questions.md b/specs/99-open-questions.md index 5e1fb91c..6655405a 100644 --- a/specs/99-open-questions.md +++ b/specs/99-open-questions.md @@ -150,7 +150,9 @@ This is environment, not bug — but it does mean the test can't be hermetic wit ### 2. `live` (sync + async) — requires the bounty / microengine pipeline -`live_start` returns a `LiveYaraRuleset` with `livescan_id=None` because the local e2e has no microengines processing submissions. The lifecycle calls (`ruleset_create`, `live_start`, `live_stop`) all return 200, but the feed never receives results and `livescan_id` doesn't get assigned. Same shape as #1 — environment, not bug. +The local e2e has no microengines processing submissions, so a live hunt never receives results and its feed stays empty — which is why the rules tests assert a zero-result `livescan_id` feed rather than a populated one. Same shape as #1 — environment, not bug. + +(This previously said `livescan_id` itself is never assigned. That is no longer true and may never have been: `live_start` does get a `livescan_id`, the rules tests now assert it as a hard contract, and `test/vcr/test_rules.vcr` records a real one. Only the *results* are missing on this stack.) ## What works (the other 42 cassettes) diff --git a/src/polyswarm_api/__init__.py b/src/polyswarm_api/__init__.py index 64f6de30..dadcc971 100644 --- a/src/polyswarm_api/__init__.py +++ b/src/polyswarm_api/__init__.py @@ -1,5 +1,5 @@ # https://www.python.org/dev/peps/pep-0008/#module-level-dunder-names -__version__ = '4.3.0' +__version__ = '4.4.0' __release_url__ = 'https://api.github.com/repos/polyswarm/polyswarm-api/releases/latest' from . import api diff --git a/src/polyswarm_api/aio/api.py b/src/polyswarm_api/aio/api.py index 18ab4921..22a3ad0a 100644 --- a/src/polyswarm_api/aio/api.py +++ b/src/polyswarm_api/aio/api.py @@ -18,7 +18,7 @@ import time from polyswarm_api import exceptions, resources, settings -from polyswarm_api.core import PolyswarmRequest +from polyswarm_api.core import PolyswarmRequest, _as_result_bound from .session import AsyncPolyswarmSession @@ -502,23 +502,35 @@ async def live_stop(self, rule_id): return await self._single(resources.LiveYaraRuleset.delete(self, rule_id=rule_id)) async def live_feed(self, since=None, rule_name=None, family=None, - polyscore_lower=None, polyscore_upper=None, community=None): + polyscore_lower=None, polyscore_upper=None, community=None, + livescan_id=None, max_results=None): """ Get live hunts feed - :param since: Fetch results from the last "since" minutes + :param since: Window in SECONDS (this said "minutes" in earlier releases and was + wrong). Absent or 0 means no time filter at all. :param rule_name: Filter hunt results on the provided rule name (exact match). :param family: Filter hunt results based on the family name (exact match). :param polyscore_lower: Polyscore lower bound for the hunt results. :param polyscore_upper: Polyscore upper bound for the hunt results. :param community: Community to retrieve live results from, or public/private. + :param livescan_id: Scope the feed to one live hunt's results. + :param max_results: Total results to yield, not a page size — paging + continues in the server's own chunks until the total is reached. + None, 0 or a negative means no bound: every page, as before. :return: Generator of HuntResult resources """ + bound = _as_result_bound(max_results) + yielded = 0 async for item in self._paginate(resources.LiveHuntResult.list( self, since=since, rule_name=rule_name, family=family, polyscore_lower=polyscore_lower, polyscore_upper=polyscore_upper, + livescan_id=livescan_id, community=community or self.community)): yield item + yielded += 1 + if bound is not None and yielded >= bound: + return async def live_feed_delete(self, result_ids): """ @@ -684,15 +696,45 @@ async def ruleset_delete(self, ruleset_id): logger.info('Delete ruleset %s', ruleset_id) return await self._single(resources.YaraRuleset.delete(self, id=ruleset_id, community=self.community)) - async def ruleset_list(self): + async def ruleset_list(self, name=None, status=None, favorites_only=None, + has_new_results=None): """ List all YaraRulesets for the current account. + + All filters are optional and conjunctive: + :param name: Case-insensitive substring match on the ruleset name. + :param status: 'active' returns only rulesets whose live hunt is + currently running. + :param favorites_only: True returns only favorited rulesets. + :param has_new_results: True returns only rulesets whose stored + new-results counter is positive. The counter (and its window) is + maintained server-side by a scheduled refresh; rows carry it as + ``new_results_count`` with ``new_results_counted_at`` marking when + it was last refreshed. There is no per-request window parameter. :return: A generator of YaraRuleset resources """ logger.info('List rulesets') - async for item in self._paginate(resources.YaraRuleset.list(self, community=self.community)): + async for item in self._paginate(resources.YaraRuleset.list( + self, name=name, status=status, favorites_only=favorites_only, + has_new_results=has_new_results, community=self.community)): yield item + async def ruleset_favorite(self, ruleset_id, favorite=True): + """ + Favorite or unfavorite a YaraRuleset. Idempotent; works while a live + hunt is running. Favorites are shared by the whole team and capped + (the response carries favorites_used / favorites_limit); when the + budget is exhausted the server refuses with a machine-readable + FAVORITE_LIMIT error. + + :param ruleset_id: Id of the ruleset + :param favorite: True to star, False to unstar + :return: A YaraRulesetFavorite resource + """ + logger.info('%s ruleset %s', 'Favorite' if favorite else 'Unfavorite', ruleset_id) + return await self._single(resources.YaraRulesetFavorite.update( + self, id=ruleset_id, favorite=favorite, community=self.community)) + async def tag_link_get(self, sha256): """ Fetch the Tags and Families associated with the given sha256. diff --git a/src/polyswarm_api/api.py b/src/polyswarm_api/api.py index 53400201..3e67d983 100644 --- a/src/polyswarm_api/api.py +++ b/src/polyswarm_api/api.py @@ -20,7 +20,7 @@ import logging from polyswarm_api import exceptions, resources, settings -from polyswarm_api.core import PolyswarmRequest +from polyswarm_api.core import PolyswarmRequest, _as_result_bound from .session import PolyswarmSession @@ -568,18 +568,27 @@ def live_feed( polyscore_lower=None, polyscore_upper=None, community=None, + livescan_id=None, + max_results=None, ): """ Get live hunts feed - :param since: Fetch results from the last "since" minutes + :param since: Window in SECONDS (this said "minutes" in earlier releases and was + wrong). Absent or 0 means no time filter at all. :param rule_name: Filter hunt results on the provided rule name (exact match). :param family: Filter hunt results based on the family name (exact match). :param polyscore_lower: Polyscore lower bound for the hunt results. :param polyscore_upper: Polyscore upper bound for the hunt results. :param community: Community to retrieve live results from, or public/private. + :param livescan_id: Scope the feed to one live hunt's results. + :param max_results: Total results to yield, not a page size — paging + continues in the server's own chunks until the total is reached. + None, 0 or a negative means no bound: every page, as before. :return: Generator of HuntResult resources """ + bound = _as_result_bound(max_results) + yielded = 0 for item in self._paginate( resources.LiveHuntResult.list( self, @@ -588,10 +597,14 @@ def live_feed( family=family, polyscore_lower=polyscore_lower, polyscore_upper=polyscore_upper, + livescan_id=livescan_id, community=community or self.community, ) ): yield item + yielded += 1 + if bound is not None and yielded >= bound: + return def live_feed_delete(self, result_ids): """ @@ -823,17 +836,58 @@ def ruleset_delete(self, ruleset_id): resources.YaraRuleset.delete(self, id=ruleset_id, community=self.community) ) - def ruleset_list(self): + def ruleset_list( + self, name=None, status=None, favorites_only=None, has_new_results=None + ): """ List all YaraRulesets for the current account. + + All filters are optional and conjunctive: + :param name: Case-insensitive substring match on the ruleset name. + :param status: 'active' returns only rulesets whose live hunt is + currently running. + :param favorites_only: True returns only favorited rulesets. + :param has_new_results: True returns only rulesets whose stored + new-results counter is positive. The counter (and its window) is + maintained server-side by a scheduled refresh; rows carry it as + ``new_results_count`` with ``new_results_counted_at`` marking when + it was last refreshed. There is no per-request window parameter. :return: A generator of YaraRuleset resources """ logger.info("List rulesets") for item in self._paginate( - resources.YaraRuleset.list(self, community=self.community) + resources.YaraRuleset.list( + self, + name=name, + status=status, + favorites_only=favorites_only, + has_new_results=has_new_results, + community=self.community, + ) ): yield item + def ruleset_favorite(self, ruleset_id, favorite=True): + """ + Favorite or unfavorite a YaraRuleset. Idempotent; works while a live + hunt is running. Favorites are shared by the whole team and capped + (the response carries favorites_used / favorites_limit); when the + budget is exhausted the server refuses with a machine-readable + FAVORITE_LIMIT error. + + :param ruleset_id: Id of the ruleset + :param favorite: True to star, False to unstar + :return: A YaraRulesetFavorite resource + """ + logger.info( + "%s ruleset %s", "Favorite" if favorite else "Unfavorite", ruleset_id + ) + return self._single( + resources.YaraRulesetFavorite.update( + self, id=ruleset_id, favorite=favorite, community=self.community + ) + ) + def tag_link_get(self, sha256): """ Fetch the Tags and Families associated with the given sha256. diff --git a/src/polyswarm_api/core.py b/src/polyswarm_api/core.py index f6e43f44..cbf0ffeb 100644 --- a/src/polyswarm_api/core.py +++ b/src/polyswarm_api/core.py @@ -16,7 +16,8 @@ builders (``create``, ``get``, ``head``, ``update``, ``delete``, ``list``) construct ``PolyswarmRequest`` descriptors. - ``Hashable`` + ``is_valid_*`` validators, ``parse_isoformat``, - ``_normalise_bool_params``, ``RequestParamsEncoder``. + ``_normalise_bool_params``, ``RequestParamsEncoder``, + ``_as_result_bound``. Both transports import from here. """ @@ -668,6 +669,16 @@ def list(cls, api, **kwargs): ) +def _as_result_bound(max_results): + """The caller's result bound, or None when there isn't one. + + None, 0 and negatives all mean "no bound". + """ + if not max_results or max_results < 0: + return None + return max_results + + # ── Hash helpers ─────────────────────────────────────────────────── diff --git a/src/polyswarm_api/resources.py b/src/polyswarm_api/resources.py index fa5f1c1d..b898ac8f 100644 --- a/src/polyswarm_api/resources.py +++ b/src/polyswarm_api/resources.py @@ -746,12 +746,51 @@ def __init__(self, content, api=None): self.modified = core.parse_isoformat(content.get('modified')) self.deleted = content.get('deleted') self.yara = content.get('yara') + # Hunt-page tracking fields. All additive — an older server simply + # leaves them None. + self.favorite = content.get('favorite') + self.favorited_at = core.parse_isoformat(content.get('favorited_at')) + # Number of rules in the ruleset body. None means "no answer" (the + # server could not or has not counted), which is different from 0. + self.rule_count = content.get('rule_count') + self.historical_hunt_count = content.get('historical_hunt_count') + # Live results collected in the product window — a STORED counter the + # server refreshes on a schedule, always present on list rows with a + # running live hunt. None means "not yet refreshed / no live hunt", + # which is different from 0 ("refreshed, nothing new"). + self.new_results_count = content.get('new_results_count') + # When the server last refreshed the counter — the staleness marker + # that makes a stored count trustworthy (None iff the count is None). + self.new_results_counted_at = core.parse_isoformat( + content.get('new_results_counted_at')) class LiveYaraRuleset(YaraRuleset): RESOURCE_ENDPOINT = '/hunt/rule/live' +class YaraRulesetFavorite(core.BaseJsonResource): + """The favorite-toggle response: the ruleset's new star state plus the + team's budget usage, so callers can render "N of M used" without counting + client-side.""" + RESOURCE_ENDPOINT = '/hunt/rule/favorite' + # The toggle's payload ({id, favorite}) rides the JSON body — the server + # reads BOTH from the body — so the default RESOURCE_ID_KEYS (['id']) + # would move `id` into the query string and the server would 400 with + # "A valid rule id must be provided". `community` stays a query-string + # key to match where every ruleset GET/list sends it (the server accepts + # it from either the query or the body, but never from both at once). + RESOURCE_ID_KEYS = ['community'] + + def __init__(self, content, api=None): + super().__init__(content, api=api) + self.id = content.get('id') + self.favorite = content.get('favorite') + self.favorited_at = core.parse_isoformat(content.get('favorited_at')) + self.favorites_used = content.get('favorites_used') + self.favorites_limit = content.get('favorites_limit') + + class LiveHuntResult(core.BaseJsonResource): RESOURCE_ENDPOINT = '/hunt/live' @@ -794,6 +833,16 @@ def __init__(self, content, api=None): self.progress = content['progress'] self.results_csv_uri = content['results_csv_uri'] self.communities = content.get('communities') + # Source-rule provenance. rule_id is the ruleset this hunt was + # triggered from (None for raw-yara hunts and hunts predating the + # tracking); rule_modified is a freeze-time audit timestamp. + self.rule_id = content.get('rule_id') + self.rule_modified = core.parse_isoformat(content.get('rule_modified')) + # Tri-state: has the source ruleset's body changed SINCE THE HUNT + # FROZE IT? True/False when the server could compare; None means + # unknown (no source rule, or nothing to compare against) — not + # "unchanged". + self.source_rule_changed = content.get('source_rule_changed') class HistoricalHuntList(HistoricalHunt): diff --git a/test/_e2e_helpers.py b/test/_e2e_helpers.py index 59c5d21c..88884220 100644 --- a/test/_e2e_helpers.py +++ b/test/_e2e_helpers.py @@ -23,6 +23,7 @@ import os import re import tempfile +import time from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager @@ -75,8 +76,8 @@ def uid_yara(uid): The artifact embeds ``uid`` (see ``malicious_artifact``), so a rule keying on that literal matches just this run's submission — isolating a live/historical - hunt from every other test's EICAR artifact (the generic eicar.yara substring - rule would match them all). + hunt from every other test's EICAR artifact (a generic EICAR-substring rule + would match them all). """ ident = re.sub(r'\W', '_', uid) return f'rule sdk_{ident} {{ strings: $u = "{uid}" condition: $u }}' @@ -132,3 +133,51 @@ async def run_concurrently_async(coros): if _vcr_off() and len(coros) > 1: return await asyncio.gather(*coros) return [await c for c in coros] + + +def poll_equals(read, want, tries=30, delay=1.0): + """Poll a zero-arg ``read`` until it returns ``want`` (or tries run out), + returning the last value read. For read-after-write assertions against + replica-backed GET endpoints (specs/04 in the server repo): on the e2e + stack the replica IS the primary so the first read usually wins, but a + real-replica stack lags — without the poll those assertions are + lag-flaky, and the changed-since-freeze one flakes in the silent + direction (stale source body reads as "unchanged"). Not-found during the + lag window counts as "not yet" — which is exactly why ``want`` must never + be None: a 404 would compare equal to it and turn a vanished resource + into a passing assertion (poll a boolean instead — ``read`` returning + ``value is None``, want=True). Enforced below. Sleeps are free on VCR + replay (``_skip_poll_sleep_on_replay``).""" + if want is None: + raise ValueError('poll_equals(want=None) would treat a 404 as a match; ' + 'poll a boolean instead (read: value is None, want=True).') + from polyswarm_api import exceptions as _exceptions + value = None + for _ in range(tries): + try: + value = read() + except (_exceptions.NotFoundException, _exceptions.NoResultsException): + value = None + if value == want: + return value + time.sleep(delay) + return value + + +async def poll_equals_async(read, want, tries=30, delay=1.0): + """The asyncio twin of ``poll_equals`` (``read`` is a zero-arg coroutine + function), including the want-is-None refusal.""" + if want is None: + raise ValueError('poll_equals_async(want=None) would treat a 404 as a ' + 'match; poll a boolean instead.') + from polyswarm_api import exceptions as _exceptions + value = None + for _ in range(tries): + try: + value = await read() + except (_exceptions.NotFoundException, _exceptions.NoResultsException): + value = None + if value == want: + return value + await asyncio.sleep(delay) + return value diff --git a/test/async_client_test.py b/test/async_client_test.py index b633553e..8d7bf462 100644 --- a/test/async_client_test.py +++ b/test/async_client_test.py @@ -29,7 +29,7 @@ from polyswarm_api import exceptions from test._e2e_helpers import ( - EICAR_STRING, malicious_artifact, artifact_file, uid_ip, uid_host, uid_yara, + EICAR_STRING, malicious_artifact, artifact_file, uid_ip, uid_host, uid_yara, poll_equals_async, assert_scanned, run_concurrently_async, ) @@ -608,33 +608,170 @@ async def test_async_sample(self, uid): # ── YARA Rulesets ───────────────────────────────────────────────────────── @vcr.use_cassette() - async def test_async_rules(self): + async def test_async_rules(self, uid): async with self._api() as api: - with open('test/eicar.yara') as f: - contents = f.read() - rule = await api.ruleset_create('test', contents) - assert rule.name == 'test' - assert rule.yara == contents + # A uid-namespaced single-rule body: unique name on the shared + # stack, deterministic rule_count of 1. + contents = uid_yara(uid) + rule = await api.ruleset_create(uid, contents) + hunt = None + # try opens IMMEDIATELY — see the sync twin: a failed assertion + # must still reach the finally's ruleset_delete. try: + assert rule.name == uid + assert rule.yara == contents + # Tracking fields are live from creation. + assert rule.rule_count == 1 + assert rule.favorite is False + assert rule.favorited_at is None + assert rule.historical_hunt_count == 0 # The e2e may carry leftover rulesets from prior runs; use # a presence assertion instead of an exact count. rules = [r async for r in api.ruleset_list()] assert any(r.id == rule.id for r in rules) + # Universal arms, not presence-only — an ignored param would + # pass the membership check alone. + by_name = [r async for r in api.ruleset_list(name=uid)] + assert rule.id in {r.id for r in by_name} + assert all(uid.lower() in (r.name or '').lower() for r in by_name) got = await api.ruleset_get(rule.id) - assert got.name == 'test' + assert got.name == uid + + # favorite round-trip with the server-owned budget counts + fav = await api.ruleset_favorite(rule.id, True) + assert fav.favorite is True + assert fav.favorited_at is not None + # limit is a PIN (fixed product cap); used is a BOUND (the + # stack budget is shared across runs) + # The cap is a per-deployment setting, so pin the RELATION rather + # than the number: an exact pin mirrors a code default the + # deployment can override, and fails a live run with VCR off. + assert fav.favorites_limit >= 1 + assert 1 <= fav.favorites_used <= fav.favorites_limit + # The star was written on the line above — the sharpest + # read-after-write here, so it polls like the rest (specs/04). + # One read per attempt: the rows are kept for the arm below. + starred = [] + async def _is_starred(): + try: + starred[:] = [r async for r in + api.ruleset_list(favorites_only=True)] + except exceptions.NoResultsException: + starred[:] = [] + return rule.id in {r.id for r in starred} + assert await poll_equals_async(_is_starred, True) + assert all(r.favorite for r in starred) + # unstarring here is the CONTRACT assertion; slot hygiene does + # not depend on reaching it — the finally's ruleset_delete + # soft-deletes and the budget counts only deleted=false rows + unfav = await api.ruleset_favorite(rule.id, False) + assert unfav.favorite is False + assert unfav.favorited_at is None + + # live-hunt scope: the stored new-results counter and the + # livescan_id feed both need a running hunt. The badge is + # written ONLY by the server's scheduled refresh job (never on + # a request), and that job does not run on the e2e stack — so + # the row reads null ("never refreshed"): never 0, with the + # staleness marker riding along. Counting semantics are pinned + # by the server's own HTTP + CLI suites. + await api.live_start(int(rule.id)) + try: + # the enable lands asynchronously and the GET reads the + # replica — poll rather than read-one-line-later + async def _has_livescan(): + return (await api.ruleset_get(rule.id)).livescan_id is not None + assert await poll_equals_async(_has_livescan, True) + livescan_id = (await api.ruleset_get(rule.id)).livescan_id + active = {r.id async for r in api.ruleset_list(status='active')} + assert rule.id in active + row = None + async for r in api.ruleset_list(): + if r.id == rule.id: + row = r + assert row is not None + assert row.new_results_count is None + assert row.new_results_counted_at is None + # has_new_results reads the stored counter (> 0): a never- + # refreshed hunt must NOT match + try: + hot_ids = {r.id async for r in + api.ruleset_list(has_new_results=True)} + except exceptions.NoResultsException: + hot_ids = set() + assert rule.id not in hot_ids + # NOTE: zero-result hunt — the feed check pins the wire + # shape and the empty pass-through only; the scoping + # semantics are pinned by the server's own HTTP suite + try: + feed = [r async for r in api.live_feed(livescan_id=livescan_id)] + assert feed == [] + except exceptions.NoResultsException: + pass + finally: + # MUST stop before the outer finally's ruleset_delete — a + # running live hunt blocks deletion server-side + await api.live_stop(int(rule.id)) - updated = await api.ruleset_update(rule.id, name='test2', description='test') - assert updated.name == 'test2' + async def _rule_is_inactive(): + try: + active_now = {r.id async for r in + api.ruleset_list(status='active')} + except exceptions.NoResultsException: + return True # nothing live anywhere: also a pass + return rule.id not in active_now + # the stop's detach reads back off the replica too — poll + assert await poll_equals_async(_rule_is_inactive, True) + + # a hunt triggered FROM the ruleset carries provenance and + # bumps the counter; the create response answers + # source_rule_changed=False directly — the freeze stamped the + # anchor from the very row it froze, so the answer is knowable + # without a re-read + hunt = await api.historical_create(int(rule.id)) + assert hunt.rule_id == rule.id + assert hunt.rule_modified is not None + assert hunt.source_rule_changed is False + + # replica-backed GETs: poll so a lagging replica (real + # stacks, not e2e — a fresh hunt can even 404 for a beat) + # can't flake these. The False poll below cannot defend + # against a stale False (it returns on the first False); + # its poll exists for the 404-window, while the later TRUE + # poll genuinely rides out a stale read. + async def _hunt_count(): + return (await api.ruleset_get(rule.id)).historical_hunt_count + + async def _changed(): + return (await api.historical_get(hunt.id)).source_rule_changed + + assert await poll_equals_async(_hunt_count, 1) == 1 + assert await poll_equals_async(_changed, False) is False + + # a body edit flips the hunt's source_rule_changed + updated = await api.ruleset_update( + rule.id, name=f'{uid}2', rules=f'{contents}\n// edited', description='test') + assert updated.name == f'{uid}2' assert updated.description == 'test' + assert await poll_equals_async(_changed, True) is True finally: - await api.ruleset_delete(rule.id) - remaining_ids = [] - try: - remaining_ids = [r.id async for r in api.ruleset_list()] - except exceptions.NoResultsException: - pass - assert rule.id not in remaining_ids + # The ruleset delete is the slot-hygiene guarantee — keep it + # reachable even when the hunt delete fails (see sync twin). + try: + if hunt is not None: + await api.historical_delete(hunt.id) + finally: + await api.ruleset_delete(rule.id) + async def _rule_is_unlisted(): + try: + listed = {r.id async for r in api.ruleset_list()} + except exceptions.NoResultsException: + return True + return rule.id not in listed + # the delete reads back off the replica — poll like the other + # read-after-writes in this test + assert await poll_equals_async(_rule_is_unlisted, True) # ── Historical Hunting ──────────────────────────────────────────────────── @@ -733,8 +870,14 @@ async def test_async_live(self, uid): await api.live_result(result_id) await api.live_stop(rule_id=rule_id) - stopped = await api.ruleset_get(rule_id) - assert stopped.livescan_id is None + + # the stop's detach reads back off the replica — poll instead + # of reading one line later. Poll a BOOLEAN, never want=None: + # the helper maps a 404 during the lag window to None, so + # want=None would let a vanished ruleset pass silently. + async def _stopped_livescan_cleared(): + return (await api.ruleset_get(rule_id)).livescan_id is None + assert await poll_equals_async(_stopped_livescan_cleared, True) finally: # Always tear the hunt down (see sync test_live): a leftover # active hunt captures every later EICAR submit and is what diff --git a/test/client_scan_test.py b/test/client_scan_test.py index 443f4a56..912bed55 100644 --- a/test/client_scan_test.py +++ b/test/client_scan_test.py @@ -17,7 +17,7 @@ from polyswarm_api import exceptions from test._e2e_helpers import ( - EICAR_STRING, malicious_artifact, artifact_file, uid_ip, uid_host, uid_yara, + EICAR_STRING, malicious_artifact, artifact_file, uid_ip, uid_host, uid_yara, poll_equals, assert_scanned, run_concurrently, ) @@ -224,6 +224,13 @@ def test_json_get(self): assert obj._get('path1.path3.path5') is None +def _favorites_or_empty(api): + """The starred rulesets, or [] when the server answers 204.""" + try: + return list(api.ruleset_list(favorites_only=True)) + except exceptions.NoResultsException: + return [] + class ScanTestCaseV2(TestCase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -506,10 +513,14 @@ def test_live(self): api.live_result(result_id) # Stop returns the just-stopped livescan_id; the ruleset's stored - # livescan_id flips back to None on the next read. + # livescan_id flips back to None on a subsequent read — which hits + # the replica, so poll instead of reading one line later. Poll a + # BOOLEAN, never want=None: the helper maps a 404 during the lag + # window to None, so want=None would let a vanished ruleset pass + # the assertion the old direct read would have raised on. api.live_stop(rule_id=rule_id) - stopped = api.ruleset_get(rule_id) - assert stopped.livescan_id is None + assert poll_equals( + lambda: api.ruleset_get(rule_id).livescan_id is None, True) finally: # Always tear the hunt down, even on a mid-test failure: a left- # over active live hunt captures every later EICAR submission in @@ -569,34 +580,163 @@ def test_historical_results(self): @vcr.use_cassette() def test_rules(self): api = PolyswarmAPI(self.test_api_key, uri=f'http://ai:9696/{self.api_version}', community='gamma') - # creating - with open('test/eicar.yara') as rule: - contents = rule.read() - rule = api.ruleset_create('test', contents) - assert rule.name == 'test' - assert rule.yara == contents + # creating — a uid-namespaced single-rule body, so the name is unique + # on the shared stack and rule_count is deterministically 1. + uid = self._testMethodName + contents = uid_yara(uid) + rule = api.ruleset_create(uid, contents) + hunt = None + # try opens IMMEDIATELY: every assertion below is inside it, so a + # failure still reaches the finally's ruleset_delete. A leaked ruleset + # on the shared stack is not free — a starred one holds a favorite slot. try: + assert rule.name == uid + assert rule.yara == contents + # The tracking fields are live from creation: the body was counted + # on the way in, nothing is starred yet, no hunts triggered. + assert rule.rule_count == 1 + assert rule.favorite is False + assert rule.favorited_at is None + assert rule.historical_hunt_count == 0 # listing — the created rule must be in the list; the e2e may carry # other rulesets from earlier runs, so use a presence assertion # rather than an exact count. rules = list(api.ruleset_list()) assert any(r.id == rule.id for r in rules) + # the name filter narrows to this test's own rule + # Both arms are UNIVERSAL, not presence-only: `rule.id in by_name` + # holds identically if the server ignored the param and returned + # the unfiltered list, which is exactly the regression a filter + # test exists to catch. + by_name = list(api.ruleset_list(name=uid)) + assert rule.id in {r.id for r in by_name} + assert all(uid.lower() in (r.name or '').lower() for r in by_name) # getting got = api.ruleset_get(rule.id) - assert got.name == 'test' - # updating - updated = api.ruleset_update(rule.id, name='test2', description='test') - assert updated.name == 'test2' + assert got.name == uid + # favorite round-trip, with the budget counts the server owns + fav = api.ruleset_favorite(rule.id, True) + assert fav.favorite is True + assert fav.favorited_at is not None + # the limit is a PIN (the fixed product cap, no plan scaling); + # used is a BOUND because the stack budget is shared across runs + # The cap is a per-deployment setting, so pin the RELATION rather + # than the number: an exact pin mirrors a code default the + # deployment can override, and fails a live run with VCR off. + assert fav.favorites_limit >= 1 + assert 1 <= fav.favorites_used <= fav.favorites_limit + # The star was written on the line above — the sharpest + # read-after-write here, so it polls like the rest (specs/04). + # One read per attempt: the rows are kept for the arm below. + starred = [] + def _is_starred(): + starred[:] = _favorites_or_empty(api) + return rule.id in {r.id for r in starred} + assert poll_equals(_is_starred, True) + assert all(r.favorite for r in starred) + # unstarring here is the CONTRACT assertion; slot hygiene does not + # depend on reaching it — the finally's ruleset_delete soft-deletes + # and the server's budget counts only deleted=false rows, so a + # failed run's star frees itself with the rule + unfav = api.ruleset_favorite(rule.id, False) + assert unfav.favorite is False + assert unfav.favorited_at is None + # live-hunt scope: the stored new-results counter and the + # livescan_id feed both need a running hunt. The badge is written + # ONLY by the server's scheduled refresh job (never on a request), + # and that job does not run on the e2e stack — so the row reads + # null ("never refreshed"), which is exactly the additive-field + # contract this SDK pins: never 0, and the staleness marker rides + # with it. The counting semantics themselves are pinned by the + # server's own HTTP + CLI suites. + api.live_start(int(rule.id)) + try: + # the enable lands asynchronously and the GET reads the + # replica — poll rather than read-one-line-later + assert poll_equals( + lambda: api.ruleset_get(rule.id).livescan_id is not None, + True) + livescan_id = api.ruleset_get(rule.id).livescan_id + assert rule.id in {r.id for r in api.ruleset_list(status='active')} + row = next(r for r in api.ruleset_list() if r.id == rule.id) + assert row.new_results_count is None + assert row.new_results_counted_at is None + # has_new_results reads the stored counter (> 0): a never- + # refreshed hunt must NOT match + try: + hot_ids = {r.id for r in api.ruleset_list(has_new_results=True)} + except exceptions.NoResultsException: + hot_ids = set() + assert rule.id not in hot_ids + # NOTE: with a zero-result hunt the feed check pins only the + # wire shape and the empty pass-through — it cannot tell a + # working filter from an ignored param (that would need a + # second hunt WITH results). The scoping semantics are pinned + # by the server's own HTTP suite. + try: + assert list(api.live_feed(livescan_id=livescan_id)) == [] + except exceptions.NoResultsException: + pass + finally: + # MUST stop before the outer finally's ruleset_delete — a + # running live hunt blocks deletion server-side + api.live_stop(int(rule.id)) + + def _active_ids(): + try: + return {r.id for r in api.ruleset_list(status='active')} + except exceptions.NoResultsException: + return set() # nothing live anywhere: also a pass + # the stop's detach reads back off the replica too — poll + assert poll_equals(lambda: rule.id not in _active_ids(), True) + # a historical hunt triggered FROM the ruleset carries the + # provenance and bumps the ruleset's counter; the create response + # answers source_rule_changed=False directly — the freeze stamped + # the anchor from the very row it froze, so the answer is knowable + # without a re-read + hunt = api.historical_create(int(rule.id)) + assert hunt.rule_id == rule.id + assert hunt.rule_modified is not None + assert hunt.source_rule_changed is False + # the GETs below read the replica; poll so a lagging replica + # (real stacks, not e2e — a fresh hunt can even 404 there for a + # beat) can't flake these. Note the False poll below cannot defend + # against a stale False (it returns on the first False, stale or + # not); its poll exists for the 404-window, while the later TRUE + # poll is the one that genuinely rides out a stale read. + assert poll_equals( + lambda: api.ruleset_get(rule.id).historical_hunt_count, 1) == 1 + # a read of the fresh hunt resolves the comparison: unchanged body + hunt_read = api.historical_get(hunt.id) + assert hunt_read.rule_id == rule.id + assert poll_equals( + lambda: api.historical_get(hunt.id).source_rule_changed, False) is False + # updating — a body edit flips the hunt's source_rule_changed + updated = api.ruleset_update( + rule.id, name=f'{uid}2', rules=f'{contents}\n// edited', description='test') + assert updated.name == f'{uid}2' assert updated.description == 'test' + assert poll_equals( + lambda: api.historical_get(hunt.id).source_rule_changed, True) is True finally: - # deleting — the created rule disappears from the list. - api.ruleset_delete(rule.id) - remaining_ids = [] - try: - remaining_ids = [r.id for r in api.ruleset_list()] - except exceptions.NoResultsException: - pass - assert rule.id not in remaining_ids + # The ruleset delete is the slot-hygiene guarantee other comments + # lean on, so it must not sit behind the hunt delete (a transient + # 5xx, or a hunt already DELETING, would otherwise leak a ruleset + # on the shared stack). + try: + if hunt is not None: + api.historical_delete(hunt.id) + finally: + # deleting — the created rule disappears from the list. + api.ruleset_delete(rule.id) + def _listed_ids(): + try: + return {r.id for r in api.ruleset_list()} + except exceptions.NoResultsException: + return set() + # the delete reads back off the replica — poll like the other + # read-after-writes in this test + assert poll_equals(lambda: rule.id not in _listed_ids(), True) @vcr.use_cassette() def test_tool_metadata(self): diff --git a/test/conftest.py b/test/conftest.py index c9eecb40..921a7738 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -121,6 +121,13 @@ async def _noop_async_sleep(*_a, **_k): "sample", # sandbox completion + metadata "stream", # global archiver batching "rescan", # rescan retry loop + settle + # Exact, because every substring of these two is also a prefix of + # test_ruleset_* — "rules", "test_rules" and "_rules" each caught the + # instant unit tests as well. A "::" fragment is matched as a nodeid + # SUFFIX (see _long_pole_rank), which is the only form that can name a + # test whose name is a prefix of another's. + "::test_rules", # live enable/stop + ~6 poll loops + "::test_async_rules", # the async twin "hash_search", # search-index lag "existence_probe", # submit + settle + search-index lag (the HEAD probe tests) "sandboxtask", # sandbox completion + index lag @@ -130,7 +137,9 @@ async def _noop_async_sleep(*_a, **_k): def _long_pole_rank(item): name = item.nodeid for rank, frag in enumerate(_LONG_POLE_FRAGMENTS): - if frag in name: + # A "::" fragment names one test exactly; anything else is a substring + # hint that may legitimately span several. + if name.endswith(frag) if frag.startswith('::') else frag in name: return rank return len(_LONG_POLE_FRAGMENTS) # unit/respx tests backfill the tail diff --git a/test/eicar.yara b/test/eicar.yara deleted file mode 100644 index 0c32b59f..00000000 --- a/test/eicar.yara +++ /dev/null @@ -1,34 +0,0 @@ -rule eicar_av_test { - /* - Per standard, match only if entire file is EICAR string plus optional trailing whitespace. - The raw EICAR string to be matched is: - X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H* - */ - - meta: - description = "This is a standard AV test, intended to verify that BinaryAlert is working correctly." - author = "Austin Byers | Airbnb CSIRT" - reference = "http://www.eicar.org/86-0-Intended-use.html" - - strings: - $eicar_regex = /^X5O!P%@AP\[4\\PZX54\(P\^\)7CC\)7\}\$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!\$H\+H\*\s*$/ - - condition: - all of them -} - -rule eicar_substring_test { - /* - More generic - match just the embedded EICAR string (e.g. in packed executables, PDFs, etc) - */ - - meta: - description = "Standard AV test, checking for an EICAR substring" - author = "Austin Byers | Airbnb CSIRT" - - strings: - $eicar_substring = "$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!" - - condition: - all of them -} \ No newline at end of file diff --git a/test/hunt_tracking_builder_test.py b/test/hunt_tracking_builder_test.py new file mode 100644 index 00000000..1aefe79d --- /dev/null +++ b/test/hunt_tracking_builder_test.py @@ -0,0 +1,284 @@ +"""Pure-unit request-shape tests for the hunt-page tracking builders. + +No HTTP at all (the pure-unit tier — see specs/04-testing.md): these pin the +request *construction* for the new surfaces — and specifically the two shapes +that are entirely consequences of ``core._params`` plumbing rather than +anything visible at the call site: + +* ``YaraRulesetFavorite`` narrows ``RESOURCE_ID_KEYS`` to ``['community']``, + which is what splits the request: the server reads BOTH ``id`` and + ``favorite`` from the JSON body (the default ``['id']`` would move ``id`` + into the query string and the server would 400), while ``community`` rides + the query string to match where the ruleset GET/list calls send it; and +* booleans serialise as ``1``/``0`` ints, not JSON ``true``/``false`` + (``core._params`` coerces before body/query routing) — the server's + boolean parser accepts exactly that, so the int-vs-bool body contract is + load-bearing. + +Endpoint *behaviour* is covered by the live-e2e VCR lifecycle tests +(``test_rules`` / ``test_async_rules``). +""" +import asyncio + +from polyswarm_api import core, resources +from polyswarm_api.aio import PolySwarmAsyncAPI +from polyswarm_api.api import PolyswarmAPI + + +class _FakeApi: + uri = 'https://api.example.test' + community = 'gamma' + + +class TestYaraRulesetFavoriteBuilder: + def test_update_splits_community_to_query_and_toggle_to_body(self): + api = _FakeApi() + req = resources.YaraRulesetFavorite.update( + api, id=5, favorite=True, community=api.community) + assert req.method == 'PUT' + assert req.url == f'{api.uri}/hunt/rule/favorite' + # RESOURCE_ID_KEYS = ['community'] is load-bearing: with the base + # ['id'] the id would ride the query string on a PUT, and the server + # reads the toggle's id exclusively from the body — a 400. community + # goes to the query to match the ruleset GET/list placement (the + # server accepts it from either side, never both at once). favorite + # serialises as int 1, not JSON true. + assert req.params == {'community': 'gamma'} + assert req.input_json == {'id': '5', 'favorite': 1} + assert req.result_parser is resources.YaraRulesetFavorite + + def test_unfavorite_serialises_false_as_zero(self): + req = resources.YaraRulesetFavorite.update( + _FakeApi(), id=5, favorite=False, community='gamma') + assert req.input_json['favorite'] == 0 + + +class TestRulesetListFilterBuilder: + def test_list_routes_filters_to_the_query_with_int_bools(self): + api = _FakeApi() + req = resources.YaraRuleset.list( + api, name='alpha', status='active', favorites_only=True, + has_new_results=True, community=api.community) + assert req.method == 'GET' + assert req.url == f'{api.uri}/hunt/rule/list' + assert req.params == { + 'name': 'alpha', 'status': 'active', 'favorites_only': 1, + 'has_new_results': 1, 'community': 'gamma'} + + def test_list_omits_every_unset_filter(self): + # The no-filter request is byte-compatible with the pre-filter + # contract: nothing but community rides the query string. + req = resources.YaraRuleset.list( + _FakeApi(), name=None, status=None, favorites_only=None, + has_new_results=None, community='gamma') + assert req.params == {'community': 'gamma'} + + +class TestYaraRulesetStoredCounterParse: + """Parse-side pins for the stored counter — the recorded cassettes carry + only null counters (the refresh job does not run on the e2e stack), so + the populated shape is otherwise asserted nowhere.""" + + def test_counter_and_staleness_marker_parse(self): + row = resources.YaraRuleset( + {'id': '5', 'new_results_count': 3, + 'new_results_counted_at': '2026-08-25T12:00:00+00:00'}, api=None) + assert row.new_results_count == 3 + assert row.new_results_counted_at is not None + assert row.new_results_counted_at.isoformat() == '2026-08-25T12:00:00+00:00' + + def test_absent_counter_is_none_never_zero(self): + # An older server (or a never-refreshed row) leaves both None — + # "no answer", distinct from a refreshed 0. + row = resources.YaraRuleset({'id': '5'}, api=None) + assert row.new_results_count is None + assert row.new_results_counted_at is None + + +class TestProvenanceAndCounterAbsentArms: + """The None arms the cassettes cannot carry: every recorded hunt resolves + its provenance (true/false) and every recorded ruleset has answers, so + the "None is unknown / no answer — NEVER a value" halves of the tri-state + and counter contracts are pinned here, purely on the parse.""" + + def test_hunt_without_provenance_parses_all_three_none(self): + # A pre-migration or raw-yara hunt: no rule_id, no anchor. None means + # UNKNOWN — a consumer rendering it as "unchanged" is the exact bug + # the tri-state exists to prevent. + hunt = resources.HistoricalHunt( + {'id': '9', 'created': '2026-08-25T12:00:00+00:00', + 'status': 'PENDING', 'progress': 0.0, 'results_csv_uri': None}, + api=None) + assert hunt.rule_id is None + assert hunt.rule_modified is None + assert hunt.source_rule_changed is None + + def test_ruleset_counters_absent_is_none_never_zero(self): + # rule_count / historical_hunt_count: "no answer" must not read as 0. + row = resources.YaraRuleset({'id': '5'}, api=None) + assert row.rule_count is None + assert row.historical_hunt_count is None + + +class TestRulesetListExplicitFalseFilters: + """An explicit ``False`` filter serialises to ``0`` on the query string. + + ``core._params`` coerces bools to ints before routing, so these land as + ``0`` rather than ``False``. The server's boolean argument parser accepts + exactly ``0``/``1``/``false``/``true`` and maps ``0`` to false, so an + explicit False really does mean unfiltered — but only the ``True`` and + omitted arms were covered, and an inverted filter is the one failure mode + that silently returns the wrong rows.""" + + def test_explicit_false_serialises_to_zero(self): + request = resources.YaraRuleset.list( + _FakeApi(), favorites_only=False, has_new_results=False, + community='gamma') + assert request.params['favorites_only'] == 0 + assert request.params['has_new_results'] == 0 + + +class TestLiveFeedScopeBuilder: + def test_list_routes_livescan_id_to_the_query_as_digit_string(self): + # *_id kwargs stringify (core._params); the server casts back to int. + req = resources.LiveHuntResult.list( + _FakeApi(), since=60, livescan_id=45392847561029383, + rule_name=None, family=None, polyscore_lower=None, + polyscore_upper=None, community='gamma') + assert req.url == 'https://api.example.test/hunt/live/list' + assert req.params == {'since': 60, 'livescan_id': '45392847561029383', + 'community': 'gamma'} + +class TestResultBound: + """``core._as_result_bound`` — one definition of "is there a bound".""" + + def test_no_bound_forms(self): + for no_bound in (None, 0, -1): + assert core._as_result_bound(no_bound) is None + + def test_a_real_bound_passes_through_unclamped(self): + assert core._as_result_bound(5) == 5 + assert core._as_result_bound(10_000) == 10_000 + + +class TestLiveFeedMaxResults: + """``live_feed(max_results=)`` stops the generator at the bound. + + Pinned here rather than against the stack: producing more feed rows than a + page holds on the shared e2e stack would mean generating real live-hunt + volume. ``_paginate`` is the seam — it is what would otherwise keep + following cursors — so it is what this stands in for.""" + + @staticmethod + def _api_yielding(count): + api = PolyswarmAPI.__new__(PolyswarmAPI) + api.uri = _FakeApi.uri + api.community = _FakeApi.community + api._paginate = lambda *a, **kw: iter(range(count)) + return api + + def test_unbounded_by_default_yields_everything(self): + assert list(self._api_yielding(7).live_feed()) == list(range(7)) + + def test_the_bound_truncates(self): + assert list(self._api_yielding(7).live_feed(max_results=3)) == [0, 1, 2] + + def test_a_bound_larger_than_the_feed_is_not_padding(self): + assert list(self._api_yielding(2).live_feed(max_results=9)) == [0, 1] + + def test_a_negative_bound_is_no_bound(self): + assert list(self._api_yielding(4).live_feed(max_results=-1)) == list(range(4)) + + def test_zero_is_no_bound_not_a_bound_of_one(self): + # 0 means no bound, as it does for `since` on the same call. Testing + # `is not None` instead makes max_results=0 yield exactly one result. + assert list(self._api_yielding(7).live_feed(max_results=0)) == list(range(7)) + +class TestLiveFeedLimitOnTheWire: + """``max_results`` is a total, so it must not reach the wire as ``limit``. + + The page stays the server's to choose and the read keeps paginating in + those chunks; sending the total as a page size also 400s above whatever + per-page cap the deployment configures. + """ + + @staticmethod + def _params(**kwargs): + api = PolyswarmAPI.__new__(PolyswarmAPI) + api.uri = _FakeApi.uri + api.community = _FakeApi.community + captured = {} + + def capture(request, *a, **kw): + captured.update(request.params) + return iter(()) + + api._paginate = capture + list(api.live_feed(**kwargs)) + return captured + + def test_no_limit_is_ever_sent(self): + for kwargs in ({}, {'max_results': 5}, {'max_results': 10_000}, + {'max_results': 0}, {'max_results': -1}): + assert 'limit' not in self._params(**kwargs) + + +class TestLiveFeedSinceOnTheWire: + """``since=0`` has to REACH the server to mean what it means. + + The contract is that absent-or-0 applies no time filter, and the server + implements it with a truthiness test. That only holds because ``_params`` + drops ``None`` and not ``0``: were falsy values dropped, ``since=0`` and + ``since=5`` would build the same request and the parameter would be + unusable for anything but its default.""" + + def test_zero_is_sent_and_absent_is_omitted(self): + sent = resources.LiveHuntResult.list(_FakeApi(), since=0, community='gamma') + assert sent.params['since'] == 0 + omitted = resources.LiveHuntResult.list(_FakeApi(), since=None, community='gamma') + assert 'since' not in omitted.params + +class TestAsyncLiveFeedMaxResults: + """The CANONICAL async bound loop, not the generated mirror. + + Every other max_results test drives ``PolyswarmAPI`` — the unasync output. + The ``async for`` + ``yielded``/``return`` shape is precisely the part + unasync REWRITES rather than copies, so a mirror-only test would keep + passing if the canonical source's loop were wrong.""" + + @staticmethod + def _api_yielding(count): + api = PolySwarmAsyncAPI.__new__(PolySwarmAsyncAPI) + api.uri = _FakeApi.uri + api.community = _FakeApi.community + captured = {} + + async def paginate(request, *a, **kw): + captured.update(request.params) + for item in range(count): + yield item + + api._paginate = paginate + return api, captured + + @staticmethod + def _collect(agen): + async def run(): + return [item async for item in agen] + + return asyncio.run(run()) + + def test_the_bound_truncates(self): + api, _ = self._api_yielding(7) + assert self._collect(api.live_feed(max_results=3)) == [0, 1, 2] + + def test_no_bound_yields_everything(self): + for no_bound in (None, 0, -1): + api, _ = self._api_yielding(4) + assert self._collect(api.live_feed(max_results=no_bound)) == list(range(4)) + + def test_no_limit_is_ever_sent(self): + for kwargs in ({}, {'max_results': 5}, {'max_results': 0}): + api, captured = self._api_yielding(0) + self._collect(api.live_feed(**kwargs)) + assert 'limit' not in captured diff --git a/test/ruleset_favorite_respx_test.py b/test/ruleset_favorite_respx_test.py new file mode 100644 index 00000000..cdfb3eca --- /dev/null +++ b/test/ruleset_favorite_respx_test.py @@ -0,0 +1,59 @@ +"""The favorite toggle's transport contract, respx-mocked on BOTH clients +(``ClientTestCase`` — specs/04-testing.md invariant 5). + +Two things live here because the shared e2e stack cannot pin either: + +* the ``FAVORITE_LIMIT`` refusal — producing a genuinely full budget on the + shared stack would mean holding all five team slots, racing every other + run; and +* the query/body split — the toggle's payload (``id``, ``favorite``) must + ride the JSON body (the server reads both from it; an ``id`` moved into + the query string by the default ``RESOURCE_ID_KEYS`` is a 400), while + ``community`` rides the query string to match the ruleset GET/list + placement. The e2e stack is single-community and its ids always exist, so + neither mis-split shows up there. +""" +import pytest + +from polyswarm_api import exceptions + +from test._client_harness import BASE_URL, COMMUNITY, ClientTestCase + +_FAVORITE_URL = f'{BASE_URL}/hunt/rule/favorite' + + +class RulesetFavoriteTestCase(ClientTestCase): + def test_favorite_limit_refusal_is_machine_readable(self): + # There is deliberately no typed exception (specs/05): the + # machine-readable contract is the raw envelope at + # ``exc.request.errors`` — the code plus the same counters a + # successful toggle returns. + envelope = { + 'status': 'error', + 'result': 'Favorite limit reached (5 of 5 used).', + 'errors': {'code': 'FAVORITE_LIMIT', + 'favorites_used': 5, 'favorites_limit': 5}, + } + self.mock.add('PUT', f'{_FAVORITE_URL}?community={COMMUNITY}', + json=envelope, status=400) + with pytest.raises(exceptions.RequestException) as excinfo: + self.api.ruleset_favorite(5, True) + errors = excinfo.value.request.errors + assert errors['code'] == 'FAVORITE_LIMIT' + assert errors['favorites_used'] == 5 + assert errors['favorites_limit'] == 5 + + def test_community_rides_the_query_and_the_toggle_rides_the_body(self): + ok = {'status': 'OK', + 'result': {'id': '5', 'favorite': True, 'favorited_at': None, + 'favorites_used': 1, 'favorites_limit': 5}} + self.mock.add('PUT', f'{_FAVORITE_URL}?community={COMMUNITY}', json=ok) + result = self.api.ruleset_favorite(5, True) + assert result.favorite is True + assert f'community={COMMUNITY}' in self.mock.last_request_url + body = self.mock.last_request_body + # The client's wire coercions apply to the body: ids stringify and + # bools ride as 1/0 (the server's cast=bool accepts both) — pinned so + # a coercion change is a deliberate one. + assert body == {'id': '5', 'favorite': 1} + assert 'community' not in body diff --git a/test/vcr/test_async_rules.vcr b/test/vcr/test_async_rules.vcr index 751c4b2a..175ad61d 100644 --- a/test/vcr/test_async_rules.vcr +++ b/test/vcr/test_async_rules.vcr @@ -1,17 +1,7 @@ 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 - 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"}' + body: '{"yara":"rule sdk_test_async_rules { strings: $u = \"test_async_rules\" + condition: $u }","name":"test_async_rules"}' headers: accept: - '*/*' @@ -22,49 +12,515 @@ interactions: connection: - keep-alive content-length: - - '1126' + - '115' content-type: - application/json host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: POST uri: http://ai:9696/v3/hunt/rule response: body: - string: '{"result":{"created":"2026-06-02T21:30:27.081195+00:00","deleted":false,"description":null,"id":"40732127887168358","livescan_created":null,"livescan_id":null,"modified":"2026-06-02T21:30:27.081195+00:00","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 - = \"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}"},"status":"OK"} + string: '{"result":{"created":"2026-08-25T18:24:13.239703+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"89620634730010513","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:24:13.239703+00:00","name":"test_async_rules","rule_count":1,"yara":"rule + sdk_test_async_rules { strings: $u = \"test_async_rules\" condition: $u }"},"status":"OK"} ' headers: - Access-Control-Allow-Origin: + access-control-allow-origin: - '*' - Access-Control-Expose-Headers: + access-control-expose-headers: - Authorization - Connection: + connection: + - keep-alive + content-length: + - '413' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:13 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?community=gamma + response: + body: + string: '{"has_more":false,"limit":50,"result":[{"created":"2026-08-25T18:24:13.239703+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"89620634730010513","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:24:13.239703+00:00","name":"test_async_rules","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null}],"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '422' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:13 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?name=test_async_rules&community=gamma + response: + body: + string: '{"has_more":false,"limit":50,"result":[{"created":"2026-08-25T18:24:13.239703+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"89620634730010513","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:24:13.239703+00:00","name":"test_async_rules","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null}],"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '422' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:13 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule?id=89620634730010513&community=gamma + response: + body: + string: '{"result":{"created":"2026-08-25T18:24:13.239703+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"89620634730010513","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:24:13.239703+00:00","name":"test_async_rules","rule_count":1,"yara":"rule + sdk_test_async_rules { strings: $u = \"test_async_rules\" condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '413' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:13 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"id":"89620634730010513","favorite":1}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '39' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: PUT + uri: http://ai:9696/v3/hunt/rule/favorite?community=gamma + response: + body: + string: '{"result":{"favorite":true,"favorited_at":"2026-08-25T18:24:13.531691+00:00","favorites_limit":5,"favorites_used":1,"id":"89620634730010513"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '157' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:13 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?favorites_only=1&community=gamma + response: + body: + string: '{"has_more":false,"limit":50,"result":[{"created":"2026-08-25T18:24:13.239703+00:00","deleted":false,"description":null,"favorite":true,"favorited_at":"2026-08-25T18:24:13.531691+00:00","historical_hunt_count":0,"id":"89620634730010513","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:24:13.239703+00:00","name":"test_async_rules","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null}],"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '451' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:13 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"id":"89620634730010513","favorite":0}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '39' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: PUT + uri: http://ai:9696/v3/hunt/rule/favorite?community=gamma + response: + body: + string: '{"result":{"favorite":false,"favorited_at":null,"favorites_limit":5,"favorites_used":0,"id":"89620634730010513"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '128' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:13 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"rule_id":"89620634730010513"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '31' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: POST + uri: http://ai:9696/v3/hunt/rule/live + response: + body: + string: '{"result":{"created":"2026-08-25T18:24:13.239703+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"89620634730010513","livescan_created":"2026-08-25T18:24:17.121237+00:00","livescan_id":"7969689239265785","modified":"2026-08-25T18:24:17.108313+00:00","name":"test_async_rules","rule_count":1,"yara":"rule + sdk_test_async_rules { strings: $u = \"test_async_rules\" condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '457' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:17 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule?id=89620634730010513&community=gamma + response: + body: + string: '{"result":{"created":"2026-08-25T18:24:13.239703+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"89620634730010513","livescan_created":"2026-08-25T18:24:17.121237+00:00","livescan_id":"7969689239265785","modified":"2026-08-25T18:24:17.108313+00:00","name":"test_async_rules","rule_count":1,"yara":"rule + sdk_test_async_rules { strings: $u = \"test_async_rules\" condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '457' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:17 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule?id=89620634730010513&community=gamma + response: + body: + string: '{"result":{"created":"2026-08-25T18:24:13.239703+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"89620634730010513","livescan_created":"2026-08-25T18:24:17.121237+00:00","livescan_id":"7969689239265785","modified":"2026-08-25T18:24:17.108313+00:00","name":"test_async_rules","rule_count":1,"yara":"rule + sdk_test_async_rules { strings: $u = \"test_async_rules\" condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '457' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:17 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?status=active&community=gamma + response: + body: + string: '{"has_more":false,"limit":50,"result":[{"created":"2026-08-25T18:24:13.239703+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"89620634730010513","livescan_created":"2026-08-25T18:24:17.121237+00:00","livescan_id":"7969689239265785","modified":"2026-08-25T18:24:17.108313+00:00","name":"test_async_rules","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null}],"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '466' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:17 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?community=gamma + response: + body: + string: '{"has_more":false,"limit":50,"result":[{"created":"2026-08-25T18:24:13.239703+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"89620634730010513","livescan_created":"2026-08-25T18:24:17.121237+00:00","livescan_id":"7969689239265785","modified":"2026-08-25T18:24:17.108313+00:00","name":"test_async_rules","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null}],"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: - keep-alive - Content-Length: - - '1346' - Content-Type: + content-length: + - '466' + content-type: - application/json - Date: - - Tue, 02 Jun 2026 21:30:27 GMT - Server: + date: + - Tue, 25 Aug 2026 18:24:17 GMT + server: - gunicorn - X-Billing-ID: + x-billing-id: - '111' status: code: 200 @@ -83,30 +539,233 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: GET - uri: http://ai:9696/v3/hunt/rule/list?community=gamma + uri: http://ai:9696/v3/hunt/rule/list?has_new_results=1&community=gamma + response: + body: + string: '' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-type: + - text/html; charset=utf-8 + date: + - Tue, 25 Aug 2026 18:24:17 GMT + server: + - gunicorn + status: + code: 204 + message: NO CONTENT +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/live/list?livescan_id=7969689239265785&community=gamma + response: + body: + string: '' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-type: + - text/html; charset=utf-8 + date: + - Tue, 25 Aug 2026 18:24:17 GMT + server: + - gunicorn + status: + code: 204 + message: NO CONTENT +- request: + body: '{"rule_id":"89620634730010513"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '31' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: DELETE + uri: http://ai:9696/v3/hunt/rule/live + response: + body: + string: '{"result":{"created":"2026-08-25T18:24:13.239703+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"89620634730010513","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:24:17.862491+00:00","name":"test_async_rules","rule_count":1,"yara":"rule + sdk_test_async_rules { strings: $u = \"test_async_rules\" condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '413' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:17 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?status=active&community=gamma + response: + body: + string: '' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-type: + - text/html; charset=utf-8 + date: + - Tue, 25 Aug 2026 18:24:18 GMT + server: + - gunicorn + status: + code: 204 + message: NO CONTENT +- request: + body: '{"rule_id":"89620634730010513","community":"gamma"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '51' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: POST + uri: http://ai:9696/v3/hunt/historical + response: + body: + string: '{"result":{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"communities":["gamma"],"created":"2026-08-25T18:24:18.313049+00:00","failed_max_retries":0,"failed_other":0,"id":"90911086658025862","progress":null,"results_csv_uri":null,"rule_id":"89620634730010513","rule_modified":"2026-08-25T18:24:17.862491+00:00","ruleset_name":"test_async_rules","source_rule_changed":false,"status":"PENDING","summary":null,"user_account_number":"111","yara":"rule + sdk_test_async_rules { strings: $u = \"test_async_rules\" condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '579' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:18 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule?id=89620634730010513&community=gamma response: body: - string: '{"has_more":false,"limit":50,"result":[{"created":"2026-06-02T21:30:27.081195+00:00","deleted":false,"description":null,"id":"40732127887168358","livescan_created":null,"livescan_id":null,"modified":"2026-06-02T21:30:27.081195+00:00","name":"test","yara":null}],"status":"OK"} + string: '{"result":{"created":"2026-08-25T18:24:13.239703+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":1,"id":"89620634730010513","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:24:17.862491+00:00","name":"test_async_rules","rule_count":1,"yara":"rule + sdk_test_async_rules { strings: $u = \"test_async_rules\" condition: $u }"},"status":"OK"} ' headers: - Access-Control-Allow-Origin: + access-control-allow-origin: - '*' - Access-Control-Expose-Headers: + access-control-expose-headers: - Authorization - Connection: + connection: - keep-alive - Content-Length: - - '277' - Content-Type: + content-length: + - '413' + content-type: - application/json - Date: - - Tue, 02 Jun 2026 21:30:27 GMT - Server: + date: + - Tue, 25 Aug 2026 18:24:18 GMT + server: - gunicorn - X-Billing-ID: + x-billing-id: - '111' status: code: 200 @@ -125,49 +784,38 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: GET - uri: http://ai:9696/v3/hunt/rule?id=40732127887168358&community=gamma + uri: http://ai:9696/v3/hunt/historical?id=90911086658025862&community=gamma response: body: - string: '{"result":{"created":"2026-06-02T21:30:27.081195+00:00","deleted":false,"description":null,"id":"40732127887168358","livescan_created":null,"livescan_id":null,"modified":"2026-06-02T21:30:27.081195+00:00","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 - = \"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}"},"status":"OK"} + string: '{"result":{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"communities":["gamma"],"created":"2026-08-25T18:24:18.313049+00:00","failed_max_retries":0,"failed_other":0,"id":"90911086658025862","progress":null,"results_csv_uri":null,"rule_id":"89620634730010513","rule_modified":"2026-08-25T18:24:17.862491+00:00","ruleset_name":"test_async_rules","source_rule_changed":false,"status":"PENDING","summary":null,"user_account_number":"111","yara":"rule + sdk_test_async_rules { strings: $u = \"test_async_rules\" condition: $u }"},"status":"OK"} ' headers: - Access-Control-Allow-Origin: + access-control-allow-origin: - '*' - Access-Control-Expose-Headers: + access-control-expose-headers: - Authorization - Connection: + connection: - keep-alive - Content-Length: - - '1346' - Content-Type: + content-length: + - '579' + content-type: - application/json - Date: - - Tue, 02 Jun 2026 21:30:27 GMT - Server: + date: + - Tue, 25 Aug 2026 18:24:18 GMT + server: - gunicorn - X-Billing-ID: + x-billing-id: - '111' status: code: 200 message: OK - request: - body: '{"name":"test2","description":"test","community":"gamma"}' + body: '{"name":"test_async_rules2","yara":"rule sdk_test_async_rules { strings: + $u = \"test_async_rules\" condition: $u }\n// edited","description":"test","community":"gamma"}' headers: accept: - '*/*' @@ -178,49 +826,128 @@ interactions: connection: - keep-alive content-length: - - '57' + - '168' content-type: - application/json host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: PUT - uri: http://ai:9696/v3/hunt/rule?id=40732127887168358 + uri: http://ai:9696/v3/hunt/rule?id=89620634730010513 + response: + body: + string: '{"result":{"created":"2026-08-25T18:24:13.239703+00:00","deleted":false,"description":"test","favorite":false,"favorited_at":null,"historical_hunt_count":1,"id":"89620634730010513","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:24:19.124101+00:00","name":"test_async_rules2","rule_count":1,"yara":"rule + sdk_test_async_rules { strings: $u = \"test_async_rules\" condition: $u }\n// + edited"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '427' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:19 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/historical?id=90911086658025862&community=gamma + response: + body: + string: '{"result":{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"communities":["gamma"],"created":"2026-08-25T18:24:18.313049+00:00","failed_max_retries":0,"failed_other":0,"id":"90911086658025862","progress":null,"results_csv_uri":null,"rule_id":"89620634730010513","rule_modified":"2026-08-25T18:24:17.862491+00:00","ruleset_name":"test_async_rules","source_rule_changed":true,"status":"PENDING","summary":null,"user_account_number":"111","yara":"rule + sdk_test_async_rules { strings: $u = \"test_async_rules\" condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '578' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:19 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"community":"gamma"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '21' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: DELETE + uri: http://ai:9696/v3/hunt/historical?id=90911086658025862 response: body: - string: '{"result":{"created":"2026-06-02T21:30:27.081195+00:00","deleted":false,"description":"test","id":"40732127887168358","livescan_created":null,"livescan_id":null,"modified":"2026-06-02T21:30:27.081195+00:00","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 - = \"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}"},"status":"OK"} + string: '{"result":{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"communities":["gamma"],"created":"2026-08-25T18:24:18.313049+00:00","failed_max_retries":0,"failed_other":0,"id":"90911086658025862","progress":null,"results_csv_uri":null,"rule_id":"89620634730010513","rule_modified":"2026-08-25T18:24:17.862491+00:00","ruleset_name":"test_async_rules","source_rule_changed":true,"status":"DELETING","summary":null,"user_account_number":"111","yara":"rule + sdk_test_async_rules { strings: $u = \"test_async_rules\" condition: $u }"},"status":"OK"} ' headers: - Access-Control-Allow-Origin: + access-control-allow-origin: - '*' - Access-Control-Expose-Headers: + access-control-expose-headers: - Authorization - Connection: + connection: - keep-alive - Content-Length: - - '1349' - Content-Type: + content-length: + - '579' + content-type: - application/json - Date: - - Tue, 02 Jun 2026 21:30:27 GMT - Server: + date: + - Tue, 25 Aug 2026 18:24:19 GMT + server: - gunicorn - X-Billing-ID: + x-billing-id: - '111' status: code: 200 @@ -243,43 +970,32 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: DELETE - uri: http://ai:9696/v3/hunt/rule?id=40732127887168358 + uri: http://ai:9696/v3/hunt/rule?id=89620634730010513 response: body: - string: '{"result":{"created":"2026-06-02T21:30:27.081195+00:00","deleted":true,"description":"test","id":"40732127887168358","livescan_created":null,"livescan_id":null,"modified":"2026-06-02T21:30:27.152617+00:00","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 - = \"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}"},"status":"OK"} + string: '{"result":{"created":"2026-08-25T18:24:13.239703+00:00","deleted":true,"description":"test","favorite":false,"favorited_at":null,"historical_hunt_count":1,"id":"89620634730010513","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:24:19.351670+00:00","name":"test_async_rules2","rule_count":1,"yara":"rule + sdk_test_async_rules { strings: $u = \"test_async_rules\" condition: $u }\n// + edited"},"status":"OK"} ' headers: - Access-Control-Allow-Origin: + access-control-allow-origin: - '*' - Access-Control-Expose-Headers: + access-control-expose-headers: - Authorization - Connection: + connection: - keep-alive - Content-Length: - - '1348' - Content-Type: + content-length: + - '426' + content-type: - application/json - Date: - - Tue, 02 Jun 2026 21:30:27 GMT - Server: + date: + - Tue, 25 Aug 2026 18:24:19 GMT + server: - gunicorn - X-Billing-ID: + x-billing-id: - '111' status: code: 200 @@ -298,24 +1014,24 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: GET uri: http://ai:9696/v3/hunt/rule/list?community=gamma response: body: string: '' headers: - Access-Control-Allow-Origin: + access-control-allow-origin: - '*' - Access-Control-Expose-Headers: + access-control-expose-headers: - Authorization - Connection: + connection: - keep-alive - Content-Type: + content-type: - text/html; charset=utf-8 - Date: - - Tue, 02 Jun 2026 21:30:27 GMT - Server: + date: + - Tue, 25 Aug 2026 18:24:19 GMT + server: - gunicorn status: code: 204 diff --git a/test/vcr/test_rules.vcr b/test/vcr/test_rules.vcr index 7a3d8518..1eb018a1 100644 --- a/test/vcr/test_rules.vcr +++ b/test/vcr/test_rules.vcr @@ -1,17 +1,7 @@ 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 - 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"}' + body: '{"yara":"rule sdk_test_rules { strings: $u = \"test_rules\" condition: + $u }","name":"test_rules"}' headers: accept: - '*/*' @@ -22,49 +12,634 @@ interactions: connection: - keep-alive content-length: - - '1126' + - '97' content-type: - application/json host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: POST uri: http://ai:9696/v3/hunt/rule response: body: - string: '{"result":{"created":"2026-06-02T21:32:01.681881+00:00","deleted":false,"description":null,"id":"68232116824597140","livescan_created":null,"livescan_id":null,"modified":"2026-06-02T21:32:01.681881+00:00","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 - = \"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}"},"status":"OK"} + string: '{"result":{"created":"2026-08-25T18:23:50.641609+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"46961337172843885","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:23:50.641609+00:00","name":"test_rules","rule_count":1,"yara":"rule + sdk_test_rules { strings: $u = \"test_rules\" condition: $u }"},"status":"OK"} ' headers: - Access-Control-Allow-Origin: + access-control-allow-origin: - '*' - Access-Control-Expose-Headers: + access-control-expose-headers: - Authorization - Connection: + connection: + - keep-alive + content-length: + - '395' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:23:50 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?community=gamma + response: + body: + string: '{"has_more":false,"limit":50,"result":[{"created":"2026-08-25T18:23:50.641609+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"46961337172843885","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:23:50.641609+00:00","name":"test_rules","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null}],"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '416' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:23:56 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?name=test_rules&community=gamma + response: + body: + string: '{"has_more":false,"limit":50,"result":[{"created":"2026-08-25T18:23:50.641609+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"46961337172843885","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:23:50.641609+00:00","name":"test_rules","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null}],"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '416' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:23:56 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule?id=46961337172843885&community=gamma + response: + body: + string: '{"result":{"created":"2026-08-25T18:23:50.641609+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"46961337172843885","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:23:50.641609+00:00","name":"test_rules","rule_count":1,"yara":"rule + sdk_test_rules { strings: $u = \"test_rules\" condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '395' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:23:56 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"id":"46961337172843885","favorite":1}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '39' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: PUT + uri: http://ai:9696/v3/hunt/rule/favorite?community=gamma + response: + body: + string: '{"result":{"favorite":true,"favorited_at":"2026-08-25T18:23:56.845109+00:00","favorites_limit":5,"favorites_used":1,"id":"46961337172843885"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '157' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:23:56 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?favorites_only=1&community=gamma + response: + body: + string: '{"has_more":false,"limit":50,"result":[{"created":"2026-08-25T18:23:50.641609+00:00","deleted":false,"description":null,"favorite":true,"favorited_at":"2026-08-25T18:23:56.845109+00:00","historical_hunt_count":0,"id":"46961337172843885","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:23:50.641609+00:00","name":"test_rules","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null}],"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '445' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:23:57 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"id":"46961337172843885","favorite":0}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '39' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: PUT + uri: http://ai:9696/v3/hunt/rule/favorite?community=gamma + response: + body: + string: '{"result":{"favorite":false,"favorited_at":null,"favorites_limit":5,"favorites_used":0,"id":"46961337172843885"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '128' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:23:57 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"rule_id":"46961337172843885"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '31' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: POST + uri: http://ai:9696/v3/hunt/rule/live + response: + body: + string: '{"result":{"created":"2026-08-25T18:23:50.641609+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"46961337172843885","livescan_created":"2026-08-25T18:23:58.370832+00:00","livescan_id":"72927285313305230","modified":"2026-08-25T18:23:58.358140+00:00","name":"test_rules","rule_count":1,"yara":"rule + sdk_test_rules { strings: $u = \"test_rules\" condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '440' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:23:58 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule?id=46961337172843885&community=gamma + response: + body: + string: '{"result":{"created":"2026-08-25T18:23:50.641609+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"46961337172843885","livescan_created":"2026-08-25T18:23:58.370832+00:00","livescan_id":"72927285313305230","modified":"2026-08-25T18:23:58.358140+00:00","name":"test_rules","rule_count":1,"yara":"rule + sdk_test_rules { strings: $u = \"test_rules\" condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '440' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:23:59 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule?id=46961337172843885&community=gamma + response: + body: + string: '{"result":{"created":"2026-08-25T18:23:50.641609+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"46961337172843885","livescan_created":"2026-08-25T18:23:58.370832+00:00","livescan_id":"72927285313305230","modified":"2026-08-25T18:23:58.358140+00:00","name":"test_rules","rule_count":1,"yara":"rule + sdk_test_rules { strings: $u = \"test_rules\" condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '440' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:23:59 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?status=active&community=gamma + response: + body: + string: '{"has_more":false,"limit":50,"result":[{"created":"2026-08-25T18:23:50.641609+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"46961337172843885","livescan_created":"2026-08-25T18:23:58.370832+00:00","livescan_id":"72927285313305230","modified":"2026-08-25T18:23:58.358140+00:00","name":"test_rules","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null}],"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '461' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:00 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?community=gamma + response: + body: + string: '{"has_more":false,"limit":50,"result":[{"created":"2026-08-25T18:23:50.641609+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"46961337172843885","livescan_created":"2026-08-25T18:23:58.370832+00:00","livescan_id":"72927285313305230","modified":"2026-08-25T18:23:58.358140+00:00","name":"test_rules","new_results_count":null,"new_results_counted_at":null,"rule_count":1,"yara":null}],"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '461' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:00 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule/list?has_new_results=1&community=gamma + response: + body: + string: '' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-type: + - text/html; charset=utf-8 + date: + - Tue, 25 Aug 2026 18:24:00 GMT + server: + - gunicorn + status: + code: 204 + message: NO CONTENT +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/live/list?livescan_id=72927285313305230&community=gamma + response: + body: + string: '' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-type: + - text/html; charset=utf-8 + date: + - Tue, 25 Aug 2026 18:24:00 GMT + server: + - gunicorn + status: + code: 204 + message: NO CONTENT +- request: + body: '{"rule_id":"46961337172843885"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '31' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: DELETE + uri: http://ai:9696/v3/hunt/rule/live + response: + body: + string: '{"result":{"created":"2026-08-25T18:23:50.641609+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":0,"id":"46961337172843885","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:24:00.810701+00:00","name":"test_rules","rule_count":1,"yara":"rule + sdk_test_rules { strings: $u = \"test_rules\" condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: - keep-alive - Content-Length: - - '1346' - Content-Type: + content-length: + - '395' + content-type: - application/json - Date: - - Tue, 02 Jun 2026 21:32:01 GMT - Server: + date: + - Tue, 25 Aug 2026 18:24:00 GMT + server: - gunicorn - X-Billing-ID: + x-billing-id: - '111' status: code: 200 @@ -83,30 +658,157 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: GET - uri: http://ai:9696/v3/hunt/rule/list?community=gamma + uri: http://ai:9696/v3/hunt/rule/list?status=active&community=gamma + response: + body: + string: '' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-type: + - text/html; charset=utf-8 + date: + - Tue, 25 Aug 2026 18:24:01 GMT + server: + - gunicorn + status: + code: 204 + message: NO CONTENT +- request: + body: '{"rule_id":"46961337172843885","community":"gamma"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '51' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: POST + uri: http://ai:9696/v3/hunt/historical + response: + body: + string: '{"result":{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"communities":["gamma"],"created":"2026-08-25T18:24:01.406656+00:00","failed_max_retries":0,"failed_other":0,"id":"64777357306306635","progress":null,"results_csv_uri":null,"rule_id":"46961337172843885","rule_modified":"2026-08-25T18:24:00.810701+00:00","ruleset_name":"test_rules","source_rule_changed":false,"status":"PENDING","summary":null,"user_account_number":"111","yara":"rule + sdk_test_rules { strings: $u = \"test_rules\" condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '561' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:01 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/rule?id=46961337172843885&community=gamma + response: + body: + string: '{"result":{"created":"2026-08-25T18:23:50.641609+00:00","deleted":false,"description":null,"favorite":false,"favorited_at":null,"historical_hunt_count":1,"id":"46961337172843885","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:24:00.810701+00:00","name":"test_rules","rule_count":1,"yara":"rule + sdk_test_rules { strings: $u = \"test_rules\" condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '395' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:01 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/historical?id=64777357306306635&community=gamma response: body: - string: '{"has_more":false,"limit":50,"result":[{"created":"2026-06-02T21:32:01.681881+00:00","deleted":false,"description":null,"id":"68232116824597140","livescan_created":null,"livescan_id":null,"modified":"2026-06-02T21:32:01.681881+00:00","name":"test","yara":null}],"status":"OK"} + string: '{"result":{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"communities":["gamma"],"created":"2026-08-25T18:24:01.406656+00:00","failed_max_retries":0,"failed_other":0,"id":"64777357306306635","progress":null,"results_csv_uri":null,"rule_id":"46961337172843885","rule_modified":"2026-08-25T18:24:00.810701+00:00","ruleset_name":"test_rules","source_rule_changed":false,"status":"PENDING","summary":null,"user_account_number":"111","yara":"rule + sdk_test_rules { strings: $u = \"test_rules\" condition: $u }"},"status":"OK"} ' headers: - Access-Control-Allow-Origin: + access-control-allow-origin: - '*' - Access-Control-Expose-Headers: + access-control-expose-headers: - Authorization - Connection: + connection: - keep-alive - Content-Length: - - '277' - Content-Type: + content-length: + - '561' + content-type: - application/json - Date: - - Tue, 02 Jun 2026 21:32:01 GMT - Server: + date: + - Tue, 25 Aug 2026 18:24:02 GMT + server: - gunicorn - X-Billing-ID: + x-billing-id: - '111' status: code: 200 @@ -125,49 +827,38 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: GET - uri: http://ai:9696/v3/hunt/rule?id=68232116824597140&community=gamma + uri: http://ai:9696/v3/hunt/historical?id=64777357306306635&community=gamma response: body: - string: '{"result":{"created":"2026-06-02T21:32:01.681881+00:00","deleted":false,"description":null,"id":"68232116824597140","livescan_created":null,"livescan_id":null,"modified":"2026-06-02T21:32:01.681881+00:00","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 - = \"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}"},"status":"OK"} + string: '{"result":{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"communities":["gamma"],"created":"2026-08-25T18:24:01.406656+00:00","failed_max_retries":0,"failed_other":0,"id":"64777357306306635","progress":null,"results_csv_uri":null,"rule_id":"46961337172843885","rule_modified":"2026-08-25T18:24:00.810701+00:00","ruleset_name":"test_rules","source_rule_changed":false,"status":"PENDING","summary":null,"user_account_number":"111","yara":"rule + sdk_test_rules { strings: $u = \"test_rules\" condition: $u }"},"status":"OK"} ' headers: - Access-Control-Allow-Origin: + access-control-allow-origin: - '*' - Access-Control-Expose-Headers: + access-control-expose-headers: - Authorization - Connection: + connection: - keep-alive - Content-Length: - - '1346' - Content-Type: + content-length: + - '561' + content-type: - application/json - Date: - - Tue, 02 Jun 2026 21:32:01 GMT - Server: + date: + - Tue, 25 Aug 2026 18:24:02 GMT + server: - gunicorn - X-Billing-ID: + x-billing-id: - '111' status: code: 200 message: OK - request: - body: '{"name":"test2","description":"test","community":"gamma"}' + body: '{"name":"test_rules2","yara":"rule sdk_test_rules { strings: $u = \"test_rules\" + condition: $u }\n// edited","description":"test","community":"gamma"}' headers: accept: - '*/*' @@ -178,49 +869,127 @@ interactions: connection: - keep-alive content-length: - - '57' + - '150' content-type: - application/json host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: PUT - uri: http://ai:9696/v3/hunt/rule?id=68232116824597140 + uri: http://ai:9696/v3/hunt/rule?id=46961337172843885 + response: + body: + string: '{"result":{"created":"2026-08-25T18:23:50.641609+00:00","deleted":false,"description":"test","favorite":false,"favorited_at":null,"historical_hunt_count":1,"id":"46961337172843885","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:24:02.939069+00:00","name":"test_rules2","rule_count":1,"yara":"rule + sdk_test_rules { strings: $u = \"test_rules\" condition: $u }\n// edited"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '409' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:03 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: GET + uri: http://ai:9696/v3/hunt/historical?id=64777357306306635&community=gamma + response: + body: + string: '{"result":{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"communities":["gamma"],"created":"2026-08-25T18:24:01.406656+00:00","failed_max_retries":0,"failed_other":0,"id":"64777357306306635","progress":null,"results_csv_uri":null,"rule_id":"46961337172843885","rule_modified":"2026-08-25T18:24:00.810701+00:00","ruleset_name":"test_rules","source_rule_changed":true,"status":"PENDING","summary":null,"user_account_number":"111","yara":"rule + sdk_test_rules { strings: $u = \"test_rules\" condition: $u }"},"status":"OK"} + + ' + headers: + access-control-allow-origin: + - '*' + access-control-expose-headers: + - Authorization + connection: + - keep-alive + content-length: + - '560' + content-type: + - application/json + date: + - Tue, 25 Aug 2026 18:24:03 GMT + server: + - gunicorn + x-billing-id: + - '111' + status: + code: 200 + message: OK +- request: + body: '{"community":"gamma"}' + headers: + accept: + - '*/*' + accept-encoding: + - gzip, deflate + authorization: + - '11111111111111111111111111111111' + connection: + - keep-alive + content-length: + - '21' + content-type: + - application/json + host: + - ai:9696 + user-agent: + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) + method: DELETE + uri: http://ai:9696/v3/hunt/historical?id=64777357306306635 response: body: - string: '{"result":{"created":"2026-06-02T21:32:01.681881+00:00","deleted":false,"description":"test","id":"68232116824597140","livescan_created":null,"livescan_id":null,"modified":"2026-06-02T21:32:01.681881+00:00","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 - = \"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}"},"status":"OK"} + string: '{"result":{"account_number":"111","archives_in_flight":0,"archives_scanned":0,"archives_total":0,"communities":["gamma"],"created":"2026-08-25T18:24:01.406656+00:00","failed_max_retries":0,"failed_other":0,"id":"64777357306306635","progress":null,"results_csv_uri":null,"rule_id":"46961337172843885","rule_modified":"2026-08-25T18:24:00.810701+00:00","ruleset_name":"test_rules","source_rule_changed":true,"status":"DELETING","summary":null,"user_account_number":"111","yara":"rule + sdk_test_rules { strings: $u = \"test_rules\" condition: $u }"},"status":"OK"} ' headers: - Access-Control-Allow-Origin: + access-control-allow-origin: - '*' - Access-Control-Expose-Headers: + access-control-expose-headers: - Authorization - Connection: + connection: - keep-alive - Content-Length: - - '1349' - Content-Type: + content-length: + - '561' + content-type: - application/json - Date: - - Tue, 02 Jun 2026 21:32:01 GMT - Server: + date: + - Tue, 25 Aug 2026 18:24:03 GMT + server: - gunicorn - X-Billing-ID: + x-billing-id: - '111' status: code: 200 @@ -243,43 +1012,31 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: DELETE - uri: http://ai:9696/v3/hunt/rule?id=68232116824597140 + uri: http://ai:9696/v3/hunt/rule?id=46961337172843885 response: body: - string: '{"result":{"created":"2026-06-02T21:32:01.681881+00:00","deleted":true,"description":"test","id":"68232116824597140","livescan_created":null,"livescan_id":null,"modified":"2026-06-02T21:32:01.766631+00:00","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 - = \"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}"},"status":"OK"} + string: '{"result":{"created":"2026-08-25T18:23:50.641609+00:00","deleted":true,"description":"test","favorite":false,"favorited_at":null,"historical_hunt_count":1,"id":"46961337172843885","livescan_created":null,"livescan_id":null,"modified":"2026-08-25T18:24:03.962187+00:00","name":"test_rules2","rule_count":1,"yara":"rule + sdk_test_rules { strings: $u = \"test_rules\" condition: $u }\n// edited"},"status":"OK"} ' headers: - Access-Control-Allow-Origin: + access-control-allow-origin: - '*' - Access-Control-Expose-Headers: + access-control-expose-headers: - Authorization - Connection: + connection: - keep-alive - Content-Length: - - '1348' - Content-Type: + content-length: + - '408' + content-type: - application/json - Date: - - Tue, 02 Jun 2026 21:32:01 GMT - Server: + date: + - Tue, 25 Aug 2026 18:24:04 GMT + server: - gunicorn - X-Billing-ID: + x-billing-id: - '111' status: code: 200 @@ -298,24 +1055,24 @@ interactions: host: - ai:9696 user-agent: - - polyswarm_api/3.21.0 (x86_64-Linux-CPython-3.14.4) + - polyswarm_api/4.3.0 (x86_64-Darwin-CPython-3.11.3) method: GET uri: http://ai:9696/v3/hunt/rule/list?community=gamma response: body: string: '' headers: - Access-Control-Allow-Origin: + access-control-allow-origin: - '*' - Access-Control-Expose-Headers: + access-control-expose-headers: - Authorization - Connection: + connection: - keep-alive - Content-Type: + content-type: - text/html; charset=utf-8 - Date: - - Tue, 02 Jun 2026 21:32:01 GMT - Server: + date: + - Tue, 25 Aug 2026 18:24:04 GMT + server: - gunicorn status: code: 204