polyswarm-api 4.2.0__tar.gz → 4.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {polyswarm_api-4.2.0/src/polyswarm_api.egg-info → polyswarm_api-4.4.0}/PKG-INFO +1 -1
  2. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/pyproject.toml +14 -9
  3. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/src/polyswarm_api/__init__.py +1 -1
  4. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/src/polyswarm_api/aio/api.py +106 -8
  5. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/src/polyswarm_api/api.py +117 -7
  6. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/src/polyswarm_api/core.py +43 -3
  7. polyswarm_api-4.4.0/src/polyswarm_api/exceptions.py +135 -0
  8. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/src/polyswarm_api/resources.py +57 -3
  9. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0/src/polyswarm_api.egg-info}/PKG-INFO +1 -1
  10. polyswarm_api-4.2.0/src/polyswarm_api/exceptions.py +0 -85
  11. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/LICENSE +0 -0
  12. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/README.md +0 -0
  13. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/setup.cfg +0 -0
  14. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/src/polyswarm_api/aio/__init__.py +0 -0
  15. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/src/polyswarm_api/aio/session.py +0 -0
  16. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/src/polyswarm_api/session.py +0 -0
  17. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/src/polyswarm_api/settings.py +0 -0
  18. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/src/polyswarm_api.egg-info/SOURCES.txt +0 -0
  19. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/src/polyswarm_api.egg-info/dependency_links.txt +0 -0
  20. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/src/polyswarm_api.egg-info/requires.txt +0 -0
  21. {polyswarm_api-4.2.0 → polyswarm_api-4.4.0}/src/polyswarm_api.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polyswarm_api
3
- Version: 4.2.0
3
+ Version: 4.4.0
4
4
  Summary: Client library to simplify interacting with the PolySwarm consumer API
5
5
  Author-email: PolySwarm Developers <info@polyswarm.io>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "polyswarm_api"
7
- version = "4.2.0"
7
+ version = "4.4.0"
8
8
  description = "Client library to simplify interacting with the PolySwarm consumer API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10,<4"
@@ -55,7 +55,7 @@ package-dir = { "" = "src" }
55
55
  where = ["src"]
56
56
 
57
57
  [tool.bumpversion]
58
- current_version = "4.2.0"
58
+ current_version = "4.4.0"
59
59
  commit = true
60
60
  tag = false
61
61
  sign_tags = true
@@ -76,11 +76,17 @@ search = "__version__ = '{current_version}'"
76
76
  replace = "__version__ = '{new_version}'"
77
77
 
78
78
  [tool.pytest.ini_options]
79
- log_cli = true
80
- # Default live-log level. INFO keeps the useful app request/response lines while
81
- # dropping the DEBUG firehose (VCR per-request match trace, parse traces) that
82
- # otherwise bloats CI logs ~1000x. Override per-run with the TESTS_LOG_LEVEL env
83
- # var (e.g. TESTS_LOG_LEVEL=DEBUG to log everything); see test/conftest.py.
79
+ # Live logging OFF by default. When on, pytest streams log records as the run goes
80
+ # AND forces every test onto its own nodeid line (verbose-style) — ~130 lines of
81
+ # pure noise on a green xdist run. With it off the run prints dots + an end
82
+ # summary, and a FAILING test still shows its captured logs (at log_level, below)
83
+ # plus the traceback. Opt back into live streaming with TESTS_LOG_CLI=1 (or an
84
+ # explicit --log-cli-level) when debugging a live run; see test/conftest.py.
85
+ log_cli = false
86
+ # Level used for live logging when it's opted into (TESTS_LOG_CLI=1). INFO keeps
87
+ # the useful app request/response lines while dropping the DEBUG firehose (VCR
88
+ # per-request match trace, parse traces) that otherwise bloats logs ~1000x.
89
+ # TESTS_LOG_LEVEL overrides both this and the captured-log level (conftest.py).
84
90
  log_cli_level = "INFO"
85
91
  log_format = "%(asctime)s %(levelname)-2s [%(name)s:%(filename)s:%(lineno)d:%(funcName)1s()] %(message)s"
86
92
  python_files = ["test_*.py", "*_test.py"]
@@ -95,7 +101,6 @@ filterwarnings = [
95
101
  asyncio_mode = "auto"
96
102
  addopts = [
97
103
  "--continue-on-collection-errors",
98
- "-v",
99
104
  "-s",
100
- "-rxXs",
105
+ "-ra",
101
106
  ]
@@ -1,5 +1,5 @@
1
1
  # https://www.python.org/dev/peps/pep-0008/#module-level-dunder-names
2
- __version__ = '4.2.0'
2
+ __version__ = '4.4.0'
3
3
  __release_url__ = 'https://api.github.com/repos/polyswarm/polyswarm-api/releases/latest'
4
4
 
5
5
  from . import api
@@ -18,7 +18,7 @@ import logging
18
18
  import time
19
19
 
20
20
  from polyswarm_api import exceptions, resources, settings
21
- from polyswarm_api.core import PolyswarmRequest
21
+ from polyswarm_api.core import PolyswarmRequest, _as_result_bound
22
22
 
23
23
  from .session import AsyncPolyswarmSession
24
24
 
@@ -502,23 +502,35 @@ class PolySwarmAsyncAPI:
502
502
  return await self._single(resources.LiveYaraRuleset.delete(self, rule_id=rule_id))
503
503
 
504
504
  async def live_feed(self, since=None, rule_name=None, family=None,
505
- polyscore_lower=None, polyscore_upper=None, community=None):
505
+ polyscore_lower=None, polyscore_upper=None, community=None,
506
+ livescan_id=None, max_results=None):
506
507
  """
507
508
  Get live hunts feed
508
509
 
509
- :param since: Fetch results from the last "since" minutes
510
+ :param since: Window in SECONDS (this said "minutes" in earlier releases and was
511
+ wrong). Absent or 0 means no time filter at all.
510
512
  :param rule_name: Filter hunt results on the provided rule name (exact match).
511
513
  :param family: Filter hunt results based on the family name (exact match).
512
514
  :param polyscore_lower: Polyscore lower bound for the hunt results.
513
515
  :param polyscore_upper: Polyscore upper bound for the hunt results.
514
516
  :param community: Community to retrieve live results from, or public/private.
517
+ :param livescan_id: Scope the feed to one live hunt's results.
518
+ :param max_results: Total results to yield, not a page size — paging
519
+ continues in the server's own chunks until the total is reached.
520
+ None, 0 or a negative means no bound: every page, as before.
515
521
  :return: Generator of HuntResult resources
516
522
  """
523
+ bound = _as_result_bound(max_results)
524
+ yielded = 0
517
525
  async for item in self._paginate(resources.LiveHuntResult.list(
518
526
  self, since=since, rule_name=rule_name, family=family,
519
527
  polyscore_lower=polyscore_lower, polyscore_upper=polyscore_upper,
528
+ livescan_id=livescan_id,
520
529
  community=community or self.community)):
521
530
  yield item
531
+ yielded += 1
532
+ if bound is not None and yielded >= bound:
533
+ return
522
534
 
523
535
  async def live_feed_delete(self, result_ids):
524
536
  """
@@ -684,15 +696,45 @@ class PolySwarmAsyncAPI:
684
696
  logger.info('Delete ruleset %s', ruleset_id)
685
697
  return await self._single(resources.YaraRuleset.delete(self, id=ruleset_id, community=self.community))
686
698
 
687
- async def ruleset_list(self):
699
+ async def ruleset_list(self, name=None, status=None, favorites_only=None,
700
+ has_new_results=None):
688
701
  """
689
702
  List all YaraRulesets for the current account.
703
+
704
+ All filters are optional and conjunctive:
705
+ :param name: Case-insensitive substring match on the ruleset name.
706
+ :param status: 'active' returns only rulesets whose live hunt is
707
+ currently running.
708
+ :param favorites_only: True returns only favorited rulesets.
709
+ :param has_new_results: True returns only rulesets whose stored
710
+ new-results counter is positive. The counter (and its window) is
711
+ maintained server-side by a scheduled refresh; rows carry it as
712
+ ``new_results_count`` with ``new_results_counted_at`` marking when
713
+ it was last refreshed. There is no per-request window parameter.
690
714
  :return: A generator of YaraRuleset resources
691
715
  """
692
716
  logger.info('List rulesets')
693
- async for item in self._paginate(resources.YaraRuleset.list(self, community=self.community)):
717
+ async for item in self._paginate(resources.YaraRuleset.list(
718
+ self, name=name, status=status, favorites_only=favorites_only,
719
+ has_new_results=has_new_results, community=self.community)):
694
720
  yield item
695
721
 
722
+ async def ruleset_favorite(self, ruleset_id, favorite=True):
723
+ """
724
+ Favorite or unfavorite a YaraRuleset. Idempotent; works while a live
725
+ hunt is running. Favorites are shared by the whole team and capped
726
+ (the response carries favorites_used / favorites_limit); when the
727
+ budget is exhausted the server refuses with a machine-readable
728
+ FAVORITE_LIMIT error.
729
+
730
+ :param ruleset_id: Id of the ruleset
731
+ :param favorite: True to star, False to unstar
732
+ :return: A YaraRulesetFavorite resource
733
+ """
734
+ logger.info('%s ruleset %s', 'Favorite' if favorite else 'Unfavorite', ruleset_id)
735
+ return await self._single(resources.YaraRulesetFavorite.update(
736
+ self, id=ruleset_id, favorite=favorite, community=self.community))
737
+
696
738
  async def tag_link_get(self, sha256):
697
739
  """
698
740
  Fetch the Tags and Families associated with the given sha256.
@@ -939,6 +981,13 @@ class PolySwarmAsyncAPI:
939
981
  :param fh: A file-like object which we are going to write the contents of the artifact to.
940
982
  :param hash_type: Hash type of the provided hash_. Will attempt to auto-detect if not explicitly provided.
941
983
  :return: A LocalHandle resource
984
+ :raises KnownGoodWithheldException: the sha256 is catalogued as a known-good binary,
985
+ so its bytes are withheld by design. A ``NotFoundException`` subclass, so existing
986
+ ``except NotFoundException`` handling still catches it; catch it specifically to
987
+ tell a deliberate refusal apart from a missing artifact, and read
988
+ ``.sources`` for the feeds that flagged it. Nothing is written to the destination —
989
+ the status is checked before any write (the handle is the caller's and arrives
990
+ already open, so "before the file is opened" would be the wrong reason here).
942
991
  """
943
992
  logger.info('Downloading %s into handle', hash_)
944
993
  hash_ = resources.Hash.from_hashable(hash_, hash_type=hash_type)
@@ -1599,6 +1648,12 @@ class PolySwarmAsyncAPI:
1599
1648
  :param hash_: Hashable (Artifact, LocalArtifact, Hash) or hex-encoded SHA256/SHA1/MD5.
1600
1649
  :param hash_type: Hash type; auto-detected from the literal if not provided.
1601
1650
  :return: A ``LocalArtifact`` resource with its handle already closed.
1651
+ :raises KnownGoodWithheldException: the sha256 is catalogued as a known-good binary,
1652
+ so its bytes are withheld by design. A ``NotFoundException`` subclass, so existing
1653
+ ``except NotFoundException`` handling still catches it; catch it specifically to
1654
+ tell a deliberate refusal apart from a missing artifact, and read
1655
+ ``.sources`` for the feeds that flagged it. Nothing is written to the destination —
1656
+ the status is checked before the file is opened.
1602
1657
  """
1603
1658
  logger.info('Downloading %s into %s', hash_, out_dir)
1604
1659
  hash_ = resources.Hash.from_hashable(hash_, hash_type=hash_type)
@@ -1609,7 +1664,15 @@ class PolySwarmAsyncAPI:
1609
1664
  return artifact
1610
1665
 
1611
1666
  async def download_id(self, out_dir, instance_id):
1612
- """Download an artifact by its instance id into ``out_dir``."""
1667
+ """Download an artifact by its instance id into ``out_dir``.
1668
+
1669
+ :raises KnownGoodWithheldException: the sha256 is catalogued as a known-good binary,
1670
+ so its bytes are withheld by design. A ``NotFoundException`` subclass, so existing
1671
+ ``except NotFoundException`` handling still catches it; catch it specifically to
1672
+ tell a deliberate refusal apart from a missing artifact, and read
1673
+ ``.sources`` for the feeds that flagged it. Nothing is written to the destination —
1674
+ the status is checked before the file is opened.
1675
+ """
1613
1676
  logger.info('Downloading %s into %s', instance_id, out_dir)
1614
1677
  artifact = await self._single(
1615
1678
  resources.LocalArtifact.download_id(self, instance_id, folder=out_dir),
@@ -1618,7 +1681,21 @@ class PolySwarmAsyncAPI:
1618
1681
  return artifact
1619
1682
 
1620
1683
  async def download_sandbox_artifact(self, out_dir, sandbox_task_id, instance_id):
1621
- """Download a sandbox-produced artifact (e.g. PCAP, dropped file) into ``out_dir``."""
1684
+ """Download a sandbox-produced artifact (e.g. PCAP, dropped file) into ``out_dir``.
1685
+
1686
+ The known-good gate applies to **every** sandbox artifact by its own sha256 —
1687
+ dropped file, screenshot, report, … — with no sample-vs-evidence carve-out
1688
+ server-side, so any member whose digest is catalogued (and eligible) raises below.
1689
+ In practice that is almost always a dropped file; evidence collides only on byte
1690
+ identity, and the empty-file digest is refused at the catalogue boundary instead.
1691
+
1692
+ :raises KnownGoodWithheldException: the sha256 is catalogued as a known-good binary,
1693
+ so its bytes are withheld by design. A ``NotFoundException`` subclass, so existing
1694
+ ``except NotFoundException`` handling still catches it; catch it specifically to
1695
+ tell a deliberate refusal apart from a missing artifact, and read
1696
+ ``.sources`` for the feeds that flagged it. Nothing is written to the destination —
1697
+ the status is checked before the file is opened.
1698
+ """
1622
1699
  logger.info('Downloading sandbox artifact %s %s', sandbox_task_id, instance_id)
1623
1700
  sandbox_artifact = await self._single(
1624
1701
  resources.LocalArtifact.download_sandbox_artifact(
@@ -1642,8 +1719,29 @@ class PolySwarmAsyncAPI:
1642
1719
 
1643
1720
  :param hash_: Hashable (Artifact, LocalArtifact, Hash) or hex-encoded SHA256/SHA1/MD5.
1644
1721
  :param hash_type: Hash type; auto-detected if not provided.
1645
- :param require_scan: If True, only count artifacts that have been scanned.
1722
+ :param require_scan: If True, only count artifacts that have been *scanned*.
1723
+ Being catalogued as known-good is **not** a scan: the platform never scans
1724
+ such a sample, so a hash whose only record is a known-good reference reports
1725
+ absent under ``require_scan`` (and present without it, because that reference
1726
+ is a real searchable record).
1646
1727
  :return: ``True`` if the artifact exists in PolySwarm's index.
1728
+
1729
+ .. note::
1730
+ This is an existence probe, and its status codes are a **frozen contract**:
1731
+ ``200`` means found, ``204`` means not found, and anything else means the
1732
+ *request* was wrong.
1733
+
1734
+ Only ``200`` is ``True``. ``204`` and ``404`` are both ``False`` — the server
1735
+ answers ``204`` for absent, and ``404`` is tolerated as absent for historical
1736
+ reasons.
1737
+
1738
+ A non-2xx status does not raise here, and the reason is the **method**, not a
1739
+ missing result parser: ``parse_response`` short-circuits ``HEAD``, setting the
1740
+ status code as the result and returning before it reaches the non-2xx→exception
1741
+ mapping (which otherwise applies whether or not a parser is set). So on this
1742
+ endpoint a server error also collapses to ``False``, indistinguishable from a
1743
+ genuine "does not exist" — which is why neither side may widen what counts as
1744
+ found. See ``specs/03-endpoints.md``.
1647
1745
  """
1648
1746
  logger.info('Exists for hash %s', hash_)
1649
1747
  hash_ = resources.Hash.from_hashable(hash_, hash_type=hash_type)
@@ -20,7 +20,7 @@ import io
20
20
  import logging
21
21
 
22
22
  from polyswarm_api import exceptions, resources, settings
23
- from polyswarm_api.core import PolyswarmRequest
23
+ from polyswarm_api.core import PolyswarmRequest, _as_result_bound
24
24
 
25
25
  from .session import PolyswarmSession
26
26
 
@@ -568,18 +568,27 @@ class PolyswarmAPI:
568
568
  polyscore_lower=None,
569
569
  polyscore_upper=None,
570
570
  community=None,
571
+ livescan_id=None,
572
+ max_results=None,
571
573
  ):
572
574
  """
573
575
  Get live hunts feed
574
576
 
575
- :param since: Fetch results from the last "since" minutes
577
+ :param since: Window in SECONDS (this said "minutes" in earlier releases and was
578
+ wrong). Absent or 0 means no time filter at all.
576
579
  :param rule_name: Filter hunt results on the provided rule name (exact match).
577
580
  :param family: Filter hunt results based on the family name (exact match).
578
581
  :param polyscore_lower: Polyscore lower bound for the hunt results.
579
582
  :param polyscore_upper: Polyscore upper bound for the hunt results.
580
583
  :param community: Community to retrieve live results from, or public/private.
584
+ :param livescan_id: Scope the feed to one live hunt's results.
585
+ :param max_results: Total results to yield, not a page size — paging
586
+ continues in the server's own chunks until the total is reached.
587
+ None, 0 or a negative means no bound: every page, as before.
581
588
  :return: Generator of HuntResult resources
582
589
  """
590
+ bound = _as_result_bound(max_results)
591
+ yielded = 0
583
592
  for item in self._paginate(
584
593
  resources.LiveHuntResult.list(
585
594
  self,
@@ -588,10 +597,14 @@ class PolyswarmAPI:
588
597
  family=family,
589
598
  polyscore_lower=polyscore_lower,
590
599
  polyscore_upper=polyscore_upper,
600
+ livescan_id=livescan_id,
591
601
  community=community or self.community,
592
602
  )
593
603
  ):
594
604
  yield item
605
+ yielded += 1
606
+ if bound is not None and yielded >= bound:
607
+ return
595
608
 
596
609
  def live_feed_delete(self, result_ids):
597
610
  """
@@ -823,17 +836,58 @@ class PolyswarmAPI:
823
836
  resources.YaraRuleset.delete(self, id=ruleset_id, community=self.community)
824
837
  )
825
838
 
826
- def ruleset_list(self):
839
+ def ruleset_list(
840
+ self, name=None, status=None, favorites_only=None, has_new_results=None
841
+ ):
827
842
  """
828
843
  List all YaraRulesets for the current account.
844
+
845
+ All filters are optional and conjunctive:
846
+ :param name: Case-insensitive substring match on the ruleset name.
847
+ :param status: 'active' returns only rulesets whose live hunt is
848
+ currently running.
849
+ :param favorites_only: True returns only favorited rulesets.
850
+ :param has_new_results: True returns only rulesets whose stored
851
+ new-results counter is positive. The counter (and its window) is
852
+ maintained server-side by a scheduled refresh; rows carry it as
853
+ ``new_results_count`` with ``new_results_counted_at`` marking when
854
+ it was last refreshed. There is no per-request window parameter.
829
855
  :return: A generator of YaraRuleset resources
830
856
  """
831
857
  logger.info("List rulesets")
832
858
  for item in self._paginate(
833
- resources.YaraRuleset.list(self, community=self.community)
859
+ resources.YaraRuleset.list(
860
+ self,
861
+ name=name,
862
+ status=status,
863
+ favorites_only=favorites_only,
864
+ has_new_results=has_new_results,
865
+ community=self.community,
866
+ )
834
867
  ):
835
868
  yield item
836
869
 
870
+ def ruleset_favorite(self, ruleset_id, favorite=True):
871
+ """
872
+ Favorite or unfavorite a YaraRuleset. Idempotent; works while a live
873
+ hunt is running. Favorites are shared by the whole team and capped
874
+ (the response carries favorites_used / favorites_limit); when the
875
+ budget is exhausted the server refuses with a machine-readable
876
+ FAVORITE_LIMIT error.
877
+
878
+ :param ruleset_id: Id of the ruleset
879
+ :param favorite: True to star, False to unstar
880
+ :return: A YaraRulesetFavorite resource
881
+ """
882
+ logger.info(
883
+ "%s ruleset %s", "Favorite" if favorite else "Unfavorite", ruleset_id
884
+ )
885
+ return self._single(
886
+ resources.YaraRulesetFavorite.update(
887
+ self, id=ruleset_id, favorite=favorite, community=self.community
888
+ )
889
+ )
890
+
837
891
  def tag_link_get(self, sha256):
838
892
  """
839
893
  Fetch the Tags and Families associated with the given sha256.
@@ -1139,6 +1193,13 @@ class PolyswarmAPI:
1139
1193
  :param fh: A file-like object which we are going to write the contents of the artifact to.
1140
1194
  :param hash_type: Hash type of the provided hash_. Will attempt to auto-detect if not explicitly provided.
1141
1195
  :return: A LocalHandle resource
1196
+ :raises KnownGoodWithheldException: the sha256 is catalogued as a known-good binary,
1197
+ so its bytes are withheld by design. A ``NotFoundException`` subclass, so existing
1198
+ ``except NotFoundException`` handling still catches it; catch it specifically to
1199
+ tell a deliberate refusal apart from a missing artifact, and read
1200
+ ``.sources`` for the feeds that flagged it. Nothing is written to the destination —
1201
+ the status is checked before any write (the handle is the caller's and arrives
1202
+ already open, so "before the file is opened" would be the wrong reason here).
1142
1203
  """
1143
1204
  logger.info("Downloading %s into handle", hash_)
1144
1205
  hash_ = resources.Hash.from_hashable(hash_, hash_type=hash_type)
@@ -1941,6 +2002,12 @@ class PolyswarmAPI:
1941
2002
  :param hash_: Hashable (Artifact, LocalArtifact, Hash) or hex-encoded SHA256/SHA1/MD5.
1942
2003
  :param hash_type: Hash type; auto-detected from the literal if not provided.
1943
2004
  :return: A ``LocalArtifact`` resource with its handle already closed.
2005
+ :raises KnownGoodWithheldException: the sha256 is catalogued as a known-good binary,
2006
+ so its bytes are withheld by design. A ``NotFoundException`` subclass, so existing
2007
+ ``except NotFoundException`` handling still catches it; catch it specifically to
2008
+ tell a deliberate refusal apart from a missing artifact, and read
2009
+ ``.sources`` for the feeds that flagged it. Nothing is written to the destination —
2010
+ the status is checked before the file is opened.
1944
2011
  """
1945
2012
  logger.info("Downloading %s into %s", hash_, out_dir)
1946
2013
  hash_ = resources.Hash.from_hashable(hash_, hash_type=hash_type)
@@ -1953,7 +2020,15 @@ class PolyswarmAPI:
1953
2020
  return artifact
1954
2021
 
1955
2022
  def download_id(self, out_dir, instance_id):
1956
- """Download an artifact by its instance id into ``out_dir``."""
2023
+ """Download an artifact by its instance id into ``out_dir``.
2024
+
2025
+ :raises KnownGoodWithheldException: the sha256 is catalogued as a known-good binary,
2026
+ so its bytes are withheld by design. A ``NotFoundException`` subclass, so existing
2027
+ ``except NotFoundException`` handling still catches it; catch it specifically to
2028
+ tell a deliberate refusal apart from a missing artifact, and read
2029
+ ``.sources`` for the feeds that flagged it. Nothing is written to the destination —
2030
+ the status is checked before the file is opened.
2031
+ """
1957
2032
  logger.info("Downloading %s into %s", instance_id, out_dir)
1958
2033
  artifact = self._single(
1959
2034
  resources.LocalArtifact.download_id(self, instance_id, folder=out_dir),
@@ -1962,7 +2037,21 @@ class PolyswarmAPI:
1962
2037
  return artifact
1963
2038
 
1964
2039
  def download_sandbox_artifact(self, out_dir, sandbox_task_id, instance_id):
1965
- """Download a sandbox-produced artifact (e.g. PCAP, dropped file) into ``out_dir``."""
2040
+ """Download a sandbox-produced artifact (e.g. PCAP, dropped file) into ``out_dir``.
2041
+
2042
+ The known-good gate applies to **every** sandbox artifact by its own sha256 —
2043
+ dropped file, screenshot, report, … — with no sample-vs-evidence carve-out
2044
+ server-side, so any member whose digest is catalogued (and eligible) raises below.
2045
+ In practice that is almost always a dropped file; evidence collides only on byte
2046
+ identity, and the empty-file digest is refused at the catalogue boundary instead.
2047
+
2048
+ :raises KnownGoodWithheldException: the sha256 is catalogued as a known-good binary,
2049
+ so its bytes are withheld by design. A ``NotFoundException`` subclass, so existing
2050
+ ``except NotFoundException`` handling still catches it; catch it specifically to
2051
+ tell a deliberate refusal apart from a missing artifact, and read
2052
+ ``.sources`` for the feeds that flagged it. Nothing is written to the destination —
2053
+ the status is checked before the file is opened.
2054
+ """
1966
2055
  logger.info("Downloading sandbox artifact %s %s", sandbox_task_id, instance_id)
1967
2056
  sandbox_artifact = self._single(
1968
2057
  resources.LocalArtifact.download_sandbox_artifact(
@@ -1989,8 +2078,29 @@ class PolyswarmAPI:
1989
2078
 
1990
2079
  :param hash_: Hashable (Artifact, LocalArtifact, Hash) or hex-encoded SHA256/SHA1/MD5.
1991
2080
  :param hash_type: Hash type; auto-detected if not provided.
1992
- :param require_scan: If True, only count artifacts that have been scanned.
2081
+ :param require_scan: If True, only count artifacts that have been *scanned*.
2082
+ Being catalogued as known-good is **not** a scan: the platform never scans
2083
+ such a sample, so a hash whose only record is a known-good reference reports
2084
+ absent under ``require_scan`` (and present without it, because that reference
2085
+ is a real searchable record).
1993
2086
  :return: ``True`` if the artifact exists in PolySwarm's index.
2087
+
2088
+ .. note::
2089
+ This is an existence probe, and its status codes are a **frozen contract**:
2090
+ ``200`` means found, ``204`` means not found, and anything else means the
2091
+ *request* was wrong.
2092
+
2093
+ Only ``200`` is ``True``. ``204`` and ``404`` are both ``False`` — the server
2094
+ answers ``204`` for absent, and ``404`` is tolerated as absent for historical
2095
+ reasons.
2096
+
2097
+ A non-2xx status does not raise here, and the reason is the **method**, not a
2098
+ missing result parser: ``parse_response`` short-circuits ``HEAD``, setting the
2099
+ status code as the result and returning before it reaches the non-2xx→exception
2100
+ mapping (which otherwise applies whether or not a parser is set). So on this
2101
+ endpoint a server error also collapses to ``False``, indistinguishable from a
2102
+ genuine "does not exist" — which is why neither side may widen what counts as
2103
+ found. See ``specs/03-endpoints.md``.
1994
2104
  """
1995
2105
  logger.info("Exists for hash %s", hash_)
1996
2106
  hash_ = resources.Hash.from_hashable(hash_, hash_type=hash_type)
@@ -16,7 +16,8 @@ parsing logic have a single source of truth:
16
16
  builders (``create``, ``get``, ``head``, ``update``, ``delete``,
17
17
  ``list``) construct ``PolyswarmRequest`` descriptors.
18
18
  - ``Hashable`` + ``is_valid_*`` validators, ``parse_isoformat``,
19
- ``_normalise_bool_params``, ``RequestParamsEncoder``.
19
+ ``_normalise_bool_params``, ``RequestParamsEncoder``,
20
+ ``_as_result_bound``.
20
21
 
21
22
  Both transports import from here.
22
23
  """
@@ -330,7 +331,9 @@ def _raise_for_status(response, request):
330
331
 
331
332
  Shared by ``parse_response`` (buffered/JSON path) and the session's
332
333
  streaming-download path, so both raise identically (429/404/422/other,
333
- plus the non-JSON-body fallbacks). The response body must already be
334
+ plus the non-JSON-body fallbacks). A 404 whose ``errors`` payload carries the
335
+ ``KNOWN_GOOD`` code raises the ``NotFoundException`` subclass
336
+ ``KnownGoodWithheldException``. The response body must already be
334
337
  readable — streaming callers ``read()`` / ``aread()`` it first. **Always
335
338
  raises; never returns.**
336
339
  """
@@ -354,6 +357,17 @@ def _raise_for_status(response, request):
354
357
  )
355
358
  raise exceptions.UsageLimitsExceededException(request, message)
356
359
  elif request.status_code == 404:
360
+ # A download refused because the artifact is a known-good binary carries a
361
+ # machine-readable code in the error envelope's ``errors`` slot. Raise the
362
+ # ``NotFoundException`` subclass so callers can tell "withheld by design"
363
+ # apart from a plain miss; every other 404 stays a plain NotFoundException.
364
+ # The raw ``sources`` payload goes in as-is — the exception normalises it
365
+ # into the documented list-of-feed-names shape.
366
+ errors = request.errors
367
+ if isinstance(errors, dict) and errors.get('code') == 'KNOWN_GOOD':
368
+ raise exceptions.KnownGoodWithheldException(
369
+ request, request._result, sources=errors.get('sources'),
370
+ )
357
371
  raise exceptions.NotFoundException(request, request._result)
358
372
  elif request.status_code == 422:
359
373
  raise exceptions.FailedInstanceException(request, request._result)
@@ -383,7 +397,23 @@ def _bad_status_message(request):
383
397
  f'Message: {request._result}'
384
398
  )
385
399
  if request.errors:
386
- errors = '\n'.join(str(error) for error in request.errors)
400
+ # Two ``errors`` shapes are in play. The legacy shape is a **list** of
401
+ # per-error entries — one rendered line each. The way-forward shape is a
402
+ # **mapping** carrying the machine-readable code plus its context
403
+ # (``{'code': 'KNOWN_GOOD', 'known_good': True, 'sources': [...]}``), and the
404
+ # server forwards it on every status, not just the 404 the code was
405
+ # introduced for. Iterating a mapping yields only its KEYS, so rendering it
406
+ # like a list would drop every value from the message the caller sees —
407
+ # render it as ``key=value`` lines instead.
408
+ # The same reasoning applies to a bare string: iterating it yields characters, so
409
+ # `"errors": "some prose"` would render one letter per line. Only genuinely
410
+ # sequence-shaped payloads get the line-per-entry treatment.
411
+ if isinstance(request.errors, dict):
412
+ errors = '\n'.join(f'{k}={v}' for k, v in request.errors.items())
413
+ elif isinstance(request.errors, (list, tuple)):
414
+ errors = '\n'.join(str(error) for error in request.errors)
415
+ else:
416
+ errors = str(request.errors)
387
417
  message = f'{message}\nErrors:\n{errors}'
388
418
  return message
389
419
 
@@ -639,6 +669,16 @@ class BaseJsonResource(BaseResource):
639
669
  )
640
670
 
641
671
 
672
+ def _as_result_bound(max_results):
673
+ """The caller's result bound, or None when there isn't one.
674
+
675
+ None, 0 and negatives all mean "no bound".
676
+ """
677
+ if not max_results or max_results < 0:
678
+ return None
679
+ return max_results
680
+
681
+
642
682
  # ── Hash helpers ───────────────────────────────────────────────────
643
683
 
644
684
 
@@ -0,0 +1,135 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger(__name__)
4
+
5
+
6
+ class PolyswarmException(Exception):
7
+ pass
8
+
9
+
10
+ #########################################
11
+ # API layer exceptions
12
+ #########################################
13
+
14
+ class PolyswarmAPIException(PolyswarmException):
15
+ pass
16
+
17
+
18
+ class TimeoutException(PolyswarmAPIException):
19
+ pass
20
+
21
+
22
+ #########################################
23
+ # Request layer exceptions
24
+ #########################################
25
+
26
+ class RequestException(PolyswarmException):
27
+ def __init__(self, request, *args):
28
+ super().__init__(*args)
29
+ self.request = request
30
+
31
+
32
+ class UsageLimitsExceededException(RequestException):
33
+ pass
34
+
35
+
36
+ class NotFoundException(RequestException):
37
+ pass
38
+
39
+
40
+ def _normalise_sources(sources):
41
+ """Coerce a server-supplied ``sources`` payload into a list of feed names.
42
+
43
+ ``.sources`` is published as a list of strings and consumers iterate it, so a
44
+ bare ``'nsrl'`` cannot be handed through — it would iterate as characters.
45
+ The server sends a list of strings today; a feed-dict entry (the shape the
46
+ instance-level ``known_good`` field uses) yields ``feed['tool']`` defensively
47
+ rather than being dropped. Anything unrecognised becomes ``[]`` and is logged,
48
+ so a discard is never silent. The raw payload stays at ``exc.request.errors``.
49
+ """
50
+ if isinstance(sources, str):
51
+ return [sources]
52
+ if isinstance(sources, list):
53
+ names = []
54
+ for source in sources:
55
+ if isinstance(source, str):
56
+ names.append(source)
57
+ elif isinstance(source, dict) and isinstance(source.get('tool'), str):
58
+ names.append(source['tool'])
59
+ else:
60
+ logger.warning('Dropping unrecognised known-good sources entry: %r', source)
61
+ return names
62
+ if sources is not None:
63
+ logger.warning('Dropping unrecognised known-good sources payload: %r', sources)
64
+ return []
65
+
66
+
67
+ class KnownGoodWithheldException(NotFoundException):
68
+ """404 for a known-good binary: the platform never stores or serves its bytes.
69
+
70
+ Subclasses ``NotFoundException`` so every existing ``except NotFoundException``
71
+ handler keeps working unchanged; catch this subclass only when the caller wants
72
+ to tell "withheld by design" apart from a plain "not found". The metadata the
73
+ platform holds about the artifact is still readable through the search /
74
+ instance endpoints.
75
+ """
76
+
77
+ def __init__(self, request, *args, sources=None):
78
+ super().__init__(request, *args)
79
+ # Known-good feeds that flagged the hash (e.g. ``['nsrl']``); always a list
80
+ # of strings — normalised here, at the boundary, so the documented shape
81
+ # holds whatever the envelope carried. Empty when the server named none.
82
+ self.sources = _normalise_sources(sources)
83
+
84
+
85
+ class NoResultsException(RequestException):
86
+ pass
87
+
88
+
89
+ class FailedInstanceException(RequestException):
90
+ pass
91
+
92
+
93
+ #########################################
94
+ # Types layer exceptions
95
+ #########################################
96
+
97
+
98
+ class TypeException(PolyswarmException):
99
+ pass
100
+
101
+
102
+ class MissingAPIInstanceException(TypeException):
103
+ pass
104
+
105
+
106
+ class InvalidJSONResponseException(TypeException):
107
+ pass
108
+
109
+
110
+ class DecodeErrorException(TypeException):
111
+ pass
112
+
113
+
114
+ class InvalidValueException(TypeException):
115
+ pass
116
+
117
+
118
+ class ArtifactDeletedException(TypeException):
119
+ pass
120
+
121
+
122
+ class InvalidYaraRulesException(TypeException):
123
+ pass
124
+
125
+
126
+ class NotImportedException(TypeException):
127
+ pass
128
+
129
+
130
+ #########################################
131
+ # Warnings
132
+ #########################################
133
+
134
+ class APIWarning(Warning):
135
+ pass
@@ -275,9 +275,14 @@ class ArtifactInstance(core.BaseJsonResource, core.Hashable):
275
275
  self.known_good_sources = sorted(
276
276
  {feed['tool'] for feed in self.known_good if feed.get('tool')}
277
277
  ) if self.known_good else []
278
- # Friendly bounty-state NAME (e.g. 'KNOWN_GOOD' / 'SETTLED' / 'STORED'),
279
- # additive alongside the numeric bounty_state. Optional — older servers
280
- # omit it, so .get() yields None (no behaviour change).
278
+ # Friendly REPORTED-state NAME (e.g. 'KNOWN_GOOD' / 'SETTLED' / 'STORED' /
279
+ # 'NOT_STORED'). The server derives it from its two-predicate known-good model
280
+ # (artifact-index specs/05): 'KNOWN_GOOD' while the file is currently known-good,
281
+ # 'NOT_STORED' for a submission it declined as known-good whose hash is no longer
282
+ # currently known-good (nothing was ever stored; a fresh submit works), otherwise
283
+ # the instance's own persisted state. Additive alongside the numeric
284
+ # bounty_state, and optional — older servers omit it, so .get() yields None
285
+ # (no behaviour change).
281
286
  self.state = content.get('state')
282
287
 
283
288
  # ArtifactInstance fields
@@ -741,12 +746,51 @@ class YaraRuleset(core.BaseJsonResource):
741
746
  self.modified = core.parse_isoformat(content.get('modified'))
742
747
  self.deleted = content.get('deleted')
743
748
  self.yara = content.get('yara')
749
+ # Hunt-page tracking fields. All additive — an older server simply
750
+ # leaves them None.
751
+ self.favorite = content.get('favorite')
752
+ self.favorited_at = core.parse_isoformat(content.get('favorited_at'))
753
+ # Number of rules in the ruleset body. None means "no answer" (the
754
+ # server could not or has not counted), which is different from 0.
755
+ self.rule_count = content.get('rule_count')
756
+ self.historical_hunt_count = content.get('historical_hunt_count')
757
+ # Live results collected in the product window — a STORED counter the
758
+ # server refreshes on a schedule, always present on list rows with a
759
+ # running live hunt. None means "not yet refreshed / no live hunt",
760
+ # which is different from 0 ("refreshed, nothing new").
761
+ self.new_results_count = content.get('new_results_count')
762
+ # When the server last refreshed the counter — the staleness marker
763
+ # that makes a stored count trustworthy (None iff the count is None).
764
+ self.new_results_counted_at = core.parse_isoformat(
765
+ content.get('new_results_counted_at'))
744
766
 
745
767
 
746
768
  class LiveYaraRuleset(YaraRuleset):
747
769
  RESOURCE_ENDPOINT = '/hunt/rule/live'
748
770
 
749
771
 
772
+ class YaraRulesetFavorite(core.BaseJsonResource):
773
+ """The favorite-toggle response: the ruleset's new star state plus the
774
+ team's budget usage, so callers can render "N of M used" without counting
775
+ client-side."""
776
+ RESOURCE_ENDPOINT = '/hunt/rule/favorite'
777
+ # The toggle's payload ({id, favorite}) rides the JSON body — the server
778
+ # reads BOTH from the body — so the default RESOURCE_ID_KEYS (['id'])
779
+ # would move `id` into the query string and the server would 400 with
780
+ # "A valid rule id must be provided". `community` stays a query-string
781
+ # key to match where every ruleset GET/list sends it (the server accepts
782
+ # it from either the query or the body, but never from both at once).
783
+ RESOURCE_ID_KEYS = ['community']
784
+
785
+ def __init__(self, content, api=None):
786
+ super().__init__(content, api=api)
787
+ self.id = content.get('id')
788
+ self.favorite = content.get('favorite')
789
+ self.favorited_at = core.parse_isoformat(content.get('favorited_at'))
790
+ self.favorites_used = content.get('favorites_used')
791
+ self.favorites_limit = content.get('favorites_limit')
792
+
793
+
750
794
  class LiveHuntResult(core.BaseJsonResource):
751
795
  RESOURCE_ENDPOINT = '/hunt/live'
752
796
 
@@ -789,6 +833,16 @@ class HistoricalHunt(core.BaseJsonResource):
789
833
  self.progress = content['progress']
790
834
  self.results_csv_uri = content['results_csv_uri']
791
835
  self.communities = content.get('communities')
836
+ # Source-rule provenance. rule_id is the ruleset this hunt was
837
+ # triggered from (None for raw-yara hunts and hunts predating the
838
+ # tracking); rule_modified is a freeze-time audit timestamp.
839
+ self.rule_id = content.get('rule_id')
840
+ self.rule_modified = core.parse_isoformat(content.get('rule_modified'))
841
+ # Tri-state: has the source ruleset's body changed SINCE THE HUNT
842
+ # FROZE IT? True/False when the server could compare; None means
843
+ # unknown (no source rule, or nothing to compare against) — not
844
+ # "unchanged".
845
+ self.source_rule_changed = content.get('source_rule_changed')
792
846
 
793
847
 
794
848
  class HistoricalHuntList(HistoricalHunt):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polyswarm_api
3
- Version: 4.2.0
3
+ Version: 4.4.0
4
4
  Summary: Client library to simplify interacting with the PolySwarm consumer API
5
5
  Author-email: PolySwarm Developers <info@polyswarm.io>
6
6
  License: MIT
@@ -1,85 +0,0 @@
1
- class PolyswarmException(Exception):
2
- pass
3
-
4
-
5
- #########################################
6
- # API layer exceptions
7
- #########################################
8
-
9
- class PolyswarmAPIException(PolyswarmException):
10
- pass
11
-
12
-
13
- class TimeoutException(PolyswarmAPIException):
14
- pass
15
-
16
-
17
- #########################################
18
- # Request layer exceptions
19
- #########################################
20
-
21
- class RequestException(PolyswarmException):
22
- def __init__(self, request, *args):
23
- super().__init__(*args)
24
- self.request = request
25
-
26
-
27
- class UsageLimitsExceededException(RequestException):
28
- pass
29
-
30
-
31
- class NotFoundException(RequestException):
32
- pass
33
-
34
-
35
- class NoResultsException(RequestException):
36
- pass
37
-
38
-
39
- class FailedInstanceException(RequestException):
40
- pass
41
-
42
-
43
- #########################################
44
- # Types layer exceptions
45
- #########################################
46
-
47
-
48
- class TypeException(PolyswarmException):
49
- pass
50
-
51
-
52
- class MissingAPIInstanceException(TypeException):
53
- pass
54
-
55
-
56
- class InvalidJSONResponseException(TypeException):
57
- pass
58
-
59
-
60
- class DecodeErrorException(TypeException):
61
- pass
62
-
63
-
64
- class InvalidValueException(TypeException):
65
- pass
66
-
67
-
68
- class ArtifactDeletedException(TypeException):
69
- pass
70
-
71
-
72
- class InvalidYaraRulesException(TypeException):
73
- pass
74
-
75
-
76
- class NotImportedException(TypeException):
77
- pass
78
-
79
-
80
- #########################################
81
- # Warnings
82
- #########################################
83
-
84
- class APIWarning(Warning):
85
- pass
File without changes
File without changes
File without changes