polyswarm-api 4.5.0__tar.gz → 4.8.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.5.0/src/polyswarm_api.egg-info → polyswarm_api-4.8.0}/PKG-INFO +1 -1
  2. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/pyproject.toml +2 -2
  3. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/__init__.py +2 -1
  4. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/aio/api.py +115 -14
  5. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/api.py +135 -14
  6. polyswarm_api-4.8.0/src/polyswarm_api/refang.py +113 -0
  7. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/resources.py +28 -2
  8. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0/src/polyswarm_api.egg-info}/PKG-INFO +1 -1
  9. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api.egg-info/SOURCES.txt +1 -0
  10. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/LICENSE +0 -0
  11. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/README.md +0 -0
  12. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/setup.cfg +0 -0
  13. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/aio/__init__.py +0 -0
  14. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/aio/session.py +0 -0
  15. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/core.py +0 -0
  16. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/exceptions.py +0 -0
  17. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/session.py +0 -0
  18. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/settings.py +0 -0
  19. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api.egg-info/dependency_links.txt +0 -0
  20. {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api.egg-info/requires.txt +0 -0
  21. {polyswarm_api-4.5.0 → polyswarm_api-4.8.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.5.0
3
+ Version: 4.8.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.5.0"
7
+ version = "4.8.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.5.0"
58
+ current_version = "4.8.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.5.0'
2
+ __version__ = '4.8.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()
@@ -261,6 +288,51 @@ class PolySwarmAsyncAPI:
261
288
  result_parser=resources.MetadataMapping,
262
289
  )
263
290
 
291
+ async def metadata_raw_search(self, body, community=None):
292
+ """Run a raw OpenSearch query body against the metadata corpus.
293
+
294
+ The server overlays the community filter itself and caps the work, so
295
+ ``body`` is a plain ``_search`` body and the community travels in the
296
+ query string rather than in it. The accepted grammar is closed: a
297
+ construct the server does not recognise is refused rather than passed
298
+ through, and so is a top-level ``community``/``communities`` key.
299
+
300
+ Entitlement-gated and metered per query, including a query the server
301
+ refuses after running it.
302
+
303
+ :param body: An OpenSearch ``_search`` body (``query``, ``aggs``,
304
+ ``size``, ``sort``, ``_source``, ...). Omitting ``size`` returns
305
+ **no documents**: the server defaults it to 0 so an aggregation
306
+ caller does not pay for hits they did not ask for. Ask for hits
307
+ by setting it.
308
+ :param community: Community to search; defaults to the client's.
309
+ :return: A ``MetadataRawSearch`` whose ``json`` carries the engine's
310
+ ``hits`` and ``aggregations``, plus ``partial`` when the server
311
+ could not establish that the answer is complete.
312
+
313
+ ``partial`` is a **list to inspect, not a flag**. Its entries are
314
+ ``{'code': ..., 'message': ...}`` and they make different claims,
315
+ so branch on ``code`` - ``message`` is for a human and may be
316
+ reworded. The codes are a closed set:
317
+
318
+ * ``timed_out`` / ``terminated_early`` - the search stopped early.
319
+ * ``shards_failed`` - a shard was lost; also carries ``failed``.
320
+ * ``buckets_clamped`` - a ``terms`` list dropped terms.
321
+ * ``composite_paged`` - a ``composite`` may have more pages; page
322
+ it with its ``after_key``.
323
+ * ``bucket_count_approximate`` - a ``terms`` count is approximate
324
+ rather than truncated.
325
+ * ``hit_total_capped`` - the hit counter stopped at
326
+ ``track_total_hits``. This is what *every* large query that asks
327
+ for hits returns, so on its own it says nothing about the
328
+ aggregations; the server omits it when the body asked for no
329
+ documents. ``hits.total.relation`` carries the same fact as the
330
+ engine's own field, ``eq`` exact and ``gte`` capped.
331
+ """
332
+ logger.info('Running a raw metadata search')
333
+ return await self._single(
334
+ resources.MetadataRawSearch.search(self, body, community))
335
+
264
336
  async def metadata_field_properties_write(self, field_path, description,
265
337
  example=None, category=None, aliases=None):
266
338
  """Upsert a metadata field properties entry (the single write path).
@@ -340,6 +412,7 @@ class PolySwarmAsyncAPI:
340
412
  :param url: A url to be searched by exact match
341
413
  :return: Generator of ArtifactInstance resources
342
414
  """
415
+ url = self._refang(url)
343
416
  logger.info('Searching for url %s', url)
344
417
  async for item in self._paginate(resources.ArtifactInstance.search_url(self, url)):
345
418
  yield item
@@ -365,6 +438,7 @@ class PolySwarmAsyncAPI:
365
438
  :param exclude: A list of fields to be excluded from the result (.* wildcards are accepted)
366
439
  :return: Generator of ArtifactInstance resources
367
440
  """
441
+ ips, urls, domains = self._refang_all(ips), self._refang_all(urls), self._refang_all(domains)
368
442
  logger.info('Searching for metadata %s', query)
369
443
  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
444
  yield item
@@ -381,18 +455,28 @@ class PolySwarmAsyncAPI:
381
455
  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
456
  yield item
383
457
 
384
- async def search_by_ioc(self, ip=None, domain=None, ttp=None, imphash=None):
458
+ async def search_by_ioc(self, ip=None, domain=None, ttp=None, imphash=None, with_artifacts=False):
385
459
  """
386
460
  Search artifacts by IOC (ip, domain, ttp, or imphash)
387
-
461
+
388
462
  :param ip: ip address to search by
389
463
  :param domain: domain address to search by
390
464
  :param ttp: ttp to search by
391
465
  :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)):
466
+ :param with_artifacts: True yields a Metadata resource per matching artifact
467
+ (a metadata-search row trimmed to a field set the server owns: artifact.*,
468
+ the scan summary, ssdeep/tlsh and the malware family) instead of its bare
469
+ sha256. With no ip/domain/ttp/imphash the server refuses it with a 400
470
+ (a typed exception); without it a bare call behaves as it always has.
471
+ Needs a server that supports the parameter:
472
+ an older one ignores it and answers bare sha256 strings, which fail to
473
+ parse as Metadata (TypeError).
474
+ :return: Generator of IOC resources whose ``json`` is a sha256 string, or of
475
+ Metadata resources when ``with_artifacts`` is True
476
+ """
477
+ ip, domain = self._refang(ip), self._refang(domain)
478
+ logger.info('Searching by ioc %s', dict(ip=ip, domain=domain, ttp=ttp, imphash=imphash, with_artifacts=with_artifacts))
479
+ async for item in self._paginate(resources.IOC.ioc_search(self, ip=ip, domain=domain, ttp=ttp, imphash=imphash, with_artifacts=with_artifacts)):
396
480
  yield item
397
481
 
398
482
  async def check_known_hosts(self, ips=[], domains=[]):
@@ -403,6 +487,7 @@ class PolySwarmAsyncAPI:
403
487
  :param domains
404
488
  :return: Generator of IOC resources
405
489
  """
490
+ ips, domains = self._refang_all(ips), self._refang_all(domains)
406
491
  logger.info('Checking known hosts ips: %s, domains: %s', ips, domains)
407
492
  async for item in self._paginate(resources.IOC.check_known_hosts(self, ips, domains)):
408
493
  yield item
@@ -416,6 +501,7 @@ class PolySwarmAsyncAPI:
416
501
  :param host
417
502
  :return: IOC resource
418
503
  """
504
+ host = self._refang(host)
419
505
  logger.info('Creating known good ioc %s %s %s', type, host, source)
420
506
  return await self._single(resources.IOC.create_known_good(self, type, host, source))
421
507
 
@@ -428,6 +514,7 @@ class PolySwarmAsyncAPI:
428
514
  :param host
429
515
  :return: IOC resource
430
516
  """
517
+ host = self._refang(host)
431
518
  logger.info('Creating known bad ioc %s %s %s', type, host, source)
432
519
  return await self._single(resources.IOC.create_known_bad(self, type, host, source))
433
520
 
@@ -440,6 +527,7 @@ class PolySwarmAsyncAPI:
440
527
  :param host
441
528
  :return: IOC resource
442
529
  """
530
+ host = self._refang(host)
443
531
  logger.info('Updating known good ioc %s %s %s %s', id, type, host, source)
444
532
  return await self._single(resources.IOC.update_known_good(self, id, type, host, source, good))
445
533
 
@@ -554,18 +642,21 @@ class PolySwarmAsyncAPI:
554
642
  """
555
643
  return await self._single(resources.LiveHuntResult.get(self, id=result_id))
556
644
 
557
- async def historical_create(self, rule=None, ruleset_name=None):
645
+ async def historical_create(self, rule=None, ruleset_name=None, window_days=None):
558
646
  """
559
647
  Run a new historical hunt.
560
648
 
561
649
  :param rule: YaraRuleset object or string containing YARA rules to install
562
650
  :param ruleset_name: Name of the ruleset.
651
+ :param window_days: Days back the hunt reaches: 30, 60, 90 or 180. Omit it for the
652
+ platform maximum. The server validates the value; the SDK does not.
563
653
  :return: The created Hunt resource
564
654
  """
565
655
  logger.info('Create historical hunt %s', rule)
566
656
  rule, rule_id = self._parse_rule(rule)
567
657
  return await self._single(resources.HistoricalHunt.create(self, yara=rule.yara if rule else None, rule_id=rule_id,
568
- ruleset_name=ruleset_name, community=self.community))
658
+ ruleset_name=ruleset_name, window_days=window_days,
659
+ community=self.community))
569
660
 
570
661
  async def historical_get(self, hunt=None):
571
662
  """
@@ -1373,11 +1464,12 @@ class PolySwarmAsyncAPI:
1373
1464
  self, artifact, artifact_type=artifact_type, artifact_name=artifact_name
1374
1465
  )
1375
1466
  elif artifact_type == resources.ArtifactType.URL:
1376
- if preprocessing and preprocessing["type"] == "qrcode":
1467
+ if preprocessing and preprocessing.get("type") == "qrcode":
1377
1468
  artifact = resources.LocalArtifact.from_path(
1378
1469
  self, artifact, artifact_type=artifact_type, artifact_name=artifact_name
1379
1470
  )
1380
1471
  else:
1472
+ artifact = self._refang(artifact)
1381
1473
  artifact = resources.LocalArtifact.from_content(
1382
1474
  self, artifact, artifact_name=artifact_name or artifact,
1383
1475
  artifact_type=artifact_type,
@@ -1480,10 +1572,18 @@ class PolySwarmAsyncAPI:
1480
1572
  self, artifact, artifact_type=artifact_type, artifact_name=artifact_name
1481
1573
  )
1482
1574
  elif artifact_type == resources.ArtifactType.URL:
1483
- artifact = resources.LocalArtifact.from_content(
1484
- self, artifact, artifact_name=artifact_name or artifact,
1485
- artifact_type=artifact_type,
1486
- )
1575
+ # A QR-code submission's argument is an image path, not a URL
1576
+ # (same rule as ``submit``): read the image, never refang it.
1577
+ if preprocessing and preprocessing.get("type") == "qrcode":
1578
+ artifact = resources.LocalArtifact.from_path(
1579
+ self, artifact, artifact_type=artifact_type, artifact_name=artifact_name
1580
+ )
1581
+ else:
1582
+ artifact = self._refang(artifact)
1583
+ artifact = resources.LocalArtifact.from_content(
1584
+ self, artifact, artifact_name=artifact_name or artifact,
1585
+ artifact_type=artifact_type,
1586
+ )
1487
1587
 
1488
1588
  json_params = {
1489
1589
  "artifact_name": artifact.artifact_name,
@@ -1542,6 +1642,7 @@ class PolySwarmAsyncAPI:
1542
1642
  else:
1543
1643
  local = artifact
1544
1644
  else:
1645
+ url = self._refang(url)
1545
1646
  local = resources.LocalArtifact.from_content(
1546
1647
  self, url, artifact_name=artifact_name or url,
1547
1648
  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()
@@ -276,6 +303,50 @@ class PolyswarmAPI:
276
303
  result_parser=resources.MetadataMapping,
277
304
  )
278
305
 
306
+ def metadata_raw_search(self, body, community=None):
307
+ """Run a raw OpenSearch query body against the metadata corpus.
308
+
309
+ The server overlays the community filter itself and caps the work, so
310
+ ``body`` is a plain ``_search`` body and the community travels in the
311
+ query string rather than in it. The accepted grammar is closed: a
312
+ construct the server does not recognise is refused rather than passed
313
+ through, and so is a top-level ``community``/``communities`` key.
314
+
315
+ Entitlement-gated and metered per query, including a query the server
316
+ refuses after running it.
317
+
318
+ :param body: An OpenSearch ``_search`` body (``query``, ``aggs``,
319
+ ``size``, ``sort``, ``_source``, ...). Omitting ``size`` returns
320
+ **no documents**: the server defaults it to 0 so an aggregation
321
+ caller does not pay for hits they did not ask for. Ask for hits
322
+ by setting it.
323
+ :param community: Community to search; defaults to the client's.
324
+ :return: A ``MetadataRawSearch`` whose ``json`` carries the engine's
325
+ ``hits`` and ``aggregations``, plus ``partial`` when the server
326
+ could not establish that the answer is complete.
327
+
328
+ ``partial`` is a **list to inspect, not a flag**. Its entries are
329
+ ``{'code': ..., 'message': ...}`` and they make different claims,
330
+ so branch on ``code`` - ``message`` is for a human and may be
331
+ reworded. The codes are a closed set:
332
+
333
+ * ``timed_out`` / ``terminated_early`` - the search stopped early.
334
+ * ``shards_failed`` - a shard was lost; also carries ``failed``.
335
+ * ``buckets_clamped`` - a ``terms`` list dropped terms.
336
+ * ``composite_paged`` - a ``composite`` may have more pages; page
337
+ it with its ``after_key``.
338
+ * ``bucket_count_approximate`` - a ``terms`` count is approximate
339
+ rather than truncated.
340
+ * ``hit_total_capped`` - the hit counter stopped at
341
+ ``track_total_hits``. This is what *every* large query that asks
342
+ for hits returns, so on its own it says nothing about the
343
+ aggregations; the server omits it when the body asked for no
344
+ documents. ``hits.total.relation`` carries the same fact as the
345
+ engine's own field, ``eq`` exact and ``gte`` capped.
346
+ """
347
+ logger.info("Running a raw metadata search")
348
+ return self._single(resources.MetadataRawSearch.search(self, body, community))
349
+
279
350
  def metadata_field_properties_write(
280
351
  self, field_path, description, example=None, category=None, aliases=None
281
352
  ):
@@ -366,6 +437,7 @@ class PolyswarmAPI:
366
437
  :param url: A url to be searched by exact match
367
438
  :return: Generator of ArtifactInstance resources
368
439
  """
440
+ url = self._refang(url)
369
441
  logger.info("Searching for url %s", url)
370
442
  for item in self._paginate(resources.ArtifactInstance.search_url(self, url)):
371
443
  yield item
@@ -395,6 +467,11 @@ class PolyswarmAPI:
395
467
  :param exclude: A list of fields to be excluded from the result (.* wildcards are accepted)
396
468
  :return: Generator of ArtifactInstance resources
397
469
  """
470
+ ips, urls, domains = (
471
+ self._refang_all(ips),
472
+ self._refang_all(urls),
473
+ self._refang_all(domains),
474
+ )
398
475
  logger.info("Searching for metadata %s", query)
399
476
  for item in self._paginate(
400
477
  resources.Metadata.get(
@@ -426,7 +503,9 @@ class PolyswarmAPI:
426
503
  ):
427
504
  yield item
428
505
 
429
- def search_by_ioc(self, ip=None, domain=None, ttp=None, imphash=None):
506
+ def search_by_ioc(
507
+ self, ip=None, domain=None, ttp=None, imphash=None, with_artifacts=False
508
+ ):
430
509
  """
431
510
  Search artifacts by IOC (ip, domain, ttp, or imphash)
432
511
 
@@ -434,14 +513,36 @@ class PolyswarmAPI:
434
513
  :param domain: domain address to search by
435
514
  :param ttp: ttp to search by
436
515
  :param imphash: ImpHash to search by
437
- :return: Generator of ArtifactInstance resources
438
- """
516
+ :param with_artifacts: True yields a Metadata resource per matching artifact
517
+ (a metadata-search row trimmed to a field set the server owns: artifact.*,
518
+ the scan summary, ssdeep/tlsh and the malware family) instead of its bare
519
+ sha256. With no ip/domain/ttp/imphash the server refuses it with a 400
520
+ (a typed exception); without it a bare call behaves as it always has.
521
+ Needs a server that supports the parameter:
522
+ an older one ignores it and answers bare sha256 strings, which fail to
523
+ parse as Metadata (TypeError).
524
+ :return: Generator of IOC resources whose ``json`` is a sha256 string, or of
525
+ Metadata resources when ``with_artifacts`` is True
526
+ """
527
+ ip, domain = self._refang(ip), self._refang(domain)
439
528
  logger.info(
440
- "Searching by ioc %s", dict(ip=ip, domain=domain, ttp=ttp, imphash=imphash)
529
+ "Searching by ioc %s",
530
+ dict(
531
+ ip=ip,
532
+ domain=domain,
533
+ ttp=ttp,
534
+ imphash=imphash,
535
+ with_artifacts=with_artifacts,
536
+ ),
441
537
  )
442
538
  for item in self._paginate(
443
539
  resources.IOC.ioc_search(
444
- self, ip=ip, domain=domain, ttp=ttp, imphash=imphash
540
+ self,
541
+ ip=ip,
542
+ domain=domain,
543
+ ttp=ttp,
544
+ imphash=imphash,
545
+ with_artifacts=with_artifacts,
445
546
  )
446
547
  ):
447
548
  yield item
@@ -454,6 +555,7 @@ class PolyswarmAPI:
454
555
  :param domains
455
556
  :return: Generator of IOC resources
456
557
  """
558
+ ips, domains = self._refang_all(ips), self._refang_all(domains)
457
559
  logger.info("Checking known hosts ips: %s, domains: %s", ips, domains)
458
560
  for item in self._paginate(resources.IOC.check_known_hosts(self, ips, domains)):
459
561
  yield item
@@ -467,6 +569,7 @@ class PolyswarmAPI:
467
569
  :param host
468
570
  :return: IOC resource
469
571
  """
572
+ host = self._refang(host)
470
573
  logger.info("Creating known good ioc %s %s %s", type, host, source)
471
574
  return self._single(resources.IOC.create_known_good(self, type, host, source))
472
575
 
@@ -479,6 +582,7 @@ class PolyswarmAPI:
479
582
  :param host
480
583
  :return: IOC resource
481
584
  """
585
+ host = self._refang(host)
482
586
  logger.info("Creating known bad ioc %s %s %s", type, host, source)
483
587
  return self._single(resources.IOC.create_known_bad(self, type, host, source))
484
588
 
@@ -491,6 +595,7 @@ class PolyswarmAPI:
491
595
  :param host
492
596
  :return: IOC resource
493
597
  """
598
+ host = self._refang(host)
494
599
  logger.info("Updating known good ioc %s %s %s %s", id, type, host, source)
495
600
  return self._single(
496
601
  resources.IOC.update_known_good(self, id, type, host, source, good)
@@ -630,12 +735,14 @@ class PolyswarmAPI:
630
735
  """
631
736
  return self._single(resources.LiveHuntResult.get(self, id=result_id))
632
737
 
633
- def historical_create(self, rule=None, ruleset_name=None):
738
+ def historical_create(self, rule=None, ruleset_name=None, window_days=None):
634
739
  """
635
740
  Run a new historical hunt.
636
741
 
637
742
  :param rule: YaraRuleset object or string containing YARA rules to install
638
743
  :param ruleset_name: Name of the ruleset.
744
+ :param window_days: Days back the hunt reaches: 30, 60, 90 or 180. Omit it for the
745
+ platform maximum. The server validates the value; the SDK does not.
639
746
  :return: The created Hunt resource
640
747
  """
641
748
  logger.info("Create historical hunt %s", rule)
@@ -646,6 +753,7 @@ class PolyswarmAPI:
646
753
  yara=rule.yara if rule else None,
647
754
  rule_id=rule_id,
648
755
  ruleset_name=ruleset_name,
756
+ window_days=window_days,
649
757
  community=self.community,
650
758
  )
651
759
  )
@@ -1700,7 +1808,7 @@ class PolyswarmAPI:
1700
1808
  artifact_name=artifact_name,
1701
1809
  )
1702
1810
  elif artifact_type == resources.ArtifactType.URL:
1703
- if preprocessing and preprocessing["type"] == "qrcode":
1811
+ if preprocessing and preprocessing.get("type") == "qrcode":
1704
1812
  artifact = resources.LocalArtifact.from_path(
1705
1813
  self,
1706
1814
  artifact,
@@ -1708,6 +1816,7 @@ class PolyswarmAPI:
1708
1816
  artifact_name=artifact_name,
1709
1817
  )
1710
1818
  else:
1819
+ artifact = self._refang(artifact)
1711
1820
  artifact = resources.LocalArtifact.from_content(
1712
1821
  self,
1713
1822
  artifact,
@@ -1822,12 +1931,23 @@ class PolyswarmAPI:
1822
1931
  artifact_name=artifact_name,
1823
1932
  )
1824
1933
  elif artifact_type == resources.ArtifactType.URL:
1825
- artifact = resources.LocalArtifact.from_content(
1826
- self,
1827
- artifact,
1828
- artifact_name=artifact_name or artifact,
1829
- artifact_type=artifact_type,
1830
- )
1934
+ # A QR-code submission's argument is an image path, not a URL
1935
+ # (same rule as ``submit``): read the image, never refang it.
1936
+ if preprocessing and preprocessing.get("type") == "qrcode":
1937
+ artifact = resources.LocalArtifact.from_path(
1938
+ self,
1939
+ artifact,
1940
+ artifact_type=artifact_type,
1941
+ artifact_name=artifact_name,
1942
+ )
1943
+ else:
1944
+ artifact = self._refang(artifact)
1945
+ artifact = resources.LocalArtifact.from_content(
1946
+ self,
1947
+ artifact,
1948
+ artifact_name=artifact_name or artifact,
1949
+ artifact_type=artifact_type,
1950
+ )
1831
1951
 
1832
1952
  json_params = {
1833
1953
  "artifact_name": artifact.artifact_name,
@@ -1896,6 +2016,7 @@ class PolyswarmAPI:
1896
2016
  else:
1897
2017
  local = artifact
1898
2018
  else:
2019
+ url = self._refang(url)
1899
2020
  local = resources.LocalArtifact.from_content(
1900
2021
  self,
1901
2022
  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
@@ -77,6 +77,25 @@ class MetadataMapping(core.BaseJsonResource):
77
77
  RESOURCE_ENDPOINT = '/search/metadata/mappings'
78
78
 
79
79
 
80
+ class MetadataRawSearch(core.BaseJsonResource):
81
+ RESOURCE_ENDPOINT = '/search/metadata/raw'
82
+
83
+ @classmethod
84
+ def search(cls, api, body, community=None):
85
+ """``body`` is the caller's whole query and travels untouched. The
86
+ community is scope, not query, and the server reads it from the query
87
+ string - folding it into the body would pick scope with a key the
88
+ server's grammar then rejects."""
89
+ return core.PolyswarmRequest(
90
+ api=api,
91
+ method='POST',
92
+ url=f'{api.uri}{cls.RESOURCE_ENDPOINT}',
93
+ params={'community': community or api.community},
94
+ json=body,
95
+ result_parser=cls,
96
+ )
97
+
98
+
80
99
  class MetadataFieldProperties(core.BaseJsonResource):
81
100
  RESOURCE_ENDPOINT = '/search/metadata/properties'
82
101
  RESOURCE_ID_KEYS = ['field_path']
@@ -169,7 +188,7 @@ class IOC(core.BaseJsonResource):
169
188
  )
170
189
 
171
190
  @classmethod
172
- def ioc_search(cls, api, ip=None, domain=None, ttp=None, imphash=None):
191
+ def ioc_search(cls, api, ip=None, domain=None, ttp=None, imphash=None, with_artifacts=False):
173
192
  params = dict(community=api.community)
174
193
  if ip is not None:
175
194
  params['ip'] = ip
@@ -179,12 +198,17 @@ class IOC(core.BaseJsonResource):
179
198
  params['ttp'] = ttp
180
199
  if imphash is not None:
181
200
  params['imphash'] = imphash
201
+ if with_artifacts:
202
+ # An int, not a bool: the session renders bools as 'True', which the
203
+ # server's boolean parser refuses (it accepts only 0/1/false/true).
204
+ params['with_artifacts'] = 1
182
205
  return core.PolyswarmRequest(
183
206
  api=api,
184
207
  method='GET',
185
208
  url=f'{api.uri}/ioc/search',
186
209
  params=params,
187
- result_parser=cls,
210
+ # Opt-in rows are metadata-search documents; the default is bare sha256s.
211
+ result_parser=Metadata if with_artifacts else cls,
188
212
  )
189
213
 
190
214
  @classmethod
@@ -848,6 +872,8 @@ class HistoricalHunt(core.BaseJsonResource):
848
872
  # unknown (no source rule, or nothing to compare against) — not
849
873
  # "unchanged".
850
874
  self.source_rule_changed = content.get('source_rule_changed')
875
+ # None: no window requested (platform maximum), or a server predating the field.
876
+ self.window_days = content.get('window_days')
851
877
 
852
878
 
853
879
  class HistoricalHuntList(HistoricalHunt):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polyswarm_api
3
- Version: 4.5.0
3
+ Version: 4.8.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