diff --git a/.github/workflows/_deploy-docs.yml b/.github/workflows/_deploy-docs.yml index db6ef5e..0ce0e70 100644 --- a/.github/workflows/_deploy-docs.yml +++ b/.github/workflows/_deploy-docs.yml @@ -7,19 +7,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Compare tag and package version - run: | - TAG=${GITHUB_REF#refs/*/} - VERSION=$(grep -Po '(?<=version = ")[^"]*' pyproject.toml) - if [ "$TAG" != "$VERSION" ]; then - echo "Tag value and package version are different: ${TAG} != ${VERSION}" - exit 1 - fi - name: Set up Python uses: actions/setup-python@v4 with: python-version: 3.13 - name: Install build dependencies run: pip install --no-cache-dir -U pip .['docs'] + - name: Configure git + run: | + git fetch origin gh-pages --depth=1 + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" - name: Deploy to github pages run: ./scripts/cd.py --deploy-docs diff --git a/.github/workflows/main-cicd.yml b/.github/workflows/main-cicd.yml index 1aa2bc1..c3e9729 100644 --- a/.github/workflows/main-cicd.yml +++ b/.github/workflows/main-cicd.yml @@ -17,12 +17,13 @@ jobs: uses: ./.github/workflows/_build-package.yml build-docs: uses: ./.github/workflows/_build-docs.yml + deploy-docs: + if: startsWith(github.ref, 'refs/heads/main') + uses: ./.github/workflows/_deploy-docs.yml + needs: [build-docs, build-package, integration-tests, static-checks] upload-package: if: startsWith(github.ref, 'refs/tags/') uses: ./.github/workflows/_upload-package.yml - needs: [static-checks, integration-tests, build-package, build-docs] + needs: [build-docs, build-package, integration-tests, static-checks] secrets: PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} - deploy-docs: - uses: ./.github/workflows/_deploy-docs.yml - needs: [upload-package] diff --git a/README.md b/README.md index 19f1980..6e06dc3 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,43 @@ -

- TheHive Logo -

-

- thehive4py - the de facto Python API client of TheHive -

-

- - release - - - build - - - codecov - - - pypi - - - license - - - discord - -

- -# thehive4py +
+

+ TheHive Logo +

+

+ TheHive4py +

+

+ the de facto Python API client of TheHive +

+

+ + release + + + build + + + codecov + + + pypi + + + license + + + discord + +

+
+ +--- +**Documentation**: https://thehive-project.github.io/TheHive4py + +**Source Code**: https://github.com/TheHive-Project/TheHive4py + +--- + +# Introduction > [!IMPORTANT] > thehive4py v1.x is not maintained anymore as TheHive v3 and v4 are end of life. thehive4py v2.x is a complete rewrite and is not compatible with thehive4py v1.x. The library is still in beta phase. diff --git a/docs/examples/alert.md b/docs/examples/alert.md new file mode 100644 index 0000000..5e8e5f2 --- /dev/null +++ b/docs/examples/alert.md @@ -0,0 +1,208 @@ +# Alert + +## A simple alert + +A new alert requires at least these fields to be defined: + +- `type`: The type of the alert. +- `source`: The source of the alert. +- `sourceRef`: A unique reference for the alert. +- `title`: A descriptive title for the alert. +- `description`: Additional information describing the alert. + +Here's an example that demonstrates how to create the most simplistic alert possible using the [alert.create][thehive4py.endpoints.alert.AlertEndpoint.create] method: + +```python +--8<-- "examples/alert/simple.py" +``` + +## An advanced alert + +In the previous example we really kept things simple and only specified the required alert fields inline in the create method call. +With a more advanced example this can become complicated and hard to read. +Fortunately we can use `thehive4py`'s type hints to the rescue and specify more complex input alerts outside of the method call. + +Here's how: +```python +--8<-- "examples/alert/advanced.py" +``` + +In the above snippet `input_alert` is created before the create call and later passed to the `alert` argument. +Finally after the creation of the alert we saved the response in the `output_alert` to be able to use it later. + +!!! note + While the above alert is a bit more advanced it's still far from the most complex example possible. + In case you want to see the what the Alert API offers please check out the [official alert docs](https://docs.strangebee.com/thehive/api-docs/#tag/Alert). + + +## Alert observables + +TheHive API provides multiple ways to add observables to alerts, let them be textual or file based observables. + +### Add observables during alert creation + +We can add observables already during alert creation. This is a great way to combine alert and observable creation in a simple and atomic way: + +Let's create an alert with an `ip` and a `domain` observable: + +```python +--8<-- "examples/alert/observable_during_alerting.py" +``` + +### Add observables to an existing alert + +While it's probably the most convenient way to combine alert and observable creation in a single call, sometimes we don't have all the observables at hand during alert creation time. + +Fortunately TheHive API supports alert observable creation on already existing alerts. Let's repeat the previous example, but this time add the two observables to an existing alert using the [alert.create_observable][thehive4py.endpoints.alert.AlertEndpoint.create_observable] method: + + +```python +--8<-- "examples/alert/observable_after_alerting.py" +``` + +### Add file based observables + +In the previous examples we've seen how to handle simple observables without attachments. Next we will create a temporary directory with a dummy file and some dummy content that will represent our file based observable and add it to an alert: + + +```python +--8<-- "examples/alert/observable_from_file.py" +``` + +As we can see from the above example a file based observable must specify the `attachment` property with a key that links it to the attachment specified in the `attachment_map` dictionary. + +This way TheHive will know which attachment to pair with which observable behind the scenes. + +In our example `attachment_key` is used to specify the relationship between the observable and the actual file. In this case its value is a uuid, however it can be any arbitrary value, though it's important that it should uniquely identify the attachment and the observable we would like to pair in TheHive. + +## Update single and bulk + +Sometimes an existing alert needs to be updated. TheHive offers multiple ways to accomplish this task either with a single alert or multiple ones. + +### Update single + +A single alert can be updated using [alert.update][thehive4py.endpoints.alert.AlertEndpoint.update] method. The method requires the `alert_id` of the alert to be updated and the `fields` to update. + +```python +--8<-- "examples/alert/update_single.py" +``` + +In the above example we've updated the `title` and the `tags` fields. + +Be mindful though, `thehive4py` is a lightweight wrapper around TheHive API and offers no object relationship mapping functionalities, meaning that the original `original_alert` won't reflect the changes of the update. + +To work with the updated alert we fetched the latest version using the [alert.get][thehive4py.endpoints.alert.AlertEndpoint.get] method and stored it in the `updated_alert` variable. + +Now the content of `updated_alert` should reflect the changes we made with our update request. + +!!! tip + To see the full list of supported update fields please consult the [official docs](https://docs.strangebee.com/thehive/api-docs/#tag/Alert/operation/Update%20Alert). + +### Update bulk + +To update the **same fields** with the **same values** on multiple alerts at the same time, one can use [alert.bulk_update][thehive4py.endpoints.alert.AlertEndpoint.bulk_update] method. +The method accepts the same `fields` dictionary with an additional `ids` field on it, which should contain the list of ids of the alerts to be bulk updated. + +```python +--8<-- "examples/alert/update_bulk.py" +``` + +In the example we prepare two alerts for the bulk update, and collect their ids in the `original_alert_ids` list. +Then we update the fields `title` and `tags` on both alerts using the bulk update method. + +## Get and find + +There are multiple ways to retrieve already existing alerts: + +### Get a single alert + +To get a single alert one can use the [alert.get][thehive4py.endpoints.alert.AlertEndpoint.get] method with the alert's id as follows: + +```python +--8<-- "examples/alert/fetch_with_get.py" +``` + +### Find multiple alerts + +To fetch multiple alerts based on arbitrary conditions one can use the [alert.find][thehive4py.endpoints.alert.AlertEndpoint.find] method which is an abstraction on top of TheHive's Query API. + +In the next example we will create two alerts with different tags. The first alert will get the `antivirus` tag while the second one will get the `phishing` tag. + +Then we will construct a query filter that will look for alerts with these tags on them: + +```python +--8<-- "examples/alert/fetch_with_find.py" +``` + +The above example demonstrates two ways to construct query filters. + +One is to provide a raw dict based filter which is the plain format of [TheHive's Query API](https://docs.strangebee.com/thehive/api-docs/#tag/Query-and-Export). This is demonstrated in the `raw_filters` variable. + +However this can be cumbersome to remember, that's why `thehive4py` provides filter builders to conveniently build filter expressions on the client side. This alternative approach is demonstrated in the `class_filters` variable. + +These filter expressions can be chained together with different operators, just like we did with the `|` (`or`) operator in the example. + +Currently, the filter classes support the following operators: + +- `&`: Used for the Query API's `_and` construct. +- `|`: Used for the Query API's `_or` construct. +- `~`: Used for the Query API's `_not` construct. + +The full list of the filter builders can be found in the [query.filters][thehive4py.query.filters] module. + +## Promote and merge into a case + +In TheHive alerts usually represent signals of compromise while cases provide a higher level entity to group these signals into one object. +Therefore we can promote an alert into a case or merge new alerts into an existing case for a more organised investigation. + +### Promote to case + +To create a case from an alert we can use [alert.promote_to_case][thehive4py.endpoints.alert.AlertEndpoint.promote_to_case] method. + +```python +--8<-- "examples/alert/case_promote.py" +``` + +!!! tip + For additional control the method accepts a `fields` argument which can be used to modify properties on the case. + To see all available options please consult the [official docs](https://docs.strangebee.com/thehive/api-docs/#tag/Alert/operation/Create%20Case%20from%20Alert). + +### Merge into case + +Oftentimes new alerts correspond to an already existing case. Fortunately we have the option to merge such alerts into a parent case using the [alert.merge_into_case][thehive4py.endpoints.alert.AlertEndpoint.merge_into_case] method. + +```python +--8<-- "examples/alert/case_merge.py" +``` + +In the above example we prepared a `parent_case` to which we merge the `new_alert` using their ids and finally save the updated case in the `updated_parent_case` variable. + +!!! tip + It can happen that multiple new alerts belong to the same parent case. In such situation we can use the [alert.bulk_merge_into_case][thehive4py.endpoints.alert.AlertEndpoint.bulk_merge_into_case] method for a more convenient merge process. + + +## Delete single and bulk + +`thehive4py` provides two different ways to delete alerts: + +- delete a single alert +- delete alerts in bulk + +### Delete single + +To delete a single alert the [alert.delete][thehive4py.endpoints.alert.AlertEndpoint.delete] method can be used as follows: + +```python +--8<-- "examples/alert/delete_single.py" +``` + + +### Delete in bulk + +To delete multiple alerts via a single request one can use the [alert.bulk_delete][thehive4py.endpoints.alert.AlertEndpoint.bulk_delete] method as follows: + +```python +--8<-- "examples/alert/delete_bulk.py" +``` + +In the above example we created two alerts and saved their ids in the `alert_ids_to_delete` variable just to pass it in the bulk deletion method. diff --git a/docs/examples/client.md b/docs/examples/client.md new file mode 100644 index 0000000..16c162c --- /dev/null +++ b/docs/examples/client.md @@ -0,0 +1,74 @@ +# Client + +## Authentication + +TheHive API provides two ways to authenticate the client: + +- apikey auth +- username and password auth + +### Auth with apikey + +```python +--8<-- "examples/client/auth_with_apikey.py" +``` + +### Auth with username and password + +```python +--8<-- "examples/client/auth_with_username_and_password.py" +``` + +## Organisation + +The client will use the default organisation of the user. However in case the user belongs to multiple organisation the client also provides options to specify which organisation to use. + + +### Specify the organisation during init + +In this example we will instaniate a client with the `admin` organisation explicitly: + +```python +--8<-- "examples/client/org_via_constructor.py" +``` + +### Switch organisations during runtime + +In this example we will instantiate a client without explicitly specifying an organisation and switch to another organisation using the [session_organisation][thehive4py.client.TheHiveApi.session_organisation] property: + +```python +--8<-- "examples/client/org_during_runtime.py" +``` + +!!! warning + The [session_organisation][thehive4py.client.TheHiveApi.session_organisation] property is not thread-safe and it's almost always better to instantiate more clients if one wants to work with multiple organisations in parallel. + + +## SSL Verification + +By default the client verifies if the connection is going through SSL. +In case one needs to pass a custom certificate bundle or directory it's possible via the `verify` argument like: + +```python +--8<-- "examples/client/ssl.py" +``` + +!!! note + It's also possible to disable SSL verification completely by setting `verify` to `False`. + However this is greatly discouraged as it's a security bad practice. + + +## Retries + +The client comes with a sensible retry mechanism by default that will try to cover the most common use cases. +However it's also possible to configure a tailored retry mechanism via the `max_retries` argument. + +The below example will configure a custom retry mechanism with 5 total attempts, a backoff factor of 0.3 seconds on GET methods and 500 status codes: + +```python +--8<-- "examples/client/retries.py" +``` + +To learn more about the `urllib3.Retry` object please consult the official documentation [here](https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html#urllib3.util.Retry). + + diff --git a/docs/index.md b/docs/index.md index 65a0ce4..bab1a24 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,33 +1,68 @@ ---- -title: Coming Soon! -hide: - - toc - - navigation ---- -# -
-

- TheHive4py +

TheHive Logo + thehive4py

-

- :material-bee:{ .strangebee .bluebee .upleftbee } - :material-bee:{ .strangebee .yellowbee .upbee } - :material-bee:{ .strangebee .bluebee .uprightbee } -

- Coming Soon! -

- :material-bee:{ .strangebee .yellowbee .downleftbee } - :material-bee:{ .strangebee .bluebee .downbee } - :material-bee:{ .strangebee .yellowbee .downrightbee } -

+

+ the de facto Python API client of TheHive +

+

+ + release + + + build + + + codecov + + + pypi + + + license + + + discord + +

+
+--- +**Documentation**: https://thehive-project.github.io/TheHive4py/ +**Source Code**: https://github.com/TheHive-Project/TheHive4py -

Our bees are out buzzing to gather all the docs you need!

-

Check out our Quickstart in the meantime!

-
+--- + +# Introduction + +Welcome to `thehive4py`, the Python library designed to simplify interactions with StrangeBee's TheHive. Whether you're a cybersecurity enthusiast or a developer looking to integrate TheHive into your Python projects, this library has got you covered. + +Feel free to explore the library's capabilities and contribute to its development. We appreciate your support in making TheHive integration in Python more accessible and efficient. + + +# Requirements +`thehive4py` works with all currently supported python versions, at the time of writing `py>=3.8`. One can check the official version support and end of life status [here](https://devguide.python.org/versions/). + +# Installation +The `thehive4py` can be installed with pip like: + +```bash +pip install "thehive4py>=2.0.0b" +``` + + +# Quickstart + +```python +from thehive4py import TheHiveApi + +hive = TheHiveApi(url="http://localhost:9000", apikey="h1v3b33") +``` + +# Licensing +This project is licensed under the terms of the MIT license. diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 0000000..8e70d3e --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,20 @@ +[//]: # (Style hack due to capitalized h5 headers from mkdocs-material, which corrupts the reference docs display) +[//]: # (More details: https://github.com/squidfunk/mkdocs-material/issues/1522) + + +# API Reference + +::: thehive4py + options: + show_submodules: true + members: + - client + - session + - endpoints + - types + - query + - errors diff --git a/examples/alert/advanced.py b/examples/alert/advanced.py new file mode 100644 index 0000000..29abfa0 --- /dev/null +++ b/examples/alert/advanced.py @@ -0,0 +1,13 @@ +from thehive4py import TheHiveApi + +hive = TheHiveApi(url="http://localhost:9000", apikey="h1v3b33") + +simple_alert = hive.alert.create( + alert={ + "type": "simple", + "source": "tutorial", + "sourceRef": "should-be-unique", + "title": "a simple alert", + "description": "a bit too simple", + } +) diff --git a/examples/alert/case_merge.py b/examples/alert/case_merge.py new file mode 100644 index 0000000..7f5d0b4 --- /dev/null +++ b/examples/alert/case_merge.py @@ -0,0 +1,24 @@ +import uuid + +from thehive4py import TheHiveApi + +hive = TheHiveApi(url="http://localhost:9000", apikey="h1v3b33") + + +parent_case = hive.case.create( + case={"title": "parent case", "description": "a simple parent case"} +) + +new_alert = hive.alert.create( + alert={ + "type": "merge-into-case", + "source": "tutorial", + "sourceRef": uuid.uuid4().hex, + "title": "alert to merge", + "description": "a single alert to merge into a parent case", + } +) + +updated_parent_case = hive.alert.merge_into_case( + alert_id=new_alert["_id"], case_id=parent_case["_id"] +) diff --git a/examples/alert/case_promote.py b/examples/alert/case_promote.py new file mode 100644 index 0000000..2b7b5c4 --- /dev/null +++ b/examples/alert/case_promote.py @@ -0,0 +1,17 @@ +import uuid + +from thehive4py import TheHiveApi + +hive = TheHiveApi(url="http://localhost:9000", apikey="h1v3b33") + +alert_to_promote = hive.alert.create( + alert={ + "type": "promote", + "source": "tutorial", + "sourceRef": uuid.uuid4().hex, + "title": "promote to case", + "description": "an alert to promote to case", + } +) + +case_from_alert = hive.alert.promote_to_case(alert_id=alert_to_promote["_id"]) diff --git a/examples/alert/delete_bulk.py b/examples/alert/delete_bulk.py new file mode 100644 index 0000000..f97b46e --- /dev/null +++ b/examples/alert/delete_bulk.py @@ -0,0 +1,22 @@ +import uuid + +from thehive4py import TheHiveApi + +hive = TheHiveApi(url="http://localhost:9000", apikey="h1v3b33") + +alert_ids_to_delete = [] + +for i in range(2): + alert_to_delete = hive.alert.create( + alert={ + "type": "delete", + "source": "tutorial", + "sourceRef": uuid.uuid4().hex, + "title": f"delete alert #{i}", + "description": "an alert to delete", + } + ) + alert_ids_to_delete.append(alert_to_delete["_id"]) + + +hive.alert.bulk_delete(ids=alert_ids_to_delete) diff --git a/examples/alert/delete_single.py b/examples/alert/delete_single.py new file mode 100644 index 0000000..8be55ff --- /dev/null +++ b/examples/alert/delete_single.py @@ -0,0 +1,18 @@ +import uuid + +from thehive4py import TheHiveApi + +hive = TheHiveApi(url="http://localhost:9000", apikey="h1v3b33") + +alert_to_delete = hive.alert.create( + alert={ + "type": "delete", + "source": "tutorial", + "sourceRef": uuid.uuid4().hex, + "title": "delete alert", + "description": "an alert to delete", + } +) + + +hive.alert.delete(alert_id=alert_to_delete["_id"]) diff --git a/examples/alert/fetch_with_find.py b/examples/alert/fetch_with_find.py new file mode 100644 index 0000000..a0c2241 --- /dev/null +++ b/examples/alert/fetch_with_find.py @@ -0,0 +1,40 @@ +import uuid + +from thehive4py import TheHiveApi +from thehive4py.query.filters import Eq + +hive = TheHiveApi(url="http://localhost:9000", apikey="h1v3b33") + +antivirus_alert = hive.alert.create( + alert={ + "type": "find-multiple", + "source": "tutorial", + "sourceRef": uuid.uuid4().hex, + "title": "alert to find", + "description": "an alert to find with others", + "tags": ["antivirus"], + } +) + +phishing_alert = hive.alert.create( + alert={ + "type": "find-multiple", + "source": "tutorial", + "sourceRef": uuid.uuid4().hex, + "title": "alert to find", + "description": "an alert to find with others", + "tags": ["phishing"], + } +) + + +raw_filters = { + "_or": [ + {"_eq": {"_field": "tags", "_value": "antivirus"}}, + {"_eq": {"_field": "tags", "_value": "antivirus"}}, + ] +} +all_alerts_with_raw_filters = hive.alert.find(filters=raw_filters) + +class_filters = Eq(field="tags", value="antivirus") | Eq(field="tags", value="phishing") +all_alerts_with_class_filters = hive.alert.find(filters=class_filters) diff --git a/examples/alert/fetch_with_get.py b/examples/alert/fetch_with_get.py new file mode 100644 index 0000000..9bf86fb --- /dev/null +++ b/examples/alert/fetch_with_get.py @@ -0,0 +1,18 @@ +import uuid + +from thehive4py import TheHiveApi + +hive = TheHiveApi(url="http://localhost:9000", apikey="h1v3b33") + +alert_to_get = hive.alert.create( + alert={ + "type": "get-single", + "source": "tutorial", + "sourceRef": uuid.uuid4().hex, + "title": "alert to get", + "description": "a single alert to fetch", + } +) + + +fetched_alert = hive.alert.get(alert_id=alert_to_get["_id"]) diff --git a/examples/alert/observable_after_alerting.py b/examples/alert/observable_after_alerting.py new file mode 100644 index 0000000..91059b3 --- /dev/null +++ b/examples/alert/observable_after_alerting.py @@ -0,0 +1,29 @@ +import uuid + +from thehive4py import TheHiveApi +from thehive4py.types.alert import InputAlert +from thehive4py.types.observable import InputObservable + +hive = TheHiveApi(url="thehive.example", apikey="h1v3b33") + +input_alert: InputAlert = { + "type": "alert-without-observables", + "source": "example", + "sourceRef": uuid.uuid4().hex, + "title": "alert without observables", + "description": "alert without observables", +} + +output_alert = hive.alert.create(alert=input_alert) + + +input_observables: list[InputObservable] = [ + {"dataType": "ip", "data": "1.2.3.4"}, + {"dataType": "domain", "data": "example.com"}, +] + + +for input_observable in input_observables: + hive.alert.create_observable( + alert_id=output_alert["_id"], observable=input_observable + ) diff --git a/examples/alert/observable_during_alerting.py b/examples/alert/observable_during_alerting.py new file mode 100644 index 0000000..9119d85 --- /dev/null +++ b/examples/alert/observable_during_alerting.py @@ -0,0 +1,20 @@ +import uuid + +from thehive4py import TheHiveApi +from thehive4py.types.alert import InputAlert + +hive = TheHiveApi(url="thehive.example", apikey="h1v3b33") + +input_alert: InputAlert = { + "type": "alert-with-observables", + "source": "example", + "sourceRef": uuid.uuid4().hex, + "title": "alert with observables", + "description": "alert with observables", + "observables": [ + {"dataType": "ip", "data": "1.2.3.4"}, + {"dataType": "domain", "data": "example.com"}, + ], +} + +hive.alert.create(alert=input_alert) diff --git a/examples/alert/observable_from_file.py b/examples/alert/observable_from_file.py new file mode 100644 index 0000000..9331fcc --- /dev/null +++ b/examples/alert/observable_from_file.py @@ -0,0 +1,29 @@ +import os.path +import tempfile +import uuid + +from thehive4py import TheHiveApi +from thehive4py.types.alert import InputAlert + +hive = TheHiveApi(url="http://localhost:9000", apikey="h1v3b33") + +with tempfile.TemporaryDirectory() as tmpdir: + + observable_filepath = os.path.join(tmpdir, "my-observable.txt") + with open(observable_filepath) as observable_file: + observable_file.write("some observable content") + + attachment_key = uuid.uuid4().hex + attachment_map = {attachment_key: observable_filepath} + input_alert: InputAlert = { + "type": "alert-with-file-observable", + "source": "example", + "sourceRef": uuid.uuid4().hex, + "title": "alert with file observables", + "description": "alert with file observables", + "observables": [ + {"dataType": "file", "attachment": attachment_key}, + ], + } + + hive.alert.create(alert=input_alert, attachment_map=attachment_map) diff --git a/examples/alert/simple.py b/examples/alert/simple.py new file mode 100644 index 0000000..29abfa0 --- /dev/null +++ b/examples/alert/simple.py @@ -0,0 +1,13 @@ +from thehive4py import TheHiveApi + +hive = TheHiveApi(url="http://localhost:9000", apikey="h1v3b33") + +simple_alert = hive.alert.create( + alert={ + "type": "simple", + "source": "tutorial", + "sourceRef": "should-be-unique", + "title": "a simple alert", + "description": "a bit too simple", + } +) diff --git a/examples/alert/update_bulk.py b/examples/alert/update_bulk.py new file mode 100644 index 0000000..1d07d38 --- /dev/null +++ b/examples/alert/update_bulk.py @@ -0,0 +1,28 @@ +import uuid + +from thehive4py import TheHiveApi + +hive = TheHiveApi(url="http://localhost:9000", apikey="h1v3b33") + +original_alert_ids = [] +for i in range(2): + original_alert = hive.alert.create( + alert={ + "type": "update-bulk", + "source": "tutorial", + "sourceRef": uuid.uuid4().hex, + "title": f"original alert #{i}", + "description": "an alert to update in bulk", + } + ) + + original_alert_ids.append(original_alert["_id"]) + + +hive.alert.bulk_update( + fields={ + "ids": original_alert_ids, + "title": "bulk updated alert", + "tags": ["update-bulk"], + }, +) diff --git a/examples/alert/update_single.py b/examples/alert/update_single.py new file mode 100644 index 0000000..2730b21 --- /dev/null +++ b/examples/alert/update_single.py @@ -0,0 +1,26 @@ +import uuid + +from thehive4py import TheHiveApi + +hive = TheHiveApi(url="http://localhost:9000", apikey="h1v3b33") + +original_alert = hive.alert.create( + alert={ + "type": "update-single", + "source": "tutorial", + "sourceRef": uuid.uuid4().hex, + "title": "original alert", + "description": "a single alert to update", + } +) + + +hive.alert.update( + alert_id=original_alert["_id"], + fields={ + "title": "updated alert", + "tags": ["update-single"], + }, +) + +updated_alert = hive.alert.get(alert_id=original_alert["_id"]) diff --git a/examples/client/auth_with_apikey.py b/examples/client/auth_with_apikey.py new file mode 100644 index 0000000..00e392a --- /dev/null +++ b/examples/client/auth_with_apikey.py @@ -0,0 +1,6 @@ +from thehive4py import TheHiveApi + +hive = TheHiveApi( + url="http://localhost:9000", + apikey="h1v3b33", +) diff --git a/examples/client/auth_with_username_and_password.py b/examples/client/auth_with_username_and_password.py new file mode 100644 index 0000000..dcff340 --- /dev/null +++ b/examples/client/auth_with_username_and_password.py @@ -0,0 +1,7 @@ +from thehive4py import TheHiveApi + +hive = TheHiveApi( + url="http://localhost:9000", + username="analyst@thehive", + password="h1v3b33", +) diff --git a/examples/client/org_during_runtime.py b/examples/client/org_during_runtime.py new file mode 100644 index 0000000..075e677 --- /dev/null +++ b/examples/client/org_during_runtime.py @@ -0,0 +1,8 @@ +from thehive4py import TheHiveApi + +hive = TheHiveApi( + url="http://localhost:9000", + apikey="h1v3b33", +) + +hive.session_organisation = "other-org" diff --git a/examples/client/org_via_constructor.py b/examples/client/org_via_constructor.py new file mode 100644 index 0000000..6aac0da --- /dev/null +++ b/examples/client/org_via_constructor.py @@ -0,0 +1,7 @@ +from thehive4py import TheHiveApi + +client_with_organisation = TheHiveApi( + url="http://localhost:9000", + apikey="h1v3b33", + organisation="admin", +) diff --git a/examples/client/retries.py b/examples/client/retries.py new file mode 100644 index 0000000..f302f78 --- /dev/null +++ b/examples/client/retries.py @@ -0,0 +1,16 @@ +from urllib3 import Retry + +from thehive4py import TheHiveApi + +simple_retries = Retry( + total=5, + backoff_factor=0.5, + allowed_methods=["GET"], + status_forcelist=[500], +) + +hive = TheHiveApi( + url="http://localhost:9000", + apikey="h1v3b33", + max_retries=simple_retries, +) diff --git a/examples/client/ssl.py b/examples/client/ssl.py new file mode 100644 index 0000000..36b3171 --- /dev/null +++ b/examples/client/ssl.py @@ -0,0 +1,7 @@ +from thehive4py import TheHiveApi + +hive = TheHiveApi( + url="http://localhost:9000", + apikey="h1v3b33", + verify="/etc/ssl/certs/ca-certificates.crt", +) diff --git a/mkdocs.yml b/mkdocs.yml index 9c0d51e..f914c95 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,10 +1,22 @@ -site_name: TheHive4py +site_name: thehive4py +repo_name: TheHive-Project/TheHive4py +repo_url: https://github.com/TheHive-Project/TheHive4py +nav: + - TheHive4py: index.md + - Examples: + - Client: examples/client.md + - Alert: examples/alert.md + - Reference: reference.md theme: name: material favicon: img/strangebee.png logo: img/strangebee.png features: + - content.code.copy - navigation.footer + - navigation.tabs + - navigation.tabs.sticky + - toc.follow palette: - media: "(prefers-color-scheme)" toggle: @@ -26,14 +38,15 @@ theme: name: Switch to system preference extra_css: - styles/extra.css -repo_name: TheHive-Project/TheHive4py -repo_url: https://github.com/TheHive-Project/TheHive4py markdown_extensions: + - admonition - attr_list - md_in_html - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.snippets + - pymdownx.superfences extra: social: - icon: simple/discord @@ -45,5 +58,27 @@ extra: - icon: simple/python link: https://pypi.org/project/thehive4py/ name: PyPI - - + version: + provider: mike +plugins: + - autorefs + - mkdocstrings: + handlers: + python: + import: + - https://docs.python.org/3/objects.inv + - https://requests.readthedocs.io/en/stable/objects.inv + options: + show_root_heading: true + show_root_full_path: true + merge_init_into_class: true + show_source: true + show_if_no_docstring: true + members_order: source + docstring_section_style: table + signature_crossrefs: true + show_symbol_type_heading: true + show_symbol_type_toc: true +watch: + - examples + - thehive4py \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e0a87f7..c1b55a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,10 +29,10 @@ authors = [{ name = "Szabolcs Antal", email = "antalszabolcs01@gmail.com" }] [project.optional-dependencies] audit = ["bandit", "pip-audit"] build = ["build", "twine"] -docs = ["mkdocs", "mkdocs-material"] +docs = ["mkdocs", "mkdocs-material", "mkdocstrings-python", "mike"] lint = ["black", "flake8-pyproject", "mypy", "pre-commit"] test = ["pytest", "pytest-cov"] -dev = ["thehive4py[audit, lint, test, build]"] +dev = ["thehive4py[audit, lint, test, build, docs]"] [tool.setuptools.packages.find] include = ["thehive4py*"] diff --git a/scripts/cd.py b/scripts/cd.py index bf366e0..020f4ef 100755 --- a/scripts/cd.py +++ b/scripts/cd.py @@ -57,7 +57,7 @@ def run_build_docs(quiet: bool): def run_deploy_docs(quiet: bool): print("Deploying thehive4py docs to gh-pages...") _run_subprocess( - command="mkdocs gh-deploy --force", + command="mike deploy main latest -u -p --allow-empty", quiet=quiet, ) print("Successfully deployed thehive4py docs to gh-pages!") diff --git a/thehive4py/client.py b/thehive4py/client.py index f731d63..3a24340 100644 --- a/thehive4py/client.py +++ b/thehive4py/client.py @@ -17,7 +17,7 @@ from thehive4py.endpoints.custom_field import CustomFieldEndpoint from thehive4py.endpoints.observable_type import ObservableTypeEndpoint from thehive4py.endpoints.query import QueryEndpoint -from thehive4py.session import DEFAULT_RETRY, RetryValue, TheHiveSession +from thehive4py.session import DEFAULT_RETRY, RetryValue, TheHiveSession, VerifyValue class TheHiveApi: @@ -28,9 +28,27 @@ def __init__( username: Optional[str] = None, password: Optional[str] = None, organisation: Optional[str] = None, - verify=None, + verify: VerifyValue = True, max_retries: RetryValue = DEFAULT_RETRY, ): + """Create a client of TheHive API. + + Parameters: + url: TheHive's url. + apikey: TheHive's apikey. It's required if `username` and `password` + is not provided. + username: TheHive's username. It's required if `apikey` is not provided. + Must be specified together with `password`. + password: TheHive's password. It's required if `apikey` is not provided. + Must be specified together with `username`. + organisation: TheHive organisation to use in the session. + verify: Either a boolean, in which case it controls whether we verify + the server's TLS certificate, or a string, in which case it must be a + path to a CA bundle to use. + max_retries: Either `None`, in which case we do not retry failed requests, + or a `Retry` object. + + """ self.session = TheHiveSession( url=url, apikey=apikey, diff --git a/thehive4py/endpoints/alert.py b/thehive4py/endpoints/alert.py index 9bd1967..543e40d 100644 --- a/thehive4py/endpoints/alert.py +++ b/thehive4py/endpoints/alert.py @@ -24,6 +24,15 @@ class AlertEndpoint(EndpointBase): def create( self, alert: InputAlert, attachment_map: Optional[Dict[str, str]] = None ) -> OutputAlert: + """Create an alert. + + Args: + alert: The body of the alert. + attachment_map: An optional mapping of observable attachment keys and paths. + + Returns: + The created alert. + """ if attachment_map: files: Dict[str, Any] = { key: self._fileinfo_from_filepath(path) @@ -36,35 +45,102 @@ def create( return self._session.make_request("POST", path="/api/v1/alert", **kwargs) def get(self, alert_id: str) -> OutputAlert: + """Get an alert by id. + + Args: + alert_id: The id of the alert. + + Returns: + The alert specified by the id. + """ + return self._session.make_request("GET", path=f"/api/v1/alert/{alert_id}") def update(self, alert_id: str, fields: InputUpdateAlert) -> None: + """Update an alert. + + Args: + alert_id: The id of the alert. + fields: The fields of the alert to update. + + Returns: + N/A + """ return self._session.make_request( "PATCH", path=f"/api/v1/alert/{alert_id}", json=fields ) def delete(self, alert_id: str) -> None: + """Delete an alert. + + Args: + alert_id: The id of the alert. + + Returns: + N/A + """ return self._session.make_request("DELETE", path=f"/api/v1/alert/{alert_id}") def bulk_update(self, fields: InputBulkUpdateAlert) -> None: + """Update multiple alerts with the same values. + + Args: + fields: The ids and the fields of the alerts to update. + + Returns: + N/A + """ return self._session.make_request( "PATCH", path="/api/v1/alert/_bulk", json=fields ) def bulk_delete(self, ids: List[str]) -> None: + """Delete multiple alerts. + + Args: + ids: The ids of the alerts to delete. + + Returns: + N/A + """ return self._session.make_request( "POST", path="/api/v1/alert/delete/_bulk", json={"ids": ids} ) def follow(self, alert_id: str) -> None: + """Follow an alert. + + Args: + alert_id: The id of the alert. + + Returns: + N/A + """ self._session.make_request("POST", path=f"/api/v1/alert/{alert_id}/follow") def unfollow(self, alert_id: str) -> None: + """Unfollow an alert. + + Args: + alert_id: The id of the alert. + + Returns: + N/A + """ self._session.make_request("POST", path=f"/api/v1/alert/{alert_id}/unfollow") def promote_to_case( self, alert_id: str, fields: InputPromoteAlert = {} ) -> OutputCase: + """Promote an alert into a case. + + Args: + alert_id: The id of the alert. + fields: Override for the fields of the case created from the alert. + + Returns: + The case from the promoted alert. + """ return self._session.make_request( "POST", path=f"/api/v1/alert/{alert_id}/case", @@ -77,6 +153,17 @@ def create_observable( observable: InputObservable, observable_path: Optional[str] = None, ) -> List[OutputObservable]: + """Create an observable in an alert. + + Args: + alert_id: The id of the alert. + observable: The fields of the observable to create. + observable_path: Optional path in case of a file based observable. + + Returns: + The created alert observables. + """ + kwargs = self._build_observable_kwargs( observable=observable, observable_path=observable_path ) @@ -87,6 +174,15 @@ def create_observable( def add_attachment( self, alert_id: str, attachment_paths: List[str] ) -> List[OutputAttachment]: + """Create an observable in an alert. + + Args: + alert_id: The id of the alert. + attachment_paths: List of paths to the attachments to create. + + Returns: + The created alert attachments. + """ files = [ ("attachments", self._fileinfo_from_filepath(attachment_path)) for attachment_path in attachment_paths @@ -98,6 +194,16 @@ def add_attachment( def download_attachment( self, alert_id: str, attachment_id: str, attachment_path: str ) -> None: + """Download an alert attachment. + + Args: + alert_id: The id of the alert. + attachment_id: The id of the alert attachment. + attachment_path: The local path to download the attachment to. + + Returns: + N/A + """ return self._session.make_request( "GET", path=f"/api/v1/alert/{alert_id}/attachment/{attachment_id}/download", @@ -105,16 +211,44 @@ def download_attachment( ) def delete_attachment(self, alert_id: str, attachment_id: str) -> None: + """Delete an alert attachment. + + Args: + alert_id: The id of the alert. + attachment_id: The id of the alert attachment. + + Returns: + N/A + """ + return self._session.make_request( "DELETE", path=f"/api/v1/alert/{alert_id}/attachment/{attachment_id}" ) def merge_into_case(self, alert_id: str, case_id: str) -> OutputCase: + """Merge an alert into an existing case. + + Args: + alert_id: The id of the alert to merge. + case_id: The id of the case to merge the alert into. + + Returns: + The case into which the alert was merged. + """ return self._session.make_request( "POST", path=f"/api/v1/alert/{alert_id}/merge/{case_id}" ) def bulk_merge_into_case(self, case_id: str, alert_ids: List[str]) -> OutputCase: + """Merge an alert into an existing case. + + Args: + case_id: The id of the case to merge the alerts into. + alert_ids: The list of alert ids to merge. + + Returns: + The case into which the alerts were merged. + """ return self._session.make_request( "POST", path="/api/v1/alert/merge/_bulk", @@ -127,6 +261,16 @@ def find( sortby: Optional[SortExpr] = None, paginate: Optional[Paginate] = None, ) -> List[OutputAlert]: + """Find multiple alerts. + + Args: + filters: The filter expressions to apply in the query. + sortby: The sort expressions to apply in the query. + paginate: The pagination experssion to apply in the query. + + Returns: + The list of alerts matched by the query or an empty list. + """ query: QueryExpr = [ {"_name": "listAlert"}, *self._build_subquery(filters=filters, sortby=sortby, paginate=paginate), @@ -140,6 +284,15 @@ def find( ) def count(self, filters: Optional[FilterExpr] = None) -> int: + """Count alerts. + + Args: + filters: The filter expressions to apply in the query. + + Returns: + The count of alerts matched by the query. + """ + query: QueryExpr = [ {"_name": "listAlert"}, *self._build_subquery(filters=filters), @@ -160,6 +313,17 @@ def find_observables( sortby: Optional[SortExpr] = None, paginate: Optional[Paginate] = None, ) -> List[OutputObservable]: + """Find observable related to an alert. + + Args: + alert_id: The id of the alert. + filters: The filter expressions to apply in the query. + sortby: The sort expressions to apply in the query. + paginate: The pagination experssion to apply in the query. + + Returns: + The list of alert observables matched by the query or an empty list. + """ query: QueryExpr = [ {"_name": "getAlert", "idOrName": alert_id}, {"_name": "observables"}, @@ -179,6 +343,17 @@ def find_comments( sortby: Optional[SortExpr] = None, paginate: Optional[Paginate] = None, ) -> List[OutputComment]: + """Find comments related to an alert. + + Args: + alert_id: The id of the alert. + filters: The filter expressions to apply in the query. + sortby: The sort expressions to apply in the query. + paginate: The pagination experssion to apply in the query. + + Returns: + The list of alert comments matched by the query or an empty list. + """ query: QueryExpr = [ {"_name": "getAlert", "idOrName": alert_id}, {"_name": "comments"}, @@ -194,6 +369,15 @@ def find_comments( def create_procedure( self, alert_id: str, procedure: InputProcedure ) -> OutputProcedure: + """Create an alert procedure. + + Args: + alert_id: The id of the alert. + procedure: The fields of the procedure to create. + + Returns: + The created alert procedure. + """ return self._session.make_request( "POST", path=f"/api/v1/alert/{alert_id}/procedure", json=procedure ) @@ -205,6 +389,17 @@ def find_procedures( sortby: Optional[SortExpr] = None, paginate: Optional[Paginate] = None, ) -> List[OutputProcedure]: + """Find procedures related to an alert. + + Args: + alert_id: The id of the alert. + filters: The filter expressions to apply in the query. + sortby: The sort expressions to apply in the query. + paginate: The pagination experssion to apply in the query. + + Returns: + The list of alert procedures matched by the query or an empty list. + """ query: QueryExpr = [ {"_name": "getAlert", "idOrName": alert_id}, {"_name": "procedures"}, @@ -225,6 +420,17 @@ def find_attachments( sortby: Optional[SortExpr] = None, paginate: Optional[Paginate] = None, ) -> List[OutputAttachment]: + """Find attachments related to an alert. + + Args: + alert_id: The id of the alert. + filters: The filter expressions to apply in the query. + sortby: The sort expressions to apply in the query. + paginate: The pagination experssion to apply in the query. + + Returns: + The list of alert attachments matched by the query or an empty list. + """ query: QueryExpr = [ {"_name": "getAlert", "idOrName": alert_id}, {"_name": "attachments"}, diff --git a/thehive4py/errors.py b/thehive4py/errors.py index b009444..bab6f9b 100644 --- a/thehive4py/errors.py +++ b/thehive4py/errors.py @@ -4,11 +4,16 @@ class TheHiveError(Exception): - """Base error class for TheHive API.""" def __init__( self, message: str, response: Optional[Response] = None, *args, **kwargs ): + """Base error class of thehive4py. + + Args: + message: The exception message. + response: Either `None`, or a `Response` object of a failed request. + """ super().__init__(message, *args, **kwargs) self.message = message self.response = response diff --git a/thehive4py/session.py b/thehive4py/session.py index e3e261d..3ffb2a6 100644 --- a/thehive4py/session.py +++ b/thehive4py/session.py @@ -21,9 +21,10 @@ RetryValue = Union[Retry, int, None] +VerifyValue = Union[bool, str] -class SessionJSONEncoder(jsonlib.JSONEncoder): +class _SessionJSONEncoder(jsonlib.JSONEncoder): """Custom JSON encoder class for TheHive session.""" def default(self, o: Any): @@ -39,7 +40,7 @@ def __init__( apikey: Optional[str] = None, username: Optional[str] = None, password: Optional[str] = None, - verify=None, + verify: VerifyValue = True, max_retries: RetryValue = DEFAULT_RETRY, ): super().__init__() @@ -85,7 +86,7 @@ def make_request( headers = {**self.headers} if json: - data = jsonlib.dumps(json, cls=SessionJSONEncoder) + data = jsonlib.dumps(json, cls=_SessionJSONEncoder) headers = {**headers, "Content-Type": "application/json"} response = self.request(