mirror of
https://github.com/brygphilomena/pyhuntress.git
synced 2025-11-04 08:17:29 +00:00
Initial Commit
Add get endpoint for all Huntress SIEM endpoints
This commit is contained in:
parent
89bd95a2ac
commit
b3fe77725f
157
README.md
157
README.md
@ -1 +1,156 @@
|
|||||||
# pyhuntressphish
|
# pyhuntress - An API library for Huntress SIEM and Huntress Managed SAT, written in Python
|
||||||
|
|
||||||
|
pyHuntress is a full-featured, type annotated API client written in Python for the Huntress APIs.
|
||||||
|
|
||||||
|
This library has been developed with the intention of making the Huntress APIs simple and accessible to non-coders while allowing experienced coders to utilize all features the API has to offer without the boilerplate.
|
||||||
|
|
||||||
|
pyHuntress currently supports both Huntress SIEM and Huntress Managed SAT products.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
=========
|
||||||
|
- **100% API Coverage.** All endpoints and response models.
|
||||||
|
- **Non-coder friendly.** 100% annotated for full IDE auto-completion. Clients handle requests and authentication - just plug the right details in and go!
|
||||||
|
- **Fully annotated.** This library has a strong focus on type safety and type hinting. Models are declared and parsed using [Pydantic](https://github.com/pydantic/pydantic)
|
||||||
|
|
||||||
|
pyHuntress is currently in **development**.
|
||||||
|
|
||||||
|
Known Issues:
|
||||||
|
=============
|
||||||
|
- As this project is still a WIP, documentation or code commentary may not always align.
|
||||||
|
- Huntress Managed SAT is not built
|
||||||
|
- Pagination does not work
|
||||||
|
|
||||||
|
Road Map:
|
||||||
|
=============
|
||||||
|
- Add Huntress Managed SAT Report
|
||||||
|
|
||||||
|
How-to:
|
||||||
|
=============
|
||||||
|
- [Install](#install)
|
||||||
|
- [Initializing the API Clients](#initializing-the-api-clients)
|
||||||
|
- [Huntress Managed SAT](#huntress-managed-sat)
|
||||||
|
- [Huntress SIEM](#huntress-siem)
|
||||||
|
- [Working with Endpoints](#working-with-endpoints)
|
||||||
|
- [Get many](#get-many)
|
||||||
|
- [Get one](#get-one)
|
||||||
|
- [Get with params](#get-with-params)
|
||||||
|
- [Pagination](#pagination)
|
||||||
|
- [Contributing](#contributing)
|
||||||
|
- [Supporting the project](#supporting-the-project)
|
||||||
|
|
||||||
|
# Install
|
||||||
|
Open a terminal and run ```pip install pyhuntress```
|
||||||
|
|
||||||
|
# Initializing the API Clients
|
||||||
|
|
||||||
|
### Huntress Managed SAT
|
||||||
|
```python
|
||||||
|
from pyhuntress import HuntressSATAPIClient
|
||||||
|
|
||||||
|
# init client
|
||||||
|
sat_api_client = HuntressSATAPIClient(
|
||||||
|
mycurricula.com,
|
||||||
|
# your api public key,
|
||||||
|
# your api private key,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Huntress SIEM
|
||||||
|
```python
|
||||||
|
from pyhuntress import HuntressSIEMAPIClient
|
||||||
|
|
||||||
|
# init client
|
||||||
|
siem_api_client = HuntressSIEMAPIClient(
|
||||||
|
# huntress siem url
|
||||||
|
# your api public key,
|
||||||
|
# your api private key,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
# Working with Endpoints
|
||||||
|
Endpoints are 1:1 to what's available for both the Huntress Managed SAT and Huntress SIEM.
|
||||||
|
|
||||||
|
For more information, check out the following resources:
|
||||||
|
- [Huntress Managed SAT REST API Docs](https://support.meetgradient.com/huntress-managed-sat)
|
||||||
|
- [Huntress SIEM REST API Docs](https://api.huntress.io/docs)
|
||||||
|
|
||||||
|
### Get many
|
||||||
|
```python
|
||||||
|
### Managed SAT ###
|
||||||
|
|
||||||
|
# sends GET request to /company/companies endpoint
|
||||||
|
companies = manage_api_client.company.companies.get()
|
||||||
|
|
||||||
|
### SIEM ###
|
||||||
|
|
||||||
|
# sends GET request to /agents endpoint
|
||||||
|
agents = siem_api_client.agents.get()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get one
|
||||||
|
```python
|
||||||
|
### Managed SAT ###
|
||||||
|
|
||||||
|
# sends GET request to /company/companies/{id} endpoint
|
||||||
|
company = sat_api_client.company.companies.id(250).get()
|
||||||
|
|
||||||
|
### SIEM ###
|
||||||
|
|
||||||
|
# sends GET request to /agents/{id} endpoint
|
||||||
|
agent = siem_api_client.agents.id(250).get()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get with params
|
||||||
|
```python
|
||||||
|
### Managed SAT ###
|
||||||
|
|
||||||
|
# sends GET request to /company/companies with a conditions query string
|
||||||
|
conditional_company = sat_api_client.company.companies.get(params={
|
||||||
|
'conditions': 'company/id=250'
|
||||||
|
})
|
||||||
|
|
||||||
|
### SIEM ###
|
||||||
|
# sends GET request to /agents endpoint with a condition query string
|
||||||
|
conditional_agent = siem_api_client.clients.get(params={
|
||||||
|
'platform': 'windows'
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
The Huntress SIEM API paginates data for performance reasons through the ```page``` and ```limit``` query parameters. ```limit``` is limited to a maximum of 500.
|
||||||
|
|
||||||
|
To make working with paginated data easy, Endpoints that implement a GET response with an array also supply a ```paginated()``` method. Under the hood this wraps a GET request, but does a lot of neat stuff to make working with pages easier.
|
||||||
|
|
||||||
|
Working with pagination
|
||||||
|
```python
|
||||||
|
# initialize a PaginatedResponse instance for /agents, starting on page 1 with a pageSize of 100
|
||||||
|
paginated_agents = siem_api_client.agents.paginated(1,100)
|
||||||
|
|
||||||
|
# access the data from the current page using the .data field
|
||||||
|
page_one_data = paginated_agents.data
|
||||||
|
|
||||||
|
# if there's a next page, retrieve the next page worth of data
|
||||||
|
paginated_agents.get_next_page()
|
||||||
|
|
||||||
|
# if there's a previous page, retrieve the previous page worth of data
|
||||||
|
paginated_agents.get_previous_page()
|
||||||
|
|
||||||
|
# iterate over all companies on the current page
|
||||||
|
for agent in paginated_agents:
|
||||||
|
# ... do things ...
|
||||||
|
|
||||||
|
# iterate over all companies in all pages
|
||||||
|
# this works by yielding every item on the page, then fetching the next page and continuing until there's no data left
|
||||||
|
for agent in paginated_agents.all():
|
||||||
|
# ... do things ...
|
||||||
|
```
|
||||||
|
|
||||||
|
# Contributing
|
||||||
|
Contributions to the project are welcome. If you find any issues or have suggestions for improvement, please feel free to open an issue or submit a pull request.
|
||||||
|
|
||||||
|
# Supporting the project
|
||||||
|
:heart:
|
||||||
|
|
||||||
|
# Inspiration and Stolen Code
|
||||||
|
The premise behind this came from the [pyConnectWise](https://github.com/HealthITAU/pyconnectwise) package and I stole **most** of the code and adapted it to the Huntress API endpoints.
|
||||||
5
src/pyhuntress/__init__.py
Normal file
5
src/pyhuntress/__init__.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from pyhuntress.clients.managedsat_client import HuntressSATAPIClient
|
||||||
|
from pyhuntress.clients.siem_client import HuntressSIEMAPIClient
|
||||||
|
|
||||||
|
__all__ = ["HuntressSATAPIClient", "HuntressSIEMAPIClient"]
|
||||||
|
__version__ = "0.6.1"
|
||||||
0
src/pyhuntress/clients/__init__.py
Normal file
0
src/pyhuntress/clients/__init__.py
Normal file
130
src/pyhuntress/clients/huntress_client.py
Normal file
130
src/pyhuntress/clients/huntress_client.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import warnings
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests import Response
|
||||||
|
from requests.exceptions import Timeout
|
||||||
|
|
||||||
|
from pyhuntress.config import Config
|
||||||
|
from pyhuntress.exceptions import (
|
||||||
|
AuthenticationFailedException,
|
||||||
|
ConflictException,
|
||||||
|
MalformedRequestException,
|
||||||
|
MethodNotAllowedException,
|
||||||
|
NotFoundException,
|
||||||
|
ObjectExistsError,
|
||||||
|
PermissionsFailedException,
|
||||||
|
ServerError,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyhuntress.types import RequestData, RequestMethod, RequestParams
|
||||||
|
|
||||||
|
|
||||||
|
class HuntressClient(ABC):
|
||||||
|
config: Config = Config()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _get_headers(self) -> dict[str, str]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _get_url(self) -> str:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _make_request( # noqa: C901
|
||||||
|
self,
|
||||||
|
method: RequestMethod,
|
||||||
|
url: str,
|
||||||
|
data: RequestData | None = None,
|
||||||
|
params: RequestParams | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
retry_count: int = 0,
|
||||||
|
stream: bool = False, # noqa: FBT001, FBT002
|
||||||
|
) -> Response:
|
||||||
|
"""
|
||||||
|
Make an API request using the specified method, endpoint, data, and parameters.
|
||||||
|
This function isn't intended for use outside of this class.
|
||||||
|
Please use the available CRUD methods as intended.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method (str): The HTTP method to use for the request (e.g., GET, POST, PUT, etc.).
|
||||||
|
endpoint (str, optional): The endpoint to make the request to.
|
||||||
|
data (dict, optional): The request data to send.
|
||||||
|
params (dict, optional): The query parameters to include in the request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The Response object (see requests.Response).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If the request returns a status code >= 400.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not headers:
|
||||||
|
headers = self._get_headers()
|
||||||
|
|
||||||
|
# I don't like having to cast the params to a dict, but it's the only way I can get mypy to stop complaining about the type.
|
||||||
|
# TypedDicts aren't compatible with the dict type and this is the best way I can think of to handle this.
|
||||||
|
if data:
|
||||||
|
response = requests.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
json=data,
|
||||||
|
params=cast(dict[str, Any], params or {}),
|
||||||
|
stream=stream,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = requests.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
params=cast(dict[str, Any], params or {}),
|
||||||
|
stream=stream,
|
||||||
|
)
|
||||||
|
if not response.ok:
|
||||||
|
with contextlib.suppress(json.JSONDecodeError):
|
||||||
|
details: dict = response.json()
|
||||||
|
if response.status_code == 400: # noqa: SIM102 (Expecting to handle other codes in the future)
|
||||||
|
if details.get("code") == "InvalidObject":
|
||||||
|
errors = details.get("errors", [])
|
||||||
|
if len(errors) > 1:
|
||||||
|
warnings.warn(
|
||||||
|
"Found multiple errors - we may be masking some important error details. Please submit a Github issue with response.status_code and response.content so we can improve this error handling.",
|
||||||
|
stacklevel=1,
|
||||||
|
)
|
||||||
|
for error in errors:
|
||||||
|
if error.get("code") == "ObjectExists":
|
||||||
|
error.pop("code") # Don't need code in message
|
||||||
|
raise ObjectExistsError(response, extra_message=json.dumps(error, indent=4))
|
||||||
|
|
||||||
|
if response.status_code == 400:
|
||||||
|
raise MalformedRequestException(response)
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise AuthenticationFailedException(response)
|
||||||
|
if response.status_code == 403:
|
||||||
|
raise PermissionsFailedException(response)
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise NotFoundException(response)
|
||||||
|
if response.status_code == 405:
|
||||||
|
raise MethodNotAllowedException(response)
|
||||||
|
if response.status_code == 409:
|
||||||
|
raise ConflictException(response)
|
||||||
|
if response.status_code == 500:
|
||||||
|
# if timeout is mentioned anywhere in the response then we'll retry.
|
||||||
|
# Ideally we'd return immediately on any non-timeout errors (since
|
||||||
|
# retries won't help much there), but err towards classifying too much
|
||||||
|
# as retries instead of too little.
|
||||||
|
if "timeout" in (response.text + response.reason).lower():
|
||||||
|
if retry_count < self.config.max_retries:
|
||||||
|
retry_count += 1
|
||||||
|
return self._make_request(method, url, data, params, headers, retry_count)
|
||||||
|
raise Timeout(response=response)
|
||||||
|
raise ServerError(response)
|
||||||
|
|
||||||
|
return response
|
||||||
175
src/pyhuntress/clients/managedsat_client.py
Normal file
175
src/pyhuntress/clients/managedsat_client.py
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import base64
|
||||||
|
import typing
|
||||||
|
|
||||||
|
from pyhuntress.clients.huntress_client import HuntressClient
|
||||||
|
from pyhuntress.config import Config
|
||||||
|
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
from pyhuntress.endpoints.managedsat.CompanyEndpoint import CompanyEndpoint
|
||||||
|
from pyhuntress.endpoints.managedsat.ConfigurationsEndpoint import ConfigurationsEndpoint
|
||||||
|
from pyhuntress.endpoints.managedsat.ExpenseEndpoint import ExpenseEndpoint
|
||||||
|
from pyhuntress.endpoints.managedsat.FinanceEndpoint import FinanceEndpoint
|
||||||
|
from pyhuntress.endpoints.managedsat.MarketingEndpoint import MarketingEndpoint
|
||||||
|
from pyhuntress.endpoints.managedsat.ProcurementEndpoint import ProcurementEndpoint
|
||||||
|
from pyhuntress.endpoints.managedsat.ProjectEndpoint import ProjectEndpoint
|
||||||
|
from pyhuntress.endpoints.managedsat.SalesEndpoint import SalesEndpoint
|
||||||
|
from pyhuntress.endpoints.managedsat.ScheduleEndpoint import ScheduleEndpoint
|
||||||
|
from pyhuntress.endpoints.managedsat.ServiceEndpoint import ServiceEndpoint
|
||||||
|
from pyhuntress.endpoints.managedsat.SystemEndpoint import SystemEndpoint
|
||||||
|
from pyhuntress.endpoints.managedsat.TimeEndpoint import TimeEndpoint
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedSATCodebaseError(Exception):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("Could not retrieve codebase from API.")
|
||||||
|
|
||||||
|
|
||||||
|
class HuntressSATAPIClient(HuntressClient):
|
||||||
|
"""
|
||||||
|
Huntress Managed SAT API client. Handles the connection to the Huntress Managed SAT API
|
||||||
|
and the configuration of all the available endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
managedsat_url: str,
|
||||||
|
public_key: str,
|
||||||
|
private_key: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Initializes the client with the given credentials and optionally a specific codebase.
|
||||||
|
If no codebase is given, it tries to get it from the API.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
managedsat_url (str): URL of the Huntress Managed SAT instance.
|
||||||
|
public_key (str): Your Huntress Managed SAT API Public key.
|
||||||
|
private_key (str): Your Huntress Managed SAT API Private key.
|
||||||
|
"""
|
||||||
|
self.managedsat_url: str = managedsat_url
|
||||||
|
self.public_key: str = public_key
|
||||||
|
self.private_key: str = private_key
|
||||||
|
|
||||||
|
# Initializing endpoints
|
||||||
|
@property
|
||||||
|
def company(self) -> "CompanyEndpoint":
|
||||||
|
from pyhuntress.endpoints.managedsat.CompanyEndpoint import CompanyEndpoint
|
||||||
|
|
||||||
|
return CompanyEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configurations(self) -> "ConfigurationsEndpoint":
|
||||||
|
from pyhuntress.endpoints.managedsat.ConfigurationsEndpoint import ConfigurationsEndpoint
|
||||||
|
|
||||||
|
return ConfigurationsEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expense(self) -> "ExpenseEndpoint":
|
||||||
|
from pyhuntress.endpoints.managedsat.ExpenseEndpoint import ExpenseEndpoint
|
||||||
|
|
||||||
|
return ExpenseEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def finance(self) -> "FinanceEndpoint":
|
||||||
|
from pyhuntress.endpoints.managedsat.FinanceEndpoint import FinanceEndpoint
|
||||||
|
|
||||||
|
return FinanceEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def marketing(self) -> "MarketingEndpoint":
|
||||||
|
from pyhuntress.endpoints.managedsat.MarketingEndpoint import MarketingEndpoint
|
||||||
|
|
||||||
|
return MarketingEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def procurement(self) -> "ProcurementEndpoint":
|
||||||
|
from pyhuntress.endpoints.managedsat.ProcurementEndpoint import ProcurementEndpoint
|
||||||
|
|
||||||
|
return ProcurementEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def project(self) -> "ProjectEndpoint":
|
||||||
|
from pyhuntress.endpoints.managedsat.ProjectEndpoint import ProjectEndpoint
|
||||||
|
|
||||||
|
return ProjectEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sales(self) -> "SalesEndpoint":
|
||||||
|
from pyhuntress.endpoints.managedsat.SalesEndpoint import SalesEndpoint
|
||||||
|
|
||||||
|
return SalesEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def schedule(self) -> "ScheduleEndpoint":
|
||||||
|
from pyhuntress.endpoints.managedsat.ScheduleEndpoint import ScheduleEndpoint
|
||||||
|
|
||||||
|
return ScheduleEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def service(self) -> "ServiceEndpoint":
|
||||||
|
from pyhuntress.endpoints.managedsat.ServiceEndpoint import ServiceEndpoint
|
||||||
|
|
||||||
|
return ServiceEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def system(self) -> "SystemEndpoint":
|
||||||
|
from pyhuntress.endpoints.managedsat.SystemEndpoint import SystemEndpoint
|
||||||
|
|
||||||
|
return SystemEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time(self) -> "TimeEndpoint":
|
||||||
|
from pyhuntress.endpoints.managedsat.TimeEndpoint import TimeEndpoint
|
||||||
|
|
||||||
|
return TimeEndpoint(self)
|
||||||
|
|
||||||
|
def _get_url(self) -> str:
|
||||||
|
"""
|
||||||
|
Generates and returns the URL for the Huntress Managed SAT API endpoints based on the company url and codebase.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: API URL.
|
||||||
|
"""
|
||||||
|
return f"https://{self.managedsat_url}/{self.codebase.strip('/')}/apis/3.0"
|
||||||
|
|
||||||
|
def _try_get_codebase_from_api(self, managedsat_url: str, company_name: str, headers: dict[str, str]) -> str:
|
||||||
|
"""
|
||||||
|
Tries to retrieve the codebase from the API using the provided company url, company name and headers.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
company_url (str): URL of the company.
|
||||||
|
company_name (str): Name of the company.
|
||||||
|
headers (dict[str, str]): Headers to be sent in the request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Codebase string or None if an error occurs.
|
||||||
|
"""
|
||||||
|
url = f"https://{managedsat_url}/login/companyinfo/{company_name}"
|
||||||
|
response = self._make_request("GET", url, headers=headers)
|
||||||
|
return response.json().get("Codebase")
|
||||||
|
|
||||||
|
def _get_auth_string(self) -> str:
|
||||||
|
"""
|
||||||
|
Creates and returns the base64 encoded authorization string required for API requests.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Base64 encoded authorization string.
|
||||||
|
"""
|
||||||
|
return "Basic " + base64.b64encode(
|
||||||
|
bytes(
|
||||||
|
f"{self.company_name}+{self.public_key}:{self.private_key}",
|
||||||
|
encoding="utf8",
|
||||||
|
)
|
||||||
|
).decode("ascii")
|
||||||
|
|
||||||
|
def _get_headers(self) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Generates and returns the headers required for making API requests.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, str]: Dictionary of headers including Content-Type, Client ID, and Authorization.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"clientId": self.client_id,
|
||||||
|
"Authorization": self._get_auth_string(),
|
||||||
|
}
|
||||||
127
src/pyhuntress/clients/siem_client.py
Normal file
127
src/pyhuntress/clients/siem_client.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import typing
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from pyhuntress.clients.huntress_client import HuntressClient
|
||||||
|
from pyhuntress.config import Config
|
||||||
|
|
||||||
|
if typing.TYPE_CHECKING:
|
||||||
|
from pyhuntress.endpoints.siem.AccountEndpoint import AccountEndpoint
|
||||||
|
from pyhuntress.endpoints.siem.ActorEndpoint import ActorEndpoint
|
||||||
|
from pyhuntress.endpoints.siem.AgentsEndpoint import AgentsEndpoint
|
||||||
|
from pyhuntress.endpoints.siem.BillingreportsEndpoint import BillingreportsEndpoint
|
||||||
|
from pyhuntress.endpoints.siem.IncidentreportsEndpoint import IncidentreportsEndpoint
|
||||||
|
from pyhuntress.endpoints.siem.OrganizationsEndpoint import OrganizationsEndpoint
|
||||||
|
from pyhuntress.endpoints.siem.ReportsEndpoint import ReportsEndpoint
|
||||||
|
from pyhuntress.endpoints.siem.SignalsEndpoint import SignalsEndpoint
|
||||||
|
|
||||||
|
|
||||||
|
class HuntressSIEMAPIClient(HuntressClient):
|
||||||
|
"""
|
||||||
|
Huntress SIEM API client. Handles the connection to the Huntress SIEM API
|
||||||
|
and the configuration of all the available endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
siem_url: str,
|
||||||
|
publickey: str,
|
||||||
|
privatekey: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Initializes the client with the given credentials.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
siem_url (str): URL of your Huntress SIEM instance.
|
||||||
|
publickey (str): Your Huntress SIEM API public key.
|
||||||
|
privatekey (str): Your Huntress SIEM API private key.
|
||||||
|
"""
|
||||||
|
self.siem_url: str = siem_url
|
||||||
|
self.publickey: str = publickey
|
||||||
|
self.privatekey: str = privatekey
|
||||||
|
self.token_expiry_time: datetime = datetime.now(tz=timezone.utc)
|
||||||
|
|
||||||
|
# Grab first access token
|
||||||
|
self.base64_auth: str = self._get_auth_key()
|
||||||
|
|
||||||
|
# Initializing endpoints
|
||||||
|
@property
|
||||||
|
def account(self) -> "AccountEndpoint":
|
||||||
|
from pyhuntress.endpoints.siem.AccountEndpoint import AccountEndpoint
|
||||||
|
|
||||||
|
return AccountEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def actor(self) -> "ActorEndpoint":
|
||||||
|
from pyhuntress.endpoints.siem.ActorEndpoint import ActorEndpoint
|
||||||
|
|
||||||
|
return ActorEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def agents(self) -> "AgentsEndpoint":
|
||||||
|
from pyhuntress.endpoints.siem.AgentsEndpoint import AgentsEndpoint
|
||||||
|
|
||||||
|
return AgentsEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def billing_reports(self) -> "BillingreportsEndpoint":
|
||||||
|
from pyhuntress.endpoints.siem.BillingreportsEndpoint import BillingreportsEndpoint
|
||||||
|
|
||||||
|
return BillingreportsEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def incident_reports(self) -> "IncidentreportsEndpoint":
|
||||||
|
from pyhuntress.endpoints.siem.IncidentreportsEndpoint import IncidentreportsEndpoint
|
||||||
|
|
||||||
|
return IncidentreportsEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def organizations(self) -> "OrganizationsEndpoint":
|
||||||
|
from pyhuntress.endpoints.siem.OrganizationsEndpoint import OrganizationsEndpoint
|
||||||
|
|
||||||
|
return OrganizationsEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reports(self) -> "ReportsEndpoint":
|
||||||
|
from pyhuntress.endpoints.siem.ReportsEndpoint import ReportsEndpoint
|
||||||
|
|
||||||
|
return ReportsEndpoint(self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def signals(self) -> "SignalsEndpoint":
|
||||||
|
from pyhuntress.endpoints.siem.SignalsEndpoint import SignalsEndpoint
|
||||||
|
|
||||||
|
return SignalsEndpoint(self)
|
||||||
|
|
||||||
|
def _get_url(self) -> str:
|
||||||
|
"""
|
||||||
|
Generates and returns the URL for the Huntress SIEM API endpoints based on the company url and codebase.
|
||||||
|
Logs in an obtains an access token.
|
||||||
|
Returns:
|
||||||
|
str: API URL.
|
||||||
|
"""
|
||||||
|
return f"https://{self.siem_url}/v1"
|
||||||
|
|
||||||
|
def _get_auth_key(self) -> str:
|
||||||
|
"""
|
||||||
|
Creates a base64 encoded authentication string to the Huntress SIEM API to obtain an access token.
|
||||||
|
"""
|
||||||
|
# Format: base64encode(api_key:api_secret)
|
||||||
|
|
||||||
|
auth_str = f"{self.publickey}:{self.privatekey}"
|
||||||
|
auth_bytes = auth_str.encode('ascii')
|
||||||
|
base64_auth = base64.b64encode(auth_bytes).decode('ascii')
|
||||||
|
|
||||||
|
return base64_auth
|
||||||
|
|
||||||
|
def _get_headers(self) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Generates and returns the headers required for making API requests. The access token is refreshed if necessary before returning.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, str]: Dictionary of headers including Content-Type, Client ID, and Authorization.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Basic {self.base64_auth}",
|
||||||
|
}
|
||||||
9
src/pyhuntress/config.py
Normal file
9
src/pyhuntress/config.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
class Config:
|
||||||
|
def __init__(self, max_retries=3) -> None: # noqa: ANN001
|
||||||
|
"""
|
||||||
|
Initializes a new instance of the Config class.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_retries (int): The maximum number of retries for a retryable HTTP operation (500) (default = 3)
|
||||||
|
"""
|
||||||
|
self.max_retries = max_retries
|
||||||
0
src/pyhuntress/endpoints/__init__.py
Normal file
0
src/pyhuntress/endpoints/__init__.py
Normal file
0
src/pyhuntress/endpoints/base/__init__.py
Normal file
0
src/pyhuntress/endpoints/base/__init__.py
Normal file
163
src/pyhuntress/endpoints/base/huntress_endpoint.py
Normal file
163
src/pyhuntress/endpoints/base/huntress_endpoint.py
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any, TypeVar
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from requests import Response
|
||||||
|
|
||||||
|
from pyhuntress.clients.huntress_client import HuntressClient
|
||||||
|
from pyhuntress.types import (
|
||||||
|
RequestData,
|
||||||
|
RequestMethod,
|
||||||
|
RequestParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
TChildEndpoint = TypeVar("TChildEndpoint", bound="HuntressEndpoint")
|
||||||
|
TModel = TypeVar("TModel", bound="BaseModel")
|
||||||
|
|
||||||
|
|
||||||
|
class HuntressEndpoint:
|
||||||
|
"""
|
||||||
|
HuntressEndpoint is a base class for all Huntress API endpoint classes.
|
||||||
|
It provides a generic implementation for interacting with the Huntress API,
|
||||||
|
handling requests, parsing responses into model instances, and managing pagination.
|
||||||
|
|
||||||
|
HuntressEndpoint makes use of a generic type variable TModel, which represents
|
||||||
|
the expected HuntressModel type for the endpoint. This allows for type-safe
|
||||||
|
handling of model instances throughout the class.
|
||||||
|
|
||||||
|
Each derived class should specify the HuntressModel type it will be working with
|
||||||
|
when inheriting from HuntressEndpoint. For example:
|
||||||
|
class CompanyEndpoint(HuntressEndpoint[CompanyModel]).
|
||||||
|
|
||||||
|
HuntressEndpoint provides methods for making API requests and handles pagination
|
||||||
|
using the PaginatedResponse class. By default, most CRUD methods raise a
|
||||||
|
NotImplementedError, which should be overridden in derived classes to provide
|
||||||
|
endpoint-specific implementations.
|
||||||
|
|
||||||
|
HuntressEndpoint also supports handling nested endpoints, which are referred to as
|
||||||
|
child endpoints. Child endpoints can be registered and accessed through their parent
|
||||||
|
endpoint, allowing for easy navigation through related resources in the API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: The HuntressAPIClient instance.
|
||||||
|
endpoint_url (str): The base URL for the specific endpoint.
|
||||||
|
parent_endpoint (HuntressEndpoint, optional): The parent endpoint, if applicable.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
client (HuntressAPIClient): The HuntressAPIClient instance.
|
||||||
|
endpoint_url (str): The base URL for the specific endpoint.
|
||||||
|
_parent_endpoint (HuntressEndpoint): The parent endpoint, if applicable.
|
||||||
|
model_parser (ModelParser): An instance of the ModelParser class used for parsing API responses.
|
||||||
|
_model (Type[TModel]): The model class for the endpoint.
|
||||||
|
_id (int): The ID of the current resource, if applicable.
|
||||||
|
_child_endpoints (List[HuntressEndpoint]): A list of registered child endpoints.
|
||||||
|
|
||||||
|
Generic Type:
|
||||||
|
TModel: The model class for the endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: HuntressClient,
|
||||||
|
endpoint_url: str,
|
||||||
|
parent_endpoint: HuntressEndpoint | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Initialize a HuntressEndpoint instance with the client and endpoint base.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: The HuntressAPIClient instance.
|
||||||
|
endpoint_base (str): The base URL for the specific endpoint.
|
||||||
|
"""
|
||||||
|
self.client = client
|
||||||
|
self.endpoint_base = endpoint_url
|
||||||
|
self._parent_endpoint = parent_endpoint
|
||||||
|
self._id = None
|
||||||
|
self._child_endpoints: list[HuntressEndpoint] = []
|
||||||
|
|
||||||
|
def _register_child_endpoint(self, child_endpoint: TChildEndpoint) -> TChildEndpoint:
|
||||||
|
"""
|
||||||
|
Register a child endpoint to the current endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
child_endpoint (HuntressEndpoint): The child endpoint instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HuntressEndpoint: The registered child endpoint.
|
||||||
|
"""
|
||||||
|
self._child_endpoints.append(child_endpoint)
|
||||||
|
return child_endpoint
|
||||||
|
|
||||||
|
def _url_join(self, *args) -> str: # noqa: ANN002
|
||||||
|
"""
|
||||||
|
Join URL parts into a single URL string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
*args: The URL parts to join.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The joined URL string.
|
||||||
|
"""
|
||||||
|
url_parts = [str(arg).strip("/") for arg in args]
|
||||||
|
return "/".join(url_parts)
|
||||||
|
|
||||||
|
def _get_replaced_url(self) -> str:
|
||||||
|
if self._id is None:
|
||||||
|
return self.endpoint_base
|
||||||
|
return self.endpoint_base.replace("{id}", str(self._id))
|
||||||
|
|
||||||
|
def _make_request(
|
||||||
|
self,
|
||||||
|
method: RequestMethod,
|
||||||
|
endpoint: HuntressEndpoint | None = None,
|
||||||
|
data: RequestData | None = None,
|
||||||
|
params: RequestParams | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
stream: bool = False, # noqa: FBT001, FBT002
|
||||||
|
) -> Response:
|
||||||
|
"""
|
||||||
|
Make an API request using the specified method, endpoint, data, and parameters.
|
||||||
|
This function isn't intended for use outside of this class.
|
||||||
|
Please use the available CRUD methods as intended.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
method (str): The HTTP method to use for the request (e.g., GET, POST, PUT, etc.).
|
||||||
|
endpoint (str, optional): The endpoint to make the request to.
|
||||||
|
data (dict, optional): The request data to send.
|
||||||
|
params (dict, optional): The query parameters to include in the request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The Response object (see requests.Response).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If the request returns a status code >= 400.
|
||||||
|
"""
|
||||||
|
url = self._get_endpoint_url()
|
||||||
|
if endpoint:
|
||||||
|
url = self._url_join(url, endpoint)
|
||||||
|
|
||||||
|
return self.client._make_request(method, url, data, params, headers, stream)
|
||||||
|
|
||||||
|
def _build_url(self, other_endpoint: HuntressEndpoint) -> str:
|
||||||
|
if other_endpoint._parent_endpoint is not None:
|
||||||
|
parent_url = self._build_url(other_endpoint._parent_endpoint)
|
||||||
|
if other_endpoint._parent_endpoint._id is not None:
|
||||||
|
return self._url_join(
|
||||||
|
parent_url,
|
||||||
|
other_endpoint._get_replaced_url(),
|
||||||
|
)
|
||||||
|
else: # noqa: RET505
|
||||||
|
return self._url_join(parent_url, other_endpoint._get_replaced_url())
|
||||||
|
else:
|
||||||
|
return self._url_join(self.client._get_url(), other_endpoint._get_replaced_url())
|
||||||
|
|
||||||
|
def _get_endpoint_url(self) -> str:
|
||||||
|
return self._build_url(self)
|
||||||
|
|
||||||
|
def _parse_many(self, model_type: type[TModel], data: list[dict[str, Any]]) -> list[TModel]:
|
||||||
|
return [model_type.model_validate(d) for d in data]
|
||||||
|
|
||||||
|
def _parse_one(self, model_type: type[TModel], data: dict[str, Any]) -> TModel:
|
||||||
|
return model_type.model_validate(data)
|
||||||
0
src/pyhuntress/endpoints/managedsat/__init__.py
Normal file
0
src/pyhuntress/endpoints/managedsat/__init__.py
Normal file
37
src/pyhuntress/endpoints/siem/AccountEndpoint.py
Normal file
37
src/pyhuntress/endpoints/siem/AccountEndpoint.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from pyhuntress.endpoints.base.huntress_endpoint import HuntressEndpoint
|
||||||
|
from pyhuntress.interfaces import (
|
||||||
|
IGettable,
|
||||||
|
)
|
||||||
|
from pyhuntress.models.siem import SIEMAccount
|
||||||
|
from pyhuntress.types import (
|
||||||
|
JSON,
|
||||||
|
HuntressSIEMRequestParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountEndpoint(
|
||||||
|
HuntressEndpoint,
|
||||||
|
IGettable[SIEMAccount, HuntressSIEMRequestParams],
|
||||||
|
):
|
||||||
|
def __init__(self, client, parent_endpoint=None) -> None:
|
||||||
|
HuntressEndpoint.__init__(self, client, "account", parent_endpoint=parent_endpoint)
|
||||||
|
IGettable.__init__(self, SIEMAccount)
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
data: JSON | None = None,
|
||||||
|
params: HuntressSIEMRequestParams | None = None,
|
||||||
|
) -> SIEMAccount:
|
||||||
|
"""
|
||||||
|
Performs a GET request against the /account endpoint.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
data (dict[str, Any]): The data to send in the request body.
|
||||||
|
params (dict[str, int | str]): The parameters to send in the request query string.
|
||||||
|
Returns:
|
||||||
|
SIEMAuthInformation: The parsed response data.
|
||||||
|
"""
|
||||||
|
return self._parse_one(
|
||||||
|
SIEMAccount,
|
||||||
|
super()._make_request("GET", data=data, params=params).json().get('account', {}),
|
||||||
|
)
|
||||||
37
src/pyhuntress/endpoints/siem/ActorEndpoint.py
Normal file
37
src/pyhuntress/endpoints/siem/ActorEndpoint.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from pyhuntress.endpoints.base.huntress_endpoint import HuntressEndpoint
|
||||||
|
from pyhuntress.interfaces import (
|
||||||
|
IGettable,
|
||||||
|
)
|
||||||
|
from pyhuntress.models.siem import SIEMActorResponse
|
||||||
|
from pyhuntress.types import (
|
||||||
|
JSON,
|
||||||
|
HuntressSIEMRequestParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ActorEndpoint(
|
||||||
|
HuntressEndpoint,
|
||||||
|
IGettable[SIEMActorResponse, HuntressSIEMRequestParams],
|
||||||
|
):
|
||||||
|
def __init__(self, client, parent_endpoint=None) -> None:
|
||||||
|
HuntressEndpoint.__init__(self, client, "actor", parent_endpoint=parent_endpoint)
|
||||||
|
IGettable.__init__(self, SIEMActorResponse)
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
data: JSON | None = None,
|
||||||
|
params: HuntressSIEMRequestParams | None = None,
|
||||||
|
) -> SIEMActorResponse:
|
||||||
|
"""
|
||||||
|
Performs a GET request against the /Actor endpoint.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
data (dict[str, Any]): The data to send in the request body.
|
||||||
|
params (dict[str, int | str]): The parameters to send in the request query string.
|
||||||
|
Returns:
|
||||||
|
SIEMAuthInformation: The parsed response data.
|
||||||
|
"""
|
||||||
|
return self._parse_one(
|
||||||
|
SIEMActorResponse,
|
||||||
|
super()._make_request("GET", data=data, params=params).json(),
|
||||||
|
)
|
||||||
84
src/pyhuntress/endpoints/siem/AgentsEndpoint.py
Normal file
84
src/pyhuntress/endpoints/siem/AgentsEndpoint.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
from pyhuntress.endpoints.base.huntress_endpoint import HuntressEndpoint
|
||||||
|
from pyhuntress.interfaces import (
|
||||||
|
IGettable,
|
||||||
|
IPaginateable,
|
||||||
|
)
|
||||||
|
from pyhuntress.models.siem import SIEMAgentsResponse, SIEMAgents
|
||||||
|
from pyhuntress.responses.paginated_response import PaginatedResponse
|
||||||
|
from pyhuntress.types import (
|
||||||
|
JSON,
|
||||||
|
HuntressSIEMRequestParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentsEndpoint(
|
||||||
|
HuntressEndpoint,
|
||||||
|
IGettable[SIEMAgents, HuntressSIEMRequestParams],
|
||||||
|
IPaginateable[SIEMAgents, HuntressSIEMRequestParams],
|
||||||
|
):
|
||||||
|
def __init__(self, client, parent_endpoint=None) -> None:
|
||||||
|
HuntressEndpoint.__init__(self, client, "agents", parent_endpoint=parent_endpoint)
|
||||||
|
IGettable.__init__(self, SIEMAgentsResponse)
|
||||||
|
IPaginateable.__init__(self, SIEMAgentsResponse)
|
||||||
|
|
||||||
|
def id(self, id: int) -> HuntressEndpoint:
|
||||||
|
"""
|
||||||
|
Sets the ID for this endpoint and returns an initialized HuntressEndpoint object to move down the chain.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
id (int): The ID to set.
|
||||||
|
Returns:
|
||||||
|
HuntressEndpoint: The initialized HuntressEndpoint object.
|
||||||
|
"""
|
||||||
|
child = HuntressEndpoint(self.client, parent_endpoint=self)
|
||||||
|
child._id = id
|
||||||
|
return child
|
||||||
|
|
||||||
|
def paginated(
|
||||||
|
self,
|
||||||
|
page: int,
|
||||||
|
limit: int,
|
||||||
|
params: HuntressSIEMRequestParams | None = None,
|
||||||
|
) -> PaginatedResponse[SIEMAgents]:
|
||||||
|
"""
|
||||||
|
Performs a GET request against the /agents endpoint and returns an initialized PaginatedResponse object.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
page (int): The page number to request.
|
||||||
|
limit (int): The number of results to return per page.
|
||||||
|
params (dict[str, int | str]): The parameters to send in the request query string.
|
||||||
|
Returns:
|
||||||
|
PaginatedResponse[SIEMAgentsResponse]: The initialized PaginatedResponse object.
|
||||||
|
"""
|
||||||
|
if params:
|
||||||
|
params["page"] = page
|
||||||
|
params["pageSize"] = limit
|
||||||
|
else:
|
||||||
|
params = {"page": page, "pageSize": limit}
|
||||||
|
return PaginatedResponse(
|
||||||
|
super()._make_request("GET", params=params),
|
||||||
|
SIEMAgents,
|
||||||
|
self,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
data: JSON | None = None,
|
||||||
|
params: HuntressSIEMRequestParams | None = None,
|
||||||
|
) -> SIEMAgents:
|
||||||
|
"""
|
||||||
|
Performs a GET request against the /agents endpoint.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
data (dict[str, Any]): The data to send in the request body.
|
||||||
|
params (dict[str, int | str]): The parameters to send in the request query string.
|
||||||
|
Returns:
|
||||||
|
SIEMAuthInformation: The parsed response data.
|
||||||
|
"""
|
||||||
|
return self._parse_many(
|
||||||
|
SIEMAgents,
|
||||||
|
super()._make_request("GET", data=data, params=params).json().get('agents', {}),
|
||||||
|
)
|
||||||
37
src/pyhuntress/endpoints/siem/BillingreportsEndpoint.py
Normal file
37
src/pyhuntress/endpoints/siem/BillingreportsEndpoint.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from pyhuntress.endpoints.base.huntress_endpoint import HuntressEndpoint
|
||||||
|
from pyhuntress.interfaces import (
|
||||||
|
IGettable,
|
||||||
|
)
|
||||||
|
from pyhuntress.models.siem import SIEMBillingReports
|
||||||
|
from pyhuntress.types import (
|
||||||
|
JSON,
|
||||||
|
HuntressSIEMRequestParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingreportsEndpoint(
|
||||||
|
HuntressEndpoint,
|
||||||
|
IGettable[SIEMBillingReports, HuntressSIEMRequestParams],
|
||||||
|
):
|
||||||
|
def __init__(self, client, parent_endpoint=None) -> None:
|
||||||
|
HuntressEndpoint.__init__(self, client, "billing_reports", parent_endpoint=parent_endpoint)
|
||||||
|
IGettable.__init__(self, SIEMBillingReports)
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
data: JSON | None = None,
|
||||||
|
params: HuntressSIEMRequestParams | None = None,
|
||||||
|
) -> SIEMBillingReports:
|
||||||
|
"""
|
||||||
|
Performs a GET request against the /Billing_reports endpoint.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
data (dict[str, Any]): The data to send in the request body.
|
||||||
|
params (dict[str, int | str]): The parameters to send in the request query string.
|
||||||
|
Returns:
|
||||||
|
SIEMAuthInformation: The parsed response data.
|
||||||
|
"""
|
||||||
|
return self._parse_many(
|
||||||
|
SIEMBillingReports,
|
||||||
|
super()._make_request("GET", data=data, params=params).json().get('billing_reports', {}),
|
||||||
|
)
|
||||||
37
src/pyhuntress/endpoints/siem/IncidentreportsEndpoint.py
Normal file
37
src/pyhuntress/endpoints/siem/IncidentreportsEndpoint.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from pyhuntress.endpoints.base.huntress_endpoint import HuntressEndpoint
|
||||||
|
from pyhuntress.interfaces import (
|
||||||
|
IGettable,
|
||||||
|
)
|
||||||
|
from pyhuntress.models.siem import SIEMIncidentReports
|
||||||
|
from pyhuntress.types import (
|
||||||
|
JSON,
|
||||||
|
HuntressSIEMRequestParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IncidentreportsEndpoint(
|
||||||
|
HuntressEndpoint,
|
||||||
|
IGettable[SIEMIncidentReports, HuntressSIEMRequestParams],
|
||||||
|
):
|
||||||
|
def __init__(self, client, parent_endpoint=None) -> None:
|
||||||
|
HuntressEndpoint.__init__(self, client, "incident_reports", parent_endpoint=parent_endpoint)
|
||||||
|
IGettable.__init__(self, SIEMIncidentReports)
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
data: JSON | None = None,
|
||||||
|
params: HuntressSIEMRequestParams | None = None,
|
||||||
|
) -> SIEMIncidentReports:
|
||||||
|
"""
|
||||||
|
Performs a GET request against the /Incident_reports endpoint.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
data (dict[str, Any]): The data to send in the request body.
|
||||||
|
params (dict[str, int | str]): The parameters to send in the request query string.
|
||||||
|
Returns:
|
||||||
|
SIEMAuthInformation: The parsed response data.
|
||||||
|
"""
|
||||||
|
return self._parse_many(
|
||||||
|
SIEMIncidentReports,
|
||||||
|
super()._make_request("GET", data=data, params=params).json().get('incident_reports', {}),
|
||||||
|
)
|
||||||
37
src/pyhuntress/endpoints/siem/OrganizationsEndpoint.py
Normal file
37
src/pyhuntress/endpoints/siem/OrganizationsEndpoint.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from pyhuntress.endpoints.base.huntress_endpoint import HuntressEndpoint
|
||||||
|
from pyhuntress.interfaces import (
|
||||||
|
IGettable,
|
||||||
|
)
|
||||||
|
from pyhuntress.models.siem import SIEMOrganizations
|
||||||
|
from pyhuntress.types import (
|
||||||
|
JSON,
|
||||||
|
HuntressSIEMRequestParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationsEndpoint(
|
||||||
|
HuntressEndpoint,
|
||||||
|
IGettable[SIEMOrganizations, HuntressSIEMRequestParams],
|
||||||
|
):
|
||||||
|
def __init__(self, client, parent_endpoint=None) -> None:
|
||||||
|
HuntressEndpoint.__init__(self, client, "organizations", parent_endpoint=parent_endpoint)
|
||||||
|
IGettable.__init__(self, SIEMOrganizations)
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
data: JSON | None = None,
|
||||||
|
params: HuntressSIEMRequestParams | None = None,
|
||||||
|
) -> SIEMOrganizations:
|
||||||
|
"""
|
||||||
|
Performs a GET request against the /Organizations endpoint.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
data (dict[str, Any]): The data to send in the request body.
|
||||||
|
params (dict[str, int | str]): The parameters to send in the request query string.
|
||||||
|
Returns:
|
||||||
|
SIEMAuthInformation: The parsed response data.
|
||||||
|
"""
|
||||||
|
return self._parse_many(
|
||||||
|
SIEMOrganizations,
|
||||||
|
super()._make_request("GET", data=data, params=params).json().get('organizations', {}),
|
||||||
|
)
|
||||||
37
src/pyhuntress/endpoints/siem/ReportsEndpoint.py
Normal file
37
src/pyhuntress/endpoints/siem/ReportsEndpoint.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from pyhuntress.endpoints.base.huntress_endpoint import HuntressEndpoint
|
||||||
|
from pyhuntress.interfaces import (
|
||||||
|
IGettable,
|
||||||
|
)
|
||||||
|
from pyhuntress.models.siem import SIEMReports
|
||||||
|
from pyhuntress.types import (
|
||||||
|
JSON,
|
||||||
|
HuntressSIEMRequestParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportsEndpoint(
|
||||||
|
HuntressEndpoint,
|
||||||
|
IGettable[SIEMReports, HuntressSIEMRequestParams],
|
||||||
|
):
|
||||||
|
def __init__(self, client, parent_endpoint=None) -> None:
|
||||||
|
HuntressEndpoint.__init__(self, client, "reports", parent_endpoint=parent_endpoint)
|
||||||
|
IGettable.__init__(self, SIEMReports)
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
data: JSON | None = None,
|
||||||
|
params: HuntressSIEMRequestParams | None = None,
|
||||||
|
) -> SIEMReports:
|
||||||
|
"""
|
||||||
|
Performs a GET request against the /Reports endpoint.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
data (dict[str, Any]): The data to send in the request body.
|
||||||
|
params (dict[str, int | str]): The parameters to send in the request query string.
|
||||||
|
Returns:
|
||||||
|
SIEMAuthInformation: The parsed response data.
|
||||||
|
"""
|
||||||
|
return self._parse_many(
|
||||||
|
SIEMReports,
|
||||||
|
super()._make_request("GET", data=data, params=params).json().get('reports', {}),
|
||||||
|
)
|
||||||
37
src/pyhuntress/endpoints/siem/SignalsEndpoint.py
Normal file
37
src/pyhuntress/endpoints/siem/SignalsEndpoint.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from pyhuntress.endpoints.base.huntress_endpoint import HuntressEndpoint
|
||||||
|
from pyhuntress.interfaces import (
|
||||||
|
IGettable,
|
||||||
|
)
|
||||||
|
from pyhuntress.models.siem import SIEMSignals
|
||||||
|
from pyhuntress.types import (
|
||||||
|
JSON,
|
||||||
|
HuntressSIEMRequestParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SignalsEndpoint(
|
||||||
|
HuntressEndpoint,
|
||||||
|
IGettable[SIEMSignals, HuntressSIEMRequestParams],
|
||||||
|
):
|
||||||
|
def __init__(self, client, parent_endpoint=None) -> None:
|
||||||
|
HuntressEndpoint.__init__(self, client, "signals", parent_endpoint=parent_endpoint)
|
||||||
|
IGettable.__init__(self, SIEMSignals)
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
data: JSON | None = None,
|
||||||
|
params: HuntressSIEMRequestParams | None = None,
|
||||||
|
) -> SIEMSignals:
|
||||||
|
"""
|
||||||
|
Performs a GET request against the /Signals endpoint.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
data (dict[str, Any]): The data to send in the request body.
|
||||||
|
params (dict[str, int | str]): The parameters to send in the request query string.
|
||||||
|
Returns:
|
||||||
|
SIEMAuthInformation: The parsed response data.
|
||||||
|
"""
|
||||||
|
return self._parse_many(
|
||||||
|
SIEMSignals,
|
||||||
|
super()._make_request("GET", data=data, params=params).json().get('signals', {}),
|
||||||
|
)
|
||||||
0
src/pyhuntress/endpoints/siem/__init__.py
Normal file
0
src/pyhuntress/endpoints/siem/__init__.py
Normal file
85
src/pyhuntress/exceptions.py
Normal file
85
src/pyhuntress/exceptions.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import json
|
||||||
|
from typing import ClassVar
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
|
from requests import JSONDecodeError, Response
|
||||||
|
|
||||||
|
|
||||||
|
class HuntressException(Exception): # noqa: N818
|
||||||
|
_code_explanation: ClassVar[str] = "" # Ex: for 404 "Not Found"
|
||||||
|
_error_suggestion: ClassVar[str] = "" # Ex: for 404 "Check the URL you are using is correct"
|
||||||
|
|
||||||
|
def __init__(self, req_response: Response, *, extra_message: str = "") -> None:
|
||||||
|
self.response = req_response
|
||||||
|
self.extra_message = extra_message
|
||||||
|
super().__init__(self.message())
|
||||||
|
|
||||||
|
def _get_sanitized_url(self) -> str:
|
||||||
|
"""
|
||||||
|
Simplify URL down to method, hostname, and path.
|
||||||
|
"""
|
||||||
|
url_components = urlsplit(self.response.url)
|
||||||
|
return urlunsplit(
|
||||||
|
(
|
||||||
|
url_components.scheme,
|
||||||
|
url_components.hostname,
|
||||||
|
url_components.path,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def details(self) -> str:
|
||||||
|
try:
|
||||||
|
# If response was json, then format it nicely
|
||||||
|
return json.dumps(self.response.json(), indent=4)
|
||||||
|
except JSONDecodeError:
|
||||||
|
return self.response.text
|
||||||
|
|
||||||
|
def message(self) -> str:
|
||||||
|
return (
|
||||||
|
f"A HTTP {self.response.status_code} ({self._code_explanation}) error has occurred while requesting"
|
||||||
|
f" {self._get_sanitized_url()}.\n{self.response.reason}\n{self._error_suggestion}\n{self.extra_message}"
|
||||||
|
).strip() # Remove extra whitespace (Ex: if extra_message == "")
|
||||||
|
|
||||||
|
|
||||||
|
class MalformedRequestException(HuntressException):
|
||||||
|
_code_explanation = "Bad Request"
|
||||||
|
_error_suggestion = (
|
||||||
|
"The request could not be understood by the server due to malformed syntax. Please check modify your request"
|
||||||
|
" before retrying."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationFailedException(HuntressException):
|
||||||
|
_code_explanation = "Unauthorized"
|
||||||
|
_error_suggestion = "Please check your credentials are correct before retrying."
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionsFailedException(HuntressException):
|
||||||
|
_code_explanation = "Forbidden"
|
||||||
|
_error_suggestion = "You may be attempting to access a resource you do not have the appropriate permissions for."
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundException(HuntressException):
|
||||||
|
_code_explanation = "Not Found"
|
||||||
|
_error_suggestion = "You may be attempting to access a resource that has been moved or deleted."
|
||||||
|
|
||||||
|
|
||||||
|
class MethodNotAllowedException(HuntressException):
|
||||||
|
_code_explanation = "Method Not Allowed"
|
||||||
|
_error_suggestion = "This resource does not support the HTTP method you are trying to use."
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictException(HuntressException):
|
||||||
|
_code_explanation = "Conflict"
|
||||||
|
_error_suggestion = "This resource is possibly in use or conflicts with another record."
|
||||||
|
|
||||||
|
|
||||||
|
class ServerError(HuntressException):
|
||||||
|
_code_explanation = "Internal Server Error"
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectExistsError(HuntressException):
|
||||||
|
_code_explanation = "Object Exists"
|
||||||
|
_error_suggestion = "This resource already exists."
|
||||||
103
src/pyhuntress/interfaces.py
Normal file
103
src/pyhuntress/interfaces.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import TYPE_CHECKING, Generic, TypeVar
|
||||||
|
|
||||||
|
from pyhuntress.responses.paginated_response import PaginatedResponse
|
||||||
|
from pyhuntress.types import (
|
||||||
|
JSON,
|
||||||
|
HuntressSIEMRequestParams,
|
||||||
|
HuntressSATRequestParams,
|
||||||
|
PatchRequestData,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
TModel = TypeVar("TModel", bound="BaseModel")
|
||||||
|
TRequestParams = TypeVar(
|
||||||
|
"TRequestParams",
|
||||||
|
bound=HuntressSATRequestParams | HuntressSIEMRequestParams,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IMethodBase(ABC, Generic[TModel, TRequestParams]):
|
||||||
|
def __init__(self, model: TModel) -> None:
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
|
||||||
|
class IPaginateable(IMethodBase, Generic[TModel, TRequestParams]):
|
||||||
|
def __init__(self, model: TModel) -> None:
|
||||||
|
super().__init__(model)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def paginated(
|
||||||
|
self,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
params: TRequestParams | None = None,
|
||||||
|
) -> PaginatedResponse[TModel]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IGettable(IMethodBase, Generic[TModel, TRequestParams]):
|
||||||
|
def __init__(self, model: TModel) -> None:
|
||||||
|
super().__init__(model)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
data: JSON | None = None,
|
||||||
|
params: TRequestParams | None = None,
|
||||||
|
) -> TModel:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IPostable(IMethodBase, Generic[TModel, TRequestParams]):
|
||||||
|
def __init__(self, model: TModel) -> None:
|
||||||
|
super().__init__(model)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def post(
|
||||||
|
self,
|
||||||
|
data: JSON | None = None,
|
||||||
|
params: TRequestParams | None = None,
|
||||||
|
) -> TModel:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IPatchable(IMethodBase, Generic[TModel, TRequestParams]):
|
||||||
|
def __init__(self, model: TModel) -> None:
|
||||||
|
super().__init__(model)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def patch(
|
||||||
|
self,
|
||||||
|
data: PatchRequestData,
|
||||||
|
params: TRequestParams | None = None,
|
||||||
|
) -> TModel:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IPuttable(IMethodBase, Generic[TModel, TRequestParams]):
|
||||||
|
def __init__(self, model: TModel) -> None:
|
||||||
|
super().__init__(model)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def put(
|
||||||
|
self,
|
||||||
|
data: JSON | None = None,
|
||||||
|
params: TRequestParams | None = None,
|
||||||
|
) -> TModel:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IDeleteable(IMethodBase, Generic[TRequestParams]):
|
||||||
|
def __init__(self, model: TModel) -> None:
|
||||||
|
super().__init__(model)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete(
|
||||||
|
self,
|
||||||
|
data: JSON | None = None,
|
||||||
|
params: TRequestParams | None = None,
|
||||||
|
) -> None:
|
||||||
|
pass
|
||||||
0
src/pyhuntress/models/__init__.py
Normal file
0
src/pyhuntress/models/__init__.py
Normal file
0
src/pyhuntress/models/base/__init__.py
Normal file
0
src/pyhuntress/models/base/__init__.py
Normal file
60
src/pyhuntress/models/base/huntress_model.py
Normal file
60
src/pyhuntress/models/base/huntress_model.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from types import UnionType
|
||||||
|
from typing import Union, get_args, get_origin
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from pyhuntress.utils.naming import to_camel_case
|
||||||
|
|
||||||
|
|
||||||
|
class HuntressModel(BaseModel):
|
||||||
|
model_config = ConfigDict(
|
||||||
|
alias_generator=to_camel_case,
|
||||||
|
populate_by_name=True,
|
||||||
|
use_enum_values=True,
|
||||||
|
protected_namespaces=(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_field_names(cls) -> list[str]:
|
||||||
|
field_names = []
|
||||||
|
for v in cls.__fields__.values():
|
||||||
|
was_model = False
|
||||||
|
for arg in get_args(v.annotation):
|
||||||
|
if inspect.isclass(arg) and issubclass(arg, HuntressModel):
|
||||||
|
was_model = True
|
||||||
|
field_names.extend([f"{v.alias}/{sub}" for sub in arg._get_field_names()])
|
||||||
|
|
||||||
|
if not was_model:
|
||||||
|
field_names.append(v.alias)
|
||||||
|
|
||||||
|
return field_names
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_field_names_and_types(cls) -> dict[str, str]: # noqa: C901
|
||||||
|
field_names_and_types = {}
|
||||||
|
for v in cls.__fields__.values():
|
||||||
|
was_model = False
|
||||||
|
field_type = "None"
|
||||||
|
if get_origin(v.annotation) is UnionType or get_origin(v.annotation) is Union:
|
||||||
|
for arg in get_args(v.annotation):
|
||||||
|
if inspect.isclass(arg) and issubclass(arg, HuntressModel):
|
||||||
|
was_model = True
|
||||||
|
for sk, sv in arg._get_field_names_and_types().items():
|
||||||
|
field_names_and_types[f"{v.alias}/{sk}"] = sv
|
||||||
|
elif arg is not None and arg.__name__ != "NoneType":
|
||||||
|
field_type = arg.__name__
|
||||||
|
else:
|
||||||
|
if inspect.isclass(v.annotation) and issubclass(v.annotation, HuntressModel):
|
||||||
|
was_model = True
|
||||||
|
for sk, sv in v.annotation._get_field_names_and_types().items():
|
||||||
|
field_names_and_types[f"{v.alias}/{sk}"] = sv
|
||||||
|
elif v.annotation is not None and v.annotation.__name__ != "NoneType":
|
||||||
|
field_type = v.annotation.__name__
|
||||||
|
|
||||||
|
if not was_model:
|
||||||
|
field_names_and_types[v.alias] = field_type
|
||||||
|
|
||||||
|
return field_names_and_types
|
||||||
5
src/pyhuntress/models/base/message_model.py
Normal file
5
src/pyhuntress/models/base/message_model.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class GenericMessageModel(BaseModel):
|
||||||
|
message: str
|
||||||
18
src/pyhuntress/models/managedsat/__init__.py
Normal file
18
src/pyhuntress/models/managedsat/__init__.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Annotated, Any, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from pyhuntress.models.base.huntress_model import HuntressModel
|
||||||
|
|
||||||
|
|
||||||
|
#class AccountingBatch(HuntressModel):
|
||||||
|
# info: Annotated[dict[str, str] | None, Field(alias="_info")] = None
|
||||||
|
# batch_identifier: Annotated[str | None, Field(alias="batchIdentifier")] = None
|
||||||
|
# closed_flag: Annotated[bool | None, Field(alias="closedFlag")] = None
|
||||||
|
# export_expenses_flag: Annotated[bool | None, Field(alias="exportExpensesFlag")] = None
|
||||||
|
# export_invoices_flag: Annotated[bool | None, Field(alias="exportInvoicesFlag")] = None
|
||||||
|
# export_products_flag: Annotated[bool | None, Field(alias="exportProductsFlag")] = None
|
||||||
|
# id: int | None = None
|
||||||
288
src/pyhuntress/models/siem/__init__.py
Normal file
288
src/pyhuntress/models/siem/__init__.py
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from pyhuntress.models.base.huntress_model import HuntressModel
|
||||||
|
|
||||||
|
class SIEMPagination(HuntressModel):
|
||||||
|
current_page: int | None = Field(default=None, alias="CurrentPage")
|
||||||
|
current_page_count: int | None = Field(default=None, alias="CurrentPageCount")
|
||||||
|
limit: int | None = Field(default=None, alias="Limit")
|
||||||
|
total_count: int | None = Field(default=None, alias="TotalCount")
|
||||||
|
next_page: int | None = Field(default=None, alias="NextPage")
|
||||||
|
next_page_url: str | None = Field(default=None, alias="NextPageURL")
|
||||||
|
next_page_token: str | None = Field(default=None, alias="NextPageToken")
|
||||||
|
|
||||||
|
class SIEMAgents(HuntressModel):
|
||||||
|
id: int | None = Field(default=None, alias="Id")
|
||||||
|
version: str | None = Field(default=None, alias="Version")
|
||||||
|
arch: str | None = Field(default=None, alias="Arch")
|
||||||
|
win_build_number: str | None = Field(default=None, alias="WinBuildNumber")
|
||||||
|
domain_name: str | None = Field(default=None, alias="DomainName")
|
||||||
|
created_at: datetime | None = Field(default=None, alias="CreateAt")
|
||||||
|
hostname: str | None = Field(default=None, alias="Hostname")
|
||||||
|
ipv4_address: str | None = Field(default=None, alias="IPv4Address")
|
||||||
|
external_ip: str | None = Field(default=None, alias="ExternalIP")
|
||||||
|
mac_addresses: list | None = Field(default=None, alias="MacAddresses")
|
||||||
|
updated_at: datetime | None = Field(default=None, alias="IPv4Address")
|
||||||
|
last_survey_at: datetime | None = Field(default=None, alias="LastSurveyAt")
|
||||||
|
last_callback_at: datetime | None = Field(default=None, alias="LastCallbackAt")
|
||||||
|
account_id: int | None = Field(default=None, alias="AccountID")
|
||||||
|
organization_id: int | None = Field(default=None, alias="OrganizationID")
|
||||||
|
platform: Literal[
|
||||||
|
"windows",
|
||||||
|
"darwin",
|
||||||
|
"linux",
|
||||||
|
] | None = Field(default=None, alias="Platform")
|
||||||
|
os: str | None = Field(default=None, alias="OS")
|
||||||
|
service_pack_major: int | None = Field(default=None, alias="ServicePackMajor")
|
||||||
|
service_pack_minor: int | None = Field(default=None, alias="ServicePackMinor")
|
||||||
|
tags: list | None = Field(default=None, alias="Tags")
|
||||||
|
os_major: int | None = Field(default=None, alias="OSMajor")
|
||||||
|
os_minor: int | None = Field(default=None, alias="OSMinor")
|
||||||
|
os_patch: int | None = Field(default=None, alias="OSPatch")
|
||||||
|
version_number: int | None = Field(default=None, alias="VersionNumber")
|
||||||
|
edr_version: str | None = Field(default=None, alias="EDRVersion")
|
||||||
|
os_build_version: str | None = Field(default=None, alias="OSBuildVersion")
|
||||||
|
serial_number: str | None = Field(default=None, alias="SerialNumber")
|
||||||
|
defender_status: str | None = Field(default=None, alias="DefenderStatus")
|
||||||
|
defender_substatus: str | None = Field(default=None, alias="DefenderSubstatus")
|
||||||
|
defender_policy_status: str | None = Field(default=None, alias="DefenderPolicyStatus")
|
||||||
|
firewall_status: str | None = Field(default=None, alias="FirewallStatus")
|
||||||
|
|
||||||
|
class SIEMAgentsResponse(HuntressModel):
|
||||||
|
agents: dict[str, Any] | None = Field(default=None, alias="Agents")
|
||||||
|
pagination: dict[str, Any] | None = Field(default=None, alias="Pagination")
|
||||||
|
|
||||||
|
class SIEMAccount(HuntressModel):
|
||||||
|
id: int | None = Field(default=None, alias="Id")
|
||||||
|
name: str | None = Field(default=None, alias="Name")
|
||||||
|
subdomain: str | None = Field(default=None, alias="Subdomain")
|
||||||
|
status: str | None = Field(default=None, alias="Status")
|
||||||
|
|
||||||
|
class SIEMActorResponse(HuntressModel):
|
||||||
|
account: dict[str, Any] | None = Field(default=None, alias="Account")
|
||||||
|
user: str | None = Field(default=None, alias="User")
|
||||||
|
|
||||||
|
class SIEMBillingReports(HuntressModel):
|
||||||
|
id: int | None = Field(default=None, alias="Id")
|
||||||
|
plan: str | None = Field(default=None, alias="Plan")
|
||||||
|
quantity: int | None = Field(default=None, alias="Quantity")
|
||||||
|
amount: int | None = Field(default=None, alias="Amount")
|
||||||
|
currency_type: str | None = Field(default=None, alias="CurrencyType")
|
||||||
|
receipt: str | None = Field(default=None, alias="Receipt")
|
||||||
|
status: Literal[
|
||||||
|
"open",
|
||||||
|
"paid",
|
||||||
|
"failed",
|
||||||
|
"partial_refund",
|
||||||
|
"full_refund",
|
||||||
|
"draft",
|
||||||
|
"voided",
|
||||||
|
] | None = Field(default=None, alias="Status")
|
||||||
|
created_at: datetime | None = Field(default=None, alias="CreatedAt")
|
||||||
|
updated_at: datetime | None = Field(default=None, alias="UpdatedAt")
|
||||||
|
|
||||||
|
class SIEMBillingReportsResponse(HuntressModel):
|
||||||
|
billing_reports: dict[str, Any] | None = Field(default=None, alias="BillingReports")
|
||||||
|
|
||||||
|
class SIEMIncidentReportsResponse(HuntressModel):
|
||||||
|
incident_reports: dict[str, Any] | None = Field(default=None, alias="IncidentReports")
|
||||||
|
pagination: dict[str, Any] | None = Field(default=None, alias="Pagination")
|
||||||
|
|
||||||
|
class SIEMIncidentReports(HuntressModel):
|
||||||
|
id: int | None = Field(default=None, alias="Id")
|
||||||
|
status: Literal[
|
||||||
|
"sent",
|
||||||
|
"closed",
|
||||||
|
"dismissed",
|
||||||
|
"auto_remediating",
|
||||||
|
"deleting",
|
||||||
|
] | None = Field(default=None, alias="Status")
|
||||||
|
summary: str | None = Field(default=None, alias="Summary")
|
||||||
|
body: str | None = Field(default=None, alias="Body")
|
||||||
|
updated_at: datetime | None = Field(default=None, alias="UpdatedAt")
|
||||||
|
agent_id: int | None = Field(default=None, alias="AgentId")
|
||||||
|
platform: Literal[
|
||||||
|
"windows",
|
||||||
|
"darwin",
|
||||||
|
"microsoft_365",
|
||||||
|
"google",
|
||||||
|
"linux",
|
||||||
|
"other",
|
||||||
|
] | None = Field(default=None, alias="Platform")
|
||||||
|
status_updated_at: datetime | None = Field(default=None, alias="StatusUpdatedAt")
|
||||||
|
organization_id: int | None = Field(default=None, alias="OrganizationId")
|
||||||
|
sent_at: datetime | None = Field(default=None, alias="SentAt")
|
||||||
|
account_id: int | None = Field(default=None, alias="AccountId")
|
||||||
|
subject: str | None = Field(default=None, alias="Subject")
|
||||||
|
remediations: list[dict[str, Any]] | None = Field(default=None, alias="Remediations")
|
||||||
|
severity: Literal[
|
||||||
|
"low",
|
||||||
|
"high",
|
||||||
|
"critical",
|
||||||
|
] | None = Field(default=None, alias="Severity")
|
||||||
|
closed_at: datetime | None = Field(default=None, alias="ClosedAt")
|
||||||
|
indicator_types: list | None = Field(default=None, alias="IndicatorTypes")
|
||||||
|
indicator_counts: dict[str, Any] | None = Field(default=None, alias="IndicatorCounts")
|
||||||
|
|
||||||
|
|
||||||
|
class SIEMRemediations(HuntressModel):
|
||||||
|
id: int | None = Field(default=None, alias="Id")
|
||||||
|
type: str | None = Field(default=None, alias="Type")
|
||||||
|
status: str | None = Field(default=None, alias="Status")
|
||||||
|
details: dict[str, Any] | None = Field(default=None, alias="Details")
|
||||||
|
completable_by_task_response: bool | None = Field(default=None, alias="CompletedByTaskResponse")
|
||||||
|
completable_manually: bool | None = Field(default=None, alias="CompletedManually")
|
||||||
|
display_action: str | None = Field(default=None, alias="DisplayAction")
|
||||||
|
approved_at: datetime | None = Field(default=None, alias="ApprovedAt")
|
||||||
|
approved_by: dict[str, Any] | None = Field(default=None, alias="ApprovedBy")
|
||||||
|
completed_at: datetime | None = Field(default=None, alias="CompletedAt")
|
||||||
|
|
||||||
|
class SIEMRemediationsDetails(HuntressModel):
|
||||||
|
rule_id: int | None = Field(default=None, alias="RuleId")
|
||||||
|
rule_name: str | None = Field(default=None, alias="RuleName")
|
||||||
|
completed_at: datetime | None = Field(default=None, alias="CompletedAt")
|
||||||
|
forward_from: str | None = Field(default=None, alias="ForwardFrom")
|
||||||
|
remediation: str | None = Field(default=None, alias="remediation")
|
||||||
|
|
||||||
|
class SIEMRemediationsApprovedBy(HuntressModel):
|
||||||
|
id: int | None = Field(default=None, alias="Id")
|
||||||
|
email: str | None = Field(default=None, alias="Email")
|
||||||
|
first_name: str | None = Field(default=None, alias="FirstName")
|
||||||
|
last_name: str | None = Field(default=None, alias="LastName")
|
||||||
|
|
||||||
|
class SIEMIndicatorCounts(HuntressModel):
|
||||||
|
footholds: int | None = Field(default=None, alias="Footholds")
|
||||||
|
mde_detections: int | None = Field(default=None, alias="MDEDetections")
|
||||||
|
monitored_files: int | None = Field(default=None, alias="MonitoredFiles")
|
||||||
|
siem_detections: int | None = Field(default=None, alias="SIEMDetections")
|
||||||
|
managed_identity: int | None = Field(default=None, alias="ManagedIdentity")
|
||||||
|
process_detections: int | None = Field(default=None, alias="ProcessDetections")
|
||||||
|
ransomware_canaries: int | None = Field(default=None, alias="RansomwareCanaries")
|
||||||
|
antivirus_detections: int | None = Field(default=None, alias="AntivirusDetections")
|
||||||
|
|
||||||
|
class SIEMOrganizationsResponse(HuntressModel):
|
||||||
|
organizations: dict[str, Any] | None = Field(default=None, alias="Organizations")
|
||||||
|
pagination: dict[str, Any] | None = Field(default=None, alias="Pagination")
|
||||||
|
|
||||||
|
class SIEMOrganizations(HuntressModel):
|
||||||
|
id: int | None = Field(default=None, alias="Id")
|
||||||
|
name: str | None = Field(default=None, alias="Name")
|
||||||
|
created_at: datetime | None = Field(default=None, alias="CreatedAt")
|
||||||
|
updated_at: datetime | None = Field(default=None, alias="UpdatedAt")
|
||||||
|
account_id: int | None = Field(default=None, alias="AccountId")
|
||||||
|
key: str | None = Field(default=None, alias="Key")
|
||||||
|
notify_emails: list | None = Field(default=None, alias="NotifyEmails")
|
||||||
|
microsoft_365_tenant_id: str | None = Field(default=None, alias="Microsoft365TenantId")
|
||||||
|
incident_reports_count: int | None = Field(default=None, alias="IncidentsReportsCount")
|
||||||
|
agents_count: int | None = Field(default=None, alias="AgentsCount")
|
||||||
|
microsoft_365_users_count: int | None = Field(default=None, alias="Microsoft365UsersCount")
|
||||||
|
sat_learner_count: int | None = Field(default=None, alias="SATLearnerCount")
|
||||||
|
logs_sources_count: int | None = Field(default=None, alias="LogsSourcesCount")
|
||||||
|
|
||||||
|
class SIEMReportsResponse(HuntressModel):
|
||||||
|
reports: dict[str, Any] | None = Field(default=None, alias="Organizations")
|
||||||
|
pagination: dict[str, Any] | None = Field(default=None, alias="Pagination")
|
||||||
|
|
||||||
|
class SIEMReports(HuntressModel):
|
||||||
|
id: int | None = Field(default=None, alias="Id")
|
||||||
|
type: Literal[
|
||||||
|
"monthly_summary",
|
||||||
|
"quarterly_summary",
|
||||||
|
"yearly_summary",
|
||||||
|
] | None = Field(default=None, alias="Type")
|
||||||
|
period: str | None = Field(default=None, alias="Period")
|
||||||
|
organization_id: int | None = Field(default=None, alias="OrganizationId")
|
||||||
|
created_at: datetime | None = Field(default=None, alias="CreatedAt")
|
||||||
|
updated_at: datetime | None = Field(default=None, alias="UpdatedAt")
|
||||||
|
url: str | None = Field(default=None, alias="Type")
|
||||||
|
events_analyzed: int | None = Field(default=None, alias="EventsAnalyzed")
|
||||||
|
total_entities: int | None = Field(default=None, alias="TotalEntities")
|
||||||
|
signals_detected: int | None = Field(default=None, alias="SignalsDetected")
|
||||||
|
signals_investigated: int | None = Field(default=None, alias="SignalsInvestigated")
|
||||||
|
itdr_entities: int | None = Field(default=None, alias="ITDREntities")
|
||||||
|
itdr_events: int | None = Field(default=None, alias="ITDREvents")
|
||||||
|
siem_total_logs: int | None = Field(default=None, alias="SIEMTotalLogs")
|
||||||
|
siem_ingested_logs: int | None = Field(default=None, alias="SIEMIngestedLogs")
|
||||||
|
autorun_events: int | None = Field(default=None, alias="AutorunEvents")
|
||||||
|
autorun_signals_detected: int | None = Field(default=None, alias="AutorunSignalsDetected")
|
||||||
|
investigations_completed: int | None = Field(default=None, alias="InvestigationsCompleted")
|
||||||
|
autorun_signals_reviewed: int | None = Field(default=None, alias="AutorunSignalsReviewed")
|
||||||
|
incidents_reported: int | None = Field(default=None, alias="IncidentsReported")
|
||||||
|
itdr_incidents_reported: int | None = Field(default=None, alias="ITDRIncidentsReported")
|
||||||
|
siem_incidents_reported: int | None = Field(default=None, alias="SIEMIncidentsReported")
|
||||||
|
incidents_resolved: int | None = Field(default=None, alias="IncidentsResolved")
|
||||||
|
incident_severity_counts: int | None = Field(default=None, alias="IncidentSeverityCounts")
|
||||||
|
incident_product_counts: int | None = Field(default=None, alias="IncidentProductCounts")
|
||||||
|
incident_indicator_counts: int | None = Field(default=None, alias="IncidentIndicatorCounts")
|
||||||
|
top_incident_av_threats: list | None = Field(default=None, alias="TopIncidentAVThreats")
|
||||||
|
top_incident_hosts: list | None = Field(default=None, alias="TopIncidentHosts")
|
||||||
|
potential_threat_indicators: list | None = Field(default=None, alias="PotentialThreatIndicators")
|
||||||
|
agents_count: int | None = Field(default=None, alias="AgentsCount")
|
||||||
|
deployed_canaries_count: int | None = Field(default=None, alias="DeployedCanariesCount")
|
||||||
|
protected_profiles_count: int | None = Field(default=None, alias="ProtectedProfilesCount")
|
||||||
|
windows_agent_count: int | None = Field(default=None, alias="WindowsAgentCount")
|
||||||
|
macos_agent_count: int | None = Field(default=None, alias="MacOSAgentCount")
|
||||||
|
servers_agent_count: int | None = Field(default=None, alias="ServersAgentCount")
|
||||||
|
analyst_note: str | None = Field(default=None, alias="AnalystNote")
|
||||||
|
global_threats_note: str | None = Field(default=None, alias="GlobalThreatsNote")
|
||||||
|
ransomware_note: str | None = Field(default=None, alias="RansomwareNote")
|
||||||
|
incident_log: str | None = Field(default=None, alias="IncidentLog")
|
||||||
|
total_mav_detection_count: int | None = Field(default=None, alias="TotalMAVDetectionCount")
|
||||||
|
blocked_malware_count: int | None = Field(default=None, alias="BlockedMalwareCount")
|
||||||
|
investigated_mav_detection_count: int | None = Field(default=None, alias="InvestigatedMAVDetectionCount")
|
||||||
|
mav_incident_report_count: int | None = Field(default=None, alias="MAVIncidentReportCount")
|
||||||
|
autoruns_reviewed: int | None = Field(default=None, alias="AutorunsReviewed")
|
||||||
|
host_processes_analyzed: int | None = Field(default=None, alias="HostProcessesAnalyzed")
|
||||||
|
process_detections: int | None = Field(default=None, alias="ProcessDetections")
|
||||||
|
process_detections_reviewed: int | None = Field(default=None, alias="ProcessDetectionsReviewed")
|
||||||
|
process_detections_reported: int | None = Field(default=None, alias="ProcessDetectionsReported")
|
||||||
|
itdr_signals: int | None = Field(default=None, alias="ITDRSignals")
|
||||||
|
siem_signals: int | None = Field(default=None, alias="SIEMSignals")
|
||||||
|
itdr_investigations_completed: int | None = Field(default=None, alias="ITDRInvestigationsCompleted")
|
||||||
|
macos_agents: str | None = Field(default=None, alias="MacOSAgents")
|
||||||
|
windows_agents: str | None = Field(default=None, alias="WindowsAgents")
|
||||||
|
only_macos_agents: str | None = Field(default=None, alias="OnlyMacOSAgents")
|
||||||
|
antivirus_exclusions_count: int | None = Field(default=None, alias="AntivirusExclusionsCount")
|
||||||
|
new_exclusions_count: int | None = Field(default=None, alias="NewExclusionsCount")
|
||||||
|
allowed_exclusions_count: int | None = Field(default=None, alias="AllowedExclusionsCount")
|
||||||
|
risky_exclusions_removed_count: int | None = Field(default=None, alias="RiskyExclusionsRemovedCount")
|
||||||
|
|
||||||
|
class SIEMSignalsResponse(HuntressModel):
|
||||||
|
signals: dict[str, Any] | None = Field(default=None, alias="Organizations")
|
||||||
|
pagination: dict[str, Any] | None = Field(default=None, alias="Pagination")
|
||||||
|
|
||||||
|
class SIEMSignals(HuntressModel):
|
||||||
|
created_at: datetime | None = Field(default=None, alias="CreatedAt")
|
||||||
|
id: int | None = Field(default=None, alias="Id")
|
||||||
|
status: str | None = Field(default=None, alias="Status")
|
||||||
|
updated_at: datetime | None = Field(default=None, alias="UpdatedAt")
|
||||||
|
details: dict[str, Any] | None = Field(default=None, alias="Details")
|
||||||
|
entity: dict[str, Any] | None = Field(default=None, alias="Entity")
|
||||||
|
investigated_at: datetime | None = Field(default=None, alias="InvestigatedAt")
|
||||||
|
investigation_context: str | None = Field(default=None, alias="InvestigationContext")
|
||||||
|
name: str | None = Field(default=None, alias="Name")
|
||||||
|
organization: dict[str, Any] | None = Field(default=None, alias="Organization")
|
||||||
|
type: str | None = Field(default=None, alias="Type")
|
||||||
|
|
||||||
|
class SIEMSignalsDetails(HuntressModel):
|
||||||
|
identity: str | None = Field(default=None, alias="Identity")
|
||||||
|
application: str | None = Field(default=None, alias="Application")
|
||||||
|
detected_at: datetime | None = Field(default=None, alias="DetectedAt")
|
||||||
|
|
||||||
|
class SIEMSignalsEntity(HuntressModel):
|
||||||
|
id: int | None = Field(default=None, alias="Id")
|
||||||
|
name: str | None = Field(default=None, alias="Name")
|
||||||
|
type: Literal[
|
||||||
|
"user_entity",
|
||||||
|
"source",
|
||||||
|
"mailbox",
|
||||||
|
"service_principal",
|
||||||
|
"agent",
|
||||||
|
"identity",
|
||||||
|
] | None = Field(default=None, alias="Type")
|
||||||
0
src/pyhuntress/py.typed
Normal file
0
src/pyhuntress/py.typed
Normal file
0
src/pyhuntress/responses/__init__.py
Normal file
0
src/pyhuntress/responses/__init__.py
Normal file
195
src/pyhuntress/responses/paginated_response.py
Normal file
195
src/pyhuntress/responses/paginated_response.py
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Generic, TypeVar
|
||||||
|
|
||||||
|
from pyhuntress.utils.helpers import parse_link_headers
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from requests import Response
|
||||||
|
|
||||||
|
from pyhuntress.types import RequestParams
|
||||||
|
|
||||||
|
|
||||||
|
TModel = TypeVar("TModel", bound="BaseModel")
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pyhuntress.interfaces import IPaginateable
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedResponse(Generic[TModel]):
|
||||||
|
"""
|
||||||
|
PaginatedResponse is a wrapper class for handling paginated responses from the
|
||||||
|
Huntress API. It provides methods for navigating through the pages of the response
|
||||||
|
and accessing the data contained within each page.
|
||||||
|
|
||||||
|
The class is designed to work with HuntressEndpoint and its derived classes to
|
||||||
|
parse the API response into model instances. It also supports iteration, allowing
|
||||||
|
the user to loop through the items within the paginated response.
|
||||||
|
|
||||||
|
PaginatedResponse uses a generic type variable TModel, which represents the
|
||||||
|
expected model type for the response data. This allows for type-safe handling
|
||||||
|
of model instances throughout the class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
response: Response,
|
||||||
|
response_model: type[TModel],
|
||||||
|
endpoint: IPaginateable,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
params: RequestParams | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
PaginatedResponse is a wrapper class for handling paginated responses from the
|
||||||
|
Huntress API. It provides methods for navigating through the pages of the response
|
||||||
|
and accessing the data contained within each page.
|
||||||
|
|
||||||
|
The class is designed to work with HuntressEndpoint and its derived classes to
|
||||||
|
parse the API response into model instances. It also supports iteration, allowing
|
||||||
|
the user to loop through the items within the paginated response.
|
||||||
|
|
||||||
|
PaginatedResponse uses a generic type variable TModel, which represents the
|
||||||
|
expected model type for the response data. This allows for type-safe handling
|
||||||
|
of model instances throughout the class.
|
||||||
|
"""
|
||||||
|
self._initialize(response, response_model, endpoint, page, page_size, params)
|
||||||
|
|
||||||
|
def _initialize( # noqa: ANN202
|
||||||
|
self,
|
||||||
|
response: Response,
|
||||||
|
response_model: type[TModel],
|
||||||
|
endpoint: IPaginateable,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
params: RequestParams | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the instance variables using the provided response, endpoint, and page size.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response: The raw response object from the API.
|
||||||
|
endpoint (HuntressEndpoint[TModel]): The endpoint associated with the response.
|
||||||
|
page_size (int): The number of items per page.
|
||||||
|
"""
|
||||||
|
self.response = response
|
||||||
|
self.response_model = response_model
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.page_size = page_size
|
||||||
|
# The following for SIEM is in the response body, not the headers
|
||||||
|
self.parsed_pagination_response = None #parse_link_headers(response.headers)
|
||||||
|
self.params = params
|
||||||
|
if self.parsed_pagination_response is not None:
|
||||||
|
# Huntress SIEM API gives us a handy response to parse for Pagination
|
||||||
|
self.has_next_page: bool = self.parsed_link_headers.get("has_next_page", False)
|
||||||
|
self.has_prev_page: bool = self.parsed_link_headers.get("has_prev_page", False)
|
||||||
|
self.first_page: int = self.parsed_link_headers.get("first_page", None)
|
||||||
|
self.prev_page: int = self.parsed_link_headers.get("prev_page", None)
|
||||||
|
self.next_page: int = self.parsed_link_headers.get("next_page", None)
|
||||||
|
self.last_page: int = self.parsed_link_headers.get("last_page", None)
|
||||||
|
else:
|
||||||
|
# Huntress Managed SAT might, haven't worked on this yet
|
||||||
|
self.has_next_page: bool = True
|
||||||
|
self.has_prev_page: bool = page > 1
|
||||||
|
self.first_page: int = 1
|
||||||
|
self.prev_page = page - 1 if page > 1 else 1
|
||||||
|
self.next_page = page + 1
|
||||||
|
self.last_page = 999999
|
||||||
|
self.data: list[TModel] = [response_model.model_validate(d) for d in response.json()]
|
||||||
|
self.has_data = self.data and len(self.data) > 0
|
||||||
|
self.index = 0
|
||||||
|
|
||||||
|
def get_next_page(self) -> PaginatedResponse[TModel]:
|
||||||
|
"""
|
||||||
|
Fetch the next page of the paginated response.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaginatedResponse[TModel]: The updated PaginatedResponse instance
|
||||||
|
with the data from the next page or None if there is no next page.
|
||||||
|
"""
|
||||||
|
if not self.has_next_page or not self.next_page:
|
||||||
|
self.has_data = False
|
||||||
|
return self
|
||||||
|
|
||||||
|
next_response = self.endpoint.paginated(self.next_page, self.page_size, self.params)
|
||||||
|
self._initialize(
|
||||||
|
next_response.response,
|
||||||
|
next_response.response_model,
|
||||||
|
next_response.endpoint,
|
||||||
|
self.next_page,
|
||||||
|
next_response.page_size,
|
||||||
|
self.params,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def get_previous_page(self) -> PaginatedResponse[TModel]:
|
||||||
|
"""
|
||||||
|
Fetch the next page of the paginated response.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaginatedResponse[TModel]: The updated PaginatedResponse instance
|
||||||
|
with the data from the next page or None if there is no next page.
|
||||||
|
"""
|
||||||
|
if not self.has_prev_page or not self.prev_page:
|
||||||
|
self.has_data = False
|
||||||
|
return self
|
||||||
|
|
||||||
|
prev_response = self.endpoint.paginated(self.prev_page, self.page_size, self.params)
|
||||||
|
self._initialize(
|
||||||
|
prev_response.response,
|
||||||
|
prev_response.response_model,
|
||||||
|
prev_response.endpoint,
|
||||||
|
self.prev_page,
|
||||||
|
prev_response.page_size,
|
||||||
|
self.params,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self) -> Iterable[TModel]: # noqa: A003
|
||||||
|
"""
|
||||||
|
Iterate through all items in the paginated response, across all pages.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
TModel: An instance of the model class for each item in the paginated response.
|
||||||
|
"""
|
||||||
|
while self.has_data:
|
||||||
|
yield from self.data
|
||||||
|
self.get_next_page()
|
||||||
|
|
||||||
|
def __iter__(self): # noqa: ANN204
|
||||||
|
"""
|
||||||
|
Implement the iterator protocol for the PaginatedResponse class.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaginatedResponse[TModel]: The current instance of the PaginatedResponse.
|
||||||
|
"""
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __dict__(self): # noqa: ANN204
|
||||||
|
"""
|
||||||
|
Implement the iterator protocol for the PaginatedResponse class.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PaginatedResponse[TModel]: The current instance of the PaginatedResponse.
|
||||||
|
"""
|
||||||
|
return self.data
|
||||||
|
|
||||||
|
def __next__(self): # noqa: ANN204
|
||||||
|
"""
|
||||||
|
Implement the iterator protocol by getting the next item in the data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TModel: The next item in the data.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
StopIteration: If there are no more items in the data.
|
||||||
|
"""
|
||||||
|
if self.index < len(self.data):
|
||||||
|
result = self.data[self.index]
|
||||||
|
self.index += 1
|
||||||
|
return result
|
||||||
|
else: # noqa: RET505
|
||||||
|
raise StopIteration
|
||||||
53
src/pyhuntress/types.py
Normal file
53
src/pyhuntress/types.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
from typing import Literal, TypeAlias
|
||||||
|
|
||||||
|
from typing_extensions import NotRequired, TypedDict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
Literals: TypeAlias = str | int | float | bool
|
||||||
|
JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | Literals | None
|
||||||
|
|
||||||
|
|
||||||
|
class Patch(TypedDict):
|
||||||
|
op: Literal["add"] | Literal["replace"] | Literal["remove"]
|
||||||
|
path: str
|
||||||
|
value: JSON
|
||||||
|
|
||||||
|
|
||||||
|
class HuntressSATRequestParams(TypedDict):
|
||||||
|
conditions: NotRequired[str]
|
||||||
|
childConditions: NotRequired[str]
|
||||||
|
customFieldConditions: NotRequired[str]
|
||||||
|
orderBy: NotRequired[str]
|
||||||
|
page: NotRequired[int]
|
||||||
|
pageSize: NotRequired[int]
|
||||||
|
fields: NotRequired[str]
|
||||||
|
columns: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
|
class HuntressSIEMRequestParams(TypedDict):
|
||||||
|
created_at_min: NotRequired[datetime]
|
||||||
|
created_at_max: NotRequired[datetime]
|
||||||
|
updated_at_min: NotRequired[datetime]
|
||||||
|
updated_at_min: NotRequired[datetime]
|
||||||
|
customFieldConditions: NotRequired[str]
|
||||||
|
page_token: NotRequired[str]
|
||||||
|
page: NotRequired[int]
|
||||||
|
limit: NotRequired[int]
|
||||||
|
organization_id: NotRequired[int]
|
||||||
|
platform: NotRequired[str]
|
||||||
|
status: NotRequired[str]
|
||||||
|
indicator_type: NotRequired[str]
|
||||||
|
severity: NotRequired[str]
|
||||||
|
platform: NotRequired[str]
|
||||||
|
agent_id: NotRequired[str]
|
||||||
|
type: NotRequired[str]
|
||||||
|
entity_id: NotRequired[int]
|
||||||
|
types: NotRequired[str]
|
||||||
|
statuses: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
|
GenericRequestParams: TypeAlias = dict[str, Literals]
|
||||||
|
RequestParams: TypeAlias = HuntressSATRequestParams | HuntressSIEMRequestParams | GenericRequestParams
|
||||||
|
PatchRequestData: TypeAlias = list[Patch]
|
||||||
|
RequestData: TypeAlias = JSON | PatchRequestData
|
||||||
|
RequestMethod: TypeAlias = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
||||||
0
src/pyhuntress/utils/__init__.py
Normal file
0
src/pyhuntress/utils/__init__.py
Normal file
0
src/pyhuntress/utils/experimental/__init__.py
Normal file
0
src/pyhuntress/utils/experimental/__init__.py
Normal file
166
src/pyhuntress/utils/experimental/condition.py
Normal file
166
src/pyhuntress/utils/experimental/condition.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
||||||
|
|
||||||
|
from pyhuntress.utils.naming import to_camel_case
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class ValueType(Enum):
|
||||||
|
STR = 1
|
||||||
|
INT = 2
|
||||||
|
DATETIME = 3
|
||||||
|
|
||||||
|
|
||||||
|
class Condition(Generic[T]):
|
||||||
|
def __init__(self: Condition[T]) -> None:
|
||||||
|
self._condition_string: str = ""
|
||||||
|
self._field = ""
|
||||||
|
|
||||||
|
def field(self: Condition[T], selector: Callable[[type[T]], Any]) -> Condition[T]:
|
||||||
|
field = ""
|
||||||
|
|
||||||
|
frame = inspect.currentframe()
|
||||||
|
try:
|
||||||
|
context = inspect.getframeinfo(frame.f_back).code_context
|
||||||
|
caller_lines = "".join([line.strip() for line in context])
|
||||||
|
m = re.search(r"field\s*\(([^)]+)\)", caller_lines)
|
||||||
|
if m:
|
||||||
|
caller_lines = m.group(1)
|
||||||
|
|
||||||
|
field = to_camel_case("/".join(caller_lines.replace("(", "").replace(")", "").split(".")[1:]))
|
||||||
|
|
||||||
|
finally:
|
||||||
|
del frame
|
||||||
|
|
||||||
|
self._condition_string += field
|
||||||
|
return self
|
||||||
|
|
||||||
|
def equals(self: Condition[T], value: Any) -> Condition[T]: # noqa: ANN401
|
||||||
|
self._condition_string += " = "
|
||||||
|
self.__add_typed_value_to_string(value, type(value))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def not_equals(self: Condition[T], value: Any) -> Condition[T]: # noqa: ANN401
|
||||||
|
self._condition_string += " = "
|
||||||
|
self.__add_typed_value_to_string(value, type(value))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def less_than(self: Condition[T], value: Any) -> Condition[T]: # noqa: ANN401
|
||||||
|
self._condition_string += " < "
|
||||||
|
self.__add_typed_value_to_string(value, type(value))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def less_than_or_equals(
|
||||||
|
self: Condition[T],
|
||||||
|
value: Any, # noqa: ANN401
|
||||||
|
) -> Condition[T]:
|
||||||
|
self._condition_string += " <= "
|
||||||
|
self.__add_typed_value_to_string(value, type(value))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def greater_than(self: Condition[T], value: Any) -> Condition[T]: # noqa: ANN401
|
||||||
|
self._condition_string += " > "
|
||||||
|
self.__add_typed_value_to_string(value, type(value))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def greater_than_or_equals(
|
||||||
|
self: Condition[T],
|
||||||
|
value: Any, # noqa: ANN401
|
||||||
|
) -> Condition[T]:
|
||||||
|
self._condition_string += " >= "
|
||||||
|
self.__add_typed_value_to_string(value, type(value))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def contains(self: Condition[T], value: Any) -> Condition[T]: # noqa: ANN401
|
||||||
|
self._condition_string += " CONTAINS "
|
||||||
|
self.__add_typed_value_to_string(value, type(value))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def like(self: Condition[T], value: Any) -> Condition[T]: # noqa: ANN401
|
||||||
|
self._condition_string += " LIKE "
|
||||||
|
self.__add_typed_value_to_string(value, type(value))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def in_(self: Condition[T], value: Any) -> Condition[T]: # noqa: ANN401
|
||||||
|
self._condition_string += " IN "
|
||||||
|
self.__add_typed_value_to_string(value, type(value))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def not_(self: Condition[T], value: Any) -> Condition[T]: # noqa: ANN401
|
||||||
|
self._condition_string += " NOT "
|
||||||
|
self.__add_typed_value_to_string(value, type(value))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __add_typed_value_to_string( # noqa: ANN202
|
||||||
|
self: Condition[T],
|
||||||
|
value: Any, # noqa: ANN401
|
||||||
|
type: type, # noqa: A002
|
||||||
|
):
|
||||||
|
if type is str:
|
||||||
|
self._condition_string += f'"{value}"'
|
||||||
|
elif type is int: # noqa: SIM114
|
||||||
|
self._condition_string += str(value)
|
||||||
|
elif type is bool:
|
||||||
|
self._condition_string += str(value)
|
||||||
|
elif type is datetime:
|
||||||
|
self._condition_string += f"[{value}]"
|
||||||
|
else:
|
||||||
|
self._condition_string += f'"{value}"'
|
||||||
|
|
||||||
|
def and_(self: Condition[T], selector: Callable[[type[T]], Any] | None = None) -> Condition[T]:
|
||||||
|
self._condition_string += " AND "
|
||||||
|
|
||||||
|
if selector is not None:
|
||||||
|
field = ""
|
||||||
|
frame = inspect.currentframe()
|
||||||
|
try:
|
||||||
|
context = inspect.getframeinfo(frame.f_back).code_context
|
||||||
|
caller_lines = "".join([line.strip() for line in context])
|
||||||
|
m = re.search(r"and_\s*\(([^)]+)\)", caller_lines)
|
||||||
|
if m:
|
||||||
|
caller_lines = m.group(1)
|
||||||
|
|
||||||
|
field = "/".join(caller_lines.replace("(", "").replace(")", "").split(".")[1:])
|
||||||
|
|
||||||
|
finally:
|
||||||
|
del frame
|
||||||
|
|
||||||
|
self._condition_string += field
|
||||||
|
return self
|
||||||
|
|
||||||
|
def or_(self: Condition[T], selector: Callable[[type[T]], Any] | None = None) -> Condition[T]:
|
||||||
|
self._condition_string += " OR "
|
||||||
|
|
||||||
|
if selector is not None:
|
||||||
|
field = ""
|
||||||
|
frame = inspect.currentframe()
|
||||||
|
try:
|
||||||
|
context = inspect.getframeinfo(frame.f_back).code_context
|
||||||
|
caller_lines = "".join([line.strip() for line in context])
|
||||||
|
m = re.search(r"or_\s*\(([^)]+)\)", caller_lines)
|
||||||
|
if m:
|
||||||
|
caller_lines = m.group(1)
|
||||||
|
|
||||||
|
field = "/".join(caller_lines.replace("(", "").replace(")", "").split(".")[1:])
|
||||||
|
|
||||||
|
finally:
|
||||||
|
del frame
|
||||||
|
|
||||||
|
self._condition_string += field
|
||||||
|
return self
|
||||||
|
|
||||||
|
def wrap(self: Condition[T], condition: Callable[[Condition[T]], Condition[T]]) -> Condition[T]:
|
||||||
|
self._condition_string += f"({condition(Condition[T]())})"
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __str__(self: Condition[T]) -> str:
|
||||||
|
return self._condition_string.strip()
|
||||||
37
src/pyhuntress/utils/experimental/patch_maker.py
Normal file
37
src/pyhuntress/utils/experimental/patch_maker.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import json
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class Patch:
|
||||||
|
class PatchOp(Enum):
|
||||||
|
"""
|
||||||
|
PatchOperation is an enumeration of the different patch operations supported
|
||||||
|
by the Huntress API. These operations are ADD, REPLACE, and REMOVE.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ADD = 1
|
||||||
|
REPLACE = 2
|
||||||
|
REMOVE = 3
|
||||||
|
|
||||||
|
def __init__(self, op: PatchOp, path: str, value: Any) -> None: # noqa: ANN401
|
||||||
|
self.op = op.name.lower()
|
||||||
|
self.path = path
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""
|
||||||
|
Return a string representation of the model as a formatted JSON string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: A formatted JSON string representation of the model.
|
||||||
|
"""
|
||||||
|
return json.dumps(self.__dict__, default=str, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
class PatchGroup:
|
||||||
|
def __init__(self, *patches: Patch) -> None:
|
||||||
|
self.patches = list(patches)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return str(self.patches)
|
||||||
101
src/pyhuntress/utils/helpers.py
Normal file
101
src/pyhuntress/utils/helpers.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from requests.structures import CaseInsensitiveDict
|
||||||
|
|
||||||
|
|
||||||
|
def cw_format_datetime(dt: datetime) -> str:
|
||||||
|
"""Format a datetime object as a string in ISO 8601 format. This is the format that Huntress uses.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt (datetime): The datetime object to be formatted.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The formatted datetime string in the format "YYYY-MM-DDTHH:MM:SSZ".
|
||||||
|
|
||||||
|
Example:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
dt = datetime(2022, 1, 1, 12, 0, 0)
|
||||||
|
formatted_dt = cw_format_datetime(dt)
|
||||||
|
print(formatted_dt) # Output: "2022-01-01T12:00:00Z"
|
||||||
|
"""
|
||||||
|
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_link_headers( # noqa: C901
|
||||||
|
headers: CaseInsensitiveDict,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Parses link headers to extract pagination information.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
- headers: A dictionary containing the headers of an HTTP response. The value associated with the "Link" key should be a string representing the link headers.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- A dictionary containing the extracted pagination information. The keys in the dictionary include:
|
||||||
|
- "first_page": An optional integer representing the number of the first page.
|
||||||
|
- "prev_page": An optional integer representing the number of the previous page.
|
||||||
|
- "next_page": An optional integer representing the number of the next page.
|
||||||
|
- "last_page": An optional integer representing the number of the last page.
|
||||||
|
- "has_next_page": A boolean indicating whether there is a next page.
|
||||||
|
- "has_prev_page": A boolean indicating whether there is a previous page.
|
||||||
|
|
||||||
|
If the "Link" header is not present in the headers dictionary, None is returned.
|
||||||
|
|
||||||
|
Example Usage:
|
||||||
|
headers = {
|
||||||
|
"Link": '<https://example.com/api?page=1>; rel="first", <https://example.com/api?page=2>; rel="next"'
|
||||||
|
}
|
||||||
|
pagination_info = parse_link_headers(headers)
|
||||||
|
print(pagination_info)
|
||||||
|
# Output: {'first_page': 1, 'next_page': 2, 'has_next_page': True}
|
||||||
|
"""
|
||||||
|
if headers.get("Link") is None:
|
||||||
|
return None
|
||||||
|
links = headers["Link"].split(",")
|
||||||
|
has_next_page: bool = False
|
||||||
|
has_prev_page: bool = False
|
||||||
|
first_page: int | None = None
|
||||||
|
prev_page: int | None = None
|
||||||
|
next_page: int | None = None
|
||||||
|
last_page: int | None = None
|
||||||
|
|
||||||
|
for link in links:
|
||||||
|
match = re.search(r'page=(\d+)>; rel="(.*?)"', link)
|
||||||
|
if match:
|
||||||
|
page_number = int(match.group(1))
|
||||||
|
rel_value = match.group(2)
|
||||||
|
if rel_value == "first":
|
||||||
|
first_page = page_number
|
||||||
|
elif rel_value == "prev":
|
||||||
|
prev_page = page_number
|
||||||
|
has_prev_page = True
|
||||||
|
elif rel_value == "next":
|
||||||
|
next_page = page_number
|
||||||
|
has_next_page = True
|
||||||
|
elif rel_value == "last":
|
||||||
|
last_page = page_number
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
if first_page is not None:
|
||||||
|
result["first_page"] = first_page
|
||||||
|
|
||||||
|
if prev_page is not None:
|
||||||
|
result["prev_page"] = prev_page
|
||||||
|
|
||||||
|
if next_page is not None:
|
||||||
|
result["next_page"] = next_page
|
||||||
|
|
||||||
|
if last_page is not None:
|
||||||
|
result["last_page"] = last_page
|
||||||
|
|
||||||
|
if has_next_page:
|
||||||
|
result["has_next_page"] = has_next_page
|
||||||
|
|
||||||
|
if has_prev_page:
|
||||||
|
result["has_prev_page"] = has_prev_page
|
||||||
|
|
||||||
|
return result
|
||||||
23
src/pyhuntress/utils/naming.py
Normal file
23
src/pyhuntress/utils/naming.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from keyword import iskeyword
|
||||||
|
|
||||||
|
|
||||||
|
def to_snake_case(string: str) -> str:
|
||||||
|
return ("_" if string.startswith("_") else "") + "".join(
|
||||||
|
["_" + i.lower() if i.isupper() else i for i in string.lstrip("_")]
|
||||||
|
).lstrip("_")
|
||||||
|
|
||||||
|
|
||||||
|
def to_camel_case(string: str) -> str:
|
||||||
|
string_split = string.split("_")
|
||||||
|
return string_split[0] + "".join(word.capitalize() for word in string_split[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def to_title_case_preserve_case(string: str) -> str:
|
||||||
|
return string[:1].upper() + string[1:]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_not_reserved(string: str) -> str:
|
||||||
|
if iskeyword(string):
|
||||||
|
return string + "_"
|
||||||
|
else: # noqa: RET505
|
||||||
|
return string
|
||||||
43
src/scratchpad.py
Normal file
43
src/scratchpad.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import os
|
||||||
|
from pyhuntress import HuntressSIEMAPIClient
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
siem_url = os.getenv('siem_url')
|
||||||
|
publickey = os.getenv('publickey')
|
||||||
|
privatekey = os.getenv('privatekey')
|
||||||
|
|
||||||
|
# init client
|
||||||
|
siem_api_client = HuntressSIEMAPIClient(
|
||||||
|
siem_url,
|
||||||
|
publickey,
|
||||||
|
privatekey,
|
||||||
|
)
|
||||||
|
|
||||||
|
#account = siem_api_client.account.get()
|
||||||
|
#print(account)
|
||||||
|
|
||||||
|
#actor = siem_api_client.actor.get()
|
||||||
|
#print(actor)
|
||||||
|
|
||||||
|
#agents = siem_api_client.agents.get()
|
||||||
|
#print(agents)
|
||||||
|
|
||||||
|
billingreports = siem_api_client.billing_reports.get()
|
||||||
|
print(billingreports)
|
||||||
|
|
||||||
|
incidentreports = siem_api_client.incident_reports.get()
|
||||||
|
print(incidentreports)
|
||||||
|
|
||||||
|
organizations = siem_api_client.organizations.get()
|
||||||
|
print(organizations)
|
||||||
|
|
||||||
|
reports = siem_api_client.reports.get()
|
||||||
|
print(reports)
|
||||||
|
|
||||||
|
signals = siem_api_client.signals.get()
|
||||||
|
print(signals)
|
||||||
|
|
||||||
|
#paginated_agents = siem_api_client.agents.paginated(1, 10)
|
||||||
|
#print(paginated_agents)
|
||||||
Loading…
x
Reference in New Issue
Block a user