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.
- {polyswarm_api-4.5.0/src/polyswarm_api.egg-info → polyswarm_api-4.8.0}/PKG-INFO +1 -1
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/pyproject.toml +2 -2
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/__init__.py +2 -1
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/aio/api.py +115 -14
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/api.py +135 -14
- polyswarm_api-4.8.0/src/polyswarm_api/refang.py +113 -0
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/resources.py +28 -2
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0/src/polyswarm_api.egg-info}/PKG-INFO +1 -1
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api.egg-info/SOURCES.txt +1 -0
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/LICENSE +0 -0
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/README.md +0 -0
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/setup.cfg +0 -0
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/aio/__init__.py +0 -0
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/aio/session.py +0 -0
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/core.py +0 -0
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/exceptions.py +0 -0
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/session.py +0 -0
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api/settings.py +0 -0
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api.egg-info/dependency_links.txt +0 -0
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api.egg-info/requires.txt +0 -0
- {polyswarm_api-4.5.0 → polyswarm_api-4.8.0}/src/polyswarm_api.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "polyswarm_api"
|
|
7
|
-
version = "4.
|
|
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.
|
|
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.
|
|
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
|
-
:
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
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(
|
|
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
|
-
:
|
|
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",
|
|
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,
|
|
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
|
|
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
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
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
|
-
|
|
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):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|