diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml new file mode 100644 index 0000000..b143a53 --- /dev/null +++ b/.github/workflows/pythonpublish.yml @@ -0,0 +1,26 @@ +name: Upload Python Package + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install setuptools wheel twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python setup.py sdist bdist_wheel + twine upload dist/* diff --git a/LICENSE b/LICENSE index e561eb0..75560dc 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 gophish +Copyright (c) 2020 gophish Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 4a0ca2e..e19d735 100644 --- a/README.md +++ b/README.md @@ -29,4 +29,4 @@ Now you're ready to start using the API! ## Full Documentation -You can find the full Python client documentation [here.](https://gophish.gitbooks.io/python-api-client/content/) +You can find the full Python client documentation [here.](https://docs.getgophish.com/python-api-client/) diff --git a/gophish/api/api.py b/gophish/api/api.py index b266b7e..3dbea14 100644 --- a/gophish/api/api.py +++ b/gophish/api/api.py @@ -11,7 +11,6 @@ class APIEndpoint(object): Represents an API endpoint for Gophish, containing common patterns for CRUD operations. """ - def __init__(self, api, endpoint=None, cls=None): """ Creates an instance of the APIEndpoint class. @@ -24,6 +23,47 @@ def __init__(self, api, endpoint=None, cls=None): self.endpoint = endpoint self._cls = cls + def _build_url(self, *parts): + """Builds a path to an API resource by joining the individual parts + with a slash (/). + + This is used instead of urljoin since we're given relative URL parts + which need to be chained together. + + Returns: + str -- The parts joined with a slash + """ + + return '/'.join(str(part).rstrip('/') for part in parts) + + def request(self, + method, + body=None, + resource_id=None, + resource_action=None, + resource_cls=None, + single_resource=False): + + endpoint = self.endpoint + + if not resource_cls: + resource_cls = self._cls + + if resource_id: + endpoint = self._build_url(endpoint, resource_id) + + if resource_action: + endpoint = self._build_url(endpoint, resource_action) + + response = self.api.execute(method, endpoint, json=body) + if not response.ok: + raise Error.parse(response.json()) + + if resource_id or single_resource: + return resource_cls.parse(response.json()) + + return [resource_cls.parse(resource) for resource in response.json()] + def get(self, resource_id=None, resource_action=None, @@ -45,25 +85,11 @@ def get(self, One or more instances of cls parsed from the returned JSON """ - endpoint = self.endpoint - - if not resource_cls: - resource_cls = self._cls - - if resource_id: - endpoint = '{}/{}'.format(endpoint, resource_id) - - if resource_action: - endpoint = '{}/{}'.format(endpoint, resource_action) - - response = self.api.execute("GET", endpoint) - if not response.ok: - return Error.parse(response.json()) - - if resource_id or single_resource: - return resource_cls.parse(response.json()) - - return [resource_cls.parse(resource) for resource in response.json()] + return self.request("GET", + resource_id=resource_id, + resource_action=resource_action, + resource_cls=resource_cls, + single_resource=single_resource) def post(self, resource): """ Creates a new instance of the resource. @@ -72,11 +98,12 @@ def post(self, resource): resource - gophish.models.Model - The resource instance """ - response = self.api.execute( - "POST", self.endpoint, json=(resource.as_dict())) + response = self.api.execute("POST", + self.endpoint, + json=(resource.as_dict())) if not response.ok: - return Error.parse(response.json()) + raise Error.parse(response.json()) return self._cls.parse(response.json()) @@ -90,12 +117,12 @@ def put(self, resource): endpoint = self.endpoint if resource.id: - endpoint = '{}/{}'.format(endpoint, resource.id) + endpoint = self._build_url(endpoint, resource.id) - response = self.api.execute("PUT", endpoint, json=resource.as_json()) + response = self.api.execute("PUT", endpoint, json=resource.as_dict()) if not response.ok: - return Error.parse(response.json()) + raise Error.parse(response.json()) return self._cls.parse(response.json()) @@ -111,6 +138,6 @@ def delete(self, resource_id): response = self.api.execute("DELETE", endpoint) if not response.ok: - return Error.parse(response.json()) + raise Error.parse(response.json()) return self._cls.parse(response.json()) diff --git a/gophish/api/campaigns.py b/gophish/api/campaigns.py index 2401647..3827ec8 100644 --- a/gophish/api/campaigns.py +++ b/gophish/api/campaigns.py @@ -4,7 +4,7 @@ class API(APIEndpoint): - def __init__(self, api, endpoint='/api/campaigns/'): + def __init__(self, api, endpoint='api/campaigns/'): """ Creates a new instance of the campaigns API """ super(API, self).__init__(api, endpoint=endpoint, cls=Campaign) diff --git a/gophish/api/groups.py b/gophish/api/groups.py index 63ecf76..fb3fea9 100644 --- a/gophish/api/groups.py +++ b/gophish/api/groups.py @@ -3,7 +3,7 @@ class API(APIEndpoint): - def __init__(self, api, endpoint='/api/groups/'): + def __init__(self, api, endpoint='api/groups/'): super(API, self).__init__(api, endpoint=endpoint, cls=Group) def get(self, group_id=None): diff --git a/gophish/api/imap.py b/gophish/api/imap.py new file mode 100644 index 0000000..a7a8e08 --- /dev/null +++ b/gophish/api/imap.py @@ -0,0 +1,34 @@ +from gophish.models import IMAP, Success +from gophish.api import APIEndpoint + + +class API(APIEndpoint): + def __init__(self, api, endpoint='api/imap/'): + super(API, self).__init__(api, endpoint=endpoint, cls=IMAP) + + def get(self): + """Gets the configured IMAP settings + """ + + return super(API, self).get() + + def post(self, imap): + """Updates the IMAP settings + + Arguments: + imap {gophish.models.IMAP} -- The IMAP settings to configure + """ + + return super(API, self).post(imap) + + def validate(self, imap): + """Sends a validation payload to the webhook specified by the given ID + + Arguments: + webhook_id {int} -- The ID of the webhook to validate + """ + return self.request("POST", + body=imap.as_dict(), + resource_action='validate', + resource_cls=Success, + single_resource=True) diff --git a/gophish/api/pages.py b/gophish/api/pages.py index 46ec3fb..00e3bad 100644 --- a/gophish/api/pages.py +++ b/gophish/api/pages.py @@ -3,7 +3,7 @@ class API(APIEndpoint): - def __init__(self, api, endpoint='/api/pages/'): + def __init__(self, api, endpoint='api/pages/'): super(API, self).__init__(api, endpoint=endpoint, cls=Page) def get(self, page_id=None): @@ -19,7 +19,7 @@ def post(self, page): def put(self, page): """ Edits a page """ - return super(API, self).put(put) + return super(API, self).put(page) def delete(self, page_id): """ Deletes a page by ID """ diff --git a/gophish/api/smtp.py b/gophish/api/smtp.py index 13cdf67..2abf509 100644 --- a/gophish/api/smtp.py +++ b/gophish/api/smtp.py @@ -3,7 +3,7 @@ class API(APIEndpoint): - def __init__(self, api, endpoint='/api/smtp/'): + def __init__(self, api, endpoint='api/smtp/'): super(API, self).__init__(api, endpoint=endpoint, cls=SMTP) def get(self, smtp_id=None): diff --git a/gophish/api/templates.py b/gophish/api/templates.py index 6acd355..a2a4863 100644 --- a/gophish/api/templates.py +++ b/gophish/api/templates.py @@ -3,7 +3,7 @@ class API(APIEndpoint): - def __init__(self, api, endpoint='/api/templates/'): + def __init__(self, api, endpoint='api/templates/'): super(API, self).__init__(api, endpoint=endpoint, cls=Template) def get(self, template_id=None): diff --git a/gophish/api/webhooks.py b/gophish/api/webhooks.py new file mode 100644 index 0000000..84c067d --- /dev/null +++ b/gophish/api/webhooks.py @@ -0,0 +1,54 @@ +from gophish.models import Webhook +from gophish.api import APIEndpoint + + +class API(APIEndpoint): + def __init__(self, api, endpoint='api/webhooks/'): + super(API, self).__init__(api, endpoint=endpoint, cls=Webhook) + + def get(self, webhook_id=None): + """Gets one or more webhooks + + Keyword Arguments: + webhook_id {int} -- The ID of the Webhook (optional, default: {None}) + """ + + return super(API, self).get(resource_id=webhook_id) + + def post(self, webhook): + """Creates a new webhook + + Arguments: + webhook {gophish.models.Webhook} -- The webhook to create + """ + + return super(API, self).post(webhook) + + def put(self, webhook): + """Edits a webhook + + Arguments: + webhook {gophish.models.Webhook} -- The updated webhook details + """ + + return super(API, self).put(webhook) + + def delete(self, webhook_id): + """Deletes a webhook by ID + + Arguments: + webhook_id {int} -- The ID of the webhook to delete + """ + return super(API, self).delete(webhook_id) + + def validate(self, webhook_id): + """Sends a validation payload to the webhook specified by the given ID + + Arguments: + webhook_id {int} -- The ID of the webhook to validate + """ + return self.request("POST", + resource_id=webhook_id, + resource_action='validate', + resource_cls=Webhook, + single_resource=True) diff --git a/gophish/client.py b/gophish/client.py index 586c6b9..64d9c9f 100644 --- a/gophish/client.py +++ b/gophish/client.py @@ -1,16 +1,19 @@ import requests -from gophish.api import (campaigns, groups, pages, smtp, templates) +from gophish.api import (campaigns, groups, imap, pages, smtp, templates, + webhooks) -DEFAULT_URL = 'http://localhost:3333' +DEFAULT_URL = 'https://localhost:3333' class GophishClient(object): """ A standard HTTP REST client used by Gophish """ - def __init__(self, api_key, host=DEFAULT_URL, **kwargs): self.api_key = api_key - self.host = host + if host.endswith('/'): + self.host = host + else: + self.host = host + '/' self._client_kwargs = kwargs def execute(self, method, path, **kwargs): @@ -19,7 +22,10 @@ def execute(self, method, path, **kwargs): url = "{}{}".format(self.host, path) kwargs.update(self._client_kwargs) response = requests.request( - method, url, params={"api_key": self.api_key}, **kwargs) + method, + url, + headers={"Authorization": "Bearer {}".format(self.api_key)}, + **kwargs) return response @@ -32,6 +38,8 @@ def __init__(self, self.client = client(api_key, host=host, **kwargs) self.campaigns = campaigns.API(self.client) self.groups = groups.API(self.client) + self.imap = imap.API(self.client) self.pages = pages.API(self.client) self.smtp = smtp.API(self.client) self.templates = templates.API(self.client) + self.webhooks = webhooks.API(self.client) diff --git a/gophish/models.py b/gophish/models.py index 87b2297..f1a5044 100644 --- a/gophish/models.py +++ b/gophish/models.py @@ -14,6 +14,10 @@ class Model(object): def __init__(self): self._valid_properties = {} + @classmethod + def _is_builtin(cls, obj): + return isinstance(obj, (int, float, str, list, dict, bool)) + def as_dict(self): """ Returns a dict representation of the resource """ result = {} @@ -22,11 +26,19 @@ def as_dict(self): if isinstance(val, datetime): val = val.isoformat() # Parse custom classes - elif val and not isinstance(val, (int, float, str, list, dict)): + elif val and not Model._is_builtin(val): val = val.as_dict() # Parse lists of objects elif isinstance(val, list): - val = [e.as_dict() for e in val] + # We only want to call as_dict in the case where the item + # isn't a builtin type. + for i in range(len(val)): + if Model._is_builtin(val[i]): + continue + val[i] = val[i].as_dict() + # If it's a boolean, add it regardless of the value + elif isinstance(val, bool): + result[key] = val # Add it if it's not None if val: @@ -45,6 +57,7 @@ class Campaign(Model): 'name': None, 'created_date': datetime.now(tzlocal()), 'launch_date': datetime.now(tzlocal()), + 'send_by_date': None, 'completed_date': None, 'template': None, 'page': None, @@ -54,7 +67,6 @@ class Campaign(Model): 'smtp': None, 'url': None, 'groups': [], - 'profile': None } def __init__(self, **kwargs): @@ -114,6 +126,7 @@ class CampaignSummary(Model): 'name': None, 'status': None, 'created_date': None, + 'send_by_date': None, 'launch_date': None, 'completed_date': None, 'stats': None @@ -143,6 +156,7 @@ class Stat(Model): 'opened': None, 'clicked': None, 'submitted_data': None, + 'email_reported': None, 'error': None } @@ -299,9 +313,12 @@ class SMTP(Model): 'interface_type': 'SMTP', 'name': None, 'host': None, + 'username': None, + 'password': None, 'from_address': None, 'ignore_cert_errors': False, - 'modified_date': datetime.now(tzlocal()) + 'modified_date': datetime.now(tzlocal()), + 'headers': [] } def __init__(self, **kwargs): @@ -340,7 +357,7 @@ def parse(cls, json): for key, val in json.items(): if key == 'modified_date': setattr(template, key, parse_date(val)) - elif key == 'attachments': + elif key == 'attachments' and val: attachments = [ Attachment.parse(attachment) for attachment in val ] @@ -388,8 +405,86 @@ def parse(cls, json): return attachment -class Error(Model): - _valid_properties = {'message', 'success', 'data'} +class Webhook(Model): + _valid_properties = { + 'id': None, + 'name': None, + 'url': None, + 'secret': None, + 'is_active': None + } + + def __init__(self, **kwargs): + for key, default in Webhook._valid_properties.items(): + setattr(self, key, kwargs.get(key, default)) + + @classmethod + def parse(cls, json): + webhook = cls() + for key, val in json.items(): + if key in cls._valid_properties: + setattr(webhook, key, val) + return webhook + + +class IMAP(Model): + _valid_properties = { + 'enabled': None, + 'host': None, + 'port': None, + 'username': None, + 'password': None, + 'tls': None, + 'folder': None, + 'restrict_domain': None, + 'delete_reported_campaign_email': None, + 'last_login': None, + 'modified_date': None, + 'imap_freq': None + } + + def __init__(self, **kwargs): + for key, default in IMAP._valid_properties.items(): + setattr(self, key, kwargs.get(key, default)) + + @classmethod + def parse(cls, json): + imap = cls() + for key, val in json.items(): + if key in cls._valid_properties: + setattr(imap, key, val) + return imap + + +class Success(Exception, Model): + _valid_properties = {'message': None, 'success': None, 'data': None} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def __str__(self): + return self.message + + @classmethod + def parse(cls, json): + success = cls() + for key, val in json.items(): + if key in cls._valid_properties: + setattr(success, key, val) + return success + + +class Error(Exception, Model): + _valid_properties = {'message': None, 'success': None, 'data': None} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def __str__(self): + return self.message + + def __repr__(self): + return _json.dumps(self.as_dict()) @classmethod def parse(cls, json): diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index f3261e4..0000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -appdirs==1.4.0 -packaging==16.8 -pyparsing==2.1.10 -python-dateutil==2.6.0 -requests==2.12.5 -six==1.10.0 diff --git a/setup.py b/setup.py index 7a7cac6..9fc8a07 100644 --- a/setup.py +++ b/setup.py @@ -1,23 +1,36 @@ +"""This is the setup module for the Python Gophish API client.""" from setuptools import setup setup( - name='gophish', - packages=['gophish', 'gophish.api'], - version='0.1.5', - description='Python API Client for Gophish', - author='Jordan Wright', - author_email='python@getgophish.com', - url='https://github.com/gophish/api-client-python', - license='MIT', - download_url='https://github.com/gophish/api-client-python/tarball/0.1.3', - keywords=['gophish'], + name="gophish", + packages=["gophish", "gophish.api"], + version="0.5.1", + description="Python API Client for Gophish", + author="Jordan Wright", + author_email="python@getgophish.com", + url="https://github.com/gophish/api-client-python", + license="MIT", + download_url="https://github.com/gophish/api-client-python/tarball/0.5.1", + keywords=["gophish"], classifiers=[ - 'Development Status :: 3 - Alpha', - 'Intended Audience :: Developers', - 'Natural Language :: English', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Natural Language :: English", + "Programming Language :: Python", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.3", + ], + install_requires=[ + "appdirs==1.4.4", + "certifi==2020.6.20", + "chardet==3.0.4", + "idna==2.10", + "packaging==20.4", + "pyparsing==2.4.7", + "python-dateutil==2.8.1", + "requests==2.24.0", + "six==1.15.0", + "urllib3==1.25.10" ], )