polyswarm-api 4.4.0__tar.gz → 4.7.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.4.0/src/polyswarm_api.egg-info → polyswarm_api-4.7.0}/PKG-INFO +1 -1
  2. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/pyproject.toml +2 -2
  3. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/src/polyswarm_api/__init__.py +2 -1
  4. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/src/polyswarm_api/aio/api.py +104 -14
  5. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/src/polyswarm_api/api.py +130 -14
  6. polyswarm_api-4.7.0/src/polyswarm_api/refang.py +113 -0
  7. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/src/polyswarm_api/resources.py +17 -2
  8. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0/src/polyswarm_api.egg-info}/PKG-INFO +1 -1
  9. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/src/polyswarm_api.egg-info/SOURCES.txt +1 -0
  10. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/LICENSE +0 -0
  11. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/README.md +0 -0
  12. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/setup.cfg +0 -0
  13. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/src/polyswarm_api/aio/__init__.py +0 -0
  14. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/src/polyswarm_api/aio/session.py +0 -0
  15. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/src/polyswarm_api/core.py +0 -0
  16. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/src/polyswarm_api/exceptions.py +0 -0
  17. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/src/polyswarm_api/session.py +0 -0
  18. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/src/polyswarm_api/settings.py +0 -0
  19. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/src/polyswarm_api.egg-info/dependency_links.txt +0 -0
  20. {polyswarm_api-4.4.0 → polyswarm_api-4.7.0}/src/polyswarm_api.egg-info/requires.txt +0 -0
  21. {polyswarm_api-4.4.0 → polyswarm_api-4.7.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.4.0
3
+ Version: 4.7.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.4.0"
7
+ version = "4.7.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.4.0"
58
+ current_version = "4.7.0"
59
59
  commit = true
60
60
  tag = false
61
61
  sign_tags = true
@@ -1,9 +1,10 @@
1
1
  # https://www.python.org/dev/peps/pep-0008/#module-level-dunder-names
2
- __version__ = '4.4.0'
2
+ __version__ = '4.7.0'
3
3
  __release_url__ = 'https://api.github.com/repos/polyswarm/polyswarm-api/releases/latest'
4
4
 
5
5
  from . import api
6
6
  from . import exceptions
7
+ from . import refang
7
8
  from .api import PolyswarmAPI
8
9
  from .session import PolyswarmSession
9
10
  from .aio import PolySwarmAsyncAPI, AsyncPolyswarmSession
@@ -17,7 +17,7 @@ import io
17
17
  import logging
18
18
  import time
19
19
 
20
- from polyswarm_api import exceptions, resources, settings
20
+ from polyswarm_api import exceptions, refang, resources, settings
21
21
  from polyswarm_api.core import PolyswarmRequest, _as_result_bound
22
22
 
23
23
  from .session import AsyncPolyswarmSession
@@ -53,6 +53,7 @@ class PolySwarmAsyncAPI:
53
53
  verify: bool = True,
54
54
  *,
55
55
  session: AsyncPolyswarmSession | None = None,
56
+ refang_iocs: bool = False,
56
57
  **httpx_kwargs,
57
58
  ):
58
59
  key_masked = '******' + (key[-4:] if key and len(key) > 16 else '')
@@ -64,6 +65,11 @@ class PolySwarmAsyncAPI:
64
65
  self.community = community or settings.DEFAULT_COMMUNITY
65
66
  self.timeout = timeout or settings.DEFAULT_HTTP_TIMEOUT
66
67
  self.verify = verify
68
+ # Refang defanged URL / domain / IP inputs (``hxxps[:]//evil[.]com``)
69
+ # before building a request. Opt-in: the default preserves the 4.5.0
70
+ # behaviour of sending every input verbatim. See ``polyswarm_api.refang``
71
+ # and ``_refang`` below for exactly which inputs are touched.
72
+ self.refang_iocs = refang_iocs
67
73
  self._engines = None
68
74
  # Either accept a pre-built session (customization point) or
69
75
  # build the default from ``key``. Passing both is ambiguous.
@@ -88,6 +94,27 @@ class PolySwarmAsyncAPI:
88
94
  attrs = f'uri={self.uri!r}, community={self.community!r}, timeout={self.timeout!r}'
89
95
  return f'<{clsname}({attrs}) at 0x{id(self):x}>'
90
96
 
97
+ def _refang(self, value):
98
+ """Refang one URL / domain / IP input when ``refang_iocs`` is on.
99
+
100
+ Applied only to arguments that name a network indicator (a URL to
101
+ search or submit, an ``ips=`` / ``urls=`` / ``domains=`` entry, an IoC
102
+ ``ip`` / ``domain``, a known-host ``host``) — never to a free-form
103
+ metadata query or a hash.
104
+ Anything that is not a defanged network IoC is returned unchanged.
105
+ """
106
+ if not self.refang_iocs:
107
+ return value
108
+ return refang.refang_ioc(value)
109
+
110
+ def _refang_all(self, values):
111
+ """``_refang`` over a list argument; ``None`` / empty pass through."""
112
+ if not values or not self.refang_iocs:
113
+ return values
114
+ if isinstance(values, str):
115
+ return self._refang(values)
116
+ return [self._refang(v) for v in values]
117
+
91
118
  async def aclose(self):
92
119
  """Close the underlying HTTP client."""
93
120
  await self.session.aclose()
@@ -340,6 +367,7 @@ class PolySwarmAsyncAPI:
340
367
  :param url: A url to be searched by exact match
341
368
  :return: Generator of ArtifactInstance resources
342
369
  """
370
+ url = self._refang(url)
343
371
  logger.info('Searching for url %s', url)
344
372
  async for item in self._paginate(resources.ArtifactInstance.search_url(self, url)):
345
373
  yield item
@@ -365,6 +393,7 @@ class PolySwarmAsyncAPI:
365
393
  :param exclude: A list of fields to be excluded from the result (.* wildcards are accepted)
366
394
  :return: Generator of ArtifactInstance resources
367
395
  """
396
+ ips, urls, domains = self._refang_all(ips), self._refang_all(urls), self._refang_all(domains)
368
397
  logger.info('Searching for metadata %s', query)
369
398
  async for item in self._paginate(resources.Metadata.get(self, query=query, community=self.community, include=include, exclude=exclude, ips=ips, urls=urls, domains=domains)):
370
399
  yield item
@@ -381,18 +410,28 @@ class PolySwarmAsyncAPI:
381
410
  async for item in self._paginate(resources.IOC.iocs_by_hash(self, hash_value, hash_type, hide_known_good=hide_known_good, beta=beta)):
382
411
  yield item
383
412
 
384
- async def search_by_ioc(self, ip=None, domain=None, ttp=None, imphash=None):
413
+ async def search_by_ioc(self, ip=None, domain=None, ttp=None, imphash=None, with_artifacts=False):
385
414
  """
386
415
  Search artifacts by IOC (ip, domain, ttp, or imphash)
387
-
416
+
388
417
  :param ip: ip address to search by
389
418
  :param domain: domain address to search by
390
419
  :param ttp: ttp to search by
391
420
  :param imphash: ImpHash to search by
392
- :return: Generator of ArtifactInstance resources
393
- """
394
- logger.info('Searching by ioc %s', dict(ip=ip, domain=domain, ttp=ttp, imphash=imphash))
395
- async for item in self._paginate(resources.IOC.ioc_search(self, ip=ip, domain=domain, ttp=ttp, imphash=imphash)):
421
+ :param with_artifacts: True yields a Metadata resource per matching artifact
422
+ (a metadata-search row trimmed to a field set the server owns: artifact.*,
423
+ the scan summary, ssdeep/tlsh and the malware family) instead of its bare
424
+ sha256. With no ip/domain/ttp/imphash the server refuses it with a 400
425
+ (a typed exception); without it a bare call behaves as it always has.
426
+ Needs a server that supports the parameter:
427
+ an older one ignores it and answers bare sha256 strings, which fail to
428
+ parse as Metadata (TypeError).
429
+ :return: Generator of IOC resources whose ``json`` is a sha256 string, or of
430
+ Metadata resources when ``with_artifacts`` is True
431
+ """
432
+ ip, domain = self._refang(ip), self._refang(domain)
433
+ logger.info('Searching by ioc %s', dict(ip=ip, domain=domain, ttp=ttp, imphash=imphash, with_artifacts=with_artifacts))
434
+ async for item in self._paginate(resources.IOC.ioc_search(self, ip=ip, domain=domain, ttp=ttp, imphash=imphash, with_artifacts=with_artifacts)):
396
435
  yield item
397
436
 
398
437
  async def check_known_hosts(self, ips=[], domains=[]):
@@ -403,6 +442,7 @@ class PolySwarmAsyncAPI:
403
442
  :param domains
404
443
  :return: Generator of IOC resources
405
444
  """
445
+ ips, domains = self._refang_all(ips), self._refang_all(domains)
406
446
  logger.info('Checking known hosts ips: %s, domains: %s', ips, domains)
407
447
  async for item in self._paginate(resources.IOC.check_known_hosts(self, ips, domains)):
408
448
  yield item
@@ -416,6 +456,7 @@ class PolySwarmAsyncAPI:
416
456
  :param host
417
457
  :return: IOC resource
418
458
  """
459
+ host = self._refang(host)
419
460
  logger.info('Creating known good ioc %s %s %s', type, host, source)
420
461
  return await self._single(resources.IOC.create_known_good(self, type, host, source))
421
462
 
@@ -428,6 +469,7 @@ class PolySwarmAsyncAPI:
428
469
  :param host
429
470
  :return: IOC resource
430
471
  """
472
+ host = self._refang(host)
431
473
  logger.info('Creating known bad ioc %s %s %s', type, host, source)
432
474
  return await self._single(resources.IOC.create_known_bad(self, type, host, source))
433
475
 
@@ -440,6 +482,7 @@ class PolySwarmAsyncAPI:
440
482
  :param host
441
483
  :return: IOC resource
442
484
  """
485
+ host = self._refang(host)
443
486
  logger.info('Updating known good ioc %s %s %s %s', id, type, host, source)
444
487
  return await self._single(resources.IOC.update_known_good(self, id, type, host, source, good))
445
488
 
@@ -697,7 +740,8 @@ class PolySwarmAsyncAPI:
697
740
  return await self._single(resources.YaraRuleset.delete(self, id=ruleset_id, community=self.community))
698
741
 
699
742
  async def ruleset_list(self, name=None, status=None, favorites_only=None,
700
- has_new_results=None):
743
+ has_new_results=None, sort=None,
744
+ exclude_favorites=None):
701
745
  """
702
746
  List all YaraRulesets for the current account.
703
747
 
@@ -706,17 +750,53 @@ class PolySwarmAsyncAPI:
706
750
  :param status: 'active' returns only rulesets whose live hunt is
707
751
  currently running.
708
752
  :param favorites_only: True returns only favorited rulesets.
753
+ :param exclude_favorites: True returns only the rulesets that are NOT
754
+ favorited — the inverse of ``favorites_only``, and refused together
755
+ with it (a contradiction, answered with an error rather than an
756
+ empty list). It exists for clients that render the favorites as
757
+ their own list: the favorites are a separate, unpaginated fetch
758
+ bounded by the account's budget, so leaving them in the paginated
759
+ list too makes a page either repeat a row or come back short.
760
+ Appended to the signature rather than placed beside
761
+ ``favorites_only`` so a positional caller keeps working.
709
762
  :param has_new_results: True returns only rulesets whose stored
710
763
  new-results counter is positive. The counter (and its window) is
711
764
  maintained server-side by a scheduled refresh; rows carry it as
712
765
  ``new_results_count`` with ``new_results_counted_at`` marking when
713
766
  it was last refreshed. There is no per-request window parameter.
767
+ :param sort: ``'active_first'`` returns the rulesets that carry a live
768
+ hunt link first, newest first within each block. Default (None) is
769
+ newest first. "Newest first" is the server's own insertion key, NOT
770
+ the ``id`` on the rows you get back — that one is unique but
771
+ unordered, so dedupe with it and never resume or bound a walk with
772
+ it. Applied SERVER-side, across pages — the list is
773
+ keyset-paginated, so a client-side sort would only ever reorder one
774
+ page; the SDK never re-orders rows. Reuse a page's ``offset`` only
775
+ with the same ``sort``: the server refuses a cursor minted under
776
+ the other order.
777
+
778
+ Two server-side properties of that key, neither of them SDK
779
+ behaviour. It ranks on the stored link, which is a WIDER predicate
780
+ than the one ``livescan_id`` is rendered under: a legacy row whose
781
+ hunt was stopped without clearing the link ranks in the leading
782
+ block while still serializing ``livescan_id`` as ``None``. Read the
783
+ field to decide whether a ruleset is running; never the position.
784
+
785
+ And the key is MUTABLE, unlike that default: a ruleset whose
786
+ live hunt stops mid-walk falls back into the idle block below the
787
+ cursor and is yielded twice, and one started mid-walk moves above
788
+ the cursor and is skipped for the rest of that walk. That is a
789
+ property of the walk, so starting fresh from the first page does
790
+ not avoid it. This generator streams pages and does not dedupe —
791
+ dedupe by ``id`` if you consume more than one page.
714
792
  :return: A generator of YaraRuleset resources
715
793
  """
716
794
  logger.info('List rulesets')
717
795
  async for item in self._paginate(resources.YaraRuleset.list(
718
796
  self, name=name, status=status, favorites_only=favorites_only,
719
- has_new_results=has_new_results, community=self.community)):
797
+ has_new_results=has_new_results, sort=sort,
798
+ exclude_favorites=exclude_favorites,
799
+ community=self.community)):
720
800
  yield item
721
801
 
722
802
  async def ruleset_favorite(self, ruleset_id, favorite=True):
@@ -1336,11 +1416,12 @@ class PolySwarmAsyncAPI:
1336
1416
  self, artifact, artifact_type=artifact_type, artifact_name=artifact_name
1337
1417
  )
1338
1418
  elif artifact_type == resources.ArtifactType.URL:
1339
- if preprocessing and preprocessing["type"] == "qrcode":
1419
+ if preprocessing and preprocessing.get("type") == "qrcode":
1340
1420
  artifact = resources.LocalArtifact.from_path(
1341
1421
  self, artifact, artifact_type=artifact_type, artifact_name=artifact_name
1342
1422
  )
1343
1423
  else:
1424
+ artifact = self._refang(artifact)
1344
1425
  artifact = resources.LocalArtifact.from_content(
1345
1426
  self, artifact, artifact_name=artifact_name or artifact,
1346
1427
  artifact_type=artifact_type,
@@ -1443,10 +1524,18 @@ class PolySwarmAsyncAPI:
1443
1524
  self, artifact, artifact_type=artifact_type, artifact_name=artifact_name
1444
1525
  )
1445
1526
  elif artifact_type == resources.ArtifactType.URL:
1446
- artifact = resources.LocalArtifact.from_content(
1447
- self, artifact, artifact_name=artifact_name or artifact,
1448
- artifact_type=artifact_type,
1449
- )
1527
+ # A QR-code submission's argument is an image path, not a URL
1528
+ # (same rule as ``submit``): read the image, never refang it.
1529
+ if preprocessing and preprocessing.get("type") == "qrcode":
1530
+ artifact = resources.LocalArtifact.from_path(
1531
+ self, artifact, artifact_type=artifact_type, artifact_name=artifact_name
1532
+ )
1533
+ else:
1534
+ artifact = self._refang(artifact)
1535
+ artifact = resources.LocalArtifact.from_content(
1536
+ self, artifact, artifact_name=artifact_name or artifact,
1537
+ artifact_type=artifact_type,
1538
+ )
1450
1539
 
1451
1540
  json_params = {
1452
1541
  "artifact_name": artifact.artifact_name,
@@ -1505,6 +1594,7 @@ class PolySwarmAsyncAPI:
1505
1594
  else:
1506
1595
  local = artifact
1507
1596
  else:
1597
+ url = self._refang(url)
1508
1598
  local = resources.LocalArtifact.from_content(
1509
1599
  self, url, artifact_name=artifact_name or url,
1510
1600
  artifact_type=resources.ArtifactType.URL,
@@ -19,7 +19,7 @@ import time
19
19
  import io
20
20
  import logging
21
21
 
22
- from polyswarm_api import exceptions, resources, settings
22
+ from polyswarm_api import exceptions, refang, resources, settings
23
23
  from polyswarm_api.core import PolyswarmRequest, _as_result_bound
24
24
 
25
25
  from .session import PolyswarmSession
@@ -55,6 +55,7 @@ class PolyswarmAPI:
55
55
  verify: bool = True,
56
56
  *,
57
57
  session: PolyswarmSession | None = None,
58
+ refang_iocs: bool = False,
58
59
  **httpx_kwargs,
59
60
  ):
60
61
  key_masked = "******" + (key[-4:] if key and len(key) > 16 else "")
@@ -69,6 +70,11 @@ class PolyswarmAPI:
69
70
  self.community = community or settings.DEFAULT_COMMUNITY
70
71
  self.timeout = timeout or settings.DEFAULT_HTTP_TIMEOUT
71
72
  self.verify = verify
73
+ # Refang defanged URL / domain / IP inputs (``hxxps[:]//evil[.]com``)
74
+ # before building a request. Opt-in: the default preserves the 4.5.0
75
+ # behaviour of sending every input verbatim. See ``polyswarm_api.refang``
76
+ # and ``_refang`` below for exactly which inputs are touched.
77
+ self.refang_iocs = refang_iocs
72
78
  self._engines = None
73
79
  # Either accept a pre-built session (customization point) or
74
80
  # build the default from ``key``. Passing both is ambiguous.
@@ -95,6 +101,27 @@ class PolyswarmAPI:
95
101
  )
96
102
  return f"<{clsname}({attrs}) at 0x{id(self):x}>"
97
103
 
104
+ def _refang(self, value):
105
+ """Refang one URL / domain / IP input when ``refang_iocs`` is on.
106
+
107
+ Applied only to arguments that name a network indicator (a URL to
108
+ search or submit, an ``ips=`` / ``urls=`` / ``domains=`` entry, an IoC
109
+ ``ip`` / ``domain``, a known-host ``host``) — never to a free-form
110
+ metadata query or a hash.
111
+ Anything that is not a defanged network IoC is returned unchanged.
112
+ """
113
+ if not self.refang_iocs:
114
+ return value
115
+ return refang.refang_ioc(value)
116
+
117
+ def _refang_all(self, values):
118
+ """``_refang`` over a list argument; ``None`` / empty pass through."""
119
+ if not values or not self.refang_iocs:
120
+ return values
121
+ if isinstance(values, str):
122
+ return self._refang(values)
123
+ return [self._refang(v) for v in values]
124
+
98
125
  def close(self):
99
126
  """Close the underlying HTTP client."""
100
127
  self.session.close()
@@ -366,6 +393,7 @@ class PolyswarmAPI:
366
393
  :param url: A url to be searched by exact match
367
394
  :return: Generator of ArtifactInstance resources
368
395
  """
396
+ url = self._refang(url)
369
397
  logger.info("Searching for url %s", url)
370
398
  for item in self._paginate(resources.ArtifactInstance.search_url(self, url)):
371
399
  yield item
@@ -395,6 +423,11 @@ class PolyswarmAPI:
395
423
  :param exclude: A list of fields to be excluded from the result (.* wildcards are accepted)
396
424
  :return: Generator of ArtifactInstance resources
397
425
  """
426
+ ips, urls, domains = (
427
+ self._refang_all(ips),
428
+ self._refang_all(urls),
429
+ self._refang_all(domains),
430
+ )
398
431
  logger.info("Searching for metadata %s", query)
399
432
  for item in self._paginate(
400
433
  resources.Metadata.get(
@@ -426,7 +459,9 @@ class PolyswarmAPI:
426
459
  ):
427
460
  yield item
428
461
 
429
- def search_by_ioc(self, ip=None, domain=None, ttp=None, imphash=None):
462
+ def search_by_ioc(
463
+ self, ip=None, domain=None, ttp=None, imphash=None, with_artifacts=False
464
+ ):
430
465
  """
431
466
  Search artifacts by IOC (ip, domain, ttp, or imphash)
432
467
 
@@ -434,14 +469,36 @@ class PolyswarmAPI:
434
469
  :param domain: domain address to search by
435
470
  :param ttp: ttp to search by
436
471
  :param imphash: ImpHash to search by
437
- :return: Generator of ArtifactInstance resources
438
- """
472
+ :param with_artifacts: True yields a Metadata resource per matching artifact
473
+ (a metadata-search row trimmed to a field set the server owns: artifact.*,
474
+ the scan summary, ssdeep/tlsh and the malware family) instead of its bare
475
+ sha256. With no ip/domain/ttp/imphash the server refuses it with a 400
476
+ (a typed exception); without it a bare call behaves as it always has.
477
+ Needs a server that supports the parameter:
478
+ an older one ignores it and answers bare sha256 strings, which fail to
479
+ parse as Metadata (TypeError).
480
+ :return: Generator of IOC resources whose ``json`` is a sha256 string, or of
481
+ Metadata resources when ``with_artifacts`` is True
482
+ """
483
+ ip, domain = self._refang(ip), self._refang(domain)
439
484
  logger.info(
440
- "Searching by ioc %s", dict(ip=ip, domain=domain, ttp=ttp, imphash=imphash)
485
+ "Searching by ioc %s",
486
+ dict(
487
+ ip=ip,
488
+ domain=domain,
489
+ ttp=ttp,
490
+ imphash=imphash,
491
+ with_artifacts=with_artifacts,
492
+ ),
441
493
  )
442
494
  for item in self._paginate(
443
495
  resources.IOC.ioc_search(
444
- self, ip=ip, domain=domain, ttp=ttp, imphash=imphash
496
+ self,
497
+ ip=ip,
498
+ domain=domain,
499
+ ttp=ttp,
500
+ imphash=imphash,
501
+ with_artifacts=with_artifacts,
445
502
  )
446
503
  ):
447
504
  yield item
@@ -454,6 +511,7 @@ class PolyswarmAPI:
454
511
  :param domains
455
512
  :return: Generator of IOC resources
456
513
  """
514
+ ips, domains = self._refang_all(ips), self._refang_all(domains)
457
515
  logger.info("Checking known hosts ips: %s, domains: %s", ips, domains)
458
516
  for item in self._paginate(resources.IOC.check_known_hosts(self, ips, domains)):
459
517
  yield item
@@ -467,6 +525,7 @@ class PolyswarmAPI:
467
525
  :param host
468
526
  :return: IOC resource
469
527
  """
528
+ host = self._refang(host)
470
529
  logger.info("Creating known good ioc %s %s %s", type, host, source)
471
530
  return self._single(resources.IOC.create_known_good(self, type, host, source))
472
531
 
@@ -479,6 +538,7 @@ class PolyswarmAPI:
479
538
  :param host
480
539
  :return: IOC resource
481
540
  """
541
+ host = self._refang(host)
482
542
  logger.info("Creating known bad ioc %s %s %s", type, host, source)
483
543
  return self._single(resources.IOC.create_known_bad(self, type, host, source))
484
544
 
@@ -491,6 +551,7 @@ class PolyswarmAPI:
491
551
  :param host
492
552
  :return: IOC resource
493
553
  """
554
+ host = self._refang(host)
494
555
  logger.info("Updating known good ioc %s %s %s %s", id, type, host, source)
495
556
  return self._single(
496
557
  resources.IOC.update_known_good(self, id, type, host, source, good)
@@ -837,7 +898,13 @@ class PolyswarmAPI:
837
898
  )
838
899
 
839
900
  def ruleset_list(
840
- self, name=None, status=None, favorites_only=None, has_new_results=None
901
+ self,
902
+ name=None,
903
+ status=None,
904
+ favorites_only=None,
905
+ has_new_results=None,
906
+ sort=None,
907
+ exclude_favorites=None,
841
908
  ):
842
909
  """
843
910
  List all YaraRulesets for the current account.
@@ -847,11 +914,45 @@ class PolyswarmAPI:
847
914
  :param status: 'active' returns only rulesets whose live hunt is
848
915
  currently running.
849
916
  :param favorites_only: True returns only favorited rulesets.
917
+ :param exclude_favorites: True returns only the rulesets that are NOT
918
+ favorited — the inverse of ``favorites_only``, and refused together
919
+ with it (a contradiction, answered with an error rather than an
920
+ empty list). It exists for clients that render the favorites as
921
+ their own list: the favorites are a separate, unpaginated fetch
922
+ bounded by the account's budget, so leaving them in the paginated
923
+ list too makes a page either repeat a row or come back short.
924
+ Appended to the signature rather than placed beside
925
+ ``favorites_only`` so a positional caller keeps working.
850
926
  :param has_new_results: True returns only rulesets whose stored
851
927
  new-results counter is positive. The counter (and its window) is
852
928
  maintained server-side by a scheduled refresh; rows carry it as
853
929
  ``new_results_count`` with ``new_results_counted_at`` marking when
854
930
  it was last refreshed. There is no per-request window parameter.
931
+ :param sort: ``'active_first'`` returns the rulesets that carry a live
932
+ hunt link first, newest first within each block. Default (None) is
933
+ newest first. "Newest first" is the server's own insertion key, NOT
934
+ the ``id`` on the rows you get back — that one is unique but
935
+ unordered, so dedupe with it and never resume or bound a walk with
936
+ it. Applied SERVER-side, across pages — the list is
937
+ keyset-paginated, so a client-side sort would only ever reorder one
938
+ page; the SDK never re-orders rows. Reuse a page's ``offset`` only
939
+ with the same ``sort``: the server refuses a cursor minted under
940
+ the other order.
941
+
942
+ Two server-side properties of that key, neither of them SDK
943
+ behaviour. It ranks on the stored link, which is a WIDER predicate
944
+ than the one ``livescan_id`` is rendered under: a legacy row whose
945
+ hunt was stopped without clearing the link ranks in the leading
946
+ block while still serializing ``livescan_id`` as ``None``. Read the
947
+ field to decide whether a ruleset is running; never the position.
948
+
949
+ And the key is MUTABLE, unlike that default: a ruleset whose
950
+ live hunt stops mid-walk falls back into the idle block below the
951
+ cursor and is yielded twice, and one started mid-walk moves above
952
+ the cursor and is skipped for the rest of that walk. That is a
953
+ property of the walk, so starting fresh from the first page does
954
+ not avoid it. This generator streams pages and does not dedupe —
955
+ dedupe by ``id`` if you consume more than one page.
855
956
  :return: A generator of YaraRuleset resources
856
957
  """
857
958
  logger.info("List rulesets")
@@ -862,6 +963,8 @@ class PolyswarmAPI:
862
963
  status=status,
863
964
  favorites_only=favorites_only,
864
965
  has_new_results=has_new_results,
966
+ sort=sort,
967
+ exclude_favorites=exclude_favorites,
865
968
  community=self.community,
866
969
  )
867
970
  ):
@@ -1658,7 +1761,7 @@ class PolyswarmAPI:
1658
1761
  artifact_name=artifact_name,
1659
1762
  )
1660
1763
  elif artifact_type == resources.ArtifactType.URL:
1661
- if preprocessing and preprocessing["type"] == "qrcode":
1764
+ if preprocessing and preprocessing.get("type") == "qrcode":
1662
1765
  artifact = resources.LocalArtifact.from_path(
1663
1766
  self,
1664
1767
  artifact,
@@ -1666,6 +1769,7 @@ class PolyswarmAPI:
1666
1769
  artifact_name=artifact_name,
1667
1770
  )
1668
1771
  else:
1772
+ artifact = self._refang(artifact)
1669
1773
  artifact = resources.LocalArtifact.from_content(
1670
1774
  self,
1671
1775
  artifact,
@@ -1780,12 +1884,23 @@ class PolyswarmAPI:
1780
1884
  artifact_name=artifact_name,
1781
1885
  )
1782
1886
  elif artifact_type == resources.ArtifactType.URL:
1783
- artifact = resources.LocalArtifact.from_content(
1784
- self,
1785
- artifact,
1786
- artifact_name=artifact_name or artifact,
1787
- artifact_type=artifact_type,
1788
- )
1887
+ # A QR-code submission's argument is an image path, not a URL
1888
+ # (same rule as ``submit``): read the image, never refang it.
1889
+ if preprocessing and preprocessing.get("type") == "qrcode":
1890
+ artifact = resources.LocalArtifact.from_path(
1891
+ self,
1892
+ artifact,
1893
+ artifact_type=artifact_type,
1894
+ artifact_name=artifact_name,
1895
+ )
1896
+ else:
1897
+ artifact = self._refang(artifact)
1898
+ artifact = resources.LocalArtifact.from_content(
1899
+ self,
1900
+ artifact,
1901
+ artifact_name=artifact_name or artifact,
1902
+ artifact_type=artifact_type,
1903
+ )
1789
1904
 
1790
1905
  json_params = {
1791
1906
  "artifact_name": artifact.artifact_name,
@@ -1854,6 +1969,7 @@ class PolyswarmAPI:
1854
1969
  else:
1855
1970
  local = artifact
1856
1971
  else:
1972
+ url = self._refang(url)
1857
1973
  local = resources.LocalArtifact.from_content(
1858
1974
  self,
1859
1975
  url,
@@ -0,0 +1,113 @@
1
+ """Refang defanged indicators of compromise (IoCs).
2
+
3
+ Portability is part of the contract, because the same pattern can match
4
+ different characters in different regex engines. So: no case-insensitive
5
+ flag (Python's folds Unicode, e.g. U+212A KELVIN SIGN matches ``k``) — letters
6
+ are spelled as explicit ``[aA]`` classes; no ``\\b``, ``\\d``, ``\\w``, ``\\s``
7
+ or ``\\S`` (their Unicode sets differ between engines) — whitespace is the
8
+ explicit ASCII set ``[ \\t\\n\\r\\f\\v]``, and trimming strips only that set;
9
+ and full matches use ``fullmatch`` (Python's ``$`` also matches before a
10
+ trailing newline).
11
+
12
+ What the contract is, why clients refang at their own edge, where the client
13
+ applies it and what is out of scope: ``specs/05-downstream-contract.md``
14
+ §"IoC refanging".
15
+ """
16
+
17
+ import re
18
+
19
+ __all__ = ['is_network_ioc', 'refang_ioc', 'refang_text']
20
+
21
+ # ASCII whitespace only — see the module docstring on portability.
22
+ _WS_CHARS = ' \t\n\r\f\v'
23
+ _WS = r'[ \t\n\r\f\v]'
24
+
25
+ # Applied in order. The bracket rules run first so that ``hxxps[:]//`` has
26
+ # become ``hxxps://`` by the time the (anchored) scheme rules look for ``://``.
27
+ # ``hxxps`` is matched before ``hxxp``: the shorter rule would otherwise leave
28
+ # a stray ``s`` behind. Each scheme rule is a literal replacement — a capture
29
+ # group would carry the input's case (``HXXPS`` -> ``httpS``).
30
+ _RULES = (
31
+ (re.compile(rf'[\[({{]{_WS}*:{_WS}*/{_WS}*/{_WS}*[\])}}]'), '://'),
32
+ (re.compile(rf'[\[({{]{_WS}*:{_WS}*[\])}}]'), ':'),
33
+ (re.compile(rf'[\[({{]{_WS}*/{_WS}*[\])}}]'), '/'),
34
+ (re.compile(rf'[\[({{]{_WS}*\.{_WS}*[\])}}]'), '.'),
35
+ (re.compile(rf'[\[({{]{_WS}*[dD][oO][tT]{_WS}*[\])}}]'), '.'),
36
+ (re.compile(r'^[hH][xX*]{2}[pP][sS](?=://)'), 'https'),
37
+ (re.compile(r'^[hH][xX*]{2}[pP](?=://)'), 'http'),
38
+ (re.compile(r'^[fF][xX][pP][sS](?=://)'), 'ftps'),
39
+ (re.compile(r'^[fF][xX][pP](?=://)'), 'ftp'),
40
+ )
41
+
42
+ _OCTET = r'(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])'
43
+ _IPV4 = rf'{_OCTET}(?:\.{_OCTET}){{3}}'
44
+ _LABEL = r'[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?'
45
+ _TLD = r'(?:[a-zA-Z]{2,63}|[xX][nN]--[a-zA-Z0-9-]{1,59})'
46
+ _DOMAIN = rf'(?:{_LABEL}\.)+{_TLD}'
47
+ _IPV6 = r'\[[0-9a-fA-F:.]+\]'
48
+ _HOST = rf'(?:{_DOMAIN}|{_IPV4}|{_IPV6})'
49
+ _SCHEME = r'(?:[hH][tT][tT][pP][sS]?|[fF][tT][pP][sS]?)'
50
+ # Used with ``fullmatch`` — never ``^…$``, whose ``$`` accepts a trailing ``\n``.
51
+ _NETWORK_IOC = re.compile(
52
+ rf'(?:{_SCHEME}://)?(?:[^ \t\n\r\f\v/?#@]+@)?{_HOST}(?::[0-9]{{1,5}})?(?:[/?#][^ \t\n\r\f\v]*)?'
53
+ )
54
+ # An optional scheme-like token (live or defanged, e.g. ``hxxps``) plus the
55
+ # host: everything before the first ``/``, ``?`` or ``#``.
56
+ _SCHEME_AND_HOST = re.compile(r'^(?:[a-zA-Z*]+://)?[^/?#]*')
57
+ _QUERY_SYNTAX = re.compile(r'[ \t\n\r\f\v"]')
58
+
59
+
60
+ def refang_text(text):
61
+ """Apply every refang rewrite to ``text``, with no trimming and no gate.
62
+
63
+ Ungated: it rewrites anything that contains a defang token, IoC or not.
64
+ Callers that take user input want :func:`refang_ioc` instead.
65
+ """
66
+ for pattern, replacement in _RULES:
67
+ text = pattern.sub(replacement, text)
68
+ return text
69
+
70
+
71
+ def is_network_ioc(candidate):
72
+ """Whether ``candidate`` is a URL, a domain, or an IP address.
73
+
74
+ A URL may carry an http(s)/ftp(s) scheme, userinfo, a port and a path; an
75
+ IPv6 host is accepted only in its bracketed URL form.
76
+ """
77
+ return bool(_NETWORK_IOC.fullmatch(candidate))
78
+
79
+
80
+ def refang_ioc(value, accept=None):
81
+ """Return the refanged form of ``value`` if it is a defanged network IoC.
82
+
83
+ Anything else comes back exactly as given (untrimmed), so this is safe to
84
+ run on every IoC-shaped input. The gate, in order:
85
+
86
+ * nothing was defanged -> unchanged;
87
+ * the rewrite contains ASCII whitespace or a double quote -> unchanged: that is
88
+ a query or quoted data, never a single indicator;
89
+ * the rewrite keeps the input's scheme and host intact (with or without
90
+ a scheme) -> unchanged: only a path, query or fragment would change,
91
+ so a legitimate ``[.]`` in ``example.com/a[.]b`` survives;
92
+ * the rewrite is not a URL, domain or IP -> unchanged;
93
+ * ``accept`` (optional) rejects the rewrite -> unchanged. Callers use it
94
+ to require their own routing to agree, e.g. "this would be searched as
95
+ a URL".
96
+
97
+ A refanged value is returned trimmed of ASCII whitespace.
98
+ """
99
+ if not isinstance(value, str):
100
+ return value
101
+ trimmed = value.strip(_WS_CHARS)
102
+ candidate = refang_text(trimmed)
103
+ if candidate == trimmed:
104
+ return value
105
+ if _QUERY_SYNTAX.search(candidate):
106
+ return value
107
+ if candidate.startswith(_SCHEME_AND_HOST.match(trimmed).group(0)):
108
+ return value
109
+ if not is_network_ioc(candidate):
110
+ return value
111
+ if accept is not None and not accept(candidate):
112
+ return value
113
+ return candidate
@@ -169,7 +169,7 @@ class IOC(core.BaseJsonResource):
169
169
  )
170
170
 
171
171
  @classmethod
172
- def ioc_search(cls, api, ip=None, domain=None, ttp=None, imphash=None):
172
+ def ioc_search(cls, api, ip=None, domain=None, ttp=None, imphash=None, with_artifacts=False):
173
173
  params = dict(community=api.community)
174
174
  if ip is not None:
175
175
  params['ip'] = ip
@@ -179,12 +179,17 @@ class IOC(core.BaseJsonResource):
179
179
  params['ttp'] = ttp
180
180
  if imphash is not None:
181
181
  params['imphash'] = imphash
182
+ if with_artifacts:
183
+ # An int, not a bool: the session renders bools as 'True', which the
184
+ # server's boolean parser refuses (it accepts only 0/1/false/true).
185
+ params['with_artifacts'] = 1
182
186
  return core.PolyswarmRequest(
183
187
  api=api,
184
188
  method='GET',
185
189
  url=f'{api.uri}/ioc/search',
186
190
  params=params,
187
- result_parser=cls,
191
+ # Opt-in rows are metadata-search documents; the default is bare sha256s.
192
+ result_parser=Metadata if with_artifacts else cls,
188
193
  )
189
194
 
190
195
  @classmethod
@@ -805,6 +810,11 @@ class LiveHuntResult(core.BaseJsonResource):
805
810
  self.sha1 = content.get('sha1')
806
811
  self.rule_name = content['rule_name']
807
812
  self.tags = content['tags']
813
+ # `.get()`, not a subscript -- both keys are additive, so an older server omits
814
+ # them. None is AMBIGUOUS on both (four causes) and is never a claim that the
815
+ # evidence is complete. Contract: specs/05-downstream-contract.md.
816
+ self.matched_strings = content.get('matched_strings')
817
+ self.matched_strings_dropped = content.get('matched_strings_dropped')
808
818
  self.polyscore = content['polyscore']
809
819
  self.malware_family = content['malware_family']
810
820
  self.detections = content['detections']
@@ -863,6 +873,11 @@ class HistoricalHuntResult(core.BaseJsonResource):
863
873
  self.created = core.parse_isoformat(content['created'])
864
874
  self.rule_name = content['rule_name']
865
875
  self.tags = content['tags']
876
+ # `.get()`, not a subscript -- both keys are additive, so an older server omits
877
+ # them. None is AMBIGUOUS on both (four causes) and is never a claim that the
878
+ # evidence is complete. Contract: specs/05-downstream-contract.md.
879
+ self.matched_strings = content.get('matched_strings')
880
+ self.matched_strings_dropped = content.get('matched_strings_dropped')
866
881
  self.polyscore = content['polyscore']
867
882
  self.malware_family = content['malware_family']
868
883
  self.detections = content['detections']
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polyswarm_api
3
- Version: 4.4.0
3
+ Version: 4.7.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
@@ -5,6 +5,7 @@ src/polyswarm_api/__init__.py
5
5
  src/polyswarm_api/api.py
6
6
  src/polyswarm_api/core.py
7
7
  src/polyswarm_api/exceptions.py
8
+ src/polyswarm_api/refang.py
8
9
  src/polyswarm_api/resources.py
9
10
  src/polyswarm_api/session.py
10
11
  src/polyswarm_api/settings.py
File without changes
File without changes
File without changes