From a29ced97253eb7ec6407caef6710b0a1731687bd Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Sat, 4 Jan 2025 22:10:08 +0000 Subject: [PATCH 01/36] duo: mvp --- checks.sh | 6 +- src/synack/plugins/api.py | 30 +++-- src/synack/plugins/auth.py | 221 +++++++++++++++++++++++++++++++++++-- 3 files changed, 237 insertions(+), 20 deletions(-) diff --git a/checks.sh b/checks.sh index 6fa3e08..b14fab7 100755 --- a/checks.sh +++ b/checks.sh @@ -59,6 +59,6 @@ for doc in ./docs/src/usage/plugins/*.md; do fi done -coverage run --source=src --omit=src/synack/db/alembic/env.py,src/synack/db/alembic/versions/*.py -m unittest discover test -coverage report | egrep -v "^[^T].*100%" -coverage html +python3-coverage run --source=src --omit=src/synack/db/alembic/env.py,src/synack/db/alembic/versions/*.py -m unittest discover test +python3-coverage report | egrep -v "^[^T].*100%" +python3-coverage html diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index 2d8fd2e..a1f06fa 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -62,7 +62,7 @@ def notifications(self, method, path, **kwargs): self.db.notifications_token = "" return res - def request(self, method, path, **kwargs): + def request(self, method, path, include_std_headers=True, **kwargs): """Send API Request Arguments: @@ -88,10 +88,13 @@ def request(self, method, path, **kwargs): verify = True proxies = None - headers = { - 'Authorization': f'Bearer {self.db.api_token}', - 'user_id': self.db.user_id - } + if include_std_headers: + headers = { + 'Authorization': f'Bearer {self.db.api_token}', + 'user_id': self.db.user_id + } + else: + headers = dict() if kwargs.get('headers'): headers.update(kwargs.get('headers', {})) query = kwargs.get('query') @@ -116,11 +119,18 @@ def request(self, method, path, **kwargs): proxies=proxies, verify=verify) elif method.upper() == 'POST': - res = self.state.session.post(url, - json=data, - headers=headers, - proxies=proxies, - verify=verify) + if 'urlencoded' in headers.get('Content-Type', ''): + res = self.state.session.post(url, + data=data, + headers=headers, + proxies=proxies, + verify=verify) + else: + res = self.state.session.post(url, + json=data, + headers=headers, + proxies=proxies, + verify=verify) elif method.upper() == 'PUT': res = self.state.session.put(url, headers=headers, diff --git a/src/synack/plugins/auth.py b/src/synack/plugins/auth.py index d9cec6a..04c7cda 100644 --- a/src/synack/plugins/auth.py +++ b/src/synack/plugins/auth.py @@ -5,6 +5,7 @@ import pyotp import re +import time from .base import Plugin @@ -31,11 +32,14 @@ def get_api_token(self): return self.db.api_token csrf = self.get_login_csrf() progress_token = None + duo_auth_url = None grant_token = None if csrf: - progress_token = self.get_login_progress_token(csrf) - if progress_token: - grant_token = self.get_login_grant_token(csrf, progress_token) + auth_response = self.get_authentication_response(csrf) + progress_token = auth_response.get('progress_token') + duo_auth_url = auth_response.get('duo_auth_url') + if duo_auth_url: + grant_token = self.get_duo_push(duo_auth_url) if grant_token: url = 'https://platform.synack.com/' headers = { @@ -61,13 +65,212 @@ def get_login_csrf(self): res.text) return m.group(1) + def get_duo_push(self, duo_auth_url): + """Make Duo send a push notification""" + headers = { + 'Sec-Ch-Ua': '"Chromium";v="131", "Not_A Brand";v="24"', + 'Sec-Ch-Ua-Mobile': '?0', + 'Sec-Ch-Ua-Platform': '"Linux"', + 'Referrer': 'https://login.synack.com/', + 'Sec-Fetch-Site': 'cross-site', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-User': '?1', + 'Sec-Fetch-Dest': 'document' + } + res = self.api.request('GET', duo_auth_url, include_std_headers=False) + headers = { + 'Sec-Ch-Ua': '"Chromium";v="131", "Not_A Brand";v="24"', + 'Sec-Ch-Ua-Mobile': '?0', + 'Sec-Ch-Ua-Platform': '"Linux"', + 'Referer': res.url, + 'Sec-Fetch-Site': 'same-origin', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Dest': 'document', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Content-Type': 'application/x-www-form-urlencoded' + } + data = { + 'tx': re.search(' Date: Sat, 4 Jan 2025 23:55:11 +0000 Subject: [PATCH 02/36] duo: slight refactor, still terrible --- src/synack/plugins/auth.py | 221 ++++++++++++++++++------------------- src/synack/plugins/db.py | 4 +- 2 files changed, 112 insertions(+), 113 deletions(-) diff --git a/src/synack/plugins/auth.py b/src/synack/plugins/auth.py index 04c7cda..d6f89b8 100644 --- a/src/synack/plugins/auth.py +++ b/src/synack/plugins/auth.py @@ -65,8 +65,7 @@ def get_login_csrf(self): res.text) return m.group(1) - def get_duo_push(self, duo_auth_url): - """Make Duo send a push notification""" + def get_duo_push_variables(self, duo_auth_url): headers = { 'Sec-Ch-Ua': '"Chromium";v="131", "Not_A Brand";v="24"', 'Sec-Ch-Ua-Mobile': '?0', @@ -78,191 +77,191 @@ def get_duo_push(self, duo_auth_url): 'Sec-Fetch-Dest': 'document' } res = self.api.request('GET', duo_auth_url, include_std_headers=False) + if res.status_code == 200: + return { + 'post_data': { + 'tx': re.search(' Date: Mon, 6 Jan 2025 21:11:33 +0000 Subject: [PATCH 03/36] alerts: fixed slack --- setup.py | 18 ++++++------ .../f627018b273f_add_slack_app_variables.py | 28 +++++++++++++++++++ src/synack/db/models/category.py | 0 src/synack/db/models/config.py | 2 ++ src/synack/db/models/organization.py | 0 src/synack/db/models/target.py | 0 src/synack/plugins/alerts.py | 21 +++++++++++--- src/synack/plugins/db.py | 18 ++++++++++++ 8 files changed, 74 insertions(+), 13 deletions(-) create mode 100644 src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py mode change 100755 => 100644 src/synack/db/models/category.py mode change 100755 => 100644 src/synack/db/models/config.py mode change 100755 => 100644 src/synack/db/models/organization.py mode change 100755 => 100644 src/synack/db/models/target.py diff --git a/setup.py b/setup.py index ce5bf3b..9537d4b 100755 --- a/setup.py +++ b/setup.py @@ -26,14 +26,14 @@ }, package_dir={'': 'src'}, install_requires=[ - "alembic==1.8.1", - "netaddr==0.8.0", - "pathlib2==2.3.6", - "psycopg2-binary==2.9.5", - "pyaml==21.10.1", - "pyotp==2.7.0", - "requests==2.28.1", - "SQLAlchemy==1.4.44", - "urllib3==1.26.13", + "alembic>=1.8.1", + "netaddr>=0.8.0", + "pathlib2>=2.3.6", + "psycopg2-binary>=2.9.5", + "pyaml>=21.10.1", + "pyotp>=2.7.0", + "requests>=2.28.1", + "SQLAlchemy>=1.4.44", + "urllib3>=1.26.13", ] ) diff --git a/src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py b/src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py new file mode 100644 index 0000000..b8b5db9 --- /dev/null +++ b/src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py @@ -0,0 +1,28 @@ +"""add slack app variables + +Revision ID: f627018b273f +Revises: 349c447c0d37 +Create Date: 2025-01-06 20:44:52.383303 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f627018b273f' +down_revision = '349c447c0d37' +branch_labels = None +depends_on = None + + +def upgrade(): + pass + with op.batch_alter_table('config') as batch_op: + batch_op.add_column(sa.Column('slack_app_token', sa.VARCHAR(100), server_default='')) + batch_op.add_column(sa.Column('slack_channel', sa.VARCHAR(100), server_default='')) + +def downgrade(): + with op.batch_alter_table('config') as batch_op: + batch_op.drop_column('slack_app_token') + batch_op.drop_column('slack_channelslack_channel') diff --git a/src/synack/db/models/category.py b/src/synack/db/models/category.py old mode 100755 new mode 100644 diff --git a/src/synack/db/models/config.py b/src/synack/db/models/config.py old mode 100755 new mode 100644 index e0ac890..45859f1 --- a/src/synack/db/models/config.py +++ b/src/synack/db/models/config.py @@ -23,6 +23,8 @@ class Config(Base): password = sa.Column(sa.VARCHAR(150), default='') scratchspace_dir = sa.Column(sa.VARCHAR(250), default='~/Scratchspace') slack_url = sa.Column(sa.VARCHAR(500), default='') + slack_app_token = sa.Column(sa.VARCHAR(100), default='') + slack_channel = sa.Column(sa.VARCHAR(100), default='') smtp_email_from = sa.Column(sa.VARCHAR(250), default='') smtp_password = sa.Column(sa.VARCHAR(250), default='') smtp_port = sa.Column(sa.INTEGER, default=465) diff --git a/src/synack/db/models/organization.py b/src/synack/db/models/organization.py old mode 100755 new mode 100644 diff --git a/src/synack/db/models/target.py b/src/synack/db/models/target.py old mode 100755 new mode 100644 diff --git a/src/synack/plugins/alerts.py b/src/synack/plugins/alerts.py index 92086c7..177171a 100644 --- a/src/synack/plugins/alerts.py +++ b/src/synack/plugins/alerts.py @@ -58,7 +58,20 @@ def sanitize(self, message): r'(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))(?=\s|$)', '[IPv6]', message) return message - def slack(self, message='This is a test'): - requests.post(self.db.slack_url, - data=json.dumps({'text': message}), - headers={'Content-Type': 'application/json'}) + def slack(self, message='This is a test', channel=None): + if channel == None: + channel = self.db.slack_channel + if channel in [None, '']: + channel = input('Slack Channel: ') + self.db.slack_channel = channel + if self.db.slack_app_token == '': + self.db.slack_app_token = input('Slack App Token: ') + requests.post('https://slack.com/api/chat.postMessage', + data=json.dumps({ + 'text': message, + 'channel': channel, + }), + headers={ + 'Authorization': f'Bearer {self.db.slack_app_token}', + 'Content-Type': 'application/json' + }) diff --git a/src/synack/plugins/db.py b/src/synack/plugins/db.py index 6b6f5d2..a3f5c48 100644 --- a/src/synack/plugins/db.py +++ b/src/synack/plugins/db.py @@ -476,12 +476,30 @@ def set_migration(self): @property def slack_url(self): + print('The \'slack_url\' option is no longer functional and will be removed in a later version. Switch to \'slack_app_token\' and \'slack_channel\'.') return self.get_config('slack_url') @slack_url.setter def slack_url(self, value): + print('The \'slack_url\' option is no longer functional and will be removed in a later version. Switch to \'slack_app_token\' and \'slack_channel\'.') self.set_config('slack_url', value) + @property + def slack_app_token(self): + return self.get_config('slack_app_token') + + @slack_app_token.setter + def slack_app_token(self, value): + self.set_config('slack_app_token', value) + + @property + def slack_channel(self): + return self.get_config('slack_channel') + + @slack_channel.setter + def slack_channel(self, value): + self.set_config('slack_channel', value) + @property def smtp_email_from(self): return self.get_config('smtp_email_from') From 5eea26274c632d0d825ec9169f02f354ccd39224 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Mon, 6 Jan 2025 22:54:43 +0000 Subject: [PATCH 04/36] alembic: removed pass from migration --- .../db/alembic/versions/f627018b273f_add_slack_app_variables.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py b/src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py index b8b5db9..d6ed73c 100644 --- a/src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py +++ b/src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py @@ -17,7 +17,6 @@ def upgrade(): - pass with op.batch_alter_table('config') as batch_op: batch_op.add_column(sa.Column('slack_app_token', sa.VARCHAR(100), server_default='')) batch_op.add_column(sa.Column('slack_channel', sa.VARCHAR(100), server_default='')) From 6f9506a57a84ce579e32698ad63233f171d43191 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Thu, 9 Jan 2025 05:14:46 +0000 Subject: [PATCH 05/36] state: resolved backwards state/db order --- src/synack/_handler.py | 1 + src/synack/_state.py | 200 ++++++++++++++++++++++++++-- src/synack/plugins/alerts.py | 26 ++-- src/synack/plugins/api.py | 19 ++- src/synack/plugins/auth.py | 12 +- src/synack/plugins/db.py | 89 +++++-------- src/synack/plugins/debug.py | 2 +- src/synack/plugins/notifications.py | 3 +- src/synack/plugins/scratchspace.py | 2 +- src/synack/plugins/targets.py | 8 +- src/synack/plugins/templates.py | 2 +- src/synack/plugins/users.py | 2 +- 12 files changed, 253 insertions(+), 113 deletions(-) diff --git a/src/synack/_handler.py b/src/synack/_handler.py index 914b57a..75bd0f5 100644 --- a/src/synack/_handler.py +++ b/src/synack/_handler.py @@ -19,6 +19,7 @@ def __init__(self, state=State(), **kwargs): instance = subclass(self.state) setattr(self, name.lower(), instance) + self.state._db = self.db self.login() def login(self): diff --git a/src/synack/_state.py b/src/synack/_state.py index d780505..843fac7 100644 --- a/src/synack/_state.py +++ b/src/synack/_state.py @@ -11,7 +11,9 @@ class State(object): def __init__(self): + self._api_token = None self._config_dir = None + self._db = None self._debug = None self._email = None self._http_proxy = None @@ -21,13 +23,110 @@ def __init__(self): self._otp_secret = None self._password = None self._proxies = None + self._scratchspace_dir = None self._session = None + self._slack_app_token = None + self._slack_channel = None + self._slack_url = None + self._smtp_email_from = None + self._smtp_email_to = None + self._smtp_password = None + self._smtp_port = None + self._smtp_server = None + self._smtp_starttls = None + self._smtp_username = None self._template_dir = None - self._scratchspace_dir = None self._use_proxies = None self._use_scratchspace = None self._user_id = None + @property + def smtp_email_from(self) -> str: + ret = self._smtp_email_from + if ret == None: + ret = self._db.smtp_email_from + return ret + + @smtp_email_from.setter + def smtp_email_from(self, value: str) -> None: + self._smtp_email_from = value + + @property + def smtp_email_to(self) -> str: + ret = self._smtp_email_to + if ret == None: + ret = self._db.smtp_email_to + return ret + + @smtp_email_to.setter + def smtp_email_to(self, value: str) -> None: + self._smtp_email_to = value + + @property + def smtp_password(self) -> str: + ret = self._smtp_password + if ret == None: + ret = self._db.smtp_password + return ret + + @smtp_password.setter + def smtp_password(self, value: str) -> None: + self._smtp_password = value + + @property + def smtp_port(self) -> str: + ret = self._smtp_port + if ret == None: + ret = self._db.smtp_port + return ret + + @smtp_port.setter + def smtp_port(self, value: str) -> None: + self._smtp_port = value + + @property + def smtp_server(self) -> str: + ret = self._smtp_server + if ret == None: + ret = self._db.smtp_server + return ret + + @smtp_server.setter + def smtp_server(self, value: str) -> None: + self._smtp_server = value + + @property + def smtp_starttls(self) -> str: + ret = self._smtp_starttls + if ret == None: + ret = self._db.smtp_starttls + return ret + + @smtp_starttls.setter + def smtp_starttls(self, value: str) -> None: + self._smtp_starttls = value + + @property + def smtp_username(self) -> str: + ret = self._smtp_username + if ret == None: + ret = self._db.smtp_username + return ret + + @smtp_username.setter + def smtp_username(self, value: str) -> None: + self._smtp_username = value + + @property + def api_token(self) -> str: + ret = self._api_token + if ret == None: + ret = self._db.api_token + + @api_token.setter + def api_token(self, value: str) -> None: + self._api_token = value + @property def config_dir(self) -> pathlib.PosixPath: if self._config_dir is None: @@ -45,8 +144,9 @@ def config_dir(self, value: Union[str, pathlib.PosixPath]) -> None: @property def template_dir(self) -> pathlib.PosixPath: ret = self._template_dir - if ret: - ret.mkdir(parents=True, exist_ok=True) + if ret == None: + ret = self._db.template_dir + ret.mkdir(parents=True, exist_ok=True) return ret @template_dir.setter @@ -58,8 +158,9 @@ def template_dir(self, value: Union[str, pathlib.PosixPath]) -> None: @property def scratchspace_dir(self) -> pathlib.PosixPath: ret = self._scratchspace_dir - if ret: - ret.mkdir(parents=True, exist_ok=True) + if ret == None: + ret = self._db.scratchspace_dir + ret.mkdir(parents=True, exist_ok=True) return ret @scratchspace_dir.setter @@ -70,7 +171,10 @@ def scratchspace_dir(self, value: Union[str, pathlib.PosixPath]) -> None: @property def debug(self) -> bool: - return self._debug + ret = self._debug + if ret == None: + ret = self._db.debug + return ret @debug.setter def debug(self, value: bool) -> None: @@ -90,9 +194,23 @@ def login(self) -> bool: def login(self, value: bool) -> None: self._login = value + @property + def notifications_token(self) -> str: + ret = self._notifications_token + if ret == None: + ret = self._db.notifications_token + return ret + + @notifications_token.setter + def notifications_token(self, value: str) -> None: + self._notifications_token = value + @property def use_proxies(self) -> bool: - return self._use_proxies + ret = self._use_proxies + if ret == None: + ret = self._db.use_proxies + return ret @use_proxies.setter def use_proxies(self, value: bool) -> None: @@ -100,7 +218,10 @@ def use_proxies(self, value: bool) -> None: @property def use_scratchspace(self) -> bool: - return self._use_scratchspace + ret = self._use_scratchspace + if ret == None: + self._db.use_scratchspace + return ret @use_scratchspace.setter def use_scratchspace(self, value: bool) -> None: @@ -108,7 +229,10 @@ def use_scratchspace(self, value: bool) -> None: @property def http_proxy(self) -> str: - return self._http_proxy + ret = self._http_proxy + if ret == None: + ret = self._db.http_proxy + return ret @http_proxy.setter def http_proxy(self, value: str) -> None: @@ -116,7 +240,10 @@ def http_proxy(self, value: str) -> None: @property def https_proxy(self) -> str: - return self._https_proxy + ret = self._https_proxy + if ret == None: + ret = self._db.https_proxy + return ret @https_proxy.setter def https_proxy(self, value: str) -> None: @@ -131,7 +258,10 @@ def proxies(self) -> dict(): @property def otp_secret(self) -> str: - return self._otp_secret + ret = self._otp_secret + if ret == None: + ret = self._db.otp_secret + return ret @otp_secret.setter def otp_secret(self, value: str) -> None: @@ -139,15 +269,54 @@ def otp_secret(self, value: str) -> None: @property def email(self) -> str: - return self._email + ret = self._email + if ret == None: + ret = self._db.email + return ret @email.setter def email(self, value: str) -> None: self._email = value + @property + def slack_app_token(self) -> str: + ret = self._slack_app_token + if ret == None: + ret = self._db.slack_app_token + return ret + + @slack_app_token.setter + def slack_app_token(self, value: str) -> None: + self._slack_app_token = value + + @property + def slack_channel(self) -> str: + ret = self._slack_channel + if ret == None: + ret = self._db.slack_channel + return ret + + @slack_channel.setter + def slack_channel(self, value: str) -> None: + self._slack_channel = value + + @property + def slack_url(self) -> str: + ret = self._slack_url + if ret == None: + ret = self._db.slack_url + return ret + + @slack_url.setter + def slack_url(self, value: str) -> None: + self._slack_url = value + @property def password(self) -> str: - return self._password + ret = self._password + if ret == None: + ret = self._db.password + return ret @password.setter def password(self, value: str) -> None: @@ -155,7 +324,10 @@ def password(self, value: str) -> None: @property def user_id(self) -> str: - return self._user_id + ret = self._user_id + if ret == None: + ret = self._db.user_id + return ret @user_id.setter def user_id(self, value: str) -> None: diff --git a/src/synack/plugins/alerts.py b/src/synack/plugins/alerts.py index 177171a..d514a70 100644 --- a/src/synack/plugins/alerts.py +++ b/src/synack/plugins/alerts.py @@ -9,6 +9,7 @@ import re import requests import smtplib +import warnings from .base import Plugin @@ -26,15 +27,15 @@ def email(self, subject='Test Alert', message='This is a test'): msg = email.message.EmailMessage() msg.set_content(message) msg['Subject'] = subject - msg['From'] = self.db.smtp_email_from - msg['To'] = self.db.smtp_email_to + msg['From'] = self.state.smtp_email_from + msg['To'] = self.state.smtp_email_to - if self.db.smtp_starttls: - server = smtplib.SMTP_SSL(self.db.smtp_server, self.db.smtp_port) + if self.state.smtp_starttls: + server = smtplib.SMTP_SSL(self.state.smtp_server, self.state.smtp_port) else: - server = smtplib.SMTP(self.db.smtp_server, self.db.smtp_port) + server = smtplib.SMTP(self.state.smtp_server, self.state.smtp_port) - server.login(self.db.smtp_username, self.db.smtp_password) + server.login(self.state.smtp_username, self.state.smtp_password) server.send_message(msg) def sanitize(self, message): @@ -60,18 +61,15 @@ def sanitize(self, message): def slack(self, message='This is a test', channel=None): if channel == None: - channel = self.db.slack_channel - if channel in [None, '']: - channel = input('Slack Channel: ') - self.db.slack_channel = channel - if self.db.slack_app_token == '': - self.db.slack_app_token = input('Slack App Token: ') + channel = self.state.slack_channel + warnings.filterwarnings("ignore") requests.post('https://slack.com/api/chat.postMessage', data=json.dumps({ 'text': message, 'channel': channel, }), headers={ - 'Authorization': f'Bearer {self.db.slack_app_token}', + 'Authorization': f'Bearer {self.state.slack_app_token}', 'Content-Type': 'application/json' - }) + }, + verify=False) diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index a1f06fa..87812e5 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -54,12 +54,13 @@ def notifications(self, method, path, **kwargs): if not kwargs.get('headers'): kwargs['headers'] = dict() - auth = "Bearer " + self.db.notifications_token + auth = "Bearer " + self.state.notifications_token kwargs['headers']['Authorization'] = auth res = self.request(method, url, **kwargs) if res.status_code == 422: - self.db.notifications_token = "" + self.db.notifications_token = '' + self.state.notifications_token = '' return res def request(self, method, path, include_std_headers=True, **kwargs): @@ -80,18 +81,14 @@ def request(self, method, path, include_std_headers=True, **kwargs): base = 'https://platform.synack.com/api/' url = f'{base}{path}' - if self.db.use_proxies: - warnings.filterwarnings("ignore") - verify = False - proxies = self.db.proxies - else: - verify = True - proxies = None + warnings.filterwarnings("ignore") + verify = False + proxies = self.state.proxies if self.state.use_proxies else None if include_std_headers: headers = { - 'Authorization': f'Bearer {self.db.api_token}', - 'user_id': self.db.user_id + 'Authorization': f'Bearer {self.state.api_token}', + 'user_id': self.state.user_id } else: headers = dict() diff --git a/src/synack/plugins/auth.py b/src/synack/plugins/auth.py index d6f89b8..23bf749 100644 --- a/src/synack/plugins/auth.py +++ b/src/synack/plugins/auth.py @@ -20,7 +20,7 @@ def __init__(self, *args, **kwargs): def build_otp(self): """Generate and return a OTP.""" - totp = pyotp.TOTP(self.db.otp_secret) + totp = pyotp.TOTP(self.state.otp_secret) totp.digits = 7 totp.interval = 10 totp.issuer = 'synack' @@ -29,7 +29,7 @@ def build_otp(self): def get_api_token(self): """Log in to get a new API token.""" if self.users.get_profile(): - return self.db.api_token + return self.state.api_token csrf = self.get_login_csrf() progress_token = None duo_auth_url = None @@ -55,6 +55,7 @@ def get_api_token(self): if res.status_code == 200: j = res.json() self.db.api_token = j.get('access_token') + self.state.api_token = j.get('access_token') self.set_login_script() return j.get('access_token') @@ -285,8 +286,8 @@ def get_authentication_response(self, csrf): 'X-CSRF-Token': csrf } data = { - 'email': self.db.email, - 'password': self.db.password + 'email': self.state.email, + 'password': self.state.password } res = self.api.login('POST', 'authenticate', @@ -305,6 +306,7 @@ def get_notifications_token(self): if res.status_code == 200: j = res.json() self.db.notifications_token = j['token'] + self.state.notifications_token = j['token'] return j['token'] def set_login_script(self): @@ -316,7 +318,7 @@ def set_login_script(self): "(function() {" +\ "sessionStorage.setItem('shared-session-com.synack.accessToken'" +\ ",'" +\ - self.db.api_token +\ + self.state.api_token +\ "');" +\ "setTimeout(forceLogin,60000);" +\ "let btn = document.createElement('button');" +\ diff --git a/src/synack/plugins/db.py b/src/synack/plugins/db.py index a3f5c48..42422a0 100644 --- a/src/synack/plugins/db.py +++ b/src/synack/plugins/db.py @@ -197,30 +197,22 @@ def categories(self): @property def debug(self): - if self.state.debug is None: - return self.get_config('debug') - else: - return self.state.debug + return self.get_config('debug') @debug.setter def debug(self, value): - self.state.debug = value self.set_config('debug', value) @property def email(self): - if self.state.email is None: - ret = self.get_config('email') - if not ret: - ret = input("Synack Email: ") - self.email = ret - return ret - else: - return self.state.email + ret = self.get_config('email') + if not ret: + ret = input('Synack Email: ') + self.email = ret + return ret @email.setter def email(self, value): - self.state.email = value self.set_config('email', value) def find_ips(self, ip=None, **kwargs): @@ -379,35 +371,26 @@ def notifications_token(self, value): @property def otp_secret(self): - if self.state.otp_secret is None: - ret = self.get_config('otp_secret') - if not ret: - ret = input("Synack OTP Secret: ") - self.otp_secret = ret - self.state.otp_secret = ret - return ret - else: - return self.state.otp_secret + ret = self.get_config('otp_secret') + if not ret: + ret = input('Synack OTP Secret: ') + self.otp_secret = ret + return ret @otp_secret.setter def otp_secret(self, value): - self.state.otp_secret = value self.set_config('otp_secret', value) @property def password(self): - if self.state.password is None: - ret = self.get_config('password') - if not ret: - ret = input("Synack Password: ") - self.password = ret - return ret - else: - return self.state.password + ret = self.get_config('password') + if not ret: + ret = input('Synack Password: ') + self.password = ret + return ret @password.setter def password(self, value): - self.state.password = value self.set_config('password', value) @property @@ -442,12 +425,7 @@ def remove_targets(self, **kwargs): @property def scratchspace_dir(self): - if self.state.scratchspace_dir is None: - ret = Path(self.get_config('scratchspace_dir')).expanduser().resolve() - self.state.scratchspace_dir = ret - else: - ret = self.state.scratchspace_dir - return ret + return Path(self.get_config('scratchspace_dir')).expanduser().resolve() @scratchspace_dir.setter def scratchspace_dir(self, value): @@ -476,17 +454,19 @@ def set_migration(self): @property def slack_url(self): - print('The \'slack_url\' option is no longer functional and will be removed in a later version. Switch to \'slack_app_token\' and \'slack_channel\'.') return self.get_config('slack_url') @slack_url.setter def slack_url(self, value): - print('The \'slack_url\' option is no longer functional and will be removed in a later version. Switch to \'slack_app_token\' and \'slack_channel\'.') self.set_config('slack_url', value) @property def slack_app_token(self): - return self.get_config('slack_app_token') + ret = self.get_config('slack_app_token') + if not ret: + ret = input('Slack App Token: ') + self.slack_app_token = ret + return ret @slack_app_token.setter def slack_app_token(self, value): @@ -494,7 +474,11 @@ def slack_app_token(self, value): @property def slack_channel(self): - return self.get_config('slack_channel') + ret = self.get_config('slack_channel') + if not ret: + ret = input('Slack Channel: ') + self.slack_channel = ret + return ret @slack_channel.setter def slack_channel(self, value): @@ -565,12 +549,7 @@ def targets(self): @property def template_dir(self): - if self.state.template_dir is None: - ret = Path(self.get_config('template_dir')).expanduser().resolve() - self.state.template_dir = ret - else: - ret = self.state.template_dir - return ret + return Path(self.get_config('template_dir')).expanduser().resolve() @template_dir.setter def template_dir(self, value): @@ -585,14 +564,10 @@ def urls(self): @property def use_proxies(self): - if self.state.use_proxies is None: - return self.get_config('use_proxies') - else: - return self.state.use_proxies + return self.get_config('use_proxies') @use_proxies.setter def use_proxies(self, value): - self.state.use_proxies = value self.set_config('use_proxies', value) @property @@ -605,12 +580,8 @@ def user_id(self, value): @property def use_scratchspace(self): - if self.state.use_scratchspace is None: - return self.get_config('use_scratchspace') - else: - return self.state.use_scratchspace + return self.get_config('use_scratchspace') @use_scratchspace.setter def use_scratchspace(self, value): - self.state.use_scratchspace = value self.set_config('use_scratchspace', value) diff --git a/src/synack/plugins/debug.py b/src/synack/plugins/debug.py index e0e0a29..39594c3 100644 --- a/src/synack/plugins/debug.py +++ b/src/synack/plugins/debug.py @@ -17,6 +17,6 @@ def __init__(self, *args, **kwargs): self.registry.get(plugin)(self.state)) def log(self, title, message): - if self.db.debug: + if self.state.debug: t = datetime.strftime(datetime.now(), "%Y-%m-%d %H:%M:%S") print(f'{t} -- {title.upper()}\n\t{message}') diff --git a/src/synack/plugins/notifications.py b/src/synack/plugins/notifications.py index ce828f1..f3d6824 100644 --- a/src/synack/plugins/notifications.py +++ b/src/synack/plugins/notifications.py @@ -23,9 +23,8 @@ def get(self): def get_unread_count(self): """Get the number of unread notifications""" - token = self.db.notifications_token query = { - "authorization_token": token + "authorization_token": self.state.notifications_token } res = self.api.notifications('GET', 'notifications/unread_count', diff --git a/src/synack/plugins/scratchspace.py b/src/synack/plugins/scratchspace.py index 436e496..150cf20 100644 --- a/src/synack/plugins/scratchspace.py +++ b/src/synack/plugins/scratchspace.py @@ -21,7 +21,7 @@ def build_filepath(self, filename, target=None, codename=None): codename = target.codename if codename: - f = self.db.scratchspace_dir + f = self.state.scratchspace_dir f = f / codename f.mkdir(parents=True, exist_ok=True) f = f / filename diff --git a/src/synack/plugins/targets.py b/src/synack/plugins/targets.py index 9cdde40..ebccf82 100644 --- a/src/synack/plugins/targets.py +++ b/src/synack/plugins/targets.py @@ -142,7 +142,7 @@ def get_assets(self, target=None, asset_type=None, host_type=None, active='true' res = self.api.request('GET', f'asset/v2/assets?{"&".join(queries)}') if res.status_code == 200: - if self.db.use_scratchspace: + if self.state.use_scratchspace: self.scratchspace.set_assets_file(res.text, target=target) return res.json() @@ -196,7 +196,7 @@ def get_credentials(self, **kwargs): res = self.api.request('POST', f'asset/v1/organizations/{target.organization}' + f'/owners/listings/{target.slug}' + - f'/users/{self.db.user_id}' + + f'/users/{self.state.user_id}' + '/credentials') if res.status_code == 200: return res.json() @@ -279,7 +279,7 @@ def get_scope_host(self, target=None, add_to_db=False, **kwargs): if len(scope) > 0: if add_to_db: self.db.add_ips(self.build_scope_host_db(target.slug, scope)) - if self.db.use_scratchspace: + if self.state.use_scratchspace: self.scratchspace.set_hosts_file(scope, target=target) return scope @@ -316,7 +316,7 @@ def get_scope_web(self, target=None, add_to_db=False, **kwargs): if len(scope) > 0: if add_to_db: self.db.add_urls(self.build_scope_web_db(scope)) - if self.db.use_scratchspace: + if self.state.use_scratchspace: self.scratchspace.set_burp_file(self.build_scope_web_burp(scope), target=target) return scope diff --git a/src/synack/plugins/templates.py b/src/synack/plugins/templates.py index 57e4305..f5fb3f6 100644 --- a/src/synack/plugins/templates.py +++ b/src/synack/plugins/templates.py @@ -18,7 +18,7 @@ def __init__(self, *args, **kwargs): self.registry.get(plugin)(self.state)) def build_filepath(self, mission, generic_ok=False): - f = self.db.template_dir + f = self.state.template_dir f = f / self.build_safe_name(mission['taskType']) if mission.get('asset'): f = f / self.build_safe_name(mission['asset']) diff --git a/src/synack/plugins/users.py b/src/synack/plugins/users.py index 3388826..c26030d 100644 --- a/src/synack/plugins/users.py +++ b/src/synack/plugins/users.py @@ -18,5 +18,5 @@ def get_profile(self, user_id="me"): """Get a user's profile""" res = self.api.request('GET', f'profiles/{user_id}') if res.status_code == 200: - self.db.user_id = res.json().get('user_id') + self.state.user_id = res.json().get('user_id') return res.json() From d78504ec536a7862037c68946957993dcb274317 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Fri, 10 Jan 2025 04:07:23 +0000 Subject: [PATCH 06/36] Added default returns to a few functions --- src/synack/plugins/missions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/synack/plugins/missions.py b/src/synack/plugins/missions.py index 8ff562e..d940933 100644 --- a/src/synack/plugins/missions.py +++ b/src/synack/plugins/missions.py @@ -111,6 +111,7 @@ def get(self, status="PUBLISHED", per_page) ret.extend(new) return ret + return [] def get_approved(self): """Get a list of missions currently approved""" @@ -142,6 +143,7 @@ def get_count(self, status="PUBLISHED", listing_uids=None): query=query) if res.status_code == 204: return int(res.headers.get('x-count', 0)) + return 0 def get_evidences(self, mission): """Download the evidences for a single mission From 48d355c625c017be63d9cf37ca1879e406c97c8f Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Mon, 13 Jan 2025 23:40:21 +0000 Subject: [PATCH 07/36] crappy hotp --- src/synack/_state.py | 13 ++++++ .../versions/c2e6de9ffc5e_add_otp_count.py | 25 +++++++++++ .../f627018b273f_add_slack_app_variables.py | 2 +- src/synack/db/models/config.py | 1 + src/synack/plugins/auth.py | 41 +++++++++++++++++-- src/synack/plugins/db.py | 12 ++++++ 6 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 src/synack/db/alembic/versions/c2e6de9ffc5e_add_otp_count.py diff --git a/src/synack/_state.py b/src/synack/_state.py index 843fac7..79ba1ec 100644 --- a/src/synack/_state.py +++ b/src/synack/_state.py @@ -21,6 +21,7 @@ def __init__(self): self._login = None self._notifications_token = None self._otp_secret = None + self._otp_count = None self._password = None self._proxies = None self._scratchspace_dir = None @@ -122,6 +123,7 @@ def api_token(self) -> str: ret = self._api_token if ret == None: ret = self._db.api_token + return ret @api_token.setter def api_token(self, value: str) -> None: @@ -267,6 +269,17 @@ def otp_secret(self) -> str: def otp_secret(self, value: str) -> None: self._otp_secret = value + @property + def otp_count(self) -> str: + ret = self._otp_count + if ret == None: + ret = self._db.otp_count + return ret + + @otp_count.setter + def otp_count(self, value: int) -> None: + self._otp_count = value + @property def email(self) -> str: ret = self._email diff --git a/src/synack/db/alembic/versions/c2e6de9ffc5e_add_otp_count.py b/src/synack/db/alembic/versions/c2e6de9ffc5e_add_otp_count.py new file mode 100644 index 0000000..8f033a4 --- /dev/null +++ b/src/synack/db/alembic/versions/c2e6de9ffc5e_add_otp_count.py @@ -0,0 +1,25 @@ +"""add otp_count + +Revision ID: c2e6de9ffc5e +Revises: f627018b273f +Create Date: 2025-01-11 22:29:05.822904 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c2e6de9ffc5e' +down_revision = 'f627018b273f' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('config') as batch_op: + batch_op.add_column(sa.Column('otp_count', sa.INTEGER, server_default='0')) + +def downgrade(): + with op.batch_alter_table('config') as batch_op: + batch_op.drop_column('otp_count') diff --git a/src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py b/src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py index d6ed73c..efe98c5 100644 --- a/src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py +++ b/src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py @@ -24,4 +24,4 @@ def upgrade(): def downgrade(): with op.batch_alter_table('config') as batch_op: batch_op.drop_column('slack_app_token') - batch_op.drop_column('slack_channelslack_channel') + batch_op.drop_column('slack_channel') diff --git a/src/synack/db/models/config.py b/src/synack/db/models/config.py index 45859f1..0159227 100644 --- a/src/synack/db/models/config.py +++ b/src/synack/db/models/config.py @@ -20,6 +20,7 @@ class Config(Base): login = sa.Column(sa.BOOLEAN, default=True) notifications_token = sa.Column(sa.VARCHAR(1000), default='') otp_secret = sa.Column(sa.VARCHAR(50), default='') + otp_count = sa.Column(sa.INTEGER, default=0) password = sa.Column(sa.VARCHAR(150), default='') scratchspace_dir = sa.Column(sa.VARCHAR(250), default='~/Scratchspace') slack_url = sa.Column(sa.VARCHAR(500), default='') diff --git a/src/synack/plugins/auth.py b/src/synack/plugins/auth.py index 23bf749..2daaf8d 100644 --- a/src/synack/plugins/auth.py +++ b/src/synack/plugins/auth.py @@ -174,6 +174,38 @@ def get_duo_push_method_data(self, push_vars): push_vars['prompt_device_index'] = phone.get('index', '') return push_vars + def get_duo_hotp(self): + hotp = pyotp.HOTP(s=self.state.otp_secret) + return hotp.generate_otp(self.state.otp_count) + + def get_duo_hotp_txid(self, push_vars): + # Doing the POST that should actually send the push notification + headers = { + 'Sec-Ch-Ua': '"Chromium";v="131", "Not_A Brand";v="24"', + 'Sec-Ch-Ua-Mobile': '?0', + 'Sec-Ch-Ua-Platform': '"Linux"', + 'Referer': f'{push_vars.get("url_base", "")}/frame/v4/auth/prompt?sid={push_vars.get("sid", "")}', + 'Sec-Fetch-Site': 'same-origin', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Dest': 'empty', + 'Accept': '*/*', + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'X-Xsrftoken': push_vars.get('xsrf', ''), + } + data = { + 'device': 'null', + 'passcode': self.get_duo_hotp(), + 'factor': 'Passcode', + 'postAuthDestination': 'OIDC_EXIT', + 'browser_features': '{"touch_supported":false,"platform_authenticator_status":"unavailable","webauthn_supported":true}', + 'sid': push_vars.get('sid', '') + } + res = self.api.request('POST', f'{push_vars.get("url_base", "")}/frame/v4/prompt', include_std_headers=False, headers=headers, data=data) + if res.status_code == 200: + push_vars['txid'] = res.json().get('response', {}).get('txid', '') + self.db.otp_count+=1 + return push_vars + def get_duo_push_notification_txid(self, push_vars): # Doing the POST that should actually send the push notification headers = { @@ -241,8 +273,10 @@ def get_duo_push_grant_token(self, push_vars): data = { 'sid': push_vars.get('sid', ''), 'txid': push_vars.get('txid', ''), - 'factor': 'Duo Push', - 'device_key': push_vars.get('prompt_device_key', ''), + 'factor': 'Passcode', + 'device_key': 'null', + #'factor': 'Duo Push', + #'device_key': push_vars.get('prompt_device_key', ''), '_xsrf': push_vars.get('xsrf', ''), 'dampen_choice': 'false' } @@ -259,7 +293,8 @@ def get_duo_push(self, duo_auth_url): push_vars = self.get_duo_push_xsrf_token(push_vars) self.get_duo_push_vars_post(push_vars) push_vars = self.get_duo_push_method_data(push_vars) - push_vars = self.get_duo_push_notification_txid(push_vars) + push_vars = self.get_duo_hotp_txid(push_vars) + #push_vars = self.get_duo_push_notification_txid(push_vars) self.get_duo_push_status(push_vars) push_vars = self.get_duo_push_grant_token(push_vars) return push_vars.get('grant_token', '') diff --git a/src/synack/plugins/db.py b/src/synack/plugins/db.py index 42422a0..5be97d2 100644 --- a/src/synack/plugins/db.py +++ b/src/synack/plugins/db.py @@ -381,6 +381,18 @@ def otp_secret(self): def otp_secret(self, value): self.set_config('otp_secret', value) + @property + def otp_count(self): + ret = self.get_config('otp_count') + if not ret: + ret = input('Synack OTP Count: ') + self.otp_count = int(ret) + return ret + + @otp_count.setter + def otp_count(self, value): + self.set_config('otp_count', value) + @property def password(self): ret = self.get_config('password') From 4b48b2bc993fcee200c240c19d53202c29fc2b96 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Sat, 25 Jan 2025 17:56:13 +0000 Subject: [PATCH 08/36] Cleaning house, about to touch auth --- docs/src/SUMMARY.md | 1 - docs/src/usage/plugins/db.md | 8 +- docs/src/usage/plugins/hydra.md | 38 ---- src/synack/_state.py | 48 ++--- .../versions/c2e6de9ffc5e_add_otp_count.py | 1 + .../f627018b273f_add_slack_app_variables.py | 1 + src/synack/plugins/__init__.py | 1 - src/synack/plugins/alerts.py | 2 +- src/synack/plugins/api.py | 9 +- src/synack/plugins/auth.py | 19 +- src/synack/plugins/hydra.py | 81 --------- src/synack/plugins/targets.py | 14 +- test/test_hydra.py | 165 ------------------ test/test_targets.py | 81 +-------- 14 files changed, 62 insertions(+), 407 deletions(-) delete mode 100644 docs/src/usage/plugins/hydra.md delete mode 100644 src/synack/plugins/hydra.py delete mode 100644 test/test_hydra.py diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index bff259c..ecf7f10 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -19,7 +19,6 @@ - [Auth](./usage/plugins/auth.md) - [Db](./usage/plugins/db.md) - [Debug](./usage/plugins/debug.md) - - [Hydra](./usage/plugins/hydra.md) - [Missions](./usage/plugins/missions.md) - [Notifications](./usage/plugins/notifications.md) - [Scratchspace](./usage/plugins/scratchspace.md) diff --git a/docs/src/usage/plugins/db.md b/docs/src/usage/plugins/db.md index 64dbc9d..6f769bf 100644 --- a/docs/src/usage/plugins/db.md +++ b/docs/src/usage/plugins/db.md @@ -82,7 +82,7 @@ Additionally, some properties can be overridden by the State, which allows you t > > | Arguments | Type | Description > | --- | --- | --- -> | `results` | list(dict) | A list of dictionaries containing results from some scan, Hydra, etc. +> | `results` | list(dict) | A list of dictionaries containing results from some scan, etc. > >> Examples >> ```python3 @@ -132,7 +132,7 @@ Additionally, some properties can be overridden by the State, which allows you t > > | Arguments | Type | Description > | --- | --- | --- -> | `results` | list(dict) | A list of dictionaries containing results from some scan, Hydra, etc. +> | `results` | list(dict) | A list of dictionaries containing results from some scan, etc. > >> Examples >> ```python3 @@ -178,7 +178,7 @@ Additionally, some properties can be overridden by the State, which allows you t > | --- | --- | --- > | `port` | int | Port number to search for (443, 80, 25, etc.) > | `protocol` | str | Protocol to search for (tcp, udp, etc.) -> | `source` | str | Source to search for (hydra, nmap, etc.) +> | `source` | str | Source to search for (masscan, nmap, etc.) > | `ip` | str | IP Address to search for > | `kwargs` | kwargs | Any attribute of the Target Database Model (codename, slug, is_active, etc.) > @@ -187,7 +187,7 @@ Additionally, some properties can be overridden by the State, which allows you t >> >>> h.db.find_ports(codename="SLEEPYPUPPY") >> [ >> { ->> 'ip': '1.2.3.4', 'source': 'hydra', 'target': '123hg912', +>> 'ip': '1.2.3.4', 'source': 'masscan', 'target': '123hg912', >> 'ports': [ >> { 'open': True, 'port': '443', 'protocol': 'tcp', 'service': 'https - Wordpress', 'updated': 1654840021 }, >> ... diff --git a/docs/src/usage/plugins/hydra.md b/docs/src/usage/plugins/hydra.md deleted file mode 100644 index 8ca26a6..0000000 --- a/docs/src/usage/plugins/hydra.md +++ /dev/null @@ -1,38 +0,0 @@ -# Hydra - -## hydra.build_db_input() - -> Builds a list of ports ready to be ingested by the Database from Hydra output -> ->> Examples ->> ```python3 ->> >>> h.hydra.build_db_input(h.hydra.get_hydra(codename='SLEEPYPUPPY', update_db=False)) ->> [ ->> { ->> 'ip': '1.2.3.4', 'source': 'hydra', 'target': '123hg912', ->> 'ports': [ ->> { 'open': True, 'port': '443', 'protocol': 'tcp', 'service': 'https - Wordpress', 'updated': 1654840021 }, ->> ... ->> ] ->> }, ->> ... ->> ] ->> ``` - -## hydra.get_hydra(page, max_page, update_db, **kwargs) - -> Returns information from Synack Hydra Service -> -> | Arguments | Type | Description -> | --- | --- | --- -> | `page` | int | Page of the Hydra Service to start on (Default: 1) -> | `max_page` | int | Highest page that should be queried (Default: 5) -> | `update_db` | bool | Store the results in the database -> ->> Examples ->> ```python3 ->> >>> h.hydra.get_hydra(codename='SLEEPYPUPPY') ->> [{'host_plugins': {}, 'ip': '1.2.3.4', 'last_changed_dt': '2022-01-01T01:02:03Z', ... }, ... ] ->> >>> h.hydra.get_hydra(codename='SLEEPYPUPPY', page=3, max_page=5, update_db=False) ->> [{'host_plugins': {}, 'ip': '3.4.5.6', 'last_changed_dt': '2022-01-01T01:02:03Z', ... }, ... ] ->> ``` diff --git a/src/synack/_state.py b/src/synack/_state.py index 79ba1ec..f595b9f 100644 --- a/src/synack/_state.py +++ b/src/synack/_state.py @@ -44,7 +44,7 @@ def __init__(self): @property def smtp_email_from(self) -> str: ret = self._smtp_email_from - if ret == None: + if ret is None: ret = self._db.smtp_email_from return ret @@ -55,7 +55,7 @@ def smtp_email_from(self, value: str) -> None: @property def smtp_email_to(self) -> str: ret = self._smtp_email_to - if ret == None: + if ret is None: ret = self._db.smtp_email_to return ret @@ -66,7 +66,7 @@ def smtp_email_to(self, value: str) -> None: @property def smtp_password(self) -> str: ret = self._smtp_password - if ret == None: + if ret is None: ret = self._db.smtp_password return ret @@ -77,7 +77,7 @@ def smtp_password(self, value: str) -> None: @property def smtp_port(self) -> str: ret = self._smtp_port - if ret == None: + if ret is None: ret = self._db.smtp_port return ret @@ -88,7 +88,7 @@ def smtp_port(self, value: str) -> None: @property def smtp_server(self) -> str: ret = self._smtp_server - if ret == None: + if ret is None: ret = self._db.smtp_server return ret @@ -99,7 +99,7 @@ def smtp_server(self, value: str) -> None: @property def smtp_starttls(self) -> str: ret = self._smtp_starttls - if ret == None: + if ret is None: ret = self._db.smtp_starttls return ret @@ -110,7 +110,7 @@ def smtp_starttls(self, value: str) -> None: @property def smtp_username(self) -> str: ret = self._smtp_username - if ret == None: + if ret is None: ret = self._db.smtp_username return ret @@ -121,7 +121,7 @@ def smtp_username(self, value: str) -> None: @property def api_token(self) -> str: ret = self._api_token - if ret == None: + if ret is None: ret = self._db.api_token return ret @@ -146,7 +146,7 @@ def config_dir(self, value: Union[str, pathlib.PosixPath]) -> None: @property def template_dir(self) -> pathlib.PosixPath: ret = self._template_dir - if ret == None: + if ret is None: ret = self._db.template_dir ret.mkdir(parents=True, exist_ok=True) return ret @@ -160,7 +160,7 @@ def template_dir(self, value: Union[str, pathlib.PosixPath]) -> None: @property def scratchspace_dir(self) -> pathlib.PosixPath: ret = self._scratchspace_dir - if ret == None: + if ret is None: ret = self._db.scratchspace_dir ret.mkdir(parents=True, exist_ok=True) return ret @@ -174,7 +174,7 @@ def scratchspace_dir(self, value: Union[str, pathlib.PosixPath]) -> None: @property def debug(self) -> bool: ret = self._debug - if ret == None: + if ret is None: ret = self._db.debug return ret @@ -199,7 +199,7 @@ def login(self, value: bool) -> None: @property def notifications_token(self) -> str: ret = self._notifications_token - if ret == None: + if ret is None: ret = self._db.notifications_token return ret @@ -210,7 +210,7 @@ def notifications_token(self, value: str) -> None: @property def use_proxies(self) -> bool: ret = self._use_proxies - if ret == None: + if ret is None: ret = self._db.use_proxies return ret @@ -221,7 +221,7 @@ def use_proxies(self, value: bool) -> None: @property def use_scratchspace(self) -> bool: ret = self._use_scratchspace - if ret == None: + if ret is None: self._db.use_scratchspace return ret @@ -232,7 +232,7 @@ def use_scratchspace(self, value: bool) -> None: @property def http_proxy(self) -> str: ret = self._http_proxy - if ret == None: + if ret is None: ret = self._db.http_proxy return ret @@ -243,7 +243,7 @@ def http_proxy(self, value: str) -> None: @property def https_proxy(self) -> str: ret = self._https_proxy - if ret == None: + if ret is None: ret = self._db.https_proxy return ret @@ -261,7 +261,7 @@ def proxies(self) -> dict(): @property def otp_secret(self) -> str: ret = self._otp_secret - if ret == None: + if ret is None: ret = self._db.otp_secret return ret @@ -272,7 +272,7 @@ def otp_secret(self, value: str) -> None: @property def otp_count(self) -> str: ret = self._otp_count - if ret == None: + if ret is None: ret = self._db.otp_count return ret @@ -283,7 +283,7 @@ def otp_count(self, value: int) -> None: @property def email(self) -> str: ret = self._email - if ret == None: + if ret is None: ret = self._db.email return ret @@ -294,7 +294,7 @@ def email(self, value: str) -> None: @property def slack_app_token(self) -> str: ret = self._slack_app_token - if ret == None: + if ret is None: ret = self._db.slack_app_token return ret @@ -305,7 +305,7 @@ def slack_app_token(self, value: str) -> None: @property def slack_channel(self) -> str: ret = self._slack_channel - if ret == None: + if ret is None: ret = self._db.slack_channel return ret @@ -316,7 +316,7 @@ def slack_channel(self, value: str) -> None: @property def slack_url(self) -> str: ret = self._slack_url - if ret == None: + if ret is None: ret = self._db.slack_url return ret @@ -327,7 +327,7 @@ def slack_url(self, value: str) -> None: @property def password(self) -> str: ret = self._password - if ret == None: + if ret is None: ret = self._db.password return ret @@ -338,7 +338,7 @@ def password(self, value: str) -> None: @property def user_id(self) -> str: ret = self._user_id - if ret == None: + if ret is None: ret = self._db.user_id return ret diff --git a/src/synack/db/alembic/versions/c2e6de9ffc5e_add_otp_count.py b/src/synack/db/alembic/versions/c2e6de9ffc5e_add_otp_count.py index 8f033a4..4f92c4a 100644 --- a/src/synack/db/alembic/versions/c2e6de9ffc5e_add_otp_count.py +++ b/src/synack/db/alembic/versions/c2e6de9ffc5e_add_otp_count.py @@ -20,6 +20,7 @@ def upgrade(): with op.batch_alter_table('config') as batch_op: batch_op.add_column(sa.Column('otp_count', sa.INTEGER, server_default='0')) + def downgrade(): with op.batch_alter_table('config') as batch_op: batch_op.drop_column('otp_count') diff --git a/src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py b/src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py index efe98c5..712680f 100644 --- a/src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py +++ b/src/synack/db/alembic/versions/f627018b273f_add_slack_app_variables.py @@ -21,6 +21,7 @@ def upgrade(): batch_op.add_column(sa.Column('slack_app_token', sa.VARCHAR(100), server_default='')) batch_op.add_column(sa.Column('slack_channel', sa.VARCHAR(100), server_default='')) + def downgrade(): with op.batch_alter_table('config') as batch_op: batch_op.drop_column('slack_app_token') diff --git a/src/synack/plugins/__init__.py b/src/synack/plugins/__init__.py index 27450df..8ed429e 100644 --- a/src/synack/plugins/__init__.py +++ b/src/synack/plugins/__init__.py @@ -5,7 +5,6 @@ from .auth import Auth from .db import Db from .debug import Debug -from .hydra import Hydra from .missions import Missions from .notifications import Notifications from .scratchspace import Scratchspace diff --git a/src/synack/plugins/alerts.py b/src/synack/plugins/alerts.py index d514a70..7318d3b 100644 --- a/src/synack/plugins/alerts.py +++ b/src/synack/plugins/alerts.py @@ -60,7 +60,7 @@ def sanitize(self, message): return message def slack(self, message='This is a test', channel=None): - if channel == None: + if channel is None: channel = self.state.slack_channel warnings.filterwarnings("ignore") requests.post('https://slack.com/api/chat.postMessage', diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index 87812e5..1b8542d 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -3,6 +3,7 @@ Functions to handle interacting with the Synack APIs """ +import time import warnings from .base import Plugin @@ -63,7 +64,7 @@ def notifications(self, method, path, **kwargs): self.state.notifications_token = '' return res - def request(self, method, path, include_std_headers=True, **kwargs): + def request(self, method, path, include_std_headers=True, attempt=0, **kwargs): """Send API Request Arguments: @@ -142,4 +143,10 @@ def request(self, method, path, include_std_headers=True, **kwargs): f"\n\tData: {data}" + f"\n\tContent: {res.content}") + if res.status_code == 429: + attempts = kwargs.get('attempts', 0) + if attempts < 5: + time.sleep(30) + attempts += 1 + return self.request(method, path, include_std_headers, attempts, **kwargs) return res diff --git a/src/synack/plugins/auth.py b/src/synack/plugins/auth.py index 2daaf8d..9b6bcae 100644 --- a/src/synack/plugins/auth.py +++ b/src/synack/plugins/auth.py @@ -252,9 +252,22 @@ def get_duo_push_status(self, push_vars): for i in range(5): res = self.api.request('POST', f'{push_vars.get("url_base", "")}/frame/v4/status', include_std_headers=False, headers=headers, data=data) - status = res.json().get('response', {}).get('result', '') - if status == 'SUCCESS': - break + if res.status_code == 200: + status_enum = res.json().get('response', {}).get('status_enum', -1) + status = res.json().get('response', {}).get('status', -1) + if status_enum == 5 or status == 'SUCCESS': # Valid Code + break + elif status_enum == 11: # Bad Code (or Future Code by 20+) + print("Bad OTP Code Sent") + print(res) + print(res.json()) + elif status_enum == 44: # Prior Code + self.db.otp_count+=5 + break + elif status_enum == -1: # Code Changed + print("Duo OTP Status Code Changed") + print(res) + print(res.json()) time.sleep(5) def get_duo_push_grant_token(self, push_vars): diff --git a/src/synack/plugins/hydra.py b/src/synack/plugins/hydra.py deleted file mode 100644 index 399383b..0000000 --- a/src/synack/plugins/hydra.py +++ /dev/null @@ -1,81 +0,0 @@ -"""plugins/hydra.py - -Functions dealing with hydra -""" - -import json -import time - -from .base import Plugin -from datetime import datetime - - -class Hydra(Plugin): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - for plugin in ['Api', 'Db']: - setattr(self, - plugin.lower(), - self.registry.get(plugin)(self.state)) - - def build_db_input(self, results): - """Format the Hydra output so that it can be ingested into the DB""" - db_input = list() - for result in results: - ports = list() - for port in result.get('ports').keys(): - for protocol in result['ports'][port].keys(): - for hydra_src in result['ports'][port][protocol].keys(): - h_src = result['ports'][port][protocol][hydra_src] - service = h_src.get('verified_service', {'parsed': 'unknown'})['parsed'] + \ - ' - ' + \ - h_src.get('product', {'parsed': 'unknown'})['parsed'] - service = service.strip(' - ') - port_open = result['ports'][port][protocol][hydra_src]['open']['parsed'] - epoch = datetime(1970, 1, 1) - try: - last_changed_dt = datetime.strptime(result['last_changed_dt'], "%Y-%m-%dT%H:%M:%SZ") - except ValueError: - last_changed_dt = datetime.strptime(result['last_changed_dt'], "%Y-%m-%dT%H:%M:%S.%fZ") - updated = int((last_changed_dt - epoch).total_seconds()) - - ports.append({ - "port": port, - "protocol": protocol, - "service": service, - "open": port_open, - "updated": updated - }) - db_input.append({ - "ip": result["ip"], - "target": result["listing_uid"], - "source": "hydra", - "ports": ports - }) - return db_input - - def get_hydra(self, page=1, max_page=5, update_db=True, **kwargs): - """Get Hydra results for target identified using kwargs (codename='x', slug='x', etc.)""" - max_page = 1000 if max_page == 0 else max_page - results = list() - targets = self.db.find_targets(**kwargs) - if targets: - target = targets[0] - if target: - query = { - 'page': page, - 'listing_uids': target.slug, - 'q': '+port_is_open:true' - } - time.sleep(page*0.01) - res = self.api.request('GET', - 'hydra_search/search', - query=query) - if res.status_code == 200: - curr_results = json.loads(res.content) - results.extend(curr_results) - if len(curr_results) == 10 and page < max_page: - results.extend(self.get_hydra(page=page+1, max_page=max_page, **kwargs)) - if update_db: - self.db.add_ports(self.build_db_input(results)) - return results diff --git a/src/synack/plugins/targets.py b/src/synack/plugins/targets.py index ebccf82..927b238 100644 --- a/src/synack/plugins/targets.py +++ b/src/synack/plugins/targets.py @@ -232,7 +232,7 @@ def get_registered_summary(self): ret[t['id']] = t return ret - def get_scope(self, add_to_db=False, **kwargs): + def get_scope(self, **kwargs): """Get the scope of a target""" if len(kwargs) > 0: target = self.db.find_targets(**kwargs) @@ -246,11 +246,11 @@ def get_scope(self, add_to_db=False, **kwargs): for category in self.db.categories: categories[category.id] = category.name if categories[target.category].lower() == 'host': - return self.get_scope_host(target, add_to_db=add_to_db) + return self.get_scope_host(target) elif categories[target.category].lower() in ['web application', 'mobile']: - return self.get_scope_web(target, add_to_db=add_to_db) + return self.get_scope_web(target) - def get_scope_host(self, target=None, add_to_db=False, **kwargs): + def get_scope_host(self, target=None, **kwargs): """Get the scope of a Host target""" if target is None: if len(kwargs) > 0: @@ -277,14 +277,12 @@ def get_scope_host(self, target=None, add_to_db=False, **kwargs): scope.discard(None) if len(scope) > 0: - if add_to_db: - self.db.add_ips(self.build_scope_host_db(target.slug, scope)) if self.state.use_scratchspace: self.scratchspace.set_hosts_file(scope, target=target) return scope - def get_scope_web(self, target=None, add_to_db=False, **kwargs): + def get_scope_web(self, target=None, **kwargs): """Get the scope of a Web target""" if target is None: if len(kwargs) > 0: @@ -314,8 +312,6 @@ def get_scope_web(self, target=None, add_to_db=False, **kwargs): }) if len(scope) > 0: - if add_to_db: - self.db.add_urls(self.build_scope_web_db(scope)) if self.state.use_scratchspace: self.scratchspace.set_burp_file(self.build_scope_web_burp(scope), target=target) diff --git a/test/test_hydra.py b/test/test_hydra.py deleted file mode 100644 index 9bdb41e..0000000 --- a/test/test_hydra.py +++ /dev/null @@ -1,165 +0,0 @@ -"""test_hydra.py - -Tests for the Hydra Plugin -""" - -import json -import os -import sys -import unittest - - -from unittest.mock import MagicMock - -sys.path.insert(0, os.path.abspath(os.path.join(__file__, '../../src'))) - -import synack # noqa: E402 - - -class HydraTestCase(unittest.TestCase): - def setUp(self): - self.state = synack._state.State() - self.hydra = synack.plugins.Hydra(self.state) - self.hydra.api = MagicMock() - self.hydra.db = MagicMock() - - def test_build_db_input(self): - """Should convert Hydra output into input for the DB""" - hydra_out = [ - { - 'host_plugins': {}, - 'ip': '1.1.1.1', - 'last_changed_dt': '2022-01-01T12:34:56Z', - 'listing_uid': 'owqeuhiqwe', - 'organization_profile_id': 0, - 'ports': { - '443': { - 'tcp': { - 'synack': { - 'cpe': { - 'last_changed_dt': '2021-01-01T01:01:01.123456Z', - 'parsed': '' - }, - 'open': { - 'last_changed_dt': '2022-01-01T12:34:56Z', - 'parsed': True - }, - 'product': { - 'last_changed_dt': '2021-10-01T12:43:06.654321Z', - 'parsed': '' - }, - 'verified_service': { - 'last_changed_dt': '2021-10-01T21:43:06.654321Z', - 'parsed': 'unknown' - } - } - }, - 'udp': {} - } - } - }, - { - 'host_plugins': {}, - 'ip': '1.1.1.2', - 'last_changed_dt': '2022-01-01T12:34:56.123456Z', - 'listing_uid': 'owqeuhiqwe', - 'organization_profile_id': 0, - 'ports': { - '443': { - 'tcp': { - 'synack': { - 'cpe': { - 'last_changed_dt': '2021-01-01T01:01:01.123456Z', - 'parsed': '' - }, - 'open': { - 'last_changed_dt': '2022-01-01T12:34:56Z', - 'parsed': True - }, - 'product': { - 'last_changed_dt': '2021-10-01T12:43:06.654321Z', - 'parsed': '' - }, - 'verified_service': { - 'last_changed_dt': '2021-10-01T21:43:06.654321Z', - 'parsed': 'unknown' - } - } - }, - 'udp': {} - } - } - } - ] - - returned = self.hydra.build_db_input(hydra_out) - expected = [{ - } - ] - self.assertTrue(returned, expected) - - def test_get_hydra(self): - """Should get information from Hydra""" - query = { - 'page': 1, - 'listing_uids': '87314gru', - 'q': '+port_is_open:true' - } - self.hydra.build_db_input = MagicMock() - self.hydra.build_db_input.return_value = 'BuildDbInputReturn' - self.hydra.db.find_targets.return_value = [ - synack.db.models.Target(codename='CRUSTYCRAB', slug='87314gru') - ] - self.hydra.api.request.return_value.status_code = 200 - content = '[{"somecontent": "content"}]' - self.hydra.api.request.return_value.content = content - returned = self.hydra.get_hydra(codename='CRUSTYCRAB') - self.assertTrue(returned == json.loads(content)) - self.hydra.api.request.assert_called_with('GET', - 'hydra_search/search', - query=query) - self.hydra.build_db_input.assert_called_with(json.loads(content)) - self.hydra.db.add_ports.assert_called_with('BuildDbInputReturn') - - def test_get_hydra_multipage(self): - """Should get information from Hydra spanning multiple pages""" - query = { - 'page': 2, - 'listing_uids': '87314gru', - 'q': '+port_is_open:true' - } - self.hydra.build_db_input = MagicMock() - self.hydra.db.find_targets.return_value = [ - synack.db.models.Target(codename='CRUSTYCRAB', slug='87314gru') - ] - content = '[' + ','.join(['{"somecontent": "content"}' for i in range(0, 10)]) + ']' - self.hydra.api.request.return_value.status_code = 200 - self.hydra.api.request.return_value.content = content - returned = self.hydra.get_hydra(codename='CRUSTYCRAB', max_page=2) - self.assertTrue(len(returned) == 20) - self.hydra.api.request.assert_called_with('GET', - 'hydra_search/search', - query=query) - - def test_get_hydra_no_update_db(self): - """Should get information from Hydra without updating the DB""" - query = { - 'page': 1, - 'listing_uids': '87314gru', - 'q': '+port_is_open:true' - } - self.hydra.build_db_input = MagicMock() - self.hydra.build_db_input.return_value = 'BuildDbInputReturn' - self.hydra.db.find_targets.return_value = [ - synack.db.models.Target(codename='CRUSTYCRAB', slug='87314gru') - ] - self.hydra.api.request.return_value.status_code = 200 - content = '[{"somecontent": "content"}]' - self.hydra.api.request.return_value.content = content - returned = self.hydra.get_hydra(codename='CRUSTYCRAB', update_db=False) - self.assertTrue(returned == json.loads(content)) - self.hydra.api.request.assert_called_with('GET', - 'hydra_search/search', - query=query) - self.hydra.build_db_input.assert_not_called() - self.hydra.db.add_ports.assert_not_called() diff --git a/test/test_targets.py b/test/test_targets.py index 9af76f6..c2d9ee5 100644 --- a/test/test_targets.py +++ b/test/test_targets.py @@ -479,19 +479,7 @@ def test_get_scope_for_host(self): self.targets.db.categories = [Category(id=1, name='Host')] out = self.targets.get_scope(slug='1392g78yr') self.targets.db.find_targets.assert_called_with(slug='1392g78yr') - self.targets.get_scope_host.assert_called_with(tgt, add_to_db=False) - self.assertEquals(out, 'HostScope') - - def test_get_scope_for_host_add_to_db(self): - """Should get the scope for a Host when given Host information""" - self.targets.get_scope_host = MagicMock() - self.targets.get_scope_host.return_value = 'HostScope' - tgt = Target(category=1) - self.targets.db.find_targets.return_value = [tgt] - self.targets.db.categories = [Category(id=1, name='Host')] - out = self.targets.get_scope(slug='1392g78yr', add_to_db=True) - self.targets.db.find_targets.assert_called_with(slug='1392g78yr') - self.targets.get_scope_host.assert_called_with(tgt, add_to_db=True) + self.targets.get_scope_host.assert_called_with(tgt) self.assertEquals(out, 'HostScope') def test_get_scope_for_web(self): @@ -503,19 +491,7 @@ def test_get_scope_for_web(self): self.targets.db.categories = [Category(id=2, name='Web Application')] out = self.targets.get_scope(slug='1392g78yr') self.targets.db.find_targets.assert_called_with(slug='1392g78yr') - self.targets.get_scope_web.assert_called_with(tgt, add_to_db=False) - self.assertEquals(out, 'WebScope') - - def test_get_scope_for_web_add_to_db(self): - """Should get the scope for a Host when given Web information""" - self.targets.get_scope_web = MagicMock() - self.targets.get_scope_web.return_value = 'WebScope' - tgt = Target(category=2) - self.targets.db.find_targets.return_value = [tgt] - self.targets.db.categories = [Category(id=2, name='Web Application')] - out = self.targets.get_scope(slug='1392g78yr', add_to_db=True) - self.targets.db.find_targets.assert_called_with(slug='1392g78yr') - self.targets.get_scope_web.assert_called_with(tgt, add_to_db=True) + self.targets.get_scope_web.assert_called_with(tgt) self.assertEquals(out, 'WebScope') def test_get_scope_host(self): @@ -537,29 +513,6 @@ def test_get_scope_host(self): self.assertEqual(ips, out) self.targets.db.find_targets.assert_called_with(codename='SASSYSQUIRREL') - def test_get_scope_host_add_to_db(self): - """Should get the scope for a Host""" - ips = {'1.1.1.1/32', '2.2.2.2/32'} - self.targets.get_assets = MagicMock() - self.targets.get_assets.return_value = [ - { - 'active': True, - 'location': '1.1.1.1/32' - }, - { - 'active': True, - 'location': '2.2.2.2/32' - } - ] - self.targets.build_scope_host_db = MagicMock() - self.targets.build_scope_host_db.return_value = 'host_db_return_value' - self.targets.db.find_targets.return_value = [Target(slug='213h89h3', codename='SASSYSQUIRREL')] - out = self.targets.get_scope_host(codename='SASSYSQUIRREL', add_to_db=True) - self.assertEqual(ips, out) - self.targets.db.find_targets.assert_called_with(codename='SASSYSQUIRREL') - self.targets.build_scope_host_db.assert_called_with('213h89h3', ips) - self.targets.db.add_ips.assert_called_with('host_db_return_value') - def test_get_scope_host_current(self): """Should get the scope for the currenly connected Host if not specified""" ips = {'1.1.1.1/32', '2.2.2.2/32'} @@ -638,36 +591,6 @@ def test_get_scope_web(self): self.targets.db.find_targets.assert_called_with(codename='SASSYSQUIRREL') self.targets.get_assets.assert_called_with(target=tgt, active='true', asset_type='webapp') - def test_get_scope_web_add_to_db(self): - """Should get the scope for a Web Application and add it to the database""" - self.targets.build_scope_web_burp = MagicMock() - self.targets.build_scope_web_db = MagicMock() - self.targets.get_assets = MagicMock() - scope = [{ - 'listing': 'uewqhuiewq', - 'location': 'https://good.things.com', - 'rule': '*.good.things.com/*', - 'status': 'in' - }] - self.targets.get_assets = MagicMock() - self.targets.get_assets.return_value = [ - { - 'active': True, - 'listings': [{'listingUid': 'uewqhuiewq', 'scope': 'in'}], - 'location': 'https://good.things.com (https://good.things.com)', - 'scopeRules': [ - {'rule': '*.good.things.com/*'} - ] - } - ] - tgt = Target(slug='213h89h3', organization='93g8eh8', codename='SASSYSQUIRREL') - self.targets.db.find_targets.return_value = [tgt] - out = self.targets.get_scope_web(codename='SASSYSQUIRREL', add_to_db=True) - self.assertEqual(scope, out) - self.targets.build_scope_web_burp.assert_called_with(scope) - self.targets.db.find_targets.assert_called_with(codename='SASSYSQUIRREL') - self.targets.db.add_urls.assert_called_with(self.targets.build_scope_web_db.return_value) - def test_get_scope_web_current(self): """Should get the scope for the currently connected Web Application if not specified""" self.targets.build_scope_web_burp = MagicMock() From 4e1bd430ef036428dcf605827a433060f0a2998e Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Sat, 25 Jan 2025 21:36:51 +0000 Subject: [PATCH 09/36] added Duo plugin and removed Authy --- docs/src/usage/plugins/auth.md | 27 ---- src/synack/plugins/__init__.py | 1 + src/synack/plugins/api.py | 7 +- src/synack/plugins/auth.py | 280 +-------------------------------- src/synack/plugins/duo.py | 245 +++++++++++++++++++++++++++++ test/test_auth.py | 35 ----- 6 files changed, 254 insertions(+), 341 deletions(-) create mode 100644 src/synack/plugins/duo.py diff --git a/docs/src/usage/plugins/auth.md b/docs/src/usage/plugins/auth.md index dfd362f..a1f6a30 100644 --- a/docs/src/usage/plugins/auth.md +++ b/docs/src/usage/plugins/auth.md @@ -2,16 +2,6 @@ This plugin deals with authenticating the user to Synack. -## auth.build_otp() - -> Use your stored otp_secret to generate a current OTP code -> ->> Examples ->> ```python3 ->> >>> h.auth.build_otp() ->> '1234567' ->> ``` - ## auth.get_api_token() > Walks through the whole authentication workflow to get a new api_token @@ -32,23 +22,6 @@ This plugin deals with authenticating the user to Synack. >> '45h998h4g5...45wh89g9wh' >> ``` -## auth.get_login_grant_token(csrf, progress_token) - -> Get a Login Grant Token by providing an OTP Code -> -> | Argument | Type | Description -> | --- | --- | --- -> | `csrf` | str | A CSRF Token used while logging in -> | `progress_token` | str | A token returned after submitting a valid username and password -> ->> Examples ->> ```python3 ->> >>> csrf = h.auth.get_login_csrf() ->> >>> lpt = h.auth.get_login_progress_token(csrf) ->> >>> h.auth.get_login_grant_token(csrf, lpt) ->> '58t7i...rh87g58' ->> ``` - ## auth.get_login_progress_token(csrf) > Get the Login Progress Token by authenticating with email and password diff --git a/src/synack/plugins/__init__.py b/src/synack/plugins/__init__.py index 8ed429e..d131265 100644 --- a/src/synack/plugins/__init__.py +++ b/src/synack/plugins/__init__.py @@ -5,6 +5,7 @@ from .auth import Auth from .db import Db from .debug import Debug +from .duo import Duo from .missions import Missions from .notifications import Notifications from .scratchspace import Scratchspace diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index 1b8542d..2234e89 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -64,7 +64,7 @@ def notifications(self, method, path, **kwargs): self.state.notifications_token = '' return res - def request(self, method, path, include_std_headers=True, attempt=0, **kwargs): + def request(self, method, path, attempts=0, **kwargs): """Send API Request Arguments: @@ -72,6 +72,7 @@ def request(self, method, path, include_std_headers=True, attempt=0, **kwargs): (GET, POST, etc.) path -- API endpoint path Can be an endpoint on platform.synack.com or a full URL + attempts -- Number of times the request has been attempted headers -- Additional headers to be added for only this request data -- POST body dictionary query -- GET query string dictionary @@ -86,7 +87,7 @@ def request(self, method, path, include_std_headers=True, attempt=0, **kwargs): verify = False proxies = self.state.proxies if self.state.use_proxies else None - if include_std_headers: + if 'synack.com/api/' in url: headers = { 'Authorization': f'Bearer {self.state.api_token}', 'user_id': self.state.user_id @@ -148,5 +149,5 @@ def request(self, method, path, include_std_headers=True, attempt=0, **kwargs): if attempts < 5: time.sleep(30) attempts += 1 - return self.request(method, path, include_std_headers, attempts, **kwargs) + return self.request(method, path, attempts, **kwargs) return res diff --git a/src/synack/plugins/auth.py b/src/synack/plugins/auth.py index 9b6bcae..2da861c 100644 --- a/src/synack/plugins/auth.py +++ b/src/synack/plugins/auth.py @@ -3,9 +3,7 @@ Functions related to handling and checking authentication. """ -import pyotp import re -import time from .base import Plugin @@ -13,19 +11,11 @@ class Auth(Plugin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - for plugin in ['Api', 'Db', 'Users']: + for plugin in ['Api', 'Db', 'Duo', 'Users']: setattr(self, plugin.lower(), self.registry.get(plugin)(self.state)) - def build_otp(self): - """Generate and return a OTP.""" - totp = pyotp.TOTP(self.state.otp_secret) - totp.digits = 7 - totp.interval = 10 - totp.issuer = 'synack' - return totp.now() - def get_api_token(self): """Log in to get a new API token.""" if self.users.get_profile(): @@ -36,10 +26,10 @@ def get_api_token(self): grant_token = None if csrf: auth_response = self.get_authentication_response(csrf) - progress_token = auth_response.get('progress_token') - duo_auth_url = auth_response.get('duo_auth_url') + progress_token = auth_response.get('progress_token', '') + duo_auth_url = auth_response.get('duo_auth_url', '') if duo_auth_url: - grant_token = self.get_duo_push(duo_auth_url) + grant_token = self.duo.get_grant_token(duo_auth_url) if grant_token: url = 'https://platform.synack.com/' headers = { @@ -66,268 +56,6 @@ def get_login_csrf(self): res.text) return m.group(1) - def get_duo_push_variables(self, duo_auth_url): - headers = { - 'Sec-Ch-Ua': '"Chromium";v="131", "Not_A Brand";v="24"', - 'Sec-Ch-Ua-Mobile': '?0', - 'Sec-Ch-Ua-Platform': '"Linux"', - 'Referrer': 'https://login.synack.com/', - 'Sec-Fetch-Site': 'cross-site', - 'Sec-Fetch-Mode': 'navigate', - 'Sec-Fetch-User': '?1', - 'Sec-Fetch-Dest': 'document' - } - res = self.api.request('GET', duo_auth_url, include_std_headers=False) - if res.status_code == 200: - return { - 'post_data': { - 'tx': re.search(' Date: Sat, 25 Jan 2025 22:36:39 +0000 Subject: [PATCH 10/36] about to break stuff --- src/synack/_handler.py | 9 ++-- src/synack/plugins/__init__.py | 1 + src/synack/plugins/auth.py | 4 +- src/synack/plugins/duo.py | 87 +++++++++++++++++++++++----------- src/synack/plugins/utils.py | 23 +++++++++ test/test_auth.py | 2 - 6 files changed, 90 insertions(+), 36 deletions(-) create mode 100644 src/synack/plugins/utils.py diff --git a/src/synack/_handler.py b/src/synack/_handler.py index 75bd0f5..22bcf7c 100644 --- a/src/synack/_handler.py +++ b/src/synack/_handler.py @@ -11,15 +11,16 @@ class Handler: def __init__(self, state=State(), **kwargs): self.state = state - for key in kwargs.keys(): - if hasattr(self.state, key): - setattr(self.state, key, kwargs.get(key)) - for name, subclass in Plugin.registry.items(): instance = subclass(self.state) setattr(self, name.lower(), instance) self.state._db = self.db + + for key in kwargs.keys(): + if hasattr(self.state, key): + setattr(self.state, key, kwargs.get(key)) + self.login() def login(self): diff --git a/src/synack/plugins/__init__.py b/src/synack/plugins/__init__.py index d131265..e6f9f92 100644 --- a/src/synack/plugins/__init__.py +++ b/src/synack/plugins/__init__.py @@ -13,3 +13,4 @@ from .templates import Templates from .transactions import Transactions from .users import Users +from .utils import Utils diff --git a/src/synack/plugins/auth.py b/src/synack/plugins/auth.py index 2da861c..aec7baf 100644 --- a/src/synack/plugins/auth.py +++ b/src/synack/plugins/auth.py @@ -21,12 +21,10 @@ def get_api_token(self): if self.users.get_profile(): return self.state.api_token csrf = self.get_login_csrf() - progress_token = None duo_auth_url = None grant_token = None if csrf: auth_response = self.get_authentication_response(csrf) - progress_token = auth_response.get('progress_token', '') duo_auth_url = auth_response.get('duo_auth_url', '') if duo_auth_url: grant_token = self.duo.get_grant_token(duo_auth_url) @@ -57,7 +55,7 @@ def get_login_csrf(self): return m.group(1) def get_authentication_response(self, csrf): - """Get progress_token and duo_auth_url from email and password login""" + """Get duo_auth_url from email and password login""" headers = { 'X-CSRF-Token': csrf } diff --git a/src/synack/plugins/duo.py b/src/synack/plugins/duo.py index 10d0c85..89c8dc0 100644 --- a/src/synack/plugins/duo.py +++ b/src/synack/plugins/duo.py @@ -5,6 +5,8 @@ from .base import Plugin +import base64 +import json import pyotp import re import time @@ -13,9 +15,9 @@ class Duo(Plugin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - for plugin in ['Api', 'Db']: + for plugin in ['Api', 'Db', 'Utils']: setattr(self, - plugin.lower(), + '_'+plugin.lower(), self.registry.get(plugin)(self.state)) self._auth_url = None @@ -50,7 +52,7 @@ def get_grant_token(self, auth_url): self._auth_url = auth_url self._get_session_variables() self._set_session_variables() - self._set_session_variables() # Yes, this needs to be called twice... + self._set_session_variables() # Yes, this needs to be called twice... self._get_txid() if self._txid: self._get_status() @@ -76,7 +78,7 @@ def _get_oidc_exit(self): '_xsrf': self._xsrf, 'dampen_choice': 'false' } - res = self.api.request('POST', f'{self._base_url}/frame/v4/oidc/exit', include_std_headers=False, headers=headers, data=data) + res = self._api.request('POST', f'{self._base_url}/frame/v4/oidc/exit', headers=headers, data=data) if res.status_code == 200: self._grant_token = re.search('grant_token=([^&]*)', res.url).group(1) @@ -89,38 +91,58 @@ def _set_session_variables(self): 'Sec-Fetch-Site': 'same-origin', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Dest': 'document', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Accept': ';'.join([ + 'text/html,application/xhtml+xml,application/xml', + 'q=0.9,image/avif,image/webp,image/apng,*/*', + 'q=0.8,application/signed-exchange', + 'v=b3;q=0.7' + ]), 'Content-Type': 'application/x-www-form-urlencoded' } - res = self.api.request('POST', self._referrer, headers=headers, data=self._session_vars) + res = self._api.request('POST', self._referrer, headers=headers, data=self._session_vars) if res.status_code == 200: self._referrer = res.url def _get_session_variables(self): self._referrer = 'https://login.synack.com/' - res = self.api.request('GET', self._auth_url, headers=self._get_headers()) + res = self._api.request('GET', self._auth_url, headers=self._get_headers()) if res.status_code == 200: self._sid = re.search('sid=([^&]*)', res.url).group(1) self._referrer = res.url - self._base_url = re.search('(https.*duosecurity\.com)/', res.url).group(1) - self._xsrf = re.search(']*name=.{field}.[^>]*value=.([^"\']*)', text) + if match.group is None: + match = re.search(f'<[^>]*value=.([^"\']*)[^>]*name=.{field}', text) + return match.group(1) if match else '' diff --git a/test/test_auth.py b/test/test_auth.py index ff16d5a..aba9cc5 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -5,7 +5,6 @@ import os import pathlib -import pyotp import sys import unittest @@ -50,7 +49,6 @@ def test_get_api_token_login_success(self): self.auth.users.get_profile.return_value = {"user_id": "john"} self.assertEqual("qweqweqwe", self.auth.get_api_token()) - def test_get_login_progress_token(self): """Should get the progress token from valid creds""" self.auth.api.login.return_value.status_code = 200 From e6b3e9eb2f1ebf6441de4cb3cb7d3e538fc9473b Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Sat, 25 Jan 2025 23:01:52 +0000 Subject: [PATCH 11/36] hid internal functions/etc --- src/synack/_handler.py | 6 +- src/synack/plugins/alerts.py | 20 +++---- src/synack/plugins/api.py | 27 ++++----- src/synack/plugins/auth.py | 37 ++++++------ src/synack/plugins/base.py | 6 +- src/synack/plugins/db.py | 10 ++-- src/synack/plugins/debug.py | 6 +- src/synack/plugins/duo.py | 12 ++-- src/synack/plugins/missions.py | 20 +++---- src/synack/plugins/notifications.py | 10 ++-- src/synack/plugins/scratchspace.py | 8 +-- src/synack/plugins/targets.py | 92 ++++++++++++++--------------- src/synack/plugins/templates.py | 10 ++-- src/synack/plugins/transactions.py | 6 +- src/synack/plugins/users.py | 8 +-- src/synack/plugins/utils.py | 4 +- 16 files changed, 142 insertions(+), 140 deletions(-) diff --git a/src/synack/_handler.py b/src/synack/_handler.py index 22bcf7c..b29fd9a 100644 --- a/src/synack/_handler.py +++ b/src/synack/_handler.py @@ -11,7 +11,7 @@ class Handler: def __init__(self, state=State(), **kwargs): self.state = state - for name, subclass in Plugin.registry.items(): + for name, subclass in Plugin._registry.items(): instance = subclass(self.state) setattr(self, name.lower(), instance) @@ -21,8 +21,8 @@ def __init__(self, state=State(), **kwargs): if hasattr(self.state, key): setattr(self.state, key, kwargs.get(key)) - self.login() + self._login() - def login(self): + def _login(self): if self.state.login: self.auth.get_api_token() diff --git a/src/synack/plugins/alerts.py b/src/synack/plugins/alerts.py index 7318d3b..e3ef7b2 100644 --- a/src/synack/plugins/alerts.py +++ b/src/synack/plugins/alerts.py @@ -19,23 +19,23 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for plugin in ['Db']: setattr(self, - plugin.lower(), - self.registry.get(plugin)(self.state)) + '_'+plugin.lower(), + self._registry.get(plugin)(self._state)) def email(self, subject='Test Alert', message='This is a test'): message += f'\nTime: {datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")}' msg = email.message.EmailMessage() msg.set_content(message) msg['Subject'] = subject - msg['From'] = self.state.smtp_email_from - msg['To'] = self.state.smtp_email_to + msg['From'] = self._state.smtp_email_from + msg['To'] = self._state.smtp_email_to - if self.state.smtp_starttls: - server = smtplib.SMTP_SSL(self.state.smtp_server, self.state.smtp_port) + if self._state.smtp_starttls: + server = smtplib.SMTP_SSL(self._state.smtp_server, self._state.smtp_port) else: - server = smtplib.SMTP(self.state.smtp_server, self.state.smtp_port) + server = smtplib.SMTP(self._state.smtp_server, self._state.smtp_port) - server.login(self.state.smtp_username, self.state.smtp_password) + server.login(self._state.smtp_username, self._state.smtp_password) server.send_message(msg) def sanitize(self, message): @@ -61,7 +61,7 @@ def sanitize(self, message): def slack(self, message='This is a test', channel=None): if channel is None: - channel = self.state.slack_channel + channel = self._state.slack_channel warnings.filterwarnings("ignore") requests.post('https://slack.com/api/chat.postMessage', data=json.dumps({ @@ -69,7 +69,7 @@ def slack(self, message='This is a test', channel=None): 'channel': channel, }), headers={ - 'Authorization': f'Bearer {self.state.slack_app_token}', + 'Authorization': f'Bearer {self._state.slack_app_token}', 'Content-Type': 'application/json' }, verify=False) diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index 2234e89..7ad5990 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -13,7 +13,7 @@ class Api(Plugin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for plugin in ['Debug', 'Db']: - setattr(self, plugin.lower(), self.registry.get(plugin)(self.state)) + setattr(self, '_'+plugin.lower(), self._registry.get(plugin)(self._state)) def login(self, method, path, **kwargs): """Modify API Request for Login @@ -55,13 +55,12 @@ def notifications(self, method, path, **kwargs): if not kwargs.get('headers'): kwargs['headers'] = dict() - auth = "Bearer " + self.state.notifications_token + auth = "Bearer " + self._state.notifications_token kwargs['headers']['Authorization'] = auth res = self.request(method, url, **kwargs) if res.status_code == 422: - self.db.notifications_token = '' - self.state.notifications_token = '' + self._db.notifications_token = '' return res def request(self, method, path, attempts=0, **kwargs): @@ -85,12 +84,12 @@ def request(self, method, path, attempts=0, **kwargs): warnings.filterwarnings("ignore") verify = False - proxies = self.state.proxies if self.state.use_proxies else None + proxies = self._state.proxies if self._state.use_proxies else None if 'synack.com/api/' in url: headers = { - 'Authorization': f'Bearer {self.state.api_token}', - 'user_id': self.state.user_id + 'Authorization': f'Bearer {self._state.api_token}', + 'user_id': self._state.user_id } else: headers = dict() @@ -100,44 +99,44 @@ def request(self, method, path, attempts=0, **kwargs): data = kwargs.get('data') if method.upper() == 'GET': - res = self.state.session.get(url, + res = self._state.session.get(url, headers=headers, proxies=proxies, params=query, verify=verify) elif method.upper() == 'HEAD': - res = self.state.session.head(url, + res = self._state.session.head(url, headers=headers, proxies=proxies, params=query, verify=verify) elif method.upper() == 'PATCH': - res = self.state.session.patch(url, + res = self._state.session.patch(url, json=data, headers=headers, proxies=proxies, verify=verify) elif method.upper() == 'POST': if 'urlencoded' in headers.get('Content-Type', ''): - res = self.state.session.post(url, + res = self._state.session.post(url, data=data, headers=headers, proxies=proxies, verify=verify) else: - res = self.state.session.post(url, + res = self._state.session.post(url, json=data, headers=headers, proxies=proxies, verify=verify) elif method.upper() == 'PUT': - res = self.state.session.put(url, + res = self._state.session.put(url, headers=headers, proxies=proxies, params=data, verify=verify) - self.debug.log("Network Request", + self._debug.log("Network Request", f"{res.status_code} -- {method.upper()} -- {url}" + f"\n\tHeaders: {headers}" + f"\n\tQuery: {query}" + diff --git a/src/synack/plugins/auth.py b/src/synack/plugins/auth.py index aec7baf..abe0ea2 100644 --- a/src/synack/plugins/auth.py +++ b/src/synack/plugins/auth.py @@ -13,13 +13,13 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for plugin in ['Api', 'Db', 'Duo', 'Users']: setattr(self, - plugin.lower(), - self.registry.get(plugin)(self.state)) + '_'+plugin.lower(), + self._registry.get(plugin)(self._state)) def get_api_token(self): """Log in to get a new API token.""" - if self.users.get_profile(): - return self.state.api_token + if self._users.get_profile(): + return self._state.api_token csrf = self.get_login_csrf() duo_auth_url = None grant_token = None @@ -27,7 +27,7 @@ def get_api_token(self): auth_response = self.get_authentication_response(csrf) duo_auth_url = auth_response.get('duo_auth_url', '') if duo_auth_url: - grant_token = self.duo.get_grant_token(duo_auth_url) + grant_token = self._duo.get_grant_token(duo_auth_url) if grant_token: url = 'https://platform.synack.com/' headers = { @@ -36,20 +36,19 @@ def get_api_token(self): query = { "grant_token": grant_token } - res = self.api.request('GET', + res = self._api.request('GET', url + 'token', headers=headers, query=query) if res.status_code == 200: j = res.json() - self.db.api_token = j.get('access_token') - self.state.api_token = j.get('access_token') + self._db.api_token = j.get('access_token') self.set_login_script() return j.get('access_token') def get_login_csrf(self): """Get the CSRF Token from the login page""" - res = self.api.request('GET', 'https://login.synack.com') + res = self._api.request('GET', 'https://login.synack.com') m = re.search(' 0: - target = self.db.find_targets(**kwargs) + target = self._db.find_targets(**kwargs) else: curr = self.get_connected() - target = self.db.find_targets(slug=curr.get('slug')) + target = self._db.find_targets(slug=curr.get('slug')) if type(scope) == str: scope = [scope] @@ -140,10 +140,10 @@ def get_assets(self, target=None, asset_type=None, host_type=None, active='true' if perPage is not None: queries.append(f'perPage={perPage}') - res = self.api.request('GET', f'asset/v2/assets?{"&".join(queries)}') + res = self._api.request('GET', f'asset/v2/assets?{"&".join(queries)}') if res.status_code == 200: - if self.state.use_scratchspace: - self.scratchspace.set_assets_file(res.text, target=target) + if self._state.use_scratchspace: + self._scratchspace.set_assets_file(res.text, target=target) return res.json() def get_attachments(self, target=None, **kwargs): @@ -151,16 +151,16 @@ def get_attachments(self, target=None, **kwargs): if target is None: if len(kwargs) == 0: kwargs = {'codename': self.get_connected().get('codename')} - target = self.db.find_targets(**kwargs) + target = self._db.find_targets(**kwargs) if target: target = target[0] - res = self.api.request('GET', f'targets/{target.slug}/resources') + res = self._api.request('GET', f'targets/{target.slug}/resources') if res.status_code == 200: return res.json() def get_connected(self): """Return information about the currenly selected target""" - res = self.api.request('GET', 'launchpoint') + res = self._api.request('GET', 'launchpoint') if res.status_code == 200: j = res.json() slug = j.get('slug') @@ -182,31 +182,31 @@ def get_connections(self, target=None, **kwargs): if target is None: if len(kwargs) == 0: kwargs = {'codename': self.get_connected().get('codename')} - target = self.db.find_targets(**kwargs) + target = self._db.find_targets(**kwargs) if target: target = target[0] - res = self.api.request('GET', "listing_analytics/connections", query={"listing_id": target.slug}) + res = self._api.request('GET', "listing_analytics/connections", query={"listing_id": target.slug}) if res.status_code == 200: return res.json()["value"] def get_credentials(self, **kwargs): """Get Credentials for a target""" - target = self.db.find_targets(**kwargs)[0] + target = self._db.find_targets(**kwargs)[0] if target: - res = self.api.request('POST', + res = self._api.request('POST', f'asset/v1/organizations/{target.organization}' + f'/owners/listings/{target.slug}' + - f'/users/{self.state.user_id}' + + f'/users/{self._state.user_id}' + '/credentials') if res.status_code == 200: return res.json() def get_query(self, status='registered', query_changes={}): """Get information about targets returned from a query""" - if not self.db.categories: + if not self._db.categories: self.get_assessments() categories = [] - for category in self.db.categories: + for category in self._db.categories: if category.passed_practical and category.passed_written: categories.append(category.id) query = { @@ -216,17 +216,17 @@ def get_query(self, status='registered', query_changes={}): 'filter[category][]': categories } query.update(query_changes) - res = self.api.request('GET', 'targets', query=query) + res = self._api.request('GET', 'targets', query=query) if res.status_code == 200: - self.db.add_targets(res.json(), is_registered=True) + self._db.add_targets(res.json(), is_registered=True) return res.json() def get_registered_summary(self): """Get information on your registered targets""" - res = self.api.request('GET', 'targets/registered_summary') + res = self._api.request('GET', 'targets/registered_summary') ret = [] if res.status_code == 200: - self.db.add_targets(res.json()) + self._db.add_targets(res.json()) ret = dict() for t in res.json(): ret[t['id']] = t @@ -235,15 +235,15 @@ def get_registered_summary(self): def get_scope(self, **kwargs): """Get the scope of a target""" if len(kwargs) > 0: - target = self.db.find_targets(**kwargs) + target = self._db.find_targets(**kwargs) else: curr = self.get_connected() - target = self.db.find_targets(slug=curr.get('slug')) + target = self._db.find_targets(slug=curr.get('slug')) if target: target = target[0] categories = dict() - for category in self.db.categories: + for category in self._db.categories: categories[category.id] = category.name if categories[target.category].lower() == 'host': return self.get_scope_host(target) @@ -254,10 +254,10 @@ def get_scope_host(self, target=None, **kwargs): """Get the scope of a Host target""" if target is None: if len(kwargs) > 0: - targets = self.db.find_targets(**kwargs) + targets = self._db.find_targets(**kwargs) else: curr = self.get_connected() - targets = self.db.find_targets(slug=curr.get('slug')) + targets = self._db.find_targets(slug=curr.get('slug')) if targets: target = next(iter(targets), None) @@ -277,8 +277,8 @@ def get_scope_host(self, target=None, **kwargs): scope.discard(None) if len(scope) > 0: - if self.state.use_scratchspace: - self.scratchspace.set_hosts_file(scope, target=target) + if self._state.use_scratchspace: + self._scratchspace.set_hosts_file(scope, target=target) return scope @@ -286,10 +286,10 @@ def get_scope_web(self, target=None, **kwargs): """Get the scope of a Web target""" if target is None: if len(kwargs) > 0: - targets = self.db.find_targets(**kwargs) + targets = self._db.find_targets(**kwargs) else: curr = self.get_connected() - targets = self.db.find_targets(slug=curr.get('slug')) + targets = self._db.find_targets(slug=curr.get('slug')) if targets: target = next(iter(targets), None) @@ -312,8 +312,8 @@ def get_scope_web(self, target=None, **kwargs): }) if len(scope) > 0: - if self.state.use_scratchspace: - self.scratchspace.set_burp_file(self.build_scope_web_burp(scope), target=target) + if self._state.use_scratchspace: + self._scratchspace.set_burp_file(self.build_scope_web_burp(scope), target=target) return scope @@ -324,11 +324,11 @@ def get_submissions(self, target=None, status="accepted", **kwargs): if target is None: if len(kwargs) == 0: kwargs = {'codename': self.get_connected().get('codename')} - target = self.db.find_targets(**kwargs) + target = self._db.find_targets(**kwargs) if target: target = target[0] query = {"listing_id": target.slug, "status": status} - res = self.api.request('GET', "listing_analytics/categories", query=query) + res = self._api.request('GET', "listing_analytics/categories", query=query) if res.status_code == 200: return res.json()["value"] @@ -337,13 +337,13 @@ def get_submissions_summary(self, target=None, hours_ago=None, **kwargs): if target is None: if len(kwargs) == 0: kwargs = {'codename': self.get_connected().get('codename')} - target = self.db.find_targets(**kwargs) + target = self._db.find_targets(**kwargs) if target: target = target[0] query = {"listing_id": target.slug} if hours_ago: query["period"] = f"{hours_ago}h" - res = self.api.request('GET', "listing_analytics/submissions", query=query) + res = self._api.request('GET', "listing_analytics/submissions", query=query) if res.status_code == 200: return res.json()["value"] @@ -367,12 +367,12 @@ def set_connected(self, target=None, **kwargs): elif len(kwargs) == 0: slug = '' else: - target = self.db.find_targets(**kwargs) + target = self._db.find_targets(**kwargs) if target: slug = target[0].slug if slug is not None: - res = self.api.request('PUT', 'launchpoint', data={'listing_id': slug}) + res = self._api.request('PUT', 'launchpoint', data={'listing_id': slug}) if res.status_code == 200: return self.get_connected() @@ -383,7 +383,7 @@ def set_registered(self, targets=None): data = '{"ResearcherListing":{"terms":1}}' ret = [] for t in targets: - res = self.api.request('POST', + res = self._api.request('POST', f'targets/{t["slug"]}/signup', data=data) if res.status_code == 200: diff --git a/src/synack/plugins/templates.py b/src/synack/plugins/templates.py index f5fb3f6..a1bd30c 100644 --- a/src/synack/plugins/templates.py +++ b/src/synack/plugins/templates.py @@ -14,11 +14,11 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for plugin in ['Alerts', 'Db', 'Targets']: setattr(self, - plugin.lower(), - self.registry.get(plugin)(self.state)) + '_'+plugin.lower(), + self._registry.get(plugin)(self._state)) def build_filepath(self, mission, generic_ok=False): - f = self.state.template_dir + f = self._state.template_dir f = f / self.build_safe_name(mission['taskType']) if mission.get('asset'): f = f / self.build_safe_name(mission['asset']) @@ -34,7 +34,7 @@ def build_filepath(self, mission, generic_ok=False): def build_replace_variables(self, text, target=None, **kwargs): """Replaces known variables within text""" if target is None: - target = self.db.find_targets(**kwargs) + target = self._db.find_targets(**kwargs) if target: target = target[0] @@ -44,7 +44,7 @@ def build_replace_variables(self, text, target=None, **kwargs): def build_safe_name(self, name): """Simplify a name to use for a file path""" - name = self.alerts.sanitize(name) + name = self._alerts.sanitize(name) name = name.lower() name = re.sub('[^a-z0-9]', '_', name) return re.sub('_+', '_', name) diff --git a/src/synack/plugins/transactions.py b/src/synack/plugins/transactions.py index c973a9d..70346fc 100644 --- a/src/synack/plugins/transactions.py +++ b/src/synack/plugins/transactions.py @@ -13,11 +13,11 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for plugin in ['Api']: setattr(self, - plugin.lower(), - self.registry.get(plugin)(self.state)) + '_'+plugin.lower(), + self._registry.get(plugin)(self._state)) def get_balance(self): """Get your current account balance and requested payout values""" - res = self.api.request('HEAD', 'transactions') + res = self._api.request('HEAD', 'transactions') if res.status_code == 200: return json.loads(res.headers.get('x-balance')) diff --git a/src/synack/plugins/users.py b/src/synack/plugins/users.py index c26030d..ea461df 100644 --- a/src/synack/plugins/users.py +++ b/src/synack/plugins/users.py @@ -11,12 +11,12 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for plugin in ['Api', 'Db']: setattr(self, - plugin.lower(), - self.registry.get(plugin)(self.state)) + '_'+plugin.lower(), + self._registry.get(plugin)(self._state)) def get_profile(self, user_id="me"): """Get a user's profile""" - res = self.api.request('GET', f'profiles/{user_id}') + res = self._api.request('GET', f'profiles/{user_id}') if res.status_code == 200: - self.state.user_id = res.json().get('user_id') + self._db.user_id = res.json().get('user_id') return res.json() diff --git a/src/synack/plugins/utils.py b/src/synack/plugins/utils.py index 4ba6fdd..6192c7b 100644 --- a/src/synack/plugins/utils.py +++ b/src/synack/plugins/utils.py @@ -12,8 +12,8 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) for plugin in []: setattr(self, - plugin.lower(), - self.registry.get(plugin)(self.state)) + '_'+plugin.lower(), + self._registry.get(plugin)(self._state)) @staticmethod def get_html_tag_value(field, text): From d35f86c2431830b484dbf6f81aa525e58c40006c Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Sat, 25 Jan 2025 23:39:40 +0000 Subject: [PATCH 12/36] fixed formatting and documentation issues --- checks.sh | 4 +- docs/src/SUMMARY.md | 1 + docs/src/usage/plugins/auth.md | 24 ++++ docs/src/usage/plugins/duo.md | 15 +++ docs/src/usage/plugins/utils.md | 17 +++ src/synack/plugins/api.py | 52 ++++---- src/synack/plugins/auth.py | 26 ++-- src/synack/plugins/db.py | 40 +++--- src/synack/plugins/duo.py | 198 ++++++++++++++-------------- src/synack/plugins/missions.py | 40 +++--- src/synack/plugins/notifications.py | 6 +- src/synack/plugins/targets.py | 12 +- src/synack/plugins/utils.py | 1 + 13 files changed, 248 insertions(+), 188 deletions(-) create mode 100644 docs/src/usage/plugins/duo.md create mode 100644 docs/src/usage/plugins/utils.md diff --git a/checks.sh b/checks.sh index b14fab7..eede3bc 100755 --- a/checks.sh +++ b/checks.sh @@ -31,7 +31,9 @@ for plugin in ./src/synack/plugins/*.py; do if [[ $? != 0 ]]; then grep "def ${def}(" ${plugin} -B1 | grep "@property" > /dev/null 2>&1 if [[ $? != 0 ]]; then - echo ${p} missing documentation for: ${def} + if [[ "${def}" != "_"* ]]; then + echo ${p} missing documentation for: ${def} + fi fi fi done diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index ecf7f10..276ab62 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -18,6 +18,7 @@ - [Api](./usage/plugins/api.md) - [Auth](./usage/plugins/auth.md) - [Db](./usage/plugins/db.md) + - [Duo](./usage/plugins/duo.md) - [Debug](./usage/plugins/debug.md) - [Missions](./usage/plugins/missions.md) - [Notifications](./usage/plugins/notifications.md) diff --git a/docs/src/usage/plugins/auth.md b/docs/src/usage/plugins/auth.md index a1f6a30..83cbad0 100644 --- a/docs/src/usage/plugins/auth.md +++ b/docs/src/usage/plugins/auth.md @@ -12,6 +12,21 @@ This plugin deals with authenticating the user to Synack. >> '489hr98hf...eh59' >> ``` +## auth.get_authentication_response(csrf) + +> Send the username and password to Synack and returns the response +> +> | Arguments | Description +> | --- | --- +> | `csrf` | CSRF token issued by Synack Authentication Workflow +> +>> Examples +>> ```python3 +>> >>> csrf = h.auth.get_login_csrf() +>> >>> h.auth.get_authentication_response(csrf) +>> {'success': True, ..., 'duo_auth_url': 'https://...'} +>> ``` + ## auth.get_login_csrf() > Pulls a CSRF Token from the Login page @@ -47,6 +62,15 @@ This plugin deals with authenticating the user to Synack. >> '958htiu...h98f5ht' >> ``` +## auth.set_api_token_invalid() + +> Invalidates the API Token by logging out +> +>> Examples +>> ```python3 +>> >>> h.auth.set_api_token_invalid() +>> ``` + ## auth.set_login_script() > Writes the current api_token to `~/.config/synack/login.js` JavaScript file to help with staying logged in. diff --git a/docs/src/usage/plugins/duo.md b/docs/src/usage/plugins/duo.md new file mode 100644 index 0000000..0347edd --- /dev/null +++ b/docs/src/usage/plugins/duo.md @@ -0,0 +1,15 @@ +# Duo + +## duo.get_grant_token(auth_url) + +> Handles Duo Security MFA stages and returns the grant_token used to finish logging into Synack +> +> | Arguments | Description +> | --- | --- +> | `auth_url` | Duo Security Authentication URL generaated by sending credentials to Synack +> +>> Examples +>> ```python3 +>> >>> h.duo.get_grant_token('https:///...duosecurity.com/...') +>> 'Y8....6g' +>> ``` diff --git a/docs/src/usage/plugins/utils.md b/docs/src/usage/plugins/utils.md new file mode 100644 index 0000000..7a6971a --- /dev/null +++ b/docs/src/usage/plugins/utils.md @@ -0,0 +1,17 @@ +# Utils + +## utils.get_html_tag_value(field, text) + +> Looks for an HTML tag in raw HTML and returns its value +> +> | Arguments | Description +> | --- | --- +> | `field` | name of HTML field to find value for +> | `text` | raw HTML content +> +>> Examples +>> ```python3 +>> >>> html = '......' +>> >>> h.utils.get_html_tag_value('tacos', html) +>> 'tasty' +>> ``` diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index 7ad5990..6af9d22 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -100,48 +100,48 @@ def request(self, method, path, attempts=0, **kwargs): if method.upper() == 'GET': res = self._state.session.get(url, - headers=headers, - proxies=proxies, - params=query, - verify=verify) - elif method.upper() == 'HEAD': - res = self._state.session.head(url, headers=headers, proxies=proxies, params=query, verify=verify) - elif method.upper() == 'PATCH': - res = self._state.session.patch(url, - json=data, + elif method.upper() == 'HEAD': + res = self._state.session.head(url, headers=headers, proxies=proxies, + params=query, verify=verify) + elif method.upper() == 'PATCH': + res = self._state.session.patch(url, + json=data, + headers=headers, + proxies=proxies, + verify=verify) elif method.upper() == 'POST': if 'urlencoded' in headers.get('Content-Type', ''): res = self._state.session.post(url, - data=data, - headers=headers, - proxies=proxies, - verify=verify) + data=data, + headers=headers, + proxies=proxies, + verify=verify) else: res = self._state.session.post(url, - json=data, - headers=headers, - proxies=proxies, - verify=verify) + json=data, + headers=headers, + proxies=proxies, + verify=verify) elif method.upper() == 'PUT': res = self._state.session.put(url, - headers=headers, - proxies=proxies, - params=data, - verify=verify) + headers=headers, + proxies=proxies, + params=data, + verify=verify) self._debug.log("Network Request", - f"{res.status_code} -- {method.upper()} -- {url}" + - f"\n\tHeaders: {headers}" + - f"\n\tQuery: {query}" + - f"\n\tData: {data}" + - f"\n\tContent: {res.content}") + f"{res.status_code} -- {method.upper()} -- {url}" + + f"\n\tHeaders: {headers}" + + f"\n\tQuery: {query}" + + f"\n\tData: {data}" + + f"\n\tContent: {res.content}") if res.status_code == 429: attempts = kwargs.get('attempts', 0) diff --git a/src/synack/plugins/auth.py b/src/synack/plugins/auth.py index abe0ea2..ae6e7a2 100644 --- a/src/synack/plugins/auth.py +++ b/src/synack/plugins/auth.py @@ -37,22 +37,15 @@ def get_api_token(self): "grant_token": grant_token } res = self._api.request('GET', - url + 'token', - headers=headers, - query=query) + url + 'token', + headers=headers, + query=query) if res.status_code == 200: j = res.json() self._db.api_token = j.get('access_token') self.set_login_script() return j.get('access_token') - def get_login_csrf(self): - """Get the CSRF Token from the login page""" - res = self._api.request('GET', 'https://login.synack.com') - m = re.search('= 15: diff --git a/src/synack/plugins/utils.py b/src/synack/plugins/utils.py index 6192c7b..05bbe2a 100644 --- a/src/synack/plugins/utils.py +++ b/src/synack/plugins/utils.py @@ -7,6 +7,7 @@ import re + class Utils(Plugin): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) From b8b2455494a673a510ba3d3ba0ff3b4ed159b954 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Sun, 26 Jan 2025 01:49:25 +0000 Subject: [PATCH 13/36] fixed failing tests --- src/synack/plugins/api.py | 9 +- src/synack/plugins/db.py | 16 +- test/test_alerts.py | 36 ++-- test/test_api.py | 176 +++++++++--------- test/test_auth.py | 82 ++++----- test/test_db.py | 127 ++----------- test/test_debug.py | 3 +- test/test_handler.py | 6 +- test/test_missions.py | 109 ++++++------ test/test_notifications.py | 21 +-- test/test_scratchspace.py | 19 +- test/test_state.py | 35 ++-- test/test_targets.py | 354 +++++++++++++++++++------------------ test/test_templates.py | 27 +-- test/test_transactions.py | 11 +- test/test_users.py | 21 +-- 16 files changed, 473 insertions(+), 579 deletions(-) diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index 6af9d22..d07fcd0 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -4,7 +4,7 @@ """ import time -import warnings +import urllib3 from .base import Plugin @@ -82,10 +82,13 @@ def request(self, method, path, attempts=0, **kwargs): base = 'https://platform.synack.com/api/' url = f'{base}{path}' - warnings.filterwarnings("ignore") - verify = False + verify = True proxies = self._state.proxies if self._state.use_proxies else None + if proxies: + verify = False + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + if 'synack.com/api/' in url: headers = { 'Authorization': f'Bearer {self._state.api_token}', diff --git a/src/synack/plugins/db.py b/src/synack/plugins/db.py index 4750952..368ae65 100644 --- a/src/synack/plugins/db.py +++ b/src/synack/plugins/db.py @@ -414,19 +414,9 @@ def ports(self): @property def proxies(self): - if self._state.http_proxy is None: - http_proxy = self.get_config('http_proxy') - else: - http_proxy = self._state.http_proxy - - if self._state.https_proxy is None: - https_proxy = self.get_config('https_proxy') - else: - https_proxy = self._state.https_proxy - return { - 'http': http_proxy, - 'https': https_proxy + 'http': self.get_config('http_proxy'), + 'https': self.get_config('https_proxy') } def remove_targets(self, **kwargs): @@ -441,7 +431,7 @@ def scratchspace_dir(self): @scratchspace_dir.setter def scratchspace_dir(self, value): - self.set_config('scratchspace_dir', value) + self.set_config('scratchspace_dir', str(value)) def set_config(self, name, value): session = self.Session() diff --git a/test/test_alerts.py b/test/test_alerts.py index 17086b2..47ccb14 100644 --- a/test/test_alerts.py +++ b/test/test_alerts.py @@ -17,27 +17,28 @@ class AlertsTestCase(unittest.TestCase): def setUp(self): self.state = synack._state.State() + self.state._db = MagicMock() self.alerts = synack.plugins.Alerts(self.state) - self.alerts.db = MagicMock() + self.alerts._db = MagicMock() def test_email_no_tls(self): """Should send a non-TLS encrypted email""" - self.alerts.db.smtp_starttls = False - self.alerts.db.smtp_server = 'smtp.email.com' - self.alerts.db.smtp_port = 587 - self.alerts.db.smtp_username = 'user5' - self.alerts.db.smtp_password = 'password123' + self.alerts._state.smtp_starttls = False + self.alerts._state.smtp_server = 'smtp.email.com' + self.alerts._state.smtp_port = 587 + self.alerts._state.smtp_username = 'user5' + self.alerts._state.smtp_password = 'password123' with patch('smtplib.SMTP') as mock_smtp: self.alerts.email('subject', 'body') mock_smtp.assert_called_with('smtp.email.com', 587) def test_email_tls(self): """Should send a TLS encrypted email""" - self.alerts.db.smtp_starttls = True - self.alerts.db.smtp_server = 'smtp.email.com' - self.alerts.db.smtp_port = 465 - self.alerts.db.smtp_username = 'user5' - self.alerts.db.smtp_password = 'password123' + self.alerts._state.smtp_starttls = True + self.alerts._state.smtp_server = 'smtp.email.com' + self.alerts._state.smtp_port = 465 + self.alerts._state.smtp_username = 'user5' + self.alerts._state.smtp_password = 'password123' with patch('smtplib.SMTP_SSL') as mock_smtp: with patch('email.message.EmailMessage') as mock_msg: with patch('datetime.datetime') as mock_dt: @@ -85,8 +86,13 @@ def test_sanitize_urls(self): def test_slack(self): """Should POST a message to slack""" with patch('requests.post') as mock_post: - self.alerts.db.slack_url = 'https://slack.com' + self.alerts._state.slack_channel = 'myslackchannel' + self.alerts._state.slack_app_token = '1234' self.alerts.slack('this is a test') - mock_post.assert_called_with('https://slack.com', - data='{"text": "this is a test"}', - headers={'Content-Type': 'application/json'}) + mock_post.assert_called_with('https://slack.com/api/chat.postMessage', + data='{"text": "this is a test", "channel": "myslackchannel"}', + headers={ + 'Authorization': 'Bearer 1234', + 'Content-Type': 'application/json' + }, + verify=False) diff --git a/test/test_api.py b/test/test_api.py index 1c4c1b8..62db387 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -17,9 +17,10 @@ class ApiTestCase(unittest.TestCase): def setUp(self): self.state = synack._state.State() + self.state._db = MagicMock() self.api = synack.plugins.Api(self.state) - self.api.debug = MagicMock() - self.api.db = MagicMock() + self.api._debug = MagicMock() + self.api._db = MagicMock() def test_login_full_path(self): """Login Base URL should prepend and request should be made""" @@ -40,7 +41,7 @@ def test_notification_bad_token(self): """Notifications token should be obtained if it doesn't exist""" self.api.request = MagicMock() self.api.request.return_value.status_code = 422 - self.api.db.notifications_token = "bad_token" + self.api._state.notifications_token = "bad_token" url = 'https://notifications.synack.com/api/v2/test' headers = {"Authorization": "Bearer bad_token"} self.api.notifications('GET', 'test') @@ -51,7 +52,7 @@ def test_notification_bad_token(self): def test_notification_full_path(self): """Notifications Base URL should prepend and request should be made""" self.api.request = MagicMock() - self.api.db.notifications_token = "something" + self.api._state.notifications_token = "something" headers = {"Authorization": "Bearer something"} url = 'http://www.google.com/api/test' self.api.notifications('GET', url) @@ -62,13 +63,13 @@ def test_notification_full_path(self): def test_notification_no_token(self): """Notifications token should be obtained if it doesn't exist""" self.api.request = MagicMock() - self.api.db.notifications_token = "" + self.api._state.notifications_token = "" self.api.notifications('GET', 'test') def test_notification_path(self): """Notifications Base URL should prepend and request should be made""" self.api.request = MagicMock() - self.api.db.notifications_token = "something" + self.api._state.notifications_token = "something" headers = {"Authorization": "Bearer something"} url = 'https://notifications.synack.com/api/v2/test' self.api.notifications('GET', 'test') @@ -78,64 +79,64 @@ def test_notification_path(self): def test_request_full_url(self): """Base URL should not be added if a full url is passed""" - self.api.state.session.get = MagicMock() - self.api.db.use_proxies = False - self.api.db.user_id = "paco" - self.api.db.api_token = "12345" + self.api._state.session.get = MagicMock() + self.api._state.use_proxies = False + self.api._state.user_id = "paco" + self.api._state.api_token = "12345" headers = { 'Authorization': 'Bearer 12345', 'user_id': 'paco' } - url = 'http://www.google.com/api/test' + url = 'http://www.synack.com/api/test' self.api.request('GET', url) - self.api.state.session.get.assert_called_with(url, - headers=headers, - proxies=None, - params=None, - verify=True) + self.api._state.session.get.assert_called_with(url, + headers=headers, + proxies=None, + params=None, + verify=True) def test_request_get(self): """GET requests should work""" - self.api.state.session.get = MagicMock() - self.api.db.use_proxies = False - self.api.db.user_id = "paco" - self.api.db.api_token = "12345" + self.api._state.session.get = MagicMock() + self.api._state.use_proxies = False + self.api._state.user_id = "paco" + self.api._state.api_token = "12345" headers = { 'Authorization': 'Bearer 12345', 'user_id': 'paco' } url = 'https://platform.synack.com/api/test' self.api.request('GET', 'test') - self.api.state.session.get.assert_called_with(url, - headers=headers, - proxies=None, - params=None, - verify=True) + self.api._state.session.get.assert_called_with(url, + headers=headers, + proxies=None, + params=None, + verify=True) def test_request_head(self): """HEAD requests should work""" - self.api.state.session.head = MagicMock() - self.api.db.use_proxies = False - self.api.db.user_id = "paco" - self.api.db.api_token = "12345" + self.api._state.session.head = MagicMock() + self.api._state.use_proxies = False + self.api._state.user_id = "paco" + self.api._state.api_token = "12345" headers = { 'Authorization': 'Bearer 12345', 'user_id': 'paco' } url = 'https://platform.synack.com/api/test' self.api.request('HEAD', 'test') - self.api.state.session.head.assert_called_with(url, - headers=headers, - proxies=None, - params=None, - verify=True) + self.api._state.session.head.assert_called_with(url, + headers=headers, + proxies=None, + params=None, + verify=True) def test_request_header_kwargs(self): """requests should merge in kwargs headers""" - self.api.state.session.get = MagicMock() - self.api.db.use_proxies = False - self.api.db.user_id = "paco" - self.api.db.api_token = "12345" + self.api._state.session.get = MagicMock() + self.api._state.use_proxies = False + self.api._state.user_id = "paco" + self.api._state.api_token = "12345" headers = { 'Authorization': 'Bearer 12345', 'user_id': 'paco', @@ -143,20 +144,20 @@ def test_request_header_kwargs(self): } url = 'https://platform.synack.com/api/test' self.api.request('GET', 'test', headers={'test': 'test'}) - self.api.state.session.get.assert_called_with(url, - headers=headers, - proxies=None, - params=None, - verify=True) + self.api._state.session.get.assert_called_with(url, + headers=headers, + proxies=None, + params=None, + verify=True) def test_request_logged(self): """All requests should call the logger""" - self.api.state.session.get = MagicMock() - self.api.state.session.get.return_value.status_code = 200 - self.api.state.session.get.return_value.content = "Returned Content" - self.api.db.use_proxies = False - self.api.db.user_id = "paco" - self.api.db.api_token = "12345" + self.api._state.session.get = MagicMock() + self.api._state.session.get.return_value.status_code = 200 + self.api._state.session.get.return_value.content = "Returned Content" + self.api._state.use_proxies = False + self.api._state.user_id = "paco" + self.api._state.api_token = "12345" headers = { 'Authorization': 'Bearer 12345', 'user_id': 'paco' @@ -167,45 +168,45 @@ def test_request_logged(self): "\n\tQuery: None" + \ "\n\tData: None" + \ "\n\tContent: Returned Content" - self.api.debug.log.assert_called_with("Network Request", message) + self.api._debug.log.assert_called_with("Network Request", message) def test_request_patch(self): """PATCH requests should work""" - self.api.state.session.patch = MagicMock() + self.api._state.session.patch = MagicMock() data = {'test': 'test'} - self.api.db.use_proxies = False - self.api.db.user_id = "paco" - self.api.db.api_token = "12345" + self.api._state.use_proxies = False + self.api._state.user_id = "paco" + self.api._state.api_token = "12345" url = 'https://platform.synack.com/api/test' headers = { 'Authorization': 'Bearer 12345', 'user_id': 'paco' } self.api.request('PATCH', 'test', data=data) - self.api.state.session.patch.assert_called_with(url, - json=data, - headers=headers, - proxies=None, - verify=True) + self.api._state.session.patch.assert_called_with(url, + json=data, + headers=headers, + proxies=None, + verify=True) def test_request_post(self): """POST requests should work""" - self.api.state.session.post = MagicMock() + self.api._state.session.post = MagicMock() data = {'test': 'test'} - self.api.db.use_proxies = False - self.api.db.user_id = "paco" - self.api.db.api_token = "12345" + self.api._state.use_proxies = False + self.api._state.user_id = "paco" + self.api._state.api_token = "12345" url = 'https://platform.synack.com/api/test' headers = { 'Authorization': 'Bearer 12345', 'user_id': 'paco' } self.api.request('POST', 'test', data=data) - self.api.state.session.post.assert_called_with(url, - json=data, - headers=headers, - proxies=None, - verify=True) + self.api._state.session.post.assert_called_with(url, + json=data, + headers=headers, + proxies=None, + verify=True) def test_request_proxies(self): """Proxies should be used if set""" @@ -213,38 +214,39 @@ def test_request_proxies(self): 'http': 'http://127.0.0.1:8080', 'https': 'http://127.0.0.1:8080', } - self.api.db.user_id = "paco" - self.api.db.api_token = "12345" + self.api._state.user_id = "paco" + self.api._state.api_token = "12345" headers = { 'Authorization': 'Bearer 12345', 'user_id': 'paco' } url = 'https://platform.synack.com/api/test' - self.api.state.session.get = MagicMock() - self.api.db.use_proxies = True - self.api.db.proxies = proxies + self.api._state.session.get = MagicMock() + self.api._state.use_proxies = True + self.api._state.http_proxy = proxies.get('http') + self.api._state.https_proxy = proxies.get('https') self.api.request('GET', 'test') - self.api.state.session.get.assert_called_with(url, - headers=headers, - proxies=proxies, - params=None, - verify=False) + self.api._state.session.get.assert_called_with(url, + headers=headers, + proxies=proxies, + params=None, + verify=False) def test_request_put(self): """PUT requests should work""" - self.api.state.session.put = MagicMock() + self.api._state.session.put = MagicMock() data = {'test': 'test'} - self.api.db.use_proxies = False - self.api.db.user_id = "paco" - self.api.db.api_token = "12345" + self.api._state.use_proxies = False + self.api._state.user_id = "paco" + self.api._state.api_token = "12345" url = 'https://platform.synack.com/api/test' headers = { 'Authorization': 'Bearer 12345', 'user_id': 'paco' } self.api.request('PUT', 'test', data=data) - self.api.state.session.put.assert_called_with(url, - params=data, - headers=headers, - proxies=None, - verify=True) + self.api._state.session.put.assert_called_with(url, + headers=headers, + proxies=None, + params=data, + verify=True) diff --git a/test/test_auth.py b/test/test_auth.py index aba9cc5..cdc3d98 100644 --- a/test/test_auth.py +++ b/test/test_auth.py @@ -18,86 +18,68 @@ class AuthTestCase(unittest.TestCase): def setUp(self): self.state = synack._state.State() + self.state._db = MagicMock() self.auth = synack.plugins.Auth(self.state) - self.auth.api = MagicMock() - self.auth.db = MagicMock() - self.auth.users = MagicMock() + self.auth._api = MagicMock() + self.auth._db = MagicMock() + self.auth._users = MagicMock() + self.auth._duo = MagicMock() def test_get_api_token(self): """Should complete the login workflow when check fails""" - self.auth.db.api_token = "" + self.auth._state.api_token = "" self.auth.set_login_script = MagicMock() - self.auth.users.get_profile = MagicMock() - self.auth.users.get_profile.return_value = None + self.auth.get_authentication_response = MagicMock() + self.auth.get_authentication_response.return_value = { + 'duo_auth_url': 'https://duoauth.local' + } + self.auth._users.get_profile = MagicMock() + self.auth._users.get_profile.return_value = None self.auth.get_login_csrf = MagicMock(return_value="csrf_fwlnm") - self.auth.get_login_progress_token = MagicMock() - self.auth.get_login_progress_token.return_value = "pt_rsaemnt" - self.auth.api.request.return_value.status_code = 200 + self.auth._api.request.return_value.status_code = 200 ret_json = {"access_token": "api_lwfaume"} - self.auth.api.request.return_value.json.return_value = ret_json + self.auth._api.request.return_value.json.return_value = ret_json self.assertEqual("api_lwfaume", self.auth.get_api_token()) self.auth.get_login_csrf.assert_called_with() self.auth.set_login_script.assert_called_with() - self.auth.get_login_progress_token.assert_called_with("csrf_fwlnm") + self.auth.get_authentication_response.assert_called_with('csrf_fwlnm') def test_get_api_token_login_success(self): """Should return the database token when check succeeds""" - self.auth.db.api_token = "qweqweqwe" + self.auth._state.api_token = "qweqweqwe" self.auth.set_login_script = MagicMock() - self.auth.users.get_profile = MagicMock() - self.auth.users.get_profile.return_value = {"user_id": "john"} + self.auth._users.get_profile = MagicMock() + self.auth._users.get_profile.return_value = {"user_id": "john"} self.assertEqual("qweqweqwe", self.auth.get_api_token()) - def test_get_login_progress_token(self): - """Should get the progress token from valid creds""" - self.auth.api.login.return_value.status_code = 200 - self.auth.api.login.return_value.json.return_value = { - "progress_token": "qwfars" - } - data = { - "email": "bob@bob.com", - "password": "123456" - } - headers = { - "X-CSRF-Token": "abcde" - } - self.auth.db.email = "bob@bob.com" - self.auth.db.password = "123456" - returned_pt = self.auth.get_login_progress_token('abcde') - self.assertEqual("qwfars", returned_pt) - self.auth.api.login.assert_called_with("POST", - "authenticate", - headers=headers, - data=data) - def test_get_notifications_token(self): """Should get the notifications token""" - self.auth.db.notifications_token = "" - self.auth.api.request.return_value.status_code = 200 + self.auth._db.notifications_token = "" + self.auth._api.request.return_value.status_code = 200 ret_value = {"token": "12345"} - self.auth.api.request.return_value.json.return_value = ret_value + self.auth._api.request.return_value.json.return_value = ret_value self.assertEqual("12345", self.auth.get_notifications_token()) - self.assertEqual("12345", self.auth.db.notifications_token) - self.auth.api.request.assert_called_with("GET", - "users/notifications_token") - self.auth.api.request.return_value.json.assert_called_with() + self.assertEqual("12345", self.auth._db.notifications_token) + self.auth._api.request.assert_called_with("GET", + "users/notifications_token") + self.auth._api.request.return_value.json.assert_called_with() def test_login_csrf(self): """Should get the login csrf token""" ret_text = '= 20 characters""" @@ -397,15 +398,15 @@ def test_set_evidences_unsafe(self): "validResponses": [{}, {"value": "uieth8rgyub"}], "listingCodename": "SLAPPYMONKEY" } - self.missions.templates.get_template = MagicMock() - self.missions.templates.get_template.return_value = template + self.missions._templates.get_template = MagicMock() + self.missions._templates.get_template.return_value = template self.missions.get_evidences = MagicMock() self.missions.get_evidences.return_value = curr - self.missions.api.request = MagicMock() - self.missions.api.request.return_value.status_code = 200 - self.missions.api.request.return_value.json.return_value = {} + self.missions._api.request = MagicMock() + self.missions._api.request.return_value.status_code = 200 + self.missions._api.request.return_value.json.return_value = {} self.missions.set_evidences(mission) - self.missions.api.request.assert_not_called() + self.missions._api.request.assert_not_called() def test_set_status(self): """Should interact with a mission""" @@ -424,7 +425,7 @@ def test_set_status(self): "status": "CLAIM", "success": True } - self.missions.api.request.return_value.status_code = 201 + self.missions._api.request.return_value.status_code = 201 self.assertEqual(ret, self.missions.set_status(m, "CLAIM")) data = {"type": "CLAIM"} calls = [ @@ -445,4 +446,4 @@ def test_set_status(self): '/transitions', data=data) ] - self.missions.api.request.has_calls(calls) + self.missions._api.request.has_calls(calls) diff --git a/test/test_notifications.py b/test/test_notifications.py index 24d9be8..ad9e6f6 100644 --- a/test/test_notifications.py +++ b/test/test_notifications.py @@ -17,27 +17,28 @@ class NotificationsTestCase(unittest.TestCase): def setUp(self): self.state = synack._state.State() + self.state._db = MagicMock() self.notifications = synack.plugins.Notifications(self.state) - self.notifications.api = MagicMock() - self.notifications.db = MagicMock() + self.notifications._api = MagicMock() + self.notifications._db = MagicMock() def test_get(self): """Should get a list of notifications""" - self.notifications.api.notifications.return_value.status_code = 200 - self.notifications.api.notifications.return_value.json.return_value = {"one": "1"} + self.notifications._api.notifications.return_value.status_code = 200 + self.notifications._api.notifications.return_value.json.return_value = {"one": "1"} path = "notifications?meta=1" self.assertEqual({"one": "1"}, self.notifications.get()) - self.notifications.api.notifications.assert_called_with("GET", path) + self.notifications._api.notifications.assert_called_with("GET", path) def test_get_unread_count(self): """Should get the number of unread notifications""" - self.notifications.api.notifications.return_value.status_code = 200 - self.notifications.api.notifications.return_value.json.return_value = {"one": "1"} - self.notifications.db.notifications_token = "good_token" + self.notifications._api.notifications.return_value.status_code = 200 + self.notifications._api.notifications.return_value.json.return_value = {"one": "1"} + self.notifications._state.notifications_token = "good_token" query = { "authorization_token": "good_token" } path = "notifications/unread_count" self.assertEqual({"one": "1"}, self.notifications.get_unread_count()) - self.notifications.api.notifications.assert_called_with("GET", path, - query=query) + self.notifications._api.notifications.assert_called_with("GET", path, + query=query) diff --git a/test/test_scratchspace.py b/test/test_scratchspace.py index c167f89..484e228 100644 --- a/test/test_scratchspace.py +++ b/test/test_scratchspace.py @@ -1,6 +1,6 @@ """test_scratchspace.py -Tests for the plugins/scratchspace.py Db class +Tests for the plugins/scratchspace.py Scratchspace class """ import os @@ -18,17 +18,18 @@ class ScratchspaceTestCase(unittest.TestCase): def setUp(self): self.state = synack._state.State() + self.state._db = MagicMock() self.scratchspace = synack.plugins.Scratchspace(self.state) def test_build_filepath_codename(self): """Should build the appropriate scratchspace filepath given a codename""" - self.scratchspace.db.scratchspace_dir = pathlib.Path('/tmp') + self.scratchspace._state.scratchspace_dir = pathlib.Path('/tmp') ret = self.scratchspace.build_filepath('test.txt', codename='TIREDTURKEY') self.assertEqual(pathlib.Path('/tmp/TIREDTURKEY/test.txt'), ret) def test_build_filepath_target(self): """Should build the appropriate scratchspace filepath given a filepath""" - self.scratchspace.db.scratchspace_dir = pathlib.Path('/tmp') + self.scratchspace._state.scratchspace_dir = pathlib.Path('/tmp') target = synack.db.models.Target(codename='TIREDTURKEY') ret = self.scratchspace.build_filepath('test.txt', target=target) self.assertEqual(pathlib.Path('/tmp/TIREDTURKEY/test.txt'), ret) @@ -82,9 +83,9 @@ def test_set_download_attachments_codename(self): dest_path = pathlib.Path('/tmp/TIREDTURKEY/burp.txt') self.scratchspace.build_filepath = MagicMock() self.scratchspace.build_filepath.return_value = dest_path - self.scratchspace.api.request = MagicMock() - self.scratchspace.api.request.return_value.status_code = 200 - self.scratchspace.api.request.return_value.content = b'file_content' + self.scratchspace._api.request = MagicMock() + self.scratchspace._api.request.return_value.status_code = 200 + self.scratchspace._api.request.return_value.content = b'file_content' m = mock_open() attachments = [ {'slug': '43i7h', 'filename': 'file1.txt', 'url': 'https://downloads.com/xyzf'} @@ -101,9 +102,9 @@ def test_set_download_attachments_prompt_overwrite(self, input_mock): dest_path = pathlib.Path('/tmp/TIREDTURKEY/burp.txt') self.scratchspace.build_filepath = MagicMock() self.scratchspace.build_filepath.return_value = dest_path - self.scratchspace.api.request = MagicMock() - self.scratchspace.api.request.return_value.status_code = 200 - self.scratchspace.api.request.return_value.content = b'file_content' + self.scratchspace._api.request = MagicMock() + self.scratchspace._api.request.return_value.status_code = 200 + self.scratchspace._api.request.return_value.content = b'file_content' attachments = [ {'slug': '43i7h', 'filename': 'file1.txt', 'url': 'https://downloads.com/xyzf'} ] diff --git a/test/test_state.py b/test/test_state.py index 70460c1..6695c43 100644 --- a/test/test_state.py +++ b/test/test_state.py @@ -9,6 +9,8 @@ import pathlib import requests +from unittest.mock import MagicMock + sys.path.insert(0, os.path.abspath(os.path.join(__file__, '../../src'))) import synack # noqa: E402 @@ -17,6 +19,7 @@ class StateTestCase(unittest.TestCase): def setUp(self): self.state = synack._state.State() + self.state._db = MagicMock() def test_config_dir(self): self.assertEqual(pathlib.PosixPath, type(self.state.config_dir)) @@ -31,32 +34,32 @@ def test_config_dir(self): self.state._config_dir) def test_debug(self): - self.assertEqual(None, self.state.debug) + self.assertEqual(self.state._db.debug, self.state.debug) self.assertEqual(None, self.state._debug) - self.state.debug = True - self.assertEqual(True, self.state.debug) + self.state._debug = True + self.assertEqual(True, self.state._debug) self.assertEqual(True, self.state._debug) def test_email(self): - self.assertEqual(None, self.state.email) + self.assertEqual(self.state._db.email, self.state.email) self.assertEqual(None, self.state._email) self.state.email = '1@2.com' self.assertEqual('1@2.com', self.state.email) self.assertEqual('1@2.com', self.state._email) def test_http_proxy(self): - self.assertEqual(None, self.state.http_proxy) + self.assertEqual(self.state._db.http_proxy, self.state.http_proxy) self.assertEqual(None, self.state._http_proxy) self.state.http_proxy = 'http://1.1.1.1:1234' self.assertEqual('http://1.1.1.1:1234', self.state._http_proxy) self.assertEqual('http://1.1.1.1:1234', self.state.http_proxy) self.assertEqual(self.state.proxies, { 'http': 'http://1.1.1.1:1234', - 'https': None + 'https': self.state._db.https_proxy }) def test_https_proxy(self): - self.assertEqual(None, self.state.https_proxy) + self.assertEqual(self.state._db.https_proxy, self.state.https_proxy) self.assertEqual(None, self.state._https_proxy) self.state.https_proxy = 'http://1.1.1.1:1234' self.assertEqual('http://1.1.1.1:1234', self.state.https_proxy) @@ -70,14 +73,14 @@ def test_login(self): self.assertEqual(False, self.state._login) def test_otp_secret(self): - self.assertEqual(None, self.state.otp_secret) + self.assertEqual(self.state._db.otp_secret, self.state.otp_secret) self.assertEqual(None, self.state._otp_secret) self.state.otp_secret = '12345' self.assertEqual('12345', self.state.otp_secret) self.assertEqual('12345', self.state._otp_secret) def test_password(self): - self.assertEqual(None, self.state.password) + self.assertEqual(self.state._db.password, self.state.password) self.assertEqual(None, self.state._password) self.state.password = 'password1234' self.assertEqual('password1234', self.state.password) @@ -85,13 +88,13 @@ def test_password(self): def test_proxies(self): self.assertEqual(self.state.proxies, { - 'http': None, - 'https': None + 'http': self.state._db.http_proxy, + 'https': self.state._db.https_proxy }) self.state.http_proxy = 'http://2.2.2.2:1234' self.assertEqual(self.state.proxies, { 'http': 'http://2.2.2.2:1234', - 'https': None + 'https': self.state._db.https_proxy }) self.state.https_proxy = 'http://1.1.1.1:1234' self.assertEqual(self.state.proxies, { @@ -100,7 +103,7 @@ def test_proxies(self): }) def test_scratchspace_dir(self): - self.assertEqual(None, self.state.scratchspace_dir) + self.assertEqual(self.state._db.scratchspace_dir, self.state.scratchspace_dir) self.assertEqual(None, self.state._scratchspace_dir) self.state.scratchspace_dir = "/tmp" self.assertEqual(pathlib.PosixPath, type(self.state.scratchspace_dir)) @@ -114,7 +117,7 @@ def test_session(self): self.assertEqual(requests.sessions.Session, type(self.state._session)) def test_template_dir(self): - self.assertEqual(None, self.state.template_dir) + self.assertEqual(self.state._db.template_dir, self.state.template_dir) self.assertEqual(None, self.state._template_dir) self.state.template_dir = "/tmp" self.assertEqual(pathlib.PosixPath, type(self.state.template_dir)) @@ -124,14 +127,14 @@ def test_template_dir(self): self.state._template_dir) def test_use_proxies(self): - self.assertEqual(None, self.state.use_proxies) + self.assertEqual(self.state._db.use_proxies, self.state.use_proxies) self.assertEqual(None, self.state._use_proxies) self.state.use_proxies = True self.assertEqual(True, self.state.use_proxies) self.assertEqual(True, self.state._use_proxies) def test_user_id(self): - self.assertEqual(None, self.state.user_id) + self.assertEqual(self.state._db.user_id, self.state.user_id) self.assertEqual(None, self.state._user_id) self.state.user_id = '12345' self.assertEqual('12345', self.state.user_id) diff --git a/test/test_targets.py b/test/test_targets.py index c2d9ee5..ffebc25 100644 --- a/test/test_targets.py +++ b/test/test_targets.py @@ -18,30 +18,31 @@ class TargetsTestCase(unittest.TestCase): def setUp(self): self.state = synack._state.State() + self.state._db = MagicMock() self.targets = synack.plugins.Targets(self.state) - self.targets.api = MagicMock() - self.targets.db = MagicMock() + self.targets._api = MagicMock() + self.targets._db = MagicMock() self.targets.scratchspace = MagicMock() self.maxDiff = None def test_build_codename_from_slug(self): """Should return a codename for a given slug""" ret_targets = [Target(codename="SLOPPYSLUG")] - self.targets.db.find_targets.return_value = ret_targets + self.targets._db.find_targets.return_value = ret_targets self.assertEqual("SLOPPYSLUG", self.targets.build_codename_from_slug("qwfars")) - self.targets.db.find_targets.assert_called_with(slug="qwfars") + self.targets._db.find_targets.assert_called_with(slug="qwfars") def test_build_codename_from_slug_invalid(self): """Should return NONE if non-real slug""" - self.targets.db.find_targets.return_value = [] + self.targets._db.find_targets.return_value = [] self.assertEqual("NONE", self.targets.build_codename_from_slug("qwfars")) - self.targets.db.find_targets.assert_called_with(slug="qwfars") + self.targets._db.find_targets.assert_called_with(slug="qwfars") def test_build_codename_from_slug_no_targets(self): """Should update the targets if empty""" - self.targets.db.find_targets.side_effect = [ + self.targets._db.find_targets.side_effect = [ [], [Target(codename="SLOPPYSLUG")] ] @@ -52,7 +53,7 @@ def test_build_codename_from_slug_no_targets(self): self.targets.get_registered_summary = MagicMock() self.assertEqual("SLOPPYSLUG", self.targets.build_codename_from_slug("qwfars")) - self.targets.db.find_targets.assert_has_calls(calls) + self.targets._db.find_targets.assert_has_calls(calls) self.targets.get_registered_summary.assert_called_with() def test_build_scope_host_db(self): @@ -163,14 +164,14 @@ def test_build_scope_web_db(self): def test_build_slug_from_codename(self): """Should return a slug for a given codename""" ret_targets = [Target(slug="qwerty")] - self.targets.db.find_targets.return_value = ret_targets + self.targets._db.find_targets.return_value = ret_targets self.assertEqual("qwerty", self.targets.build_slug_from_codename("qwerty")) - self.targets.db.find_targets.assert_called_with(codename="qwerty") + self.targets._db.find_targets.assert_called_with(codename="qwerty") def test_build_slug_from_codename_no_targets(self): """Should update the targets if empty""" - self.targets.db.find_targets.side_effect = [ + self.targets._db.find_targets.side_effect = [ [], [Target(slug="qwerty")] ] @@ -182,7 +183,7 @@ def test_build_slug_from_codename_no_targets(self): slug = self.targets.build_slug_from_codename("CHONKEYMONKEY") self.assertEqual("qwerty", slug) - self.targets.db.find_targets.assert_has_calls(calls) + self.targets._db.find_targets.assert_has_calls(calls) self.targets.get_registered_summary.assert_called_with() def test_get_assessments_all_passed(self): @@ -210,32 +211,32 @@ def test_get_assessments_all_passed(self): } ] cat1 = synack.db.models.Category() - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = assessments - self.targets.db.categories = [cat1] + self.targets. _api.request.return_value.status_code = 200 + self.targets. _api.request.return_value.json.return_value = assessments + self.targets._db.categories = [cat1] self.assertEqual([cat1], self.targets.get_assessments()) - self.targets.db.add_categories.assert_called_with(assessments) + self.targets._db.add_categories.assert_called_with(assessments) def test_get_assets(self): """Should return a list of assets for a currently connected target""" self.targets.get_connected = MagicMock() self.targets.get_connected.return_value = {'codename': 'TURBULENTTORTOISE', 'slug': '327h8iw'} - self.targets.db.find_targets.return_value = [Target(slug='327h8iw')] - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.text = 'rettext' - self.targets.api.request.return_value.json.return_value = 'retjson' + self.targets._db.find_targets.return_value = [Target(slug='327h8iw')] + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.text = 'rettext' + self.targets._api.request.return_value.json.return_value = 'retjson' self.assertEqual('retjson', self.targets.get_assets()) - self.targets.api.request.assert_called_with('GET', - 'asset/v2/assets?listingUid%5B%5D=327h8iw&scope%5B%5D=in' + - '&scope%5B%5D=discovered&sort%5B%5D=location&active=true' + - '&sortDir=asc&page=1&perPage=5000') + self.targets._api.request.assert_called_with('GET', + 'asset/v2/assets?listingUid%5B%5D=327h8iw&scope%5B%5D=in' + + '&scope%5B%5D=discovered&sort%5B%5D=location&active=true' + + '&sortDir=asc&page=1&perPage=5000') def test_get_assets_non_defaults(self): """Should return a list of assets given information to query""" - self.targets.db.find_targets.return_value = [Target(codename='TURBULENTTORTOISE', slug='327h8iw')] - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.text = 'rettext' - self.targets.api.request.return_value.json.return_value = 'retjson' + self.targets._db.find_targets.return_value = [Target(codename='TURBULENTTORTOISE', slug='327h8iw')] + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.text = 'rettext' + self.targets._api.request.return_value.json.return_value = 'retjson' self.assertEqual('retjson', self.targets.get_assets(codename='TURBULENTTORTOISE', asset_type='blah', host_type='cidr', @@ -246,11 +247,11 @@ def test_get_assets_non_defaults(self): page=3, perPage=50, organization_uid='uiehqw')) - self.targets.api.request.assert_called_with('GET', - 'asset/v2/assets?listingUid%5B%5D=327h8iw' + - '&organizationUid%5B%5D=uiehqw&assetType%5B%5D=blah' + - '&hostType%5B%5D=cidr&scope%5B%5D=secret' + - '&sort%5B%5D=loc&active=false&sortDir=desc&page=3&perPage=50') + self.targets._api.request.assert_called_with('GET', + 'asset/v2/assets?listingUid%5B%5D=327h8iw' + + '&organizationUid%5B%5D=uiehqw&assetType%5B%5D=blah' + + '&hostType%5B%5D=cidr&scope%5B%5D=secret' + + '&sort%5B%5D=loc&active=false&sortDir=desc&page=3&perPage=50') def test_get_attachments_current(self): """Should return a list of attachments based on currently selected target""" @@ -265,12 +266,12 @@ def test_get_attachments_current(self): ] self.targets.get_connected = MagicMock() self.targets.get_connected.return_value = {'codename': 'TASTYTACO', 'slug': 'u2ire'} - self.targets.db.find_targets = MagicMock() - self.targets.db.find_targets.return_value = [Target(slug='u2ire')] - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = attachments - self.assertEquals(self.targets.get_attachments(), attachments) - self.targets.api.request.assert_called_with('GET', 'targets/u2ire/resources') + self.targets._db.find_targets = MagicMock() + self.targets._db.find_targets.return_value = [Target(slug='u2ire')] + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = attachments + self.assertEqual(self.targets.get_attachments(), attachments) + self.targets._api.request.assert_called_with('GET', 'targets/u2ire/resources') def test_get_attachments_slug(self): """Should return a list of attachments given a slug""" @@ -283,12 +284,12 @@ def test_get_attachments_slug(self): "updated_at": 1667849178, } ] - self.targets.db.find_targets = MagicMock() - self.targets.db.find_targets.return_value = [Target(slug='u2ire')] - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = attachments - self.assertEquals(self.targets.get_attachments(slug='u2ire'), attachments) - self.targets.api.request.assert_called_with('GET', 'targets/u2ire/resources') + self.targets._db.find_targets = MagicMock() + self.targets._db.find_targets.return_value = [Target(slug='u2ire')] + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = attachments + self.assertEqual(self.targets.get_attachments(slug='u2ire'), attachments) + self.targets._api.request.assert_called_with('GET', 'targets/u2ire/resources') def test_get_attachments_target(self): """Should return a list of attachments given a Target""" @@ -301,15 +302,15 @@ def test_get_attachments_target(self): "updated_at": 1667849178, } ] - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = attachments - self.assertEquals(self.targets.get_attachments(target=Target(slug='u2ire')), attachments) - self.targets.api.request.assert_called_with('GET', 'targets/u2ire/resources') + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = attachments + self.assertEqual(self.targets.get_attachments(target=Target(slug='u2ire')), attachments) + self.targets._api.request.assert_called_with('GET', 'targets/u2ire/resources') def test_get_connected(self): """Should make a request to get the currently selected target""" - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = { + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = { "slug": "qwfars", "status": "connected" } @@ -324,8 +325,8 @@ def test_get_connected(self): def test_get_connected_disconnected(self): """Should report Not Connected when not connected to a target""" - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = { + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = { "slug": "", "status": "connected" } @@ -352,13 +353,13 @@ def test_get_connections(self): "current_connections": 5 } } - self.targets.db.find_targets = MagicMock() - self.targets.db.find_targets.return_value = [Target(slug='u2ire')] - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = return_data - self.assertEquals(self.targets.get_connections(slug='u2ire'), connections) - self.targets.api.request.assert_called_with('GET', 'listing_analytics/connections', - query={"listing_id": "u2ire"}) + self.targets._db.find_targets = MagicMock() + self.targets._db.find_targets.return_value = [Target(slug='u2ire')] + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = return_data + self.assertEqual(self.targets.get_connections(slug='u2ire'), connections) + self.targets._api.request.assert_called_with('GET', 'listing_analytics/connections', + query={"listing_id": "u2ire"}) def test_get_connections_no_args(self): """Should return a summary of the lifetime and current connections if no args provided""" @@ -374,36 +375,37 @@ def test_get_connections_no_args(self): "current_connections": 5 } } - self.targets.db.find_targets = MagicMock() + self.targets._db.find_targets = MagicMock() self.targets.get_connected = MagicMock() self.targets.get_connected.return_value = {'codename': 'TIREDTIGER', 'slug': 'u2ire'} - self.targets.db.find_targets.return_value = [Target(slug='u2ire')] - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = return_data - self.assertEquals(self.targets.get_connections(), connections) + self.targets._db.find_targets.return_value = [Target(slug='u2ire')] + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = return_data + self.assertEqual(self.targets.get_connections(), connections) self.targets.get_connected.assert_called_with() - self.targets.api.request.assert_called_with('GET', 'listing_analytics/connections', - query={"listing_id": "u2ire"}) + self.targets._api.request.assert_called_with('GET', 'listing_analytics/connections', + query={"listing_id": "u2ire"}) def test_get_credentials(self): """Should get credentials for a given target""" target = Target(organization="qwewqe", slug="asdasd") - self.targets.db.find_targets = MagicMock() - self.targets.api = MagicMock() - self.targets.db.find_targets.return_value = [target] - self.targets.db.user_id = 'bobby' - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = "json_return" + self.targets._db.find_targets = MagicMock() + self.targets._api = MagicMock() + self.targets._db.find_targets.return_value = [target] + self.targets._db.user_id = 'bobby' + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = "json_return" + self.targets._state.user_id = 'bobby' url = 'asset/v1/organizations/qwewqe/owners/listings/asdasd/users/bobby/credentials' self.assertEqual("json_return", self.targets.get_credentials(codename='SLEEPYSLUG')) - self.targets.api.request.assert_called_with('POST', url) + self.targets._api.request.assert_called_with('POST', url) def test_get_query(self): """Should get a list of targets""" - self.targets.db.categories = [ + self.targets._db.categories = [ Category(id=1, passed_practical=True, passed_written=True), Category(id=2, passed_practical=True, passed_written=True), Category(id=3, passed_practical=False, passed_written=False), @@ -414,37 +416,37 @@ def test_get_query(self): 'filter[industry]': 'all', 'filter[category][]': [1, 2] } - self.targets.api.request.return_value.status_code = 200 + self.targets._api.request.return_value.status_code = 200 results = [ { "codename": "SLEEPYSLUG", "slug": "1o2h8o" } ] - self.targets.api.request.return_value.json.return_value = results + self.targets._api.request.return_value.json.return_value = results self.assertEqual(results, self.targets.get_unregistered()) - self.targets.api.request.assert_called_with("GET", - "targets", - query=query) + self.targets._api.request.assert_called_with("GET", + "targets", + query=query) def test_get_query_assessments_empty(self): """Should get a list of unregistered targets""" self.targets.get_assessments = MagicMock() - self.targets.db.categories = [] + self.targets._db.categories = [] query = { 'filter[primary]': 'unregistered', 'filter[secondary]': 'all', 'filter[industry]': 'all', 'filter[category][]': [] } - self.targets.api.request.return_value.status_code = 200 + self.targets._api.request.return_value.status_code = 200 results = [] - self.targets.api.request.return_value.json.return_value = results + self.targets._api.request.return_value.json.return_value = results self.assertEqual(results, self.targets.get_unregistered()) self.targets.get_assessments.assert_called_with() - self.targets.api.request.assert_called_with("GET", - "targets", - query=query) + self.targets._api.request.assert_called_with("GET", + "targets", + query=query) def test_get_registered_summary(self): """Should make a request to get basic info about registered targets""" @@ -461,38 +463,38 @@ def test_get_registered_summary(self): "outage_windows": [], "vulnerability_discovery": True } - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = [t1] + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = [t1] out = { "qwfars": t1 } path = 'targets/registered_summary' self.assertEqual(out, self.targets.get_registered_summary()) - self.targets.api.request.assert_called_with('GET', path) + self.targets._api.request.assert_called_with('GET', path) def test_get_scope_for_host(self): """Should get the scope for a Host when given Host information""" self.targets.get_scope_host = MagicMock() self.targets.get_scope_host.return_value = 'HostScope' tgt = Target(category=1) - self.targets.db.find_targets.return_value = [tgt] - self.targets.db.categories = [Category(id=1, name='Host')] + self.targets._db.find_targets.return_value = [tgt] + self.targets._db.categories = [Category(id=1, name='Host')] out = self.targets.get_scope(slug='1392g78yr') - self.targets.db.find_targets.assert_called_with(slug='1392g78yr') + self.targets._db.find_targets.assert_called_with(slug='1392g78yr') self.targets.get_scope_host.assert_called_with(tgt) - self.assertEquals(out, 'HostScope') + self.assertEqual(out, 'HostScope') def test_get_scope_for_web(self): """Should get the scope for a Host when given Web information""" self.targets.get_scope_web = MagicMock() self.targets.get_scope_web.return_value = 'WebScope' tgt = Target(category=2) - self.targets.db.find_targets.return_value = [tgt] - self.targets.db.categories = [Category(id=2, name='Web Application')] + self.targets._db.find_targets.return_value = [tgt] + self.targets._db.categories = [Category(id=2, name='Web Application')] out = self.targets.get_scope(slug='1392g78yr') - self.targets.db.find_targets.assert_called_with(slug='1392g78yr') + self.targets._db.find_targets.assert_called_with(slug='1392g78yr') self.targets.get_scope_web.assert_called_with(tgt) - self.assertEquals(out, 'WebScope') + self.assertEqual(out, 'WebScope') def test_get_scope_host(self): """Should get the scope for a Host""" @@ -508,10 +510,10 @@ def test_get_scope_host(self): 'location': '2.2.2.2/32' } ] - self.targets.db.find_targets.return_value = [Target(slug='213h89h3', codename='SASSYSQUIRREL')] + self.targets._db.find_targets.return_value = [Target(slug='213h89h3', codename='SASSYSQUIRREL')] out = self.targets.get_scope_host(codename='SASSYSQUIRREL') self.assertEqual(ips, out) - self.targets.db.find_targets.assert_called_with(codename='SASSYSQUIRREL') + self.targets._db.find_targets.assert_called_with(codename='SASSYSQUIRREL') def test_get_scope_host_current(self): """Should get the scope for the currenly connected Host if not specified""" @@ -529,11 +531,11 @@ def test_get_scope_host_current(self): 'location': '2.2.2.2/32' } ] - self.targets.db.find_targets.return_value = [Target(slug='213h89h3', codename='SASSYSQUIRREL')] + self.targets._db.find_targets.return_value = [Target(slug='213h89h3', codename='SASSYSQUIRREL')] out = self.targets.get_scope_host() self.assertEqual(ips, out) self.targets.get_connected.assert_called_with() - self.targets.db.find_targets.assert_called_with(slug='213h89h3') + self.targets._db.find_targets.assert_called_with(slug='213h89h3') def test_get_scope_host_not_ip(self): """Should get the scope for a Host""" @@ -549,23 +551,24 @@ def test_get_scope_host_not_ip(self): 'location': '8675309' } ] - self.targets.db.find_targets.return_value = [Target(slug='213h89h3', codename='SASSYSQUIRREL')] + self.targets._db.find_targets.return_value = [Target(slug='213h89h3', codename='SASSYSQUIRREL')] out = self.targets.get_scope_host(codename='SASSYSQUIRREL') self.assertEqual(ips, out) - self.targets.db.find_targets.assert_called_with(codename='SASSYSQUIRREL') + self.targets._db.find_targets.assert_called_with(codename='SASSYSQUIRREL') def test_get_scope_no_provided(self): """Should get the scope for the currently connected target if none is specified""" self.targets.get_connected = MagicMock() self.targets.get_connected.return_value = {'slug': 'test'} - self.targets.db.find_targets.return_value = None + self.targets._db.find_targets.return_value = None self.targets.get_scope() self.targets.get_connected.assert_called_with() - self.targets.db.find_targets.assert_called_with(slug='test') + self.targets._db.find_targets.assert_called_with(slug='test') def test_get_scope_web(self): """Should get the scope for a Web Application""" self.targets.build_scope_web_burp = MagicMock() + self.targets.build_scope_web_burp.return_value = 'burp_web_scope' scope = [{ 'listing': 'uewqhuiewq', 'location': 'https://good.things.com', @@ -584,16 +587,18 @@ def test_get_scope_web(self): } ] tgt = Target(slug='213h89h3', organization='93g8eh8', codename='SASSYSQUIRREL') - self.targets.db.find_targets.return_value = [tgt] + self.targets._db.find_targets.return_value = [tgt] + self.targets._state.use_scratchspace = True out = self.targets.get_scope_web(codename='SASSYSQUIRREL') self.assertEqual(scope, out) self.targets.build_scope_web_burp.assert_called_with(scope) - self.targets.db.find_targets.assert_called_with(codename='SASSYSQUIRREL') + self.targets._db.find_targets.assert_called_with(codename='SASSYSQUIRREL') self.targets.get_assets.assert_called_with(target=tgt, active='true', asset_type='webapp') def test_get_scope_web_current(self): """Should get the scope for the currently connected Web Application if not specified""" self.targets.build_scope_web_burp = MagicMock() + self.targets.build_scope_web_burp.return_value = 'burp_formatted_scope' scope = [{ 'listing': 'uewqhuiewq', 'location': 'https://good.things.com', @@ -614,12 +619,13 @@ def test_get_scope_web_current(self): } ] tgt = Target(slug='213h89h3', organization='93g8eh8', codename='SASSYSQUIRREL') - self.targets.db.find_targets.return_value = [tgt] + self.targets._db.find_targets.return_value = [tgt] + self.targets._state.use_scratchspace = True out = self.targets.get_scope_web() self.assertEqual(scope, out) self.targets.build_scope_web_burp.assert_called_with(scope) self.targets.get_connected.assert_called_with() - self.targets.db.find_targets.assert_called_with(slug='93g8eg8') + self.targets._db.find_targets.assert_called_with(slug='93g8eg8') self.targets.get_assets.assert_called_with(target=tgt, active='true', asset_type='webapp') def test_get_submissions(self): @@ -638,13 +644,13 @@ def test_get_submissions(self): ] }] } - self.targets.db.find_targets = MagicMock() - self.targets.db.find_targets.return_value = [Target(slug='u2ire')] - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = return_data - self.assertEquals(self.targets.get_submissions(slug='u2ire'), return_data["value"]) - self.targets.api.request.assert_called_with('GET', 'listing_analytics/categories', - query={"listing_id": "u2ire", "status": "accepted"}) + self.targets._db.find_targets = MagicMock() + self.targets._db.find_targets.return_value = [Target(slug='u2ire')] + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = return_data + self.assertEqual(self.targets.get_submissions(slug='u2ire'), return_data["value"]) + self.targets._api.request.assert_called_with('GET', 'listing_analytics/categories', + query={"listing_id": "u2ire", "status": "accepted"}) def test_get_submissions_invalid_status(self): """Should return an empty dictionary if status is invalid""" @@ -662,11 +668,11 @@ def test_get_submissions_invalid_status(self): ] }] } - self.targets.db.find_targets = MagicMock() - self.targets.db.find_targets.return_value = [Target(slug='u2ire')] - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = return_data - self.assertEquals(self.targets.get_submissions(slug='u2ire', status="bad_status"), []) + self.targets._db.find_targets = MagicMock() + self.targets._db.find_targets.return_value = [Target(slug='u2ire')] + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = return_data + self.assertEqual(self.targets.get_submissions(slug='u2ire', status="bad_status"), []) def test_get_submissions_no_slug(self): """Should return info on currently connected target if slug not provided""" @@ -684,15 +690,15 @@ def test_get_submissions_no_slug(self): ] }] } - self.targets.db.find_targets = MagicMock() - self.targets.db.find_targets.return_value = [Target(slug='u2ire')] - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = return_data + self.targets._db.find_targets = MagicMock() + self.targets._db.find_targets.return_value = [Target(slug='u2ire')] + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = return_data self.targets.get_connected = MagicMock() self.targets.get_connected.return_value = {"slug": "u2ire"} - self.assertEquals(self.targets.get_submissions(), return_data["value"]) - self.targets.api.request.assert_called_with('GET', 'listing_analytics/categories', - query={"listing_id": "u2ire", "status": "accepted"}) + self.assertEqual(self.targets.get_submissions(), return_data["value"]) + self.targets._api.request.assert_called_with('GET', 'listing_analytics/categories', + query={"listing_id": "u2ire", "status": "accepted"}) def test_get_submissions_rejected(self): """Should return the accepted vulnerabilities for a target given a slug""" @@ -710,13 +716,13 @@ def test_get_submissions_rejected(self): ] }] } - self.targets.db.find_targets = MagicMock() - self.targets.db.find_targets.return_value = [Target(slug='u2ire')] - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = return_data - self.assertEquals(self.targets.get_submissions(status="rejected", slug='u2ire'), return_data["value"]) - self.targets.api.request.assert_called_with('GET', 'listing_analytics/categories', - query={"listing_id": "u2ire", "status": "rejected"}) + self.targets._db.find_targets = MagicMock() + self.targets._db.find_targets.return_value = [Target(slug='u2ire')] + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = return_data + self.assertEqual(self.targets.get_submissions(status="rejected", slug='u2ire'), return_data["value"]) + self.targets._api.request.assert_called_with('GET', 'listing_analytics/categories', + query={"listing_id": "u2ire", "status": "rejected"}) def test_get_submissions_summary(self): """Should return the amount of lifetime submissions given a slug""" @@ -725,13 +731,13 @@ def test_get_submissions_summary(self): "type": "submissions", "value": 35 } - self.targets.db.find_targets = MagicMock() - self.targets.db.find_targets.return_value = [Target(slug='u2ire')] - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = return_data - self.assertEquals(self.targets.get_submissions_summary(slug='u2ire'), 35) - self.targets.api.request.assert_called_with('GET', 'listing_analytics/submissions', - query={"listing_id": "u2ire"}) + self.targets._db.find_targets = MagicMock() + self.targets._db.find_targets.return_value = [Target(slug='u2ire')] + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = return_data + self.assertEqual(self.targets.get_submissions_summary(slug='u2ire'), 35) + self.targets._api.request.assert_called_with('GET', 'listing_analytics/submissions', + query={"listing_id": "u2ire"}) def test_get_submissions_summary_hours(self): """Should return the amount of submissions in the last x hours given a slug""" @@ -740,13 +746,13 @@ def test_get_submissions_summary_hours(self): "type": "submissions", "value": 5 } - self.targets.db.find_targets = MagicMock() - self.targets.db.find_targets.return_value = [Target(slug='u2ire')] - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = return_data - self.assertEquals(self.targets.get_submissions_summary(hours_ago=48, slug='u2ire'), 5) - self.targets.api.request.assert_called_with('GET', 'listing_analytics/submissions', - query={"listing_id": "u2ire", "period": "48h"}) + self.targets._db.find_targets = MagicMock() + self.targets._db.find_targets.return_value = [Target(slug='u2ire')] + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = return_data + self.assertEqual(self.targets.get_submissions_summary(hours_ago=48, slug='u2ire'), 5) + self.targets._api.request.assert_called_with('GET', 'listing_analytics/submissions', + query={"listing_id": "u2ire", "period": "48h"}) def test_get_submissions_summary_no_slug(self): """Should return the amount of lifetime submissions for current connected when no slug""" @@ -755,15 +761,15 @@ def test_get_submissions_summary_no_slug(self): "type": "submissions", "value": 35 } - self.targets.db.find_targets = MagicMock() + self.targets._db.find_targets = MagicMock() self.targets.get_connected = MagicMock() self.targets.get_connected.return_value = {'slug': 'u2ire'} - self.targets.db.find_targets.return_value = [Target(slug='u2ire')] - self.targets.api.request.return_value.status_code = 200 - self.targets.api.request.return_value.json.return_value = return_data - self.assertEquals(self.targets.get_submissions_summary(), 35) - self.targets.api.request.assert_called_with('GET', 'listing_analytics/submissions', - query={"listing_id": "u2ire"}) + self.targets._db.find_targets.return_value = [Target(slug='u2ire')] + self.targets._api.request.return_value.status_code = 200 + self.targets._api.request.return_value.json.return_value = return_data + self.assertEqual(self.targets.get_submissions_summary(), 35) + self.targets._api.request.assert_called_with('GET', 'listing_analytics/submissions', + query={"listing_id": "u2ire"}) def test_get_unregistered(self): """Should query for unregistered targets""" @@ -772,7 +778,7 @@ def test_get_unregistered(self): ] self.targets.get_query = MagicMock() self.targets.get_query.return_value = results - self.assertEquals(results, self.targets.get_unregistered()) + self.assertEqual(results, self.targets.get_unregistered()) self.targets.get_query.assert_called_with(status='unregistered') def test_get_upcoming(self): @@ -786,39 +792,39 @@ def test_get_upcoming(self): } self.targets.get_query = MagicMock() self.targets.get_query.return_value = results - self.assertEquals(results, self.targets.get_upcoming()) + self.assertEqual(results, self.targets.get_upcoming()) self.targets.get_query.assert_called_with(status='upcoming', query_changes=query_changes) def test_set_connected(self): """Should connect to a given target provided kwargs""" - self.targets.db.find_targets.return_value = [Target(slug='28h93iw')] - self.targets.api.request.return_value.status_code = 200 + self.targets._db.find_targets.return_value = [Target(slug='28h93iw')] + self.targets._api.request.return_value.status_code = 200 self.targets.get_connected = MagicMock() self.targets.set_connected(slug='28h93iw') - self.targets.api.request.assert_called_with('PUT', - 'launchpoint', - data={'listing_id': '28h93iw'}) + self.targets._api.request.assert_called_with('PUT', + 'launchpoint', + data={'listing_id': '28h93iw'}) self.targets.get_connected.assert_called_with() def test_set_connected_disconnect(self): """Should disconnect from target if none specified""" - self.targets.api.request.return_value.status_code = 200 + self.targets._api.request.return_value.status_code = 200 self.targets.get_connected = MagicMock() self.targets.set_connected() - self.targets.api.request.assert_called_with('PUT', - 'launchpoint', - data={'listing_id': ''}) + self.targets._api.request.assert_called_with('PUT', + 'launchpoint', + data={'listing_id': ''}) self.targets.get_connected.assert_called_with() def test_set_connected_target(self): """Should connect to a given target provided a target""" target = Target(slug='28h93iw') - self.targets.api.request.return_value.status_code = 200 + self.targets._api.request.return_value.status_code = 200 self.targets.get_connected = MagicMock() self.targets.set_connected(target) - self.targets.api.request.assert_called_with('PUT', - 'launchpoint', - data={'listing_id': '28h93iw'}) + self.targets._api.request.assert_called_with('PUT', + 'launchpoint', + data={'listing_id': '28h93iw'}) self.targets.get_connected.assert_called_with() def test_set_registered(self): @@ -843,9 +849,9 @@ def test_set_registered(self): data='{"ResearcherListing":{"terms":1}}') ] self.targets.get_unregistered.return_value = unreg - self.targets.api.request.return_value.status_code = 200 + self.targets._api.request.return_value.status_code = 200 self.assertEqual(unreg, self.targets.set_registered()) - self.targets.api.request.assert_has_calls(calls) + self.targets._api.request.assert_has_calls(calls) def test_set_registered_many(self): """Should call itself again if it has determined the page was full""" @@ -858,5 +864,5 @@ def test_set_registered_many(self): for i in range(0, 15): unreg.append(t) self.targets.get_unregistered.side_effect = [unreg, [t, t]] - self.targets.api.request.return_value.status_code = 200 + self.targets._api.request.return_value.status_code = 200 self.assertEqual(17, len(self.targets.set_registered())) diff --git a/test/test_templates.py b/test/test_templates.py index 15586bb..91cce69 100644 --- a/test/test_templates.py +++ b/test/test_templates.py @@ -19,8 +19,9 @@ class TemplatesTestCase(unittest.TestCase): def setUp(self): self.state = synack._state.State() + self.state._db = MagicMock() self.templates = synack.plugins.Templates(self.state) - self.templates.db = MagicMock() + self.templates._db = MagicMock() def test_build_filepath_from_evidences(self): """Should return path from evidences json""" @@ -35,7 +36,7 @@ def test_build_filepath_from_evidences(self): 'asset': 'web', 'title': 'Mission' } - self.templates.db.template_dir = pathlib.Path('/tmp') + self.templates._state.template_dir = pathlib.Path('/tmp') self.assertEqual('/tmp/mission/web/mission.txt', self.templates.build_filepath(mission)) @@ -54,7 +55,7 @@ def test_build_filepath_from_mission(self): ], 'title': 'Mission' } - self.templates.db.template_dir = pathlib.Path('/tmp') + self.templates._state.template_dir = pathlib.Path('/tmp') self.assertEqual('/tmp/mission/web/mission.txt', self.templates.build_filepath(mission)) @@ -75,16 +76,16 @@ def test_build_filepath_non_exist_and_generic_ok(self): } with patch('pathlib.Path.exists') as mock_exists: mock_exists.side_effect = [False, True] - self.templates.db.template_dir = pathlib.Path('/tmp') + self.templates._state.template_dir = pathlib.Path('/tmp') self.assertEqual('/tmp/mission/web/generic.txt', self.templates.build_filepath(mission, generic_ok=True)) def test_build_safe_name(self): """Should convert complex missions names to something simpler""" - self.templates.alerts = MagicMock() - self.templates.alerts.sanitize.return_value = "S!oME_RaNdOm___MISSION!" + self.templates._alerts = MagicMock() + self.templates._alerts.sanitize.return_value = "S!oME_RaNdOm___MISSION!" one = self.templates.build_safe_name("S!oME_RaNdOm___MISSION!") - self.templates.alerts.sanitize.assert_called_with("S!oME_RaNdOm___MISSION!") + self.templates._alerts.sanitize.assert_called_with("S!oME_RaNdOm___MISSION!") one_out = "s_ome_random_mission_" self.assertEqual(one_out, one) @@ -111,22 +112,22 @@ def test_build_sections(self): def test_build_text_replaced_variables(self): """Should replace variables in text given text and Target info""" - self.templates.db.find_targets = MagicMock() + self.templates._db.find_targets = MagicMock() tgts = [Target(codename='SNEAKYSASQUATCH', slug='38h24iu')] - self.templates.db.find_targets.return_value = tgts + self.templates._db.find_targets.return_value = tgts input_text = "The target is {{ TARGET_CODENAME }}" expected_output = "The target is SNEAKYSASQUATCH" - self.assertEquals(self.templates.build_replace_variables(input_text, target=tgts[0]), expected_output) + self.assertEqual(self.templates.build_replace_variables(input_text, target=tgts[0]), expected_output) def test_build_text_replaced_variables_codename(self): """Should replace variables in text given text and codename""" - self.templates.db.find_targets = MagicMock() + self.templates._db.find_targets = MagicMock() tgts = [Target(codename='SNEAKYSASQUATCH', slug='38h24iu')] - self.templates.db.find_targets.return_value = tgts + self.templates._db.find_targets.return_value = tgts input_text = "The target is {{ TARGET_CODENAME }}" expected_output = "The target is SNEAKYSASQUATCH" actual_output = self.templates.build_replace_variables(input_text, codename='SLEEPYSASQUATCH') - self.assertEquals(actual_output, expected_output) + self.assertEqual(actual_output, expected_output) def test_get_file(self): self.templates.build_filepath = MagicMock() diff --git a/test/test_transactions.py b/test/test_transactions.py index c3d7a7c..134d76e 100644 --- a/test/test_transactions.py +++ b/test/test_transactions.py @@ -19,8 +19,9 @@ class TransactionsTestCase(unittest.TestCase): def setUp(self): self.state = synack._state.State() + self.state._db = MagicMock() self.transactions = synack.plugins.Transactions(self.state) - self.transactions.api = MagicMock() + self.transactions._api = MagicMock() def test_get_balance(self): """Should get the balance of your synack account""" @@ -28,9 +29,9 @@ def test_get_balance(self): "total_balance": "10.0", "pending_payout": "0.0" }''' - self.transactions.api.request.return_value.headers = {'x-balance': bal} - self.transactions.api.request.return_value.status_code = 200 + self.transactions._api.request.return_value.headers = {'x-balance': bal} + self.transactions._api.request.return_value.status_code = 200 ret = self.transactions.get_balance() self.assertEqual(ret, json.loads(bal)) - self.transactions.api.request.assert_called_with('HEAD', - 'transactions') + self.transactions._api.request.assert_called_with('HEAD', + 'transactions') diff --git a/test/test_users.py b/test/test_users.py index 0d1d55b..7242a3a 100644 --- a/test/test_users.py +++ b/test/test_users.py @@ -17,22 +17,23 @@ class UsersTestCase(unittest.TestCase): def setUp(self): self.state = synack._state.State() + self.state._db = MagicMock() self.users = synack.plugins.Users(self.state) - self.users.api = MagicMock() - self.users.db = MagicMock() + self.users._api = MagicMock() + self.users._db = MagicMock() def test_get_profile(self): """Should get info about me""" - self.users.api.request.return_value.status_code = 200 - self.users.api.request.return_value.json.return_value = {"one": "1"} + self.users._api.request.return_value.status_code = 200 + self.users._api.request.return_value.json.return_value = {"one": "1"} self.assertEqual({"one": "1"}, self.users.get_profile()) - self.users.api.request.assert_called_with("GET", - "profiles/me") + self.users._api.request.assert_called_with("GET", + "profiles/me") def test_get_profile_other(self): """Should get info about someone else""" - self.users.api.request.return_value.status_code = 200 - self.users.api.request.return_value.json.return_value = {"one": "1"} + self.users._api.request.return_value.status_code = 200 + self.users._api.request.return_value.json.return_value = {"one": "1"} self.assertEqual({"one": "1"}, self.users.get_profile("lngvmkpj")) - self.users.api.request.assert_called_with("GET", - "profiles/lngvmkpj") + self.users._api.request.assert_called_with("GET", + "profiles/lngvmkpj") From 54065d7692e409e1283e5f290848d43c7fbdcd0b Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Sun, 26 Jan 2025 06:01:20 +0000 Subject: [PATCH 14/36] fixed scopes, improved db performance --- src/synack/_state.py | 2 +- .../6814001a4ed4_add_unique_ips_constraint.py | 27 +++ ...53c42281f78_add_unique_ports_constraint.py | 26 +++ src/synack/db/models/ip.py | 1 + src/synack/db/models/port.py | 3 +- src/synack/plugins/api.py | 5 +- src/synack/plugins/db.py | 217 ++++++++++-------- src/synack/plugins/notifications.py | 11 + src/synack/plugins/scratchspace.py | 23 +- src/synack/plugins/targets.py | 12 +- 10 files changed, 208 insertions(+), 119 deletions(-) create mode 100644 src/synack/db/alembic/versions/6814001a4ed4_add_unique_ips_constraint.py create mode 100644 src/synack/db/alembic/versions/753c42281f78_add_unique_ports_constraint.py diff --git a/src/synack/_state.py b/src/synack/_state.py index f595b9f..062055f 100644 --- a/src/synack/_state.py +++ b/src/synack/_state.py @@ -222,7 +222,7 @@ def use_proxies(self, value: bool) -> None: def use_scratchspace(self) -> bool: ret = self._use_scratchspace if ret is None: - self._db.use_scratchspace + ret = self._db.use_scratchspace return ret @use_scratchspace.setter diff --git a/src/synack/db/alembic/versions/6814001a4ed4_add_unique_ips_constraint.py b/src/synack/db/alembic/versions/6814001a4ed4_add_unique_ips_constraint.py new file mode 100644 index 0000000..95c4dae --- /dev/null +++ b/src/synack/db/alembic/versions/6814001a4ed4_add_unique_ips_constraint.py @@ -0,0 +1,27 @@ +"""add unique ips constraint + +Revision ID: 6814001a4ed4 +Revises: 753c42281f78 +Create Date: 2025-01-26 05:19:35.150476 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6814001a4ed4' +down_revision = '753c42281f78' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('ips') as batch_op: + batch_op.create_unique_constraint('uq_ip', ['ip', 'target']) + + +def downgrade(): + with op.batch_alter_table('ips') as batch_op: + batch_op.drop_constraint('uq_ip', type_='unique') + diff --git a/src/synack/db/alembic/versions/753c42281f78_add_unique_ports_constraint.py b/src/synack/db/alembic/versions/753c42281f78_add_unique_ports_constraint.py new file mode 100644 index 0000000..3993f46 --- /dev/null +++ b/src/synack/db/alembic/versions/753c42281f78_add_unique_ports_constraint.py @@ -0,0 +1,26 @@ +"""add unique ports constraint + +Revision ID: 753c42281f78 +Revises: c2e6de9ffc5e +Create Date: 2025-01-26 05:07:23.252004 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '753c42281f78' +down_revision = 'c2e6de9ffc5e' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('ports') as batch_op: + batch_op.create_unique_constraint('uq_port', ['port', 'protocol', 'ip', 'source']) + + +def downgrade(): + with op.batch_alter_table('ports') as batch_op: + batch_op.drop_constraint('uq_port', type_='unique') diff --git a/src/synack/db/models/ip.py b/src/synack/db/models/ip.py index a45be8c..83e6c36 100644 --- a/src/synack/db/models/ip.py +++ b/src/synack/db/models/ip.py @@ -15,3 +15,4 @@ class IP(Base): id = sa.Column(sa.Integer, autoincrement=True, primary_key=True) ip = sa.Column(sa.VARCHAR(40)) target = sa.Column(sa.VARCHAR(20), sa.ForeignKey(Target.slug)) + __table_args__ = (sa.UniqueConstraint('ip', 'target', name='uq_ip'),) diff --git a/src/synack/db/models/port.py b/src/synack/db/models/port.py index a592b0e..a7a9044 100644 --- a/src/synack/db/models/port.py +++ b/src/synack/db/models/port.py @@ -20,5 +20,4 @@ class Port(Base): open = sa.Column(sa.BOOLEAN, default=False) service = sa.Column(sa.VARCHAR(200), default="") updated = sa.Column(sa.INTEGER, default=0) - url = sa.Column(sa.VARCHAR(200), default="") - screenshot_url = sa.Column(sa.VARCHAR(1000), default="") + __table_args__ = (sa.UniqueConstraint('port', 'protocol', 'ip', 'source', name='uq_port'),) diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index d07fcd0..69247cc 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -5,6 +5,7 @@ import time import urllib3 +import warnings from .base import Plugin @@ -83,11 +84,13 @@ def request(self, method, path, attempts=0, **kwargs): url = f'{base}{path}' verify = True + warnings.filterwarnings('ignore') + #urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + proxies = self._state.proxies if self._state.use_proxies else None if proxies: verify = False - urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) if 'synack.com/api/' in url: headers = { diff --git a/src/synack/plugins/db.py b/src/synack/plugins/db.py index 368ae65..c5b6826 100644 --- a/src/synack/plugins/db.py +++ b/src/synack/plugins/db.py @@ -7,6 +7,8 @@ import alembic.command import sqlalchemy as sa +from sqlalchemy.dialects.sqlite import insert as sqlite_insert + from pathlib import Path from sqlalchemy.orm import sessionmaker from synack.db.models import Target @@ -28,6 +30,9 @@ def __init__(self, *args, **kwargs): self.set_migration() engine = sa.create_engine(f'sqlite:///{str(self.sqlite_db)}') + metadata = sa.MetaData() + metadata.reflect(bind=engine) + metadata.clear() sa.event.listen(engine, 'connect', self._fk_pragma_on_connect) self.Session = sessionmaker(bind=engine) @@ -51,132 +56,152 @@ def add_categories(self, categories): def add_ips(self, results, session=None): close = False + if session is None: session = self.Session() close = True - q = session.query(IP) - for result in results: - if result.get('ip'): - filt = sa.and_( - IP.ip.like(result.get('ip')), - IP.target.like(result.get('target')) - ) - db_ip = q.filter(filt).first() - if not db_ip: - db_ip = IP( - ip=result.get('ip'), - target=result.get('target')) - session.add(db_ip) + + to_insert = [ + {'ip': result['ip'], 'target': result['target']} + for result in results + if result.get('ip') and result.get('target') + ] + + stmt = sqlite_insert(IP).values(to_insert) + stmt = stmt.on_conflict_do_nothing( + index_elements=['ip', 'target'], + ) + session.execute(stmt) + if close: session.commit() session.close() def add_organizations(self, targets, session=None): close = False + if session is None: session = self.Session() close = True - q = session.query(Organization) - for t in targets: - if t.get('organization'): - slug = t['organization']['slug'] - else: - slug = t.get('organization_id') - db_o = q.filter_by(slug=slug).first() - if not db_o: - db_o = Organization(slug=slug) - session.add(db_o) + + to_insert = list() + for target in targets: + slug = target.get('organization_id', target.get('organization'. {}).get('slug')) + if slug: + to_insert.append({'slug': slug}} + + stmt = sqlite_insert(Organization).values(to_insert) + stmt = smty.on_conflict_do_nothing( + index_elements=['slug'], + ) + session.execute(stmt) + if close: session.commit() session.close() def add_ports(self, results): - self.add_ips(results) session = self.Session() - q = session.query(Port) - ips = session.query(IP) + + self.add_ips(results, session) + ip_map = {ip.ip: ip.id for ip in session.query(IP.ip, IP.id).all()} + + ports_data = list() + for result in results: - ip = ips.filter_by(ip=result.get('ip')) - if ip: - ip = ip.first() + ip_id = ip_map.get(result.get('ip')) + if ip_id: for port in result.get('ports', []): - filt = sa.and_( - Port.port.like(port.get('port')), - Port.protocol.like(port.get('protocol')), - Port.ip.like(ip.id), - Port.source.like(result.get('source'))) - db_port = q.filter(filt) - if not db_port: - db_port = Port( - port=port.get('port'), - protocol=port.get('protocol'), - service=port.get('service'), - ip=ip.id, - source=result.get('source'), - open=port.get('open'), - updated=port.get('updated') - ) - else: - db_port = db_port.first() - db_port.service = port.get('service', db_port.service) - db_port.open = port.get('open', db_port.open) - db_port.updated = port.get('updated', db_port.updated) - session.add(db_port) + ports_data.append({ + 'port': port.get('port'), + 'protocol': port.get('protocol'), + 'service': port.get('service'), + 'ip': ip_id, + 'source': result.get('source'), + 'open': port.get('open'), + 'updated': port.get('updated') + }) + + stmt = sqlite_insert(Port).values(ports_data) + stmt = stmt.on_conflict_do_update( + index_elements=['port', 'protocol', 'ip', 'source'], + set_={ + 'service': stmt.excluded.service, + 'open': stmt.excluded.open, + 'updated': stmt.excluded.updated + } + ) + + session.execute(stmt) session.commit() session.close() - def add_targets(self, targets, **kwargs): + def add_targets(self, targets): session = self.Session() + self.add_organizations(targets, session) - q = session.query(Target) - for t in targets: - if t.get('organization'): - org_slug = t['organization']['slug'] - else: - org_slug = t.get('organization_id') - slug = t.get('slug', t.get('id')) - db_t = q.filter_by(slug=slug).first() - if not db_t: - db_t = Target(slug=slug) - session.add(db_t) - for k in t.keys(): - setattr(db_t, k, t[k]) - db_t.category = t['category']['id'] - db_t.organization = org_slug - db_t.date_updated = t.get('dateUpdated') - db_t.is_active = t.get('isActive') - db_t.is_new = t.get('isNew') - db_t.is_registered = t.get('isRegistered') - db_t.is_updated = t.get('isUpdated') - db_t.last_submitted = t.get('lastSubmitted') - for k in kwargs.keys(): - setattr(db_t, k, kwargs[k]) + db_orgs = session.query(Organization.slug).all() + + targets_data = list() + + for target in targets: + org_slug = target.get('organization_id', target.get('organization', {}).get('slug')) + if org_slug in db_orgs: + targets_data.append({ + 'slug': target.get('slug', target.get('id')), + 'category': target['category']['id'], + 'organization': org_slug, + 'date_updated': target.get('dateUpdated'), + 'is_active': target.get('isActive'), + 'is_new': target.get('isNew'), + 'is_registered': target.get('isRegistered'), + 'last_submitted': target.get('lastSubmitted') + }) + + stmt = sqlite_insert(Target).values(targets_data) + stmt = stmt.on_conflict_do_update( + index_elements=['slug'], + set_={ + 'category': stmt.excluded.category, + 'organization': stmt.excluded.organization, + 'date_updated': stmt.excluded.date_updated, + 'is_active': stmt.excluded.is_active, + 'is_new': stmt.excluded.is_new, + 'is_registered': stmt.excluded.is_registered, + 'last_submitted': stmt.excluded.last_submitted, + } + ) + + session.execute(stmt) session.commit() session.close() - def add_urls(self, results, **kwargs): - self.add_ips(results) + def add_urls(self, results): session = self.Session() - q = session.query(Url) - ips = session.query(IP) + + self.add_ips(results, session) + ip_map = {ip.ip: ip.id for ip in session.query(IP.ip, IP.id).all()} + + urls_data = list() + for result in results: - ip = ips.filter_by(ip=result.get('ip')).first() - for url in result.get('urls', []): - if ip: - filt = sa.and_( - Url.url.like(url.get('url')), - Url.ip.like(ip.id)) - else: - filt = sa.and_( - Url.url.like(url.get('url'))) - db_url = q.filter(filt).first() - if not db_url: - db_url = Url() - db_url.url = url.get('url') - db_url.screenshot_url = url.get('screenshot_url') - if ip: - db_url.ip = ip.id - session.add(db_url) + ip_id = ip_map.get(result.get('ip')) + if ip_id: + for url in result.get('urls', []): + urls_data.append({ + 'url': url.get('url') + 'screenshot_url': url.get('screenshot_url') + }) + + stmt = sqlite_insert(Url).values(urls_data) + stmt = stmt.on_conflict_do_update( + index_elements=['ip', 'url'], + set_={ + 'screenshot_url': stmt.excluded.screenshot_url + } + ) + + session.execute(stmt) session.commit() session.close() diff --git a/src/synack/plugins/notifications.py b/src/synack/plugins/notifications.py index 7ec7dae..a492eeb 100644 --- a/src/synack/plugins/notifications.py +++ b/src/synack/plugins/notifications.py @@ -31,3 +31,14 @@ def get_unread_count(self): query=query) if res.status_code == 200: return res.json() + + def set_read(self): + """Set all notifications to read""" + query = { + "authorization_token": self._state.notifications_token + } + res = self._api.notifications('GET', + 'read_all', + query=query) + if res.status_code == 200: + return res.json() diff --git a/src/synack/plugins/scratchspace.py b/src/synack/plugins/scratchspace.py index edb06e0..6c5d57b 100644 --- a/src/synack/plugins/scratchspace.py +++ b/src/synack/plugins/scratchspace.py @@ -28,22 +28,10 @@ def build_filepath(self, filename, target=None, codename=None): return f def set_assets_file(self, content, target=None, codename=None): - if target or codename: - if type(content) in [list, set]: - content = '\n'.join(content) - dest_file = self.build_filepath('assets.txt', target=target, codename=codename) - with open(dest_file, 'w') as fp: - fp.write(content) - return dest_file + return self.set_file(content=content, filename='assets.txt', target=target, codename=codename) def set_burp_file(self, content, target=None, codename=None): - if target or codename: - if type(content) == dict: - content = json.dumps(content) - dest_file = self.build_filepath('burp.txt', target=target, codename=codename) - with open(dest_file, 'w') as fp: - fp.write(content) - return dest_file + return self.set_file(content=content, filename='burp.txt', target=target, codename=codename) def set_download_attachments(self, attachments, target=None, codename=None, prompt_overwrite=True, overwrite=True): downloads = list() @@ -62,11 +50,14 @@ def set_download_attachments(self, attachments, target=None, codename=None, prom downloads.append(dest_file) return downloads - def set_hosts_file(self, content, target=None, codename=None): + def set_file(self, content, filename=None, target=None, codename=None): if target or codename: if type(content) in [list, set]: content = '\n'.join(content) - dest_file = self.build_filepath('hosts.txt', target=target, codename=codename) + dest_file = self.build_filepath(filename, target=target, codename=codename) with open(dest_file, 'w') as fp: fp.write(content) return dest_file + + def set_hosts_file(self, content, target=None, codename=None): + return self.set_file(content=content, filename='hosts.txt', target=target, codename=codename) diff --git a/src/synack/plugins/targets.py b/src/synack/plugins/targets.py index 8182123..e35a239 100644 --- a/src/synack/plugins/targets.py +++ b/src/synack/plugins/targets.py @@ -115,6 +115,11 @@ def get_assets(self, target=None, asset_type=None, host_type=None, active='true' if type(scope) == str: scope = [scope] + if type(host_type) == str: + host_type = [host_type] + elif host_type is None: + host_type = list() + if target: if type(target) is list and len(target) > 0: target = target[0] @@ -125,8 +130,8 @@ def get_assets(self, target=None, asset_type=None, host_type=None, active='true' queries.append(f'organizationUid%5B%5D={organization_uid}') if asset_type is not None: queries.append(f'assetType%5B%5D={asset_type}') - if host_type is not None: - queries.append(f'hostType%5B%5D={host_type}') + for item in host_type: + queries.append(f'hostType%5B%5D={item}') for item in scope: queries.append(f'scope%5B%5D={item}') if sort is not None: @@ -252,6 +257,7 @@ def get_scope(self, **kwargs): def get_scope_host(self, target=None, **kwargs): """Get the scope of a Host target""" + if target is None: if len(kwargs) > 0: targets = self._db.find_targets(**kwargs) @@ -264,7 +270,7 @@ def get_scope_host(self, target=None, **kwargs): scope = set() if target: - assets = self.get_assets(target=target, active='true', asset_type='host', host_type='cidr') + assets = self.get_assets(target=target, active='true', asset_type='host', host_type=['cidr', 'ip']) for asset in assets: if asset.get('active'): try: From 3838fdcec909adde810bd8739b20cd899a036b7f Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Sun, 26 Jan 2025 07:16:37 +0000 Subject: [PATCH 15/36] fixed up formatting and documentation issues --- docs/src/usage/plugins/db.md | 3 -- docs/src/usage/plugins/notifications.md | 11 ++++- docs/src/usage/plugins/scratchspace.md | 24 ++++++++-- .../6814001a4ed4_add_unique_ips_constraint.py | 2 - ...53c42281f78_add_unique_ports_constraint.py | 1 - src/synack/plugins/api.py | 2 - src/synack/plugins/auth.py | 2 + src/synack/plugins/db.py | 16 ++++--- src/synack/plugins/scratchspace.py | 4 +- test/test_db.py | 46 +++++++++++-------- test/test_state.py | 2 +- 11 files changed, 74 insertions(+), 39 deletions(-) diff --git a/docs/src/usage/plugins/db.md b/docs/src/usage/plugins/db.md index 6f769bf..c77e6df 100644 --- a/docs/src/usage/plugins/db.md +++ b/docs/src/usage/plugins/db.md @@ -96,11 +96,8 @@ Additionally, some properties can be overridden by the State, which allows you t >> ... "port": "443", >> ... "protocol": "tcp", >> ... "service": "Super Apache NGINX Deluxe", ->> ... "screenshot_url": "http://127.0.0.1/h3298h23.png", ->> ... "url": "http://bubba.net", >> ... "open": True, >> ... "updated": 1654969137 ->> ... >> ... }, >> ... { >> ... "port": "53", diff --git a/docs/src/usage/plugins/notifications.md b/docs/src/usage/plugins/notifications.md index f34f50e..1a319e9 100644 --- a/docs/src/usage/plugins/notifications.md +++ b/docs/src/usage/plugins/notifications.md @@ -12,10 +12,19 @@ ## notifications.get_unread_count() -> Get the number of unread notifications. +> Get the number of unread notifications > >> Examples >> ```python3 >> >>> h.notifications.get_unread_count() >> 7 >> ``` + +## notifications.set_read() + +> Set all notifications as read +> +>> Examples +>> ```python3 +>> >>> h.notifications.set_read() +>> ``` diff --git a/docs/src/usage/plugins/scratchspace.md b/docs/src/usage/plugins/scratchspace.md index fd4a0a8..5a117b7 100644 --- a/docs/src/usage/plugins/scratchspace.md +++ b/docs/src/usage/plugins/scratchspace.md @@ -18,7 +18,7 @@ ## scratchspace.set_assets_file(content, target=None, codename=None) -> This function will save a `assets.txt` scope file within a `codename` folder in within the `self.db.scratchspace_dir` folder +> This function will save a `assets.txt` scope file within a `codename` folder within the `self.db.scratchspace_dir` folder > If `self.db.use_scratchspace` is `True`, this function is automatically run when you do `targets.get_scope()` or `targets.get_scope_web()` > > | Arguments | Type | Description @@ -36,7 +36,7 @@ ## scratchspace.set_burp_file(content, target=None, codename=None) -> This function will save a `burp.txt` scope file within a `codename` folder in within the `self.db.scratchspace_dir` folder +> This function will save a `burp.txt` scope file within a `codename` folder within the `self.db.scratchspace_dir` folder > If `self.db.use_scratchspace` is `True`, this function is automatically run when you do `targets.get_scope()` or `targets.get_scope_web()` > > | Arguments | Type | Description @@ -82,9 +82,27 @@ >> [PosixPath('/home/user/Scratchspace/SLEEPYTURTLE/file1.txt'), ...] >> ``` +## scratchspace.set_file(content, filename, target=None, codename=None) + +> This function will save a text file with a given name within a `codename` folder within the `sself.db.scratchspace_dir` folder. +> If `self.db.use_scratchspace` is `True`, this function is automatically run when you do `targets.get_scope()` or similar. +> +> | Arguments | Type | Description +> | --- | --- | --- +> | `content` | str,list(str) | Either a preformatted string or (more likely) the return of `targets.get_scope_host()` +> | `filename` | str | Desired filename +> | `target` | db.models.Target | A Target Database Object +> | `codename` | str | Codename of a Target +> +>> Examples +>> ```python3 +>> >>> h.scratchspace.set_file('some unique string', 'mystring.txt', codename='ADAMANTARDVARK') +>> '/tmp/Scratchspace/ADAMANTARDVARK/mystring.txt' +>> ``` + ## scratchspace.set_hosts_file(content, target=None, codename=None) -> This function will save a `hosts.txt` scope file within a `codename` folder in within the `self.db.scratchspace_dir` folder. +> This function will save a `hosts.txt` scope file within a `codename` folder within the `self.db.scratchspace_dir` folder. > If `self.db.use_scratchspace` is `True`, this function is automatically run when you do `targets.get_scope()` or `targets.get_scope_host()` > > | Arguments | Type | Description diff --git a/src/synack/db/alembic/versions/6814001a4ed4_add_unique_ips_constraint.py b/src/synack/db/alembic/versions/6814001a4ed4_add_unique_ips_constraint.py index 95c4dae..00058de 100644 --- a/src/synack/db/alembic/versions/6814001a4ed4_add_unique_ips_constraint.py +++ b/src/synack/db/alembic/versions/6814001a4ed4_add_unique_ips_constraint.py @@ -6,7 +6,6 @@ """ from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. @@ -24,4 +23,3 @@ def upgrade(): def downgrade(): with op.batch_alter_table('ips') as batch_op: batch_op.drop_constraint('uq_ip', type_='unique') - diff --git a/src/synack/db/alembic/versions/753c42281f78_add_unique_ports_constraint.py b/src/synack/db/alembic/versions/753c42281f78_add_unique_ports_constraint.py index 3993f46..7401f81 100644 --- a/src/synack/db/alembic/versions/753c42281f78_add_unique_ports_constraint.py +++ b/src/synack/db/alembic/versions/753c42281f78_add_unique_ports_constraint.py @@ -6,7 +6,6 @@ """ from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index 69247cc..3435672 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -4,7 +4,6 @@ """ import time -import urllib3 import warnings from .base import Plugin @@ -85,7 +84,6 @@ def request(self, method, path, attempts=0, **kwargs): verify = True warnings.filterwarnings('ignore') - #urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) proxies = self._state.proxies if self._state.use_proxies else None diff --git a/src/synack/plugins/auth.py b/src/synack/plugins/auth.py index ae6e7a2..1489064 100644 --- a/src/synack/plugins/auth.py +++ b/src/synack/plugins/auth.py @@ -85,6 +85,8 @@ def set_api_token_invalid(self): res = self._api.request('POST', 'logout') if res.status_code == 200: self._db.api_token = '' + return True + return False def set_login_script(self): script = "let forceLogin = () => {" +\ diff --git a/src/synack/plugins/db.py b/src/synack/plugins/db.py index c5b6826..a7dec8b 100644 --- a/src/synack/plugins/db.py +++ b/src/synack/plugins/db.py @@ -86,12 +86,12 @@ def add_organizations(self, targets, session=None): to_insert = list() for target in targets: - slug = target.get('organization_id', target.get('organization'. {}).get('slug')) + slug = target.get('organization_id', target.get('organization', {}).get('slug')) if slug: - to_insert.append({'slug': slug}} + to_insert.append({'slug': slug}) stmt = sqlite_insert(Organization).values(to_insert) - stmt = smty.on_conflict_do_nothing( + stmt = stmt.on_conflict_do_nothing( index_elements=['slug'], ) session.execute(stmt) @@ -136,7 +136,7 @@ def add_ports(self, results): session.commit() session.close() - def add_targets(self, targets): + def add_targets(self, targets, **kwargs): session = self.Session() self.add_organizations(targets, session) @@ -147,7 +147,7 @@ def add_targets(self, targets): for target in targets: org_slug = target.get('organization_id', target.get('organization', {}).get('slug')) if org_slug in db_orgs: - targets_data.append({ + target_data = { 'slug': target.get('slug', target.get('id')), 'category': target['category']['id'], 'organization': org_slug, @@ -156,7 +156,9 @@ def add_targets(self, targets): 'is_new': target.get('isNew'), 'is_registered': target.get('isRegistered'), 'last_submitted': target.get('lastSubmitted') - }) + } + target_data.update(kwargs) + targets_data.append(target_data) stmt = sqlite_insert(Target).values(targets_data) stmt = stmt.on_conflict_do_update( @@ -189,7 +191,7 @@ def add_urls(self, results): if ip_id: for url in result.get('urls', []): urls_data.append({ - 'url': url.get('url') + 'url': url.get('url'), 'screenshot_url': url.get('screenshot_url') }) diff --git a/src/synack/plugins/scratchspace.py b/src/synack/plugins/scratchspace.py index 6c5d57b..fae564c 100644 --- a/src/synack/plugins/scratchspace.py +++ b/src/synack/plugins/scratchspace.py @@ -50,10 +50,12 @@ def set_download_attachments(self, attachments, target=None, codename=None, prom downloads.append(dest_file) return downloads - def set_file(self, content, filename=None, target=None, codename=None): + def set_file(self, content, filename, target=None, codename=None): if target or codename: if type(content) in [list, set]: content = '\n'.join(content) + elif type(content) in [dict]: + content = json.dumps(content) dest_file = self.build_filepath(filename, target=target, codename=codename) with open(dest_file, 'w') as fp: fp.write(content) diff --git a/test/test_db.py b/test/test_db.py index a5df2ec..c04c75b 100644 --- a/test/test_db.py +++ b/test/test_db.py @@ -60,7 +60,8 @@ def test_add_categories_empty_db(self): self.db.Session.return_value.commit.assert_called_with() self.db.Session.return_value.close.assert_called_with() - def test_add_ips_existing_ips(self): + @patch('sqlalchemy.dialects.sqlite.insert') + def test_add_ips_existing_ips(self, mock_insert): """Should not add IPs if already in db""" self.db.Session = MagicMock() results = [ @@ -82,19 +83,23 @@ def test_add_ips_existing_ips(self): ] } ] - query = self.db.Session.return_value.query - with patch.object(sqlalchemy, 'and_') as mock_and: - mock_and.return_value = 'sqlalchemy.and_' - self.db.add_ips(results) - - mock_and.assert_called() - query.asset_called_with(synack.db.models.IP) - query.return_value.filter.assert_called_with('sqlalchemy.and_') - query.return_value.filter.return_value.first.assert_called_with() - self.db.Session.return_value.commit.assert_called_with() - self.db.Session.return_value.close.assert_called_with() + to_insert = [ + {'ip': '1.1.1.1', 'target': '7gh33tjf72'} + ] + self.db.add_ips(results) + mock_insert.assert_called_with(synack.db.models.IP) + mock_insert.return_value.values.assert_called_with(to_insert) + stmt = mock_insert.return_value.values.return_value + stmt.on_conflict_do_nothing.assert_called_with( + index_elements=['slug'], + ) + + self.db.Session.return_value.execute.assert_called_with(stmt) + self.db.Session.return_value.commit.assert_called_with() + self.db.Session.return_value.close.assert_called_with() - def test_add_ips_new_ips(self): + @patch('sqlalchemy.dialects.sqlite.insert') + def test_add_ips_new_ips(self, mock_insert): """Should app IPs if new""" self.db.Session = MagicMock() results = [ @@ -167,7 +172,8 @@ def test_add_organizations_organization_id(self): mock.query.return_value.filter_by.return_value.first.assert_called_with() mock.add.assert_called() - def test_add_ports_new(self): + @patch('sqlalchemy.dialects.sqlite.insert') + def test_add_ports_new(self, mock_insert): """Should add port if new""" self.db.Session = MagicMock() self.db.add_ips = MagicMock() @@ -202,7 +208,8 @@ def test_add_ports_new(self): self.db.Session.return_value.commit.assert_called_with() self.db.Session.return_value.close.assert_called_with() - def test_add_ports_update(self): + @patch('sqlalchemy.dialects.sqlite.insert') + def test_add_ports_update(self, mock_insert): """Should update ports if existing""" self.db.Session = MagicMock() self.db.add_ips = MagicMock() @@ -278,7 +285,8 @@ def test_add_targets_empty_db(self): self.db.Session.return_value.commit.assert_called_with() self.db.Session.return_value.close.assert_called_with() - def test_add_urls_new(self): + @patch('sqlalchemy.dialects.sqlite.insert') + def test_add_urls_new(self, mock_insert): """Should add url if new""" self.db.Session = MagicMock() self.db.add_ips = MagicMock() @@ -309,7 +317,8 @@ def test_add_urls_new(self): self.db.Session.return_value.commit.assert_called_with() self.db.Session.return_value.close.assert_called_with() - def test_add_urls_no_ip(self): + @patch('sqlalchemy.dialects.sqlite.insert') + def test_add_urls_no_ip(self, mock_insert): """Should be fine if IP isn't included""" self.db.Session = MagicMock() self.db.add_ips = MagicMock() @@ -339,7 +348,8 @@ def test_add_urls_no_ip(self): self.db.Session.return_value.close.assert_called_with() self.db.add_ips.assert_called_with(results) - def test_add_url_update(self): + @patch('sqlalchemy.dialects.sqlite.insert') + def test_add_url_update(self, mock_insert): """Should update urls if existing""" self.db.Session = MagicMock() self.db.add_ips = MagicMock() diff --git a/test/test_state.py b/test/test_state.py index 6695c43..84c64e6 100644 --- a/test/test_state.py +++ b/test/test_state.py @@ -141,7 +141,7 @@ def test_user_id(self): self.assertEqual('12345', self.state._user_id) def test_use_scratchspace(self): - self.assertEqual(None, self.state.use_scratchspace) + self.assertEqual(self.state._db.use_scratchspace, self.state.use_scratchspace) self.assertEqual(None, self.state._use_scratchspace) self.state.use_scratchspace = True self.assertEqual(True, self.state.use_scratchspace) From 94b716470f7104cbf30248087569798c1918be14 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Sun, 26 Jan 2025 23:44:51 +0000 Subject: [PATCH 16/36] added better error handling to db functions --- src/synack/plugins/db.py | 116 +++++++++++++++++++++------------------ 1 file changed, 63 insertions(+), 53 deletions(-) diff --git a/src/synack/plugins/db.py b/src/synack/plugins/db.py index a7dec8b..ccf3dee 100644 --- a/src/synack/plugins/db.py +++ b/src/synack/plugins/db.py @@ -9,6 +9,7 @@ from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from pprint import pprint from pathlib import Path from sqlalchemy.orm import sessionmaker from synack.db.models import Target @@ -61,17 +62,21 @@ def add_ips(self, results, session=None): session = self.Session() close = True - to_insert = [ - {'ip': result['ip'], 'target': result['target']} - for result in results - if result.get('ip') and result.get('target') - ] + ips_data = list() - stmt = sqlite_insert(IP).values(to_insert) - stmt = stmt.on_conflict_do_nothing( - index_elements=['ip', 'target'], - ) - session.execute(stmt) + for result in results: + if result.get('ip') and result.get('target'): + ips_data.append({ + 'ip': result['ip'], + 'target': result['target'] + }) + + if ips_data: + stmt = sqlite_insert(IP).values(ips_data) + stmt = stmt.on_conflict_do_nothing( + index_elements=['ip', 'target'], + ) + session.execute(stmt) if close: session.commit() @@ -84,17 +89,19 @@ def add_organizations(self, targets, session=None): session = self.Session() close = True - to_insert = list() + organizations_data = list() + for target in targets: slug = target.get('organization_id', target.get('organization', {}).get('slug')) if slug: - to_insert.append({'slug': slug}) + organizations_data.append({'slug': slug}) - stmt = sqlite_insert(Organization).values(to_insert) - stmt = stmt.on_conflict_do_nothing( - index_elements=['slug'], - ) - session.execute(stmt) + if organizations_data: + stmt = sqlite_insert(Organization).values(organizations_data) + stmt = stmt.on_conflict_do_nothing( + index_elements=['slug'], + ) + session.execute(stmt) if close: session.commit() @@ -122,17 +129,18 @@ def add_ports(self, results): 'updated': port.get('updated') }) - stmt = sqlite_insert(Port).values(ports_data) - stmt = stmt.on_conflict_do_update( - index_elements=['port', 'protocol', 'ip', 'source'], - set_={ - 'service': stmt.excluded.service, - 'open': stmt.excluded.open, - 'updated': stmt.excluded.updated - } - ) - - session.execute(stmt) + if ports_data: + stmt = sqlite_insert(Port).values(ports_data) + stmt = stmt.on_conflict_do_update( + index_elements=['port', 'protocol', 'ip', 'source'], + set_={ + 'service': stmt.excluded.service, + 'open': stmt.excluded.open, + 'updated': stmt.excluded.updated + } + ) + session.execute(stmt) + session.commit() session.close() @@ -140,7 +148,7 @@ def add_targets(self, targets, **kwargs): session = self.Session() self.add_organizations(targets, session) - db_orgs = session.query(Organization.slug).all() + db_orgs = [org[0] for org in session.query(Organization.slug).all()] targets_data = list() @@ -148,7 +156,7 @@ def add_targets(self, targets, **kwargs): org_slug = target.get('organization_id', target.get('organization', {}).get('slug')) if org_slug in db_orgs: target_data = { - 'slug': target.get('slug', target.get('id')), + 'slug': target.get('id', target.get('slug')), 'category': target['category']['id'], 'organization': org_slug, 'date_updated': target.get('dateUpdated'), @@ -160,21 +168,22 @@ def add_targets(self, targets, **kwargs): target_data.update(kwargs) targets_data.append(target_data) - stmt = sqlite_insert(Target).values(targets_data) - stmt = stmt.on_conflict_do_update( - index_elements=['slug'], - set_={ - 'category': stmt.excluded.category, - 'organization': stmt.excluded.organization, - 'date_updated': stmt.excluded.date_updated, - 'is_active': stmt.excluded.is_active, - 'is_new': stmt.excluded.is_new, - 'is_registered': stmt.excluded.is_registered, - 'last_submitted': stmt.excluded.last_submitted, - } - ) - - session.execute(stmt) + if targets_data: + stmt = sqlite_insert(Target).values(targets_data) + stmt = stmt.on_conflict_do_update( + index_elements=['slug'], + set_={ + 'category': stmt.excluded.category, + 'organization': stmt.excluded.organization, + 'date_updated': stmt.excluded.date_updated, + 'is_active': stmt.excluded.is_active, + 'is_new': stmt.excluded.is_new, + 'is_registered': stmt.excluded.is_registered, + 'last_submitted': stmt.excluded.last_submitted, + } + ) + session.execute(stmt) + session.commit() session.close() @@ -195,15 +204,16 @@ def add_urls(self, results): 'screenshot_url': url.get('screenshot_url') }) - stmt = sqlite_insert(Url).values(urls_data) - stmt = stmt.on_conflict_do_update( - index_elements=['ip', 'url'], - set_={ - 'screenshot_url': stmt.excluded.screenshot_url - } - ) + if urls_data: + stmt = sqlite_insert(Url).values(urls_data) + stmt = stmt.on_conflict_do_update( + index_elements=['ip', 'url'], + set_={ + 'screenshot_url': stmt.excluded.screenshot_url + } + ) + session.execute(stmt) - session.execute(stmt) session.commit() session.close() From 35abb9297baf091dbcd6deb23657e8849ed4763f Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Mon, 27 Jan 2025 00:53:11 +0000 Subject: [PATCH 17/36] added codenames back to targets --- src/synack/plugins/api.py | 5 +---- src/synack/plugins/db.py | 2 ++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index 3435672..38c5a3e 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -82,14 +82,11 @@ def request(self, method, path, attempts=0, **kwargs): base = 'https://platform.synack.com/api/' url = f'{base}{path}' - verify = True + verify = False warnings.filterwarnings('ignore') proxies = self._state.proxies if self._state.use_proxies else None - if proxies: - verify = False - if 'synack.com/api/' in url: headers = { 'Authorization': f'Bearer {self._state.api_token}', diff --git a/src/synack/plugins/db.py b/src/synack/plugins/db.py index ccf3dee..c18e56f 100644 --- a/src/synack/plugins/db.py +++ b/src/synack/plugins/db.py @@ -157,6 +157,7 @@ def add_targets(self, targets, **kwargs): if org_slug in db_orgs: target_data = { 'slug': target.get('id', target.get('slug')), + 'codename': target.get('codename'), 'category': target['category']['id'], 'organization': org_slug, 'date_updated': target.get('dateUpdated'), @@ -174,6 +175,7 @@ def add_targets(self, targets, **kwargs): index_elements=['slug'], set_={ 'category': stmt.excluded.category, + 'codename': stmt.excluded.codename, 'organization': stmt.excluded.organization, 'date_updated': stmt.excluded.date_updated, 'is_active': stmt.excluded.is_active, From 5cd93d1f18662f8ffe4114ff9f0424c920a894f9 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Mon, 27 Jan 2025 05:16:19 +0000 Subject: [PATCH 18/36] added flexability to searching targets in db --- src/synack/plugins/db.py | 20 +++++++++++++++++++- src/synack/plugins/targets.py | 1 + 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/synack/plugins/db.py b/src/synack/plugins/db.py index c18e56f..dfc7f12 100644 --- a/src/synack/plugins/db.py +++ b/src/synack/plugins/db.py @@ -326,7 +326,25 @@ def find_ports(self, port=None, protocol=None, source=None, ip=None, **kwargs): def find_targets(self, **kwargs): session = self.Session() - targets = session.query(Target).filter_by(**kwargs).all() + query = session.query(Target) + + filters = list() + + for key, value in kwargs.items(): + if hasattr(Target, key): + if kwargs.get('like'): + filters.append(getattr(Target, key).like(f'%{value}%')) + else: + filters.append(getattr(Target, key) == value) + + if filters: + if kwargs.get('or'): + query = query.filter(sa.or_(*filters)) + else: + query = query.filter(sa.and_(*filters)) + + targets = query.all() + session.expunge_all() session.close() return targets diff --git a/src/synack/plugins/targets.py b/src/synack/plugins/targets.py index e35a239..7fd9aa6 100644 --- a/src/synack/plugins/targets.py +++ b/src/synack/plugins/targets.py @@ -281,6 +281,7 @@ def get_scope_host(self, target=None, **kwargs): pass scope.discard(None) + scope = list(scope) if len(scope) > 0: if self._state.use_scratchspace: From 233c42278ba4df618e7522dd029403bd6ef1e728 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Wed, 29 Jan 2025 00:30:04 +0000 Subject: [PATCH 19/36] improvements to db transactions --- src/synack/plugins/db.py | 21 +++++++++++++++++++++ src/synack/plugins/missions.py | 6 +++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/synack/plugins/db.py b/src/synack/plugins/db.py index dfc7f12..ced69a2 100644 --- a/src/synack/plugins/db.py +++ b/src/synack/plugins/db.py @@ -70,6 +70,14 @@ def add_ips(self, results, session=None): 'ip': result['ip'], 'target': result['target'] }) + if len(ips_data) > 15000: + stmt = sqlite_insert(IP).values(ips_data) + stmt = stmt.on_conflict_do_nothing( + index_elements=['ip', 'target'], + ) + session.execute(stmt) + ips_data = list() + if ips_data: stmt = sqlite_insert(IP).values(ips_data) @@ -128,6 +136,19 @@ def add_ports(self, results): 'open': port.get('open'), 'updated': port.get('updated') }) + if len(ports_data) > 15000: + stmt = sqlite_insert(Port).values(ports_data) + stmt = stmt.on_conflict_do_update( + index_elements=['port', 'protocol', 'ip', 'source'], + set_={ + 'service': stmt.excluded.service, + 'open': stmt.excluded.open, + 'updated': stmt.excluded.updated + } + ) + session.execute(stmt) + ports_data = list() + if ports_data: stmt = sqlite_insert(Port).values(ports_data) diff --git a/src/synack/plugins/missions.py b/src/synack/plugins/missions.py index 9920651..b5af29b 100644 --- a/src/synack/plugins/missions.py +++ b/src/synack/plugins/missions.py @@ -78,7 +78,7 @@ def build_summary(self, missions): return ret def get(self, status="PUBLISHED", - max_pages=1, page=1, per_page=20, listing_uids=None): + max_pages=1, page=1, per_page=50, listing_uids=None): """Get a list of missions given a status Arguments: @@ -198,7 +198,7 @@ def set_disclaimed(self, mission): """ return self.set_status(mission, "DISCLAIM") - def set_evidences(self, mission, template=None): + def set_evidences(self, mission, template=None, force=False): """Upload a template to a mission Arguments: @@ -212,7 +212,7 @@ def set_evidences(self, mission, template=None): if curr: for f in ['introduction', 'testing_methodology', 'conclusion']: - if len(curr.get(f)) >= 20: + if len(curr.get(f)) >= 20 and force == False: safe = False break if safe: From 7bbd2d59c82dc51de2a17b6ff8c2c74d98e357a5 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Wed, 29 Jan 2025 13:58:10 +0000 Subject: [PATCH 20/36] Mission summary and checking improvements --- src/synack/plugins/missions.py | 79 ++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 32 deletions(-) diff --git a/src/synack/plugins/missions.py b/src/synack/plugins/missions.py index b5af29b..4b7e98f 100644 --- a/src/synack/plugins/missions.py +++ b/src/synack/plugins/missions.py @@ -49,36 +49,41 @@ def build_summary(self, missions): missions -- List of missions from one of the get_missions functions """ ret = { - "count": 0, - "value": 0, - "time": 0 + 'total': { 'count': 0, 'value': 0, 'time': 0 } } - for m in missions: - if m.get("status") == "CLAIMED": + for mission in missions: + codename = mission.get('listingCodename', 'UNKNOWN') + ret[codename] = ret.get(codename, {'count': 0, 'value': 0, 'time': 0}) + ret[codename][count] += 1 + ret[codename]['value'] += mission['payout']['amount'] + ret['total']['count'] += 1 + ret['total']['value'] += mission['payout']['amount'] + + if mission.get('status') == 'CLAIMED': utc = datetime.utcnow() try: - claimed_on = datetime.strptime(m['claimedOn'], - "%Y-%m-%dT%H:%M:%S.%fZ") + claimed_on = datetime.strptime(mission['claimedOn'], + '%Y-%m-%dT%H:%M:%S.%fZ') except ValueError: - claimed_on = datetime.strptime(m['claimedOn'], - "%Y-%m-%dT%H:%M:%SZ") + claimed_on = datetime.strptime(mission['claimedOn'], + '%Y-%m-%dT%H:%M:%SZ') try: - modified_on = datetime.strptime(m['modifiedOn'], - "%Y-%m-%dT%H:%M:%S.%fZ") + modified_on = datetime.strptime(mission['modifiedOn'], + '%Y-%m-%dT%H:%M:%S.%fZ') except ValueError: - modified_on = datetime.strptime(m['modifiedOn'], - "%Y-%m-%dT%H:%M:%SZ") + modified_on = datetime.strptime(mission['modifiedOn'], + '%Y-%m-%dT%H:%M:%SZ') report_time = claimed_on if claimed_on > modified_on else modified_on elapsed = int((utc - report_time).total_seconds()) - time = m['maxCompletionTimeInSecs'] - elapsed - if time < ret['time'] or ret['time'] == 0: - ret['time'] = time - ret['count'] = ret['count'] + 1 - ret['value'] = ret['value'] + m['payout']['amount'] + time = mission['maxCompletionTimeInSecs'] - elapsed + if time < ret['total']['time'] or ret['total']['time'] == 0: + ret['total']['time'] = time + if time < ret[codename]['time'] or ret[codename]['time'] == 0: + ret[codename]['time'] = time + return ret - def get(self, status="PUBLISHED", - max_pages=1, page=1, per_page=50, listing_uids=None): + def get(self, **kwargs): """Get a list of missions given a status Arguments: @@ -91,6 +96,12 @@ def get(self, status="PUBLISHED", (Bad: per_page=5000, per_page=1&max_pages=10) listing_uids -- A specific listing ID to check for missions """ + status = kwargs.get('status', 'PUBLISHED') + max_pages = kwargs.get('max_pages', 1) + page = kwargs.get('page', 1) + per_page = kwargs.get('per_page', 20) + listing_uids = kwargs.get('listing_uids', None) + query = { 'status': status, 'perPage': per_page, @@ -105,25 +116,28 @@ def get(self, status="PUBLISHED", if res.status_code == 200: ret = res.json() if len(ret) == per_page and page < max_pages: - new = self.get(status, - max_pages, - page+1, - per_page) + new = self.get(status=status, + max_pages=max_pages, + page=page+1, + per_page=per_page) ret.extend(new) return ret return [] - def get_approved(self): + def get_approved(self, **kwargs): """Get a list of missions currently approved""" - return self.get("APPROVED") + kwargs['status'] = 'APPROVED' + return self.get(**kwargs) - def get_available(self): + def get_available(self, **kwargs): """Get a list of missions currently available""" - return self.get("PUBLISHED") + kwargs['status'] = 'PUBLISHED' + return self.get(**kwargs) - def get_claimed(self): + def get_claimed(self, **kwargs): """Get a list of all missions you currently have""" - return self.get("CLAIMED") + kwargs['status'] = 'CLAIMED' + return self.get(**kwargs) def get_count(self, status="PUBLISHED", listing_uids=None): """Get the number of missions currently available @@ -164,9 +178,10 @@ def get_evidences(self, mission): return ret - def get_in_review(self): + def get_in_review(self, **kwargs): """Get a list of missions currently in review""" - return self.get("FOR_REVIEW") + kwargs['status'] = 'FOR_REVIEW' + return self.get(**kwargs) def get_wallet_claimed(self): """Get Current Claimed Amount for Mission Wallet""" From bfbc40abda9da6ba25a192a33e37e3f464b5a899 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Sun, 2 Feb 2025 06:25:50 +0000 Subject: [PATCH 21/36] mission fixes --- docs/src/usage/plugins/targets.md | 4 ++-- src/synack/plugins/duo.py | 2 +- src/synack/plugins/missions.py | 10 ++-------- src/synack/plugins/targets.py | 6 +++--- test/test_targets.py | 16 ++++++++-------- 5 files changed, 16 insertions(+), 22 deletions(-) diff --git a/docs/src/usage/plugins/targets.md b/docs/src/usage/plugins/targets.md index 2135395..96a37d5 100644 --- a/docs/src/usage/plugins/targets.md +++ b/docs/src/usage/plugins/targets.md @@ -210,7 +210,7 @@ >> [{"credentials": [{...},...],...}] >> ``` -## targets.get_query(status='registered', query_changes={}) +## targets.get(status='registered', query_changes={}) > Pulls back a list of targets matching the specified query > @@ -221,7 +221,7 @@ > >> Examples >> ```python3 ->> >>> h.targets.get_query(status='unregistered') +>> >>> h.targets.get(status='unregistered') >> [{"codename": "SLEEPYSLUG", ...}, ...] >> ``` diff --git a/src/synack/plugins/duo.py b/src/synack/plugins/duo.py index f1f2372..022fc65 100644 --- a/src/synack/plugins/duo.py +++ b/src/synack/plugins/duo.py @@ -63,7 +63,7 @@ def get_grant_token(self, auth_url): def _get_mfa_details(self): if self._state.otp_secret: self._device = 'null' - self._hotp = pyotp.HOTP(s=self._state.otp_secret).generate_otp(self._state.otp_count) + self._hotp = pyotp.HOTP(s=self._state.otp_secret).generate_otp(int(self._state.otp_count)) self._factor = 'Passcode' return diff --git a/src/synack/plugins/missions.py b/src/synack/plugins/missions.py index 4b7e98f..ea83725 100644 --- a/src/synack/plugins/missions.py +++ b/src/synack/plugins/missions.py @@ -54,7 +54,7 @@ def build_summary(self, missions): for mission in missions: codename = mission.get('listingCodename', 'UNKNOWN') ret[codename] = ret.get(codename, {'count': 0, 'value': 0, 'time': 0}) - ret[codename][count] += 1 + ret[codename]['count'] += 1 ret[codename]['value'] += mission['payout']['amount'] ret['total']['count'] += 1 ret['total']['value'] += mission['payout']['amount'] @@ -83,7 +83,7 @@ def build_summary(self, missions): return ret - def get(self, **kwargs): + def get(self, status='PUBLISHED', max_pages=1, page=1, per_page=20, listing_uids=None, **kwargs): """Get a list of missions given a status Arguments: @@ -96,12 +96,6 @@ def get(self, **kwargs): (Bad: per_page=5000, per_page=1&max_pages=10) listing_uids -- A specific listing ID to check for missions """ - status = kwargs.get('status', 'PUBLISHED') - max_pages = kwargs.get('max_pages', 1) - page = kwargs.get('page', 1) - per_page = kwargs.get('per_page', 20) - listing_uids = kwargs.get('listing_uids', None) - query = { 'status': status, 'perPage': per_page, diff --git a/src/synack/plugins/targets.py b/src/synack/plugins/targets.py index 7fd9aa6..28186dd 100644 --- a/src/synack/plugins/targets.py +++ b/src/synack/plugins/targets.py @@ -206,7 +206,7 @@ def get_credentials(self, **kwargs): if res.status_code == 200: return res.json() - def get_query(self, status='registered', query_changes={}): + def get(self, status='registered', query_changes={}): """Get information about targets returned from a query""" if not self._db.categories: self.get_assessments() @@ -356,7 +356,7 @@ def get_submissions_summary(self, target=None, hours_ago=None, **kwargs): def get_unregistered(self): """Get slugs of all unregistered targets""" - return self.get_query(status='unregistered') + return self.get(status='unregistered') def get_upcoming(self): """Get slugs and upcoming start dates of all upcoming targets""" @@ -364,7 +364,7 @@ def get_upcoming(self): 'sorting[field]': 'upcomingStartDate', 'sorting[direction]': 'asc' } - return self.get_query(status='upcoming', query_changes=query_changes) + return self.get(status='upcoming', query_changes=query_changes) def set_connected(self, target=None, **kwargs): """Connect to a target""" diff --git a/test/test_targets.py b/test/test_targets.py index ffebc25..d4e75c0 100644 --- a/test/test_targets.py +++ b/test/test_targets.py @@ -403,7 +403,7 @@ def test_get_credentials(self): self.targets.get_credentials(codename='SLEEPYSLUG')) self.targets._api.request.assert_called_with('POST', url) - def test_get_query(self): + def test_get(self): """Should get a list of targets""" self.targets._db.categories = [ Category(id=1, passed_practical=True, passed_written=True), @@ -429,7 +429,7 @@ def test_get_query(self): "targets", query=query) - def test_get_query_assessments_empty(self): + def test_get_assessments_empty(self): """Should get a list of unregistered targets""" self.targets.get_assessments = MagicMock() self.targets._db.categories = [] @@ -776,10 +776,10 @@ def test_get_unregistered(self): results = [ {'codename': 'SLEEPYSLUG', 'slug': '1283hi'} ] - self.targets.get_query = MagicMock() - self.targets.get_query.return_value = results + self.targets.get = MagicMock() + self.targets.get.return_value = results self.assertEqual(results, self.targets.get_unregistered()) - self.targets.get_query.assert_called_with(status='unregistered') + self.targets.get.assert_called_with(status='unregistered') def test_get_upcoming(self): """Should query for upcoming targets""" @@ -790,10 +790,10 @@ def test_get_upcoming(self): 'sorting[field]': 'upcomingStartDate', 'sorting[direction]': 'asc' } - self.targets.get_query = MagicMock() - self.targets.get_query.return_value = results + self.targets.get = MagicMock() + self.targets.get.return_value = results self.assertEqual(results, self.targets.get_upcoming()) - self.targets.get_query.assert_called_with(status='upcoming', query_changes=query_changes) + self.targets.get.assert_called_with(status='upcoming', query_changes=query_changes) def test_set_connected(self): """Should connect to a given target provided kwargs""" From dd9b74964ece60b3e25cfc7b3034611af7b6c757 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Sun, 2 Feb 2025 22:32:27 +0000 Subject: [PATCH 22/36] improved updating of existing targets --- src/synack/plugins/db.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/synack/plugins/db.py b/src/synack/plugins/db.py index ced69a2..2c0c15f 100644 --- a/src/synack/plugins/db.py +++ b/src/synack/plugins/db.py @@ -9,7 +9,6 @@ from sqlalchemy.dialects.sqlite import insert as sqlite_insert -from pprint import pprint from pathlib import Path from sqlalchemy.orm import sessionmaker from synack.db.models import Target @@ -99,8 +98,14 @@ def add_organizations(self, targets, session=None): organizations_data = list() + if isinstance(targets, dict): + targets = [value for key, value in targets.items()] + for target in targets: - slug = target.get('organization_id', target.get('organization', {}).get('slug')) + if isinstance(target.get('organization'), str): + slug = target.get('organization') + else: + slug = target.get('organization_id', target.get('organization', {}).get('slug')) if slug: organizations_data.append({'slug': slug}) @@ -173,19 +178,29 @@ def add_targets(self, targets, **kwargs): targets_data = list() + if isinstance(targets, dict): + targets = [value for key, value in targets.items()] + for target in targets: - org_slug = target.get('organization_id', target.get('organization', {}).get('slug')) + if isinstance(target.get('organization'), str): + org_slug = target.get('organization') + else: + org_slug = target.get('organization_id', target.get('organization', {}).get('slug')) + if isinstance(target.get('category'), int): + category = target.get('category') + else: + category = target.get('category', {}).get('id') if org_slug in db_orgs: target_data = { 'slug': target.get('id', target.get('slug')), 'codename': target.get('codename'), - 'category': target['category']['id'], + 'category': category, 'organization': org_slug, - 'date_updated': target.get('dateUpdated'), - 'is_active': target.get('isActive'), - 'is_new': target.get('isNew'), - 'is_registered': target.get('isRegistered'), - 'last_submitted': target.get('lastSubmitted') + 'date_updated': target.get('dateUpdated', target.get('date_updated')), + 'is_active': target.get('isActive', target.get('is_active')), + 'is_new': target.get('isNew', target.get('is_new')), + 'is_registered': target.get('isRegistered', target.get('is_registered')), + 'last_submitted': target.get('lastSubmitted', target.get('last_submitted')) } target_data.update(kwargs) targets_data.append(target_data) From 5012207ef39fafa6614f9f3b4e1617af3a6d91f4 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Wed, 5 Feb 2025 00:09:00 +0000 Subject: [PATCH 23/36] improvements to mission summary --- src/synack/plugins/api.py | 11 +++++------ src/synack/plugins/missions.py | 3 ++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index 38c5a3e..b206b04 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -144,10 +144,9 @@ def request(self, method, path, attempts=0, **kwargs): f"\n\tData: {data}" + f"\n\tContent: {res.content}") - if res.status_code == 429: - attempts = kwargs.get('attempts', 0) - if attempts < 5: - time.sleep(30) - attempts += 1 - return self.request(method, path, attempts, **kwargs) + if res.status_code == 429 and attempts < 5: + time.sleep(30) + attempts += 1 + return self.request(method, path, attempts, **kwargs) + return res diff --git a/src/synack/plugins/missions.py b/src/synack/plugins/missions.py index ea83725..e553486 100644 --- a/src/synack/plugins/missions.py +++ b/src/synack/plugins/missions.py @@ -53,9 +53,10 @@ def build_summary(self, missions): } for mission in missions: codename = mission.get('listingCodename', 'UNKNOWN') - ret[codename] = ret.get(codename, {'count': 0, 'value': 0, 'time': 0}) + ret[codename] = ret.get(codename, {'count': 0, 'value': 0, 'time': 0, 'titles': list()}) ret[codename]['count'] += 1 ret[codename]['value'] += mission['payout']['amount'] + ret[codename]['titles'].append(mission['title']) ret['total']['count'] += 1 ret['total']['value'] += mission['payout']['amount'] From 0b79bdec2cdfc1cf698ef6a5cb08aa00c4cef5a7 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Thu, 6 Feb 2025 03:05:36 +0000 Subject: [PATCH 24/36] improved api request error handling --- src/synack/plugins/api.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index b206b04..ed9db24 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -144,9 +144,23 @@ def request(self, method, path, attempts=0, **kwargs): f"\n\tData: {data}" + f"\n\tContent: {res.content}") - if res.status_code == 429 and attempts < 5: - time.sleep(30) - attempts += 1 - return self.request(method, path, attempts, **kwargs) + if res.status_code in [ 400, 403 ]: + print('Request failed... Bailing!') + print(f'\t({res.status_code} - {res.reason}) {res.url}') + elif res.status_code == 429: + print('Too many requests! Slow down there, cowpoke!') + print(f'\t({res.status_code} - {res.reason}) {res.url}') + if attempts < 5: + attempts += 1 + print('\tRetrying in 30 seconds...') + time.sleep(30) + return self.request(method, path, attempts, **kwargs) + elif res.status_code >= 400: + print(f'Request failed...') + print(f'\t({res.status_code} - {res.reason}) {res.url}') + if attempts <= 5: + attempts += 1 + print(f'Retry #{attempts + 1}') + return self.request(method, path, attempts, **kwargs) return res From 8d10960b9eb9a5560970b19917cbc507f37bf026 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Thu, 6 Feb 2025 04:36:10 +0000 Subject: [PATCH 25/36] added synack_domain to db --- src/synack/_state.py | 12 +++++++++ .../1434aa7ed47c_add_synack_domain.py | 25 +++++++++++++++++++ src/synack/db/models/config.py | 1 + src/synack/plugins/api.py | 14 +++++------ src/synack/plugins/auth.py | 8 +++--- src/synack/plugins/db.py | 8 ++++++ src/synack/plugins/duo.py | 2 +- src/synack/plugins/missions.py | 12 ++++----- 8 files changed, 64 insertions(+), 18 deletions(-) create mode 100644 src/synack/db/alembic/versions/1434aa7ed47c_add_synack_domain.py diff --git a/src/synack/_state.py b/src/synack/_state.py index 062055f..47348c2 100644 --- a/src/synack/_state.py +++ b/src/synack/_state.py @@ -36,6 +36,7 @@ def __init__(self): self._smtp_server = None self._smtp_starttls = None self._smtp_username = None + self._synack_domain = None self._template_dir = None self._use_proxies = None self._use_scratchspace = None @@ -143,6 +144,17 @@ def config_dir(self, value: Union[str, pathlib.PosixPath]) -> None: value = pathlib.Path(value).expanduser().resolve() self._config_dir = value + @property + def synack_domain(self): + ret = self._synack_domain + if ret is None: + ret = self._db.synack_domain + return ret + + @synack_domain.setter + def synack_domain(self, value): + self._synack_domain = value + @property def template_dir(self) -> pathlib.PosixPath: ret = self._template_dir diff --git a/src/synack/db/alembic/versions/1434aa7ed47c_add_synack_domain.py b/src/synack/db/alembic/versions/1434aa7ed47c_add_synack_domain.py new file mode 100644 index 0000000..454d08e --- /dev/null +++ b/src/synack/db/alembic/versions/1434aa7ed47c_add_synack_domain.py @@ -0,0 +1,25 @@ +"""add synack_domain + +Revision ID: 1434aa7ed47c +Revises: 6814001a4ed4 +Create Date: 2025-02-06 04:19:28.655055 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1434aa7ed47c' +down_revision = '6814001a4ed4' +branch_labels = None +depends_on = None + +def upgrade(): + with op.batch_alter_table('config') as batch_op: + batch_op.add_column(sa.Column('synack_domain', sa.VARCHAR(100), server_default='synack.com')) + + +def downgrade(): + with op.batch_alter_table('config') as batch_op: + batch_op.drop_column('synack_domain') diff --git a/src/synack/db/models/config.py b/src/synack/db/models/config.py index 0159227..943f1da 100644 --- a/src/synack/db/models/config.py +++ b/src/synack/db/models/config.py @@ -33,6 +33,7 @@ class Config(Base): smtp_email_to = sa.Column(sa.VARCHAR(250), default='') smtp_username = sa.Column(sa.VARCHAR(250), default='') smtp_starttls = sa.Column(sa.BOOLEAN, default=True) + synack_domain = sa.Column(sa.VARCHAR(100), default='synack.com') template_dir = sa.Column(sa.VARCHAR(250), default='~/Templates') user_id = sa.Column(sa.VARCHAR(20), default='') use_proxies = sa.Column(sa.BOOLEAN, default=False) diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index ed9db24..a447402 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -30,7 +30,7 @@ def login(self, method, path, **kwargs): if path.startswith('http'): base = '' else: - base = 'https://login.synack.com/api/' + base = f'https://login.{self._state.synack_domain}/api/' url = f'{base}{path}' res = self.request(method, url, **kwargs) return res @@ -50,7 +50,7 @@ def notifications(self, method, path, **kwargs): if path.startswith('http'): base = '' else: - base = 'https://notifications.synack.com/api/v2/' + base = f'https://notifications.{self._state.synack_domain}/api/v2/' url = f'{base}{path}' if not kwargs.get('headers'): @@ -79,7 +79,7 @@ def request(self, method, path, attempts=0, **kwargs): if path.startswith('http'): base = '' else: - base = 'https://platform.synack.com/api/' + base = f'https://platform.{self._state.synack_domain}/api/' url = f'{base}{path}' verify = False @@ -87,7 +87,7 @@ def request(self, method, path, attempts=0, **kwargs): proxies = self._state.proxies if self._state.use_proxies else None - if 'synack.com/api/' in url: + if f'{self._state.synack_domain}/api/' in url: headers = { 'Authorization': f'Bearer {self._state.api_token}', 'user_id': self._state.user_id @@ -144,7 +144,7 @@ def request(self, method, path, attempts=0, **kwargs): f"\n\tData: {data}" + f"\n\tContent: {res.content}") - if res.status_code in [ 400, 403 ]: + if res.status_code in [ 400, 401, 403 ]: print('Request failed... Bailing!') print(f'\t({res.status_code} - {res.reason}) {res.url}') elif res.status_code == 429: @@ -158,9 +158,9 @@ def request(self, method, path, attempts=0, **kwargs): elif res.status_code >= 400: print(f'Request failed...') print(f'\t({res.status_code} - {res.reason}) {res.url}') - if attempts <= 5: + if attempts < 5: + print(f'\tRetry attempt #{attempts + 1}') attempts += 1 - print(f'Retry #{attempts + 1}') return self.request(method, path, attempts, **kwargs) return res diff --git a/src/synack/plugins/auth.py b/src/synack/plugins/auth.py index 1489064..79e464b 100644 --- a/src/synack/plugins/auth.py +++ b/src/synack/plugins/auth.py @@ -29,7 +29,7 @@ def get_api_token(self): if duo_auth_url: grant_token = self._duo.get_grant_token(duo_auth_url) if grant_token: - url = 'https://platform.synack.com/' + url = f'https://platform.{self._state.synack_domain}/' headers = { 'X-Requested-With': 'XMLHttpRequest' } @@ -68,7 +68,7 @@ def get_authentication_response(self, csrf): def get_login_csrf(self): """Get the CSRF Token from the login page""" - res = self._api.request('GET', 'https://login.synack.com') + res = self._api.request('GET', f'https://login.{self._state.synack_domain}') m = re.search(' Date: Thu, 6 Feb 2025 05:22:59 +0000 Subject: [PATCH 26/36] other platform should work --- src/synack/plugins/duo.py | 25 +++++++++++++++++++++++-- src/synack/plugins/utils.py | 4 ++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/synack/plugins/duo.py b/src/synack/plugins/duo.py index 6e0c661..be3ff23 100644 --- a/src/synack/plugins/duo.py +++ b/src/synack/plugins/duo.py @@ -26,6 +26,7 @@ def __init__(self, *args, **kwargs): self._factor = None self._grant_token = None self._hotp = None + self._progress_token = None self._referrer = None self._session_vars = None self._status = None @@ -58,8 +59,24 @@ def get_grant_token(self, auth_url): self._get_status() if self._status == 'SUCCESS': self._get_oidc_exit() + if self._progress_token: + self._get_grant_token() return self._grant_token + def _get_grant_token(self): + headers = { + 'X-Csrf-Token': self._xsrf + } + data = { + 'progress_token': self._progress_token + } + res = self._api.login('POST', + 'authenticate', + data=data, + headers=headers) + if res.status_code == 200: + self._grant_token = res.json().get('grant_token') + def _get_mfa_details(self): if self._state.otp_secret: self._device = 'null' @@ -117,7 +134,11 @@ def _get_oidc_exit(self): } res = self._api.request('POST', f'{self._base_url}/frame/v4/oidc/exit', headers=headers, data=data) if res.status_code == 200: - self._grant_token = re.search('grant_token=([^&]*)', res.url).group(1) + try: + self._grant_token = re.search('grant_token=([^&]*)', res.url).group(1) + except AttributeError: + self._progress_token = re.search('token=([^&]*)', res.url).group(1) + self._xsrf = self._utils.get_html_tag_value('csrf-token', res.text) def _get_session_variables(self): self._referrer = f'https://login.{self._state.synack_domain}/' @@ -125,7 +146,7 @@ def _get_session_variables(self): if res.status_code == 200: self._sid = re.search('sid=([^&]*)', res.url).group(1) self._referrer = res.url - self._base_url = re.search('(https.*duosecurity.com)/', res.url).group(1) + self._base_url = re.search('(https.*duo[^.]*.com)/', res.url).group(1) self._xsrf = self._utils.get_html_tag_value('_xsrf', res.text) client_hints = base64.b64encode(json.dumps({ diff --git a/src/synack/plugins/utils.py b/src/synack/plugins/utils.py index 05bbe2a..0bed3fc 100644 --- a/src/synack/plugins/utils.py +++ b/src/synack/plugins/utils.py @@ -18,7 +18,7 @@ def __init__(self, *args, **kwargs): @staticmethod def get_html_tag_value(field, text): - match = re.search(f'<[^>]*name=.{field}.[^>]*value=.([^"\']*)', text) + match = re.search(f'<[^>]*name=.{field}.[^>]*(?:content|value)=.([^"\']*)', text) if match.group is None: - match = re.search(f'<[^>]*value=.([^"\']*)[^>]*name=.{field}', text) + match = re.search(f'<[^>]*(?:content|value)=.([^"\']*)[^>]*name=.{field}', text) return match.group(1) if match else '' From 3a6f26edd161b8a8d09e0e15832ccaa927b50d23 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Thu, 6 Feb 2025 06:11:43 +0000 Subject: [PATCH 27/36] fixed login.js --- src/synack/plugins/auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/synack/plugins/auth.py b/src/synack/plugins/auth.py index 79e464b..6f852b8 100644 --- a/src/synack/plugins/auth.py +++ b/src/synack/plugins/auth.py @@ -95,7 +95,8 @@ def set_login_script(self): "loc.replace('https://platform." + self._state.synack_domain + "');" +\ "}};" +\ "(function() {" +\ - "sessionStorage.setItem('shared-session-com.synack.accessToken'" +\ + "sessionStorage.setItem('shared-session-" +\ + '.'.join(reversed(self._state.synack_domain.split('.'))) + ".accessToken'" +\ ",'" +\ self._state.api_token +\ "');" +\ From 9428ec1e68b0c90fc8c104eedd13b8706956efc4 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Thu, 6 Feb 2025 13:10:49 +0000 Subject: [PATCH 28/36] improved SynackAPI Login button --- docs/src/usage/main-components/files.md | 1 + src/synack/plugins/auth.py | 11 +++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/src/usage/main-components/files.md b/docs/src/usage/main-components/files.md index 5d1a5c2..34eedb0 100644 --- a/docs/src/usage/main-components/files.md +++ b/docs/src/usage/main-components/files.md @@ -34,6 +34,7 @@ It is intended to be used with the following TamperMonkey script in order to do // @version 0.1 // @description Go to the platform automatically // @author You +// @run-at document-start // @match https://*.synack.com/* // @require file:///home//.config/synack/login.js // @grant none diff --git a/src/synack/plugins/auth.py b/src/synack/plugins/auth.py index 6f852b8..a2667ce 100644 --- a/src/synack/plugins/auth.py +++ b/src/synack/plugins/auth.py @@ -89,17 +89,16 @@ def set_api_token_invalid(self): return False def set_login_script(self): - script = "let forceLogin = () => {" +\ + script = "(function() {sessionStorage.setItem('shared-session-com.synack.accessToken'" +\ + ",'" +\ + self._state.api_token +\ + "');})();" +\ + "let forceLogin = () => {" +\ "const loc = window.location;" +\ "if(loc.href.startsWith('https://login." + self._state.synack_domain + "/')) {" +\ "loc.replace('https://platform." + self._state.synack_domain + "');" +\ "}};" +\ "(function() {" +\ - "sessionStorage.setItem('shared-session-" +\ - '.'.join(reversed(self._state.synack_domain.split('.'))) + ".accessToken'" +\ - ",'" +\ - self._state.api_token +\ - "');" +\ "setTimeout(forceLogin,60000);" +\ "let btn = document.createElement('button');" +\ "btn.addEventListener('click',forceLogin);" +\ From c406ca9d88b184982b1d08173384eb7fa34432e1 Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Sun, 2 Mar 2025 05:22:00 +0000 Subject: [PATCH 29/36] improved request and re-login handling --- src/synack/plugins/api.py | 24 ++++++++++++++---------- src/synack/plugins/auth.py | 2 +- src/synack/plugins/missions.py | 12 ++++++++++++ src/synack/plugins/scratchspace.py | 2 ++ src/synack/plugins/targets.py | 24 ++++++++++++++++++++++++ src/synack/plugins/transactions.py | 2 ++ 6 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index a447402..1cc70c1 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -144,23 +144,27 @@ def request(self, method, path, attempts=0, **kwargs): f"\n\tData: {data}" + f"\n\tContent: {res.content}") - if res.status_code in [ 400, 401, 403 ]: - print('Request failed... Bailing!') - print(f'\t({res.status_code} - {res.reason}) {res.url}') + reason_failed = 'Request failed' + if res.status_code in [ 400, 401 ]: + reason_failed = 'Request failed' + elif res.status_code == 403: + reason_failed = 'Logged out' + elif res.status_code == 412: + fail_reason = 'Mission already claimed' elif res.status_code == 429: - print('Too many requests! Slow down there, cowpoke!') - print(f'\t({res.status_code} - {res.reason}) {res.url}') + self._debug.log('Too many requests', f'({res.status_code} - {res.reason}) {res.url}') if attempts < 5: - attempts += 1 - print('\tRetrying in 30 seconds...') + self._debug.log('Pausing', 'Retrying in 30 seconds...') time.sleep(30) + attempts += 1 return self.request(method, path, attempts, **kwargs) elif res.status_code >= 400: - print(f'Request failed...') - print(f'\t({res.status_code} - {res.reason}) {res.url}') + self._debug.log(f'Request failed', f'({res.status_code} - {res.reason}) {res.url}') if attempts < 5: - print(f'\tRetry attempt #{attempts + 1}') + self._debug.log('Retrying', f'Attempt #{attempts + 1}') attempts += 1 return self.request(method, path, attempts, **kwargs) + self._debug.log('Mission already claimed', f'({res.status_code} - {res.reason}) {res.url}') + return res diff --git a/src/synack/plugins/auth.py b/src/synack/plugins/auth.py index a2667ce..5f569c2 100644 --- a/src/synack/plugins/auth.py +++ b/src/synack/plugins/auth.py @@ -18,7 +18,7 @@ def __init__(self, *args, **kwargs): def get_api_token(self): """Log in to get a new API token.""" - if self._users.get_profile(): + if self._api.request('HEAD', 'profiles/me').status_code == 200: return self._state.api_token csrf = self.get_login_csrf() duo_auth_url = None diff --git a/src/synack/plugins/missions.py b/src/synack/plugins/missions.py index 0dc0052..9c2665c 100644 --- a/src/synack/plugins/missions.py +++ b/src/synack/plugins/missions.py @@ -117,6 +117,8 @@ def get(self, status='PUBLISHED', max_pages=1, page=1, per_page=20, listing_uids per_page=per_page) ret.extend(new) return ret + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() return [] def get_approved(self, **kwargs): @@ -152,6 +154,8 @@ def get_count(self, status="PUBLISHED", listing_uids=None): query=query) if res.status_code == 204: return int(res.headers.get('x-count', 0)) + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() return 0 def get_evidences(self, mission): @@ -172,6 +176,8 @@ def get_evidences(self, mission): ret["structuredResponse"] = mission["validResponses"][1]["value"] return ret + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() def get_in_review(self, **kwargs): """Get a list of missions currently in review""" @@ -184,6 +190,8 @@ def get_wallet_claimed(self): 'tasks/v2/researcher/claimed_amount') if res.status_code == 200: return int(res.json().get('claimedAmount', '0')) + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() def get_wallet_limit(self): """Get Current Mission Wallet Limit""" @@ -191,6 +199,8 @@ def get_wallet_limit(self): 'profiles/me') if res.status_code == 200: return int(res.json().get('claim_limit', '0')) + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() def set_claimed(self, mission): """Try to claim a single mission @@ -236,6 +246,8 @@ def set_evidences(self, mission, template=None, force=False): ret["title"] = mission["title"] ret["codename"] = mission["listingCodename"] return ret + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() def set_status(self, mission, status): """Interact with single mission diff --git a/src/synack/plugins/scratchspace.py b/src/synack/plugins/scratchspace.py index fae564c..4b4a277 100644 --- a/src/synack/plugins/scratchspace.py +++ b/src/synack/plugins/scratchspace.py @@ -48,6 +48,8 @@ def set_download_attachments(self, attachments, target=None, codename=None, prom with open(dest_file, 'wb') as fp: fp.write(res.content) downloads.append(dest_file) + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() return downloads def set_file(self, content, filename, target=None, codename=None): diff --git a/src/synack/plugins/targets.py b/src/synack/plugins/targets.py index 28186dd..4561934 100644 --- a/src/synack/plugins/targets.py +++ b/src/synack/plugins/targets.py @@ -100,6 +100,8 @@ def get_assessments(self): if res.status_code == 200: self._db.add_categories(res.json()) return self._db.categories + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() def get_assets(self, target=None, asset_type=None, host_type=None, active='true', scope=['in', 'discovered'], sort='location', sort_dir='asc', @@ -150,6 +152,8 @@ def get_assets(self, target=None, asset_type=None, host_type=None, active='true' if self._state.use_scratchspace: self._scratchspace.set_assets_file(res.text, target=target) return res.json() + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() def get_attachments(self, target=None, **kwargs): """Get the attachments of a target.""" @@ -162,6 +166,8 @@ def get_attachments(self, target=None, **kwargs): res = self._api.request('GET', f'targets/{target.slug}/resources') if res.status_code == 200: return res.json() + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() def get_connected(self): """Return information about the currenly selected target""" @@ -181,6 +187,8 @@ def get_connected(self): "status": status } return ret + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() def get_connections(self, target=None, **kwargs): """Get the connection details of a target.""" @@ -193,6 +201,8 @@ def get_connections(self, target=None, **kwargs): res = self._api.request('GET', "listing_analytics/connections", query={"listing_id": target.slug}) if res.status_code == 200: return res.json()["value"] + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() def get_credentials(self, **kwargs): """Get Credentials for a target""" @@ -205,6 +215,8 @@ def get_credentials(self, **kwargs): '/credentials') if res.status_code == 200: return res.json() + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() def get(self, status='registered', query_changes={}): """Get information about targets returned from a query""" @@ -225,6 +237,8 @@ def get(self, status='registered', query_changes={}): if res.status_code == 200: self._db.add_targets(res.json(), is_registered=True) return res.json() + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() def get_registered_summary(self): """Get information on your registered targets""" @@ -235,6 +249,8 @@ def get_registered_summary(self): ret = dict() for t in res.json(): ret[t['id']] = t + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() return ret def get_scope(self, **kwargs): @@ -338,6 +354,8 @@ def get_submissions(self, target=None, status="accepted", **kwargs): res = self._api.request('GET', "listing_analytics/categories", query=query) if res.status_code == 200: return res.json()["value"] + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() def get_submissions_summary(self, target=None, hours_ago=None, **kwargs): """Get a summary of the submission analytics of a target.""" @@ -353,6 +371,8 @@ def get_submissions_summary(self, target=None, hours_ago=None, **kwargs): res = self._api.request('GET', "listing_analytics/submissions", query=query) if res.status_code == 200: return res.json()["value"] + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() def get_unregistered(self): """Get slugs of all unregistered targets""" @@ -382,6 +402,8 @@ def set_connected(self, target=None, **kwargs): res = self._api.request('PUT', 'launchpoint', data={'listing_id': slug}) if res.status_code == 200: return self.get_connected() + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() def set_registered(self, targets=None): """Register all unregistered targets""" @@ -395,6 +417,8 @@ def set_registered(self, targets=None): data=data) if res.status_code == 200: ret.append(t) + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() if len(targets) >= 15: ret.extend(self.set_registered()) return ret diff --git a/src/synack/plugins/transactions.py b/src/synack/plugins/transactions.py index 70346fc..e22a130 100644 --- a/src/synack/plugins/transactions.py +++ b/src/synack/plugins/transactions.py @@ -21,3 +21,5 @@ def get_balance(self): res = self._api.request('HEAD', 'transactions') if res.status_code == 200: return json.loads(res.headers.get('x-balance')) + elif res.status_code == 403 and self._state.login: + self._auth.get_api_token() From 259b3f8a14f1be1638df7ab03a230d6e91dff25b Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Tue, 4 Mar 2025 12:00:40 +0000 Subject: [PATCH 30/36] resolved mismatch between variable names --- src/synack/plugins/missions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/synack/plugins/missions.py b/src/synack/plugins/missions.py index 9c2665c..3ee7b33 100644 --- a/src/synack/plugins/missions.py +++ b/src/synack/plugins/missions.py @@ -164,11 +164,11 @@ def get_evidences(self, mission): Arguments: mission -- A single mission """ - evidences = self._api.request('GET', - 'tasks/v2/tasks/' + - mission['id'] + - '/evidences') - if evidences.status_code == 200: + res = self._api.request('GET', + 'tasks/v2/tasks/' + + mission['id'] + + '/evidences') + if res.status_code == 200: ret = evidences.json() ret["title"] = mission["title"] ret["asset"] = mission["assetTypes"][0] From 3be0bae95afb1a037ae5d09797e3d080cca8ccec Mon Sep 17 00:00:00 2001 From: Dylan Wilson Date: Tue, 4 Mar 2025 12:04:40 +0000 Subject: [PATCH 31/36] missed a variable --- src/synack/plugins/missions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/synack/plugins/missions.py b/src/synack/plugins/missions.py index 3ee7b33..d4f45e3 100644 --- a/src/synack/plugins/missions.py +++ b/src/synack/plugins/missions.py @@ -169,7 +169,7 @@ def get_evidences(self, mission): mission['id'] + '/evidences') if res.status_code == 200: - ret = evidences.json() + ret = res.json() ret["title"] = mission["title"] ret["asset"] = mission["assetTypes"][0] ret["taskType"] = mission["taskType"] From 74a7e044c55b6cbd5533ba28121f8fdfe0641291 Mon Sep 17 00:00:00 2001 From: Derb237 <198724448+Derb237@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:17:56 +0000 Subject: [PATCH 32/36] Fix get_config to persist new Config rows and avoid DetachedInstanceError Two bugs were present in the get_config method: 1. Missing session.commit() after creating new Config object - New config rows were added to session but never committed - This caused empty database on fresh installations - Result: synack_domain and other config values were None 2. Session closed before accessing attributes - getattr() called after session.close() - SQLAlchemy tried to lazy-load attributes from closed session - This raised DetachedInstanceError on attribute access The fix: - Add session.commit() after session.add(config) - Store the return value before closing session - This ensures the config row is persisted and attributes are loaded This is a pre-existing bug that affects all fresh database installations. --- src/synack/plugins/db.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/synack/plugins/db.py b/src/synack/plugins/db.py index 852c2a9..00866f0 100644 --- a/src/synack/plugins/db.py +++ b/src/synack/plugins/db.py @@ -428,8 +428,10 @@ def get_config(self, name=None): if not config: config = Config() session.add(config) + session.commit() + ret = getattr(config, name) if name else config session.close() - return getattr(config, name) if name else config + return ret @property def http_proxy(self): From 11a8130a21ce6b0e30854ce113209bac94d7186c Mon Sep 17 00:00:00 2001 From: Derb237 <198724448+Derb237@users.noreply.github.com> Date: Thu, 6 Nov 2025 03:17:14 +0000 Subject: [PATCH 33/36] Fix spurious debug logging in api.py Fixed three bugs in the request error handling: 1. Line 155: Fixed typo where 'fail_reason' was set instead of 'reason_failed' for HTTP 412 status codes 2. Line 170: Moved unconditional debug log into a conditional that only logs terminal failures (400, 401, 403, 412) 3. Lines 147-151: Fixed each status code to set its own specific error message instead of reusing a shared 'Request failed' message: - 400: "Bad request" - 401: "Unauthorized" - 403: "Logged out" - 412: "Mission already claimed" Previously, when debug mode was enabled, every HTTP request would log 'MISSION ALREADY CLAIMED' regardless of the actual status code or context. Additionally, 401 errors were being logged with misleading messages because the reason_failed variable was being reused across different status codes. Bug introduced in commit c406ca9d (March 2, 2025) --- src/synack/plugins/api.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index 1cc70c1..5dd6b11 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -144,13 +144,15 @@ def request(self, method, path, attempts=0, **kwargs): f"\n\tData: {data}" + f"\n\tContent: {res.content}") - reason_failed = 'Request failed' - if res.status_code in [ 400, 401 ]: - reason_failed = 'Request failed' + reason_failed = None + if res.status_code == 400: + reason_failed = 'Bad request' + elif res.status_code == 401: + reason_failed = 'Unauthorized' elif res.status_code == 403: reason_failed = 'Logged out' elif res.status_code == 412: - fail_reason = 'Mission already claimed' + reason_failed = 'Mission already claimed' elif res.status_code == 429: self._debug.log('Too many requests', f'({res.status_code} - {res.reason}) {res.url}') if attempts < 5: @@ -165,6 +167,8 @@ def request(self, method, path, attempts=0, **kwargs): attempts += 1 return self.request(method, path, attempts, **kwargs) - self._debug.log('Mission already claimed', f'({res.status_code} - {res.reason}) {res.url}') + # Log terminal failures (non-retryable errors) + if res.status_code in [400, 401, 403, 412]: + self._debug.log(reason_failed, f'({res.status_code} - {res.reason}) {res.url}') return res From da2f260b8393c1b4a64cded81390c1ed9d865877 Mon Sep 17 00:00:00 2001 From: Derb237 <198724448+Derb237@users.noreply.github.com> Date: Thu, 6 Nov 2025 04:52:15 +0000 Subject: [PATCH 34/36] Fix authentication error handling to prevent account lockout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove infinite retry loop on invalid credentials (400) - Clear stored email/password when authentication fails - Add explicit error handling for account locked (423) - Make 400 and 423 non-retryable in API layer to prevent rapid-fire retries - Raise clear error messages for both authentication failure scenarios This prevents the previous behavior where entering wrong credentials would trigger rapid retries that locked the account. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/synack/plugins/api.py | 4 +++- src/synack/plugins/auth.py | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/synack/plugins/api.py b/src/synack/plugins/api.py index 5dd6b11..19f4ef3 100644 --- a/src/synack/plugins/api.py +++ b/src/synack/plugins/api.py @@ -153,6 +153,8 @@ def request(self, method, path, attempts=0, **kwargs): reason_failed = 'Logged out' elif res.status_code == 412: reason_failed = 'Mission already claimed' + elif res.status_code == 423: + reason_failed = 'Locked' elif res.status_code == 429: self._debug.log('Too many requests', f'({res.status_code} - {res.reason}) {res.url}') if attempts < 5: @@ -168,7 +170,7 @@ def request(self, method, path, attempts=0, **kwargs): return self.request(method, path, attempts, **kwargs) # Log terminal failures (non-retryable errors) - if res.status_code in [400, 401, 403, 412]: + if res.status_code in [400, 401, 403, 412, 423]: self._debug.log(reason_failed, f'({res.status_code} - {res.reason}) {res.url}') return res diff --git a/src/synack/plugins/auth.py b/src/synack/plugins/auth.py index 5f569c2..1195ce8 100644 --- a/src/synack/plugins/auth.py +++ b/src/synack/plugins/auth.py @@ -62,9 +62,11 @@ def get_authentication_response(self, csrf): if res.status_code == 200: return res.json() elif res.status_code == 400: - csrf = self.get_login_csrf() - if csrf: - return self.get_authentication_response(csrf) + self._db.email = '' + self._db.password = '' + raise ValueError("Invalid email or password. Please run the script again to re-enter credentials.") + elif res.status_code == 423: + raise ValueError("Your account has been locked due to too many failed login attempts. Please wait and try again later.") def get_login_csrf(self): """Get the CSRF Token from the login page""" From c80c175c756bf983e570865276600d775d088aeb Mon Sep 17 00:00:00 2001 From: Derb237 <198724448+Derb237@users.noreply.github.com> Date: Thu, 6 Nov 2025 07:22:18 +0000 Subject: [PATCH 35/36] Add Duo Push auto-approval and HOTP hex conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements three-priority Duo MFA authentication system: 1. OTP (highest priority) - Auto-generates HOTP codes from secret 2. Auto-approval - Uses device credentials to approve pushes via Duo API 3. Manual push (fallback) - Traditional approve-on-phone flow Database changes: - Add duo_push_akey, duo_push_pkey, duo_push_host, duo_push_rsa_key_path columns - Add duo_device column to persist user's selected device - Migration: 20522d39dc63_add_duo_push_method Duo Push auto-approval: - Integrate with Duo device API using RSA-SHA512 signed requests - Load device credentials from database and RSA key from file - Poll for pending push notifications and auto-approve - Hard fail if auto-approval is configured but broken (prevents hanging) - Auto-correct duo_device when credentials don't match selected device HOTP hex secret auto-conversion: - Auto-detect 32-char hex format (from synackDUO's hotp_secret) - Convert by treating hex string as UTF-8, then base32 encode - Based on duo-hotp reference implementation - Accepts both hex (hotp_secret) and base32 (otpauth://) formats 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/src/usage/index.md | 8 +- docs/src/usage/main-components/state.md | 6 +- docs/src/usage/plugins/duo.md | 71 +++++ src/synack/_state.py | 60 ++++ .../6f542023f57e_add_duo_push_method.py | 38 +++ src/synack/db/models/config.py | 7 + src/synack/plugins/db.py | 79 +++++- src/synack/plugins/duo.py | 267 +++++++++++++++++- 8 files changed, 515 insertions(+), 21 deletions(-) create mode 100644 src/synack/db/alembic/versions/6f542023f57e_add_duo_push_method.py diff --git a/docs/src/usage/index.md b/docs/src/usage/index.md index 1031492..f343803 100644 --- a/docs/src/usage/index.md +++ b/docs/src/usage/index.md @@ -17,12 +17,8 @@ With that in mind, I would highly recommend you become familiar with the [Plugin ## Authentication The first time you try to do anything which requires authentication, you will be automatically prompted for your credentials. -This prompt will expect the `Synack Email` and `Synack Password`, which are fairly self explanitory, but it also asks for the `Synack OTP Secret`. +This prompt will expect the `Synack Email` and `Synack Password`, which are fairly self explanatory. -The `Synack OTP Secret` is NOT the 8 digit code you pull out of Authy. -Instead, it is a string that you must extract from Authy via a method similar to the one found [here](https://gist.github.com/gboudreau/94bb0c11a6209c82418d01a59d958c93). - -Use the above instructions at your own discression. -I TAKE NO RESPONSIBILITY IF SOMETHING BAD HAPPENS AS A RESULT. +For Duo MFA setup options, see the [Duo plugin documentation](./plugins/duo.md). Once you complete these steps, your credentials are stored in a SQLiteDB at `~/.config/synack/synackapi.db`. diff --git a/docs/src/usage/main-components/state.md b/docs/src/usage/main-components/state.md index 21df449..4f8fc90 100644 --- a/docs/src/usage/main-components/state.md +++ b/docs/src/usage/main-components/state.md @@ -55,12 +55,16 @@ In the event that one of the State variables is set and is **not** constantly at | api_token | str | This is the Synack Access Token used to authenticate requests | config_dir | pathlib.Path | The location of the Database and Login script | debug | bool | Used to show/hide debugging messages +| duo_push_akey | str | Duo device activation key for push auto-approval +| duo_push_host | str | Duo API hostname for push auto-approval +| duo_push_pkey | str | Duo device private key for push auto-approval +| duo_push_rsa_key_path | str | Path to RSA private key for signing Duo API requests | email | str | Your email address used to log into Synack | http_proxy | str | A Web Proxy (Burp, etc.) to intercept requests | https_proxy | str | A Web Proxy (Burp, etc.) to intercept requests | login | bool | Used to enable/disable a check of the api_token upon creation of the Handler | notifications_token | str | Token used for authentication when dealing with Synack Notifications -| otp_secret | str | OTP Secret held by Authy. NOT an OTP. For more information, read the Usage page +| otp_secret | str | OTP Secret held by Duo Mobile. NOT an OTP. For more information, read the Usage page | password | str | Your Synack Password | session | requests.Session | Tracks cookies and headers across various functions | template_dir | pathlib.Path | The location of your Mission Templates diff --git a/docs/src/usage/plugins/duo.md b/docs/src/usage/plugins/duo.md index 0347edd..990f4ef 100644 --- a/docs/src/usage/plugins/duo.md +++ b/docs/src/usage/plugins/duo.md @@ -1,5 +1,58 @@ # Duo +## Duo MFA Options + +When prompted during authentication, you can choose from three options: + +**Option 1: Manual Push Approval (Simplest)** +- Press Enter when prompted for OTP Secret +- Approve push notifications on your phone each time the token is expired +- No additional setup required + +**Option 2: Automated OTP (Preferred)** +- Enter your OTP Secret when prompted (accepts both hex and base32 formats) +- Automatically generates OTP codes using a counter (saved in the database) +- Extract the `hotp_secret` from Duo Mobile using [synackDUO](https://github.com/dinosn/synackDUO) (see `response.json`) +- **Note:** This is NOT the 8-digit codes from Duo Mobile, but the HOTP secret key + +**Option 3: Automated Duo Push** +- Uses Duo credentials to auto-approve push requests +- Can also approve push requests using duo.approve_pending_push(timeout) +- Extract credentials using [synackDUO](https://github.com/dinosn/synackDUO) (see `response.json`) (see below) + + +**Disclaimer:** Use the above instructions at your own discretion. I TAKE NO RESPONSIBILITY IF SOMETHING BAD HAPPENS AS A RESULT. + +## Duo Push Auto-Approval Setup + +The Duo plugin supports push notification approval using device credentials. + +### Prerequisites + +You must extract and configure four credentials from Duo Mobile: + +| Credential | Description | Example +| --- | --- | --- +| `duo_push_akey` | Device activation key | `DAXXXXXXXXXXXXXXXXXXXX` +| `duo_push_pkey` | Device private key | `DPXXXXXXXXXXXXXXXXXXXX` +| `duo_push_host` | Duo API hostname | `api-xxxxxxxx.duosecurity.com` +| `duo_push_rsa_key_path` | Path to RSA private key | `~/.config/synack/duo/key.pem` + +### Configuration + +Set credentials in the database: +PP +```python +import synack + +h = synack.Handler(login=False) + +h.db.set_config('duo_push_akey', 'DAXXXXXXXXXXXXXXXXXX') +h.db.set_config('duo_push_pkey', 'DPXXXXXXXXXXXXXXXXXX') +h.db.set_config('duo_push_host', 'api-xxxxxxxx.duosecurity.com') +h.db.set_config('duo_push_rsa_key_path', 'synackDUO/key.pem') +``` + ## duo.get_grant_token(auth_url) > Handles Duo Security MFA stages and returns the grant_token used to finish logging into Synack @@ -13,3 +66,21 @@ >> >>> h.duo.get_grant_token('https:///...duosecurity.com/...') >> 'Y8....6g' >> ``` + +## duo.approve_pending_push(timeout) + +> Wait for and approve a single Duo push notification +> +> Polls Duo's device API for pending push notifications and automatically approves the first one found. Useful for automated workflows that need to handle Duo MFA. +> +> | Argument | Type | Default | Description +> | --- | --- | --- | --- +> | `timeout` | int | 30 | Maximum seconds to wait for a push notification +> +> Returns `True` if a push was approved, `False` if timeout or error occurred. +> +>> Examples +>> ```python3 +>> >>> h.duo.approve_pending_push(timeout=60) +>> True +>> ``` diff --git a/src/synack/_state.py b/src/synack/_state.py index 47348c2..ff09c87 100644 --- a/src/synack/_state.py +++ b/src/synack/_state.py @@ -41,6 +41,11 @@ def __init__(self): self._use_proxies = None self._use_scratchspace = None self._user_id = None + self._duo_push_akey = None + self._duo_push_pkey = None + self._duo_push_host = None + self._duo_push_rsa_key_path = None + self._duo_device = None @property def smtp_email_from(self) -> str: @@ -357,3 +362,58 @@ def user_id(self) -> str: @user_id.setter def user_id(self, value: str) -> None: self._user_id = value + + @property + def duo_push_akey(self) -> str: + ret = self._duo_push_akey + if ret is None: + ret = self._db.duo_push_akey + return ret + + @duo_push_akey.setter + def duo_push_akey(self, value: str) -> None: + self._duo_push_akey = value + + @property + def duo_push_pkey(self) -> str: + ret = self._duo_push_pkey + if ret is None: + ret = self._db.duo_push_pkey + return ret + + @duo_push_pkey.setter + def duo_push_pkey(self, value: str) -> None: + self._duo_push_pkey = value + + @property + def duo_push_host(self) -> str: + ret = self._duo_push_host + if ret is None: + ret = self._db.duo_push_host + return ret + + @duo_push_host.setter + def duo_push_host(self, value: str) -> None: + self._duo_push_host = value + + @property + def duo_push_rsa_key_path(self) -> str: + ret = self._duo_push_rsa_key_path + if ret is None: + ret = self._db.duo_push_rsa_key_path + return ret + + @duo_push_rsa_key_path.setter + def duo_push_rsa_key_path(self, value: str) -> None: + self._duo_push_rsa_key_path = value + + @property + def duo_device(self) -> str: + ret = self._duo_device + if ret is None: + ret = self._db.duo_device + return ret + + @duo_device.setter + def duo_device(self, value: str) -> None: + self._duo_device = value diff --git a/src/synack/db/alembic/versions/6f542023f57e_add_duo_push_method.py b/src/synack/db/alembic/versions/6f542023f57e_add_duo_push_method.py new file mode 100644 index 0000000..0fdde9a --- /dev/null +++ b/src/synack/db/alembic/versions/6f542023f57e_add_duo_push_method.py @@ -0,0 +1,38 @@ +"""add duo push method + +Revision ID: 6f542023f57e +Revises: 1434aa7ed47c +Create Date: 2025-11-06 08:23:42.181054 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6f542023f57e' +down_revision = '1434aa7ed47c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('config') as batch_op: + batch_op.add_column(sa.Column('duo_push_akey', sa.VARCHAR(length=200), nullable=True)) + batch_op.add_column(sa.Column('duo_push_pkey', sa.VARCHAR(length=200), nullable=True)) + batch_op.add_column(sa.Column('duo_push_host', sa.VARCHAR(length=100), nullable=True)) + batch_op.add_column(sa.Column('duo_push_rsa_key_path', sa.VARCHAR(length=250), nullable=True)) + batch_op.add_column(sa.Column('duo_device', sa.VARCHAR(length=50), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('config') as batch_op: + batch_op.drop_column('duo_device') + batch_op.drop_column('duo_push_rsa_key_path') + batch_op.drop_column('duo_push_host') + batch_op.drop_column('duo_push_pkey') + batch_op.drop_column('duo_push_akey') + # ### end Alembic commands ### diff --git a/src/synack/db/models/config.py b/src/synack/db/models/config.py index 943f1da..216b467 100644 --- a/src/synack/db/models/config.py +++ b/src/synack/db/models/config.py @@ -38,3 +38,10 @@ class Config(Base): user_id = sa.Column(sa.VARCHAR(20), default='') use_proxies = sa.Column(sa.BOOLEAN, default=False) use_scratchspace = sa.Column(sa.BOOLEAN, default=False) + duo_push_akey = sa.Column(sa.VARCHAR(200), default='') + duo_push_pkey = sa.Column(sa.VARCHAR(200), default='') + duo_push_host = sa.Column(sa.VARCHAR(100), default='') + duo_push_rsa_key_path = sa.Column( + sa.VARCHAR(250), default='' + ) + duo_device = sa.Column(sa.VARCHAR(50), default='') diff --git a/src/synack/plugins/db.py b/src/synack/plugins/db.py index 00866f0..21a828d 100644 --- a/src/synack/plugins/db.py +++ b/src/synack/plugins/db.py @@ -480,14 +480,49 @@ def otp_count(self, value): def otp_secret(self): ret = self.get_config('otp_secret') if not ret: - ret = input('Synack OTP Secret: ') - self.otp_secret = ret + # Skip prompt if automated push credentials are already configured + if self.duo_push_akey and self.duo_push_pkey and self.duo_push_host: + ret = '' + self.otp_secret = ret + # Skip prompt if user has already selected a device for manual push + elif self.duo_device: + ret = '' + else: + print("\nDuo MFA Authentication Setup:") + print( + "1. Press Enter to use Duo Push notifications " + "(you'll approve on your phone)" + ) + print("2. OR enter your Duo OTP Secret for automated passcode generation") + print(" (Accepts hex (hotp_secret) or base32 (otpauth://) format)") + ret = input('\nDuo OTP Secret (or press Enter for push): ').strip() + self.otp_secret = ret if ret else '' return ret @otp_secret.setter def otp_secret(self, value): + # Auto-detect and convert hex format to base32 + # Duo's hotp_secret is a hex string, but needs to be treated as UTF-8 + # not as hex bytes (based on duo-hotp reference implementation) + if value and self._is_hex_secret(value): + import base64 + # Encode the hex string as UTF-8 bytes, then base32 + value = base64.b32encode(value.encode('utf-8')).decode('ascii').rstrip('=') self.set_config('otp_secret', value) + def _is_hex_secret(self, value): + """Check if the secret appears to be in hex format (not base32)""" + # Hex: 32 chars using only 0-9, a-f + # Base32: variable length using A-Z, 2-7 + if len(value) != 32: + return False + try: + # If it can be decoded as hex, it's hex + bytes.fromhex(value) + return True + except ValueError: + return False + @property def password(self): ret = self.get_config('password') @@ -500,6 +535,46 @@ def password(self): def password(self, value): self.set_config('password', value) + @property + def duo_push_akey(self): + return self.get_config('duo_push_akey') + + @duo_push_akey.setter + def duo_push_akey(self, value): + self.set_config('duo_push_akey', value) + + @property + def duo_push_pkey(self): + return self.get_config('duo_push_pkey') + + @duo_push_pkey.setter + def duo_push_pkey(self, value): + self.set_config('duo_push_pkey', value) + + @property + def duo_push_host(self): + return self.get_config('duo_push_host') + + @duo_push_host.setter + def duo_push_host(self, value): + self.set_config('duo_push_host', value) + + @property + def duo_push_rsa_key_path(self): + return self.get_config('duo_push_rsa_key_path') + + @duo_push_rsa_key_path.setter + def duo_push_rsa_key_path(self, value): + self.set_config('duo_push_rsa_key_path', value) + + @property + def duo_device(self): + return self.get_config('duo_device') + + @duo_device.setter + def duo_device(self, value): + self.set_config('duo_device', value) + @property def ports(self): session = self.Session() diff --git a/src/synack/plugins/duo.py b/src/synack/plugins/duo.py index be3ff23..2ddcb4a 100644 --- a/src/synack/plugins/duo.py +++ b/src/synack/plugins/duo.py @@ -9,7 +9,14 @@ import json import pyotp import re +import requests import time +from datetime import UTC, datetime +from pathlib import Path +from urllib.parse import urlencode +from Crypto.Hash import SHA512 +from Crypto.PublicKey import RSA +from Crypto.Signature import pkcs1_15 class Duo(Plugin): @@ -33,6 +40,7 @@ def __init__(self, *args, **kwargs): self._sid = None self._txid = None self._xsrf = None + self._pubkey = None def _build_headers(self, overrides=None): headers = { @@ -56,7 +64,35 @@ def get_grant_token(self, auth_url): self._set_session_variables() # Yes, this needs to be called twice... self._get_txid() if self._txid: - self._get_status() + # Priority 1: OTP (if configured) + if self._state.otp_secret: + # OTP passcode already sent in _get_txid(), just poll for status + self._get_status() + # Priority 2: Auto-approval (if configured) - HARD FAIL if broken + elif self.is_configured(): + if not self.load_rsa_key(): + raise RuntimeError( + "Duo Push auto-approval is enabled but RSA key failed to load" + ) + print("Auto-approving Duo push notification...") + if self._state.debug: + print(f"Using device: {self._device}") + print(f"Configured duo_device: {self._state.duo_device}") + if self._device != self._state.duo_device: + print(f"WARNING: Push sent to {self._device} but credentials are for {self._state.duo_device}") + # Wait 2 seconds before polling to give Duo time to register the push + time.sleep(2) + if not self.approve_pending_push(timeout=25): + raise RuntimeError( + "Duo Push auto-approval failed - check credentials or " + "disable auto-approval. Ensure duo_device matches the device " + "with extracted credentials." + ) + self._get_status() + # Priority 3: Manual push (fallback) + else: + print("Waiting for manual Duo push approval on your device...") + self._get_status() if self._status == 'SUCCESS': self._get_oidc_exit() if self._progress_token: @@ -103,16 +139,69 @@ def _get_mfa_details(self): 'sid': self._sid } res = self._api.request('GET', f'{self._base_url}/frame/v4/auth/prompt/data', headers=headers, query=query) + if res.status_code == 200: - for method in res.json().get('response', {}).get('auth_method_order', []): - if method.get('factor', '') == 'Duo Push': - device_key = method.get('deviceKey', '') - break + response_json = res.json() + response_data = response_json.get('response', {}) + phones = response_data.get('phones', []) + + # If auto-approval credentials are configured, find the matching device + if self.is_configured(): + # Match device by pkey + pkey = self._state.duo_push_pkey + for phone in phones: + if phone.get('key', '') == pkey: + self._device = phone.get('index', '') + self._factor = 'Duo Push' + # Update stored device if it doesn't match + if self._state.duo_device != self._device: + print(f"Auto-correcting duo_device from {self._state.duo_device} to {self._device}") + self._db.duo_device = self._device + return + # If no match found, credentials are for wrong account + print(f"WARNING: duo_push_pkey {pkey} not found in available devices") + print("Falling back to manual device selection") + + # Check if we have a stored device preference + if self._state.duo_device: + # Use the stored device + for phone in phones: + if phone.get('index', '') == self._state.duo_device: + self._device = phone.get('index', '') + self._factor = 'Duo Push' + return + # If stored device not found, fall through to prompt - for phone in res.json().get('response', {}).get('phones', []): - if phone.get('key', '') == device_key: - self._device = phone.get('index', '') - self._factor = 'Duo Push' + # Prompt user to select a device + if phones: + print("\nAvailable Duo devices:") + for i, phone in enumerate(phones, 1): + print(f"{i}. {phone.get('name', 'Unknown')} ({phone.get('index', '')})") + + while True: + try: + choice = input("\nSelect device number (or press Enter for first device): ").strip() + if not choice: + selected_phone = phones[0] + break + choice_num = int(choice) + if 1 <= choice_num <= len(phones): + selected_phone = phones[choice_num - 1] + break + print(f"Please enter a number between 1 and {len(phones)}") + except ValueError: + print("Please enter a valid number") + + self._device = selected_phone.get('index', '') + self._factor = 'Duo Push' + self._db.duo_device = self._device + return + + if not self._device or not self._factor: + raise ValueError( + f'Failed to determine MFA device/factor from Duo API. ' + f'HTTP {res.status_code}, device={self._device}, factor={self._factor}' + ) def _get_oidc_exit(self): headers = { @@ -204,7 +293,8 @@ def _get_status(self): 'txid': self._txid, 'sid': self._sid } - for i in range(5): + # Increase polling attempts from 5 to 12 (1 minute total with 5s intervals) + for i in range(12): res = self._api.request('POST', f'{self._base_url}/frame/v4/status', headers=headers, data=data) if res.status_code == 200: status_enum = res.json().get('response', {}).get('status_enum', -1) @@ -223,8 +313,9 @@ def _get_status(self): break elif status_enum == 13: # Awaiting Push Notification pass - elif status_enum == 15: # Push Notification MFA Blocked - break + elif status_enum == 15: # Push sent, waiting for approval + # Continue polling for both auto-approval and manual approval + pass elif status_enum == 44: # Prior Code self._db.otp_count += 5 break @@ -297,3 +388,155 @@ def _set_session_variables(self): res = self._api.request('POST', self._referrer, headers=headers, data=self._session_vars) if res.status_code == 200: self._referrer = res.url + + # Duo Push Auto-Approval Methods + + def is_configured(self): + """Check if Duo push auto-approval credentials are configured""" + return ( + self._state.duo_push_akey and + self._state.duo_push_pkey and + self._state.duo_push_host + ) + + def load_rsa_key(self): + """Load RSA key from configured path""" + if not self.is_configured(): + return False + + key_path = Path(self._state.duo_push_rsa_key_path).expanduser() + if not key_path.exists(): + print(f"Duo RSA key not found: {key_path}") + return False + + try: + with open(key_path, 'rb') as f: + self._pubkey = RSA.import_key(f.read()) + return True + except Exception as e: + print(f"Failed to load Duo RSA key: {e}") + return False + + def approve_pending_push(self, timeout=30): + """Wait for and approve a single Duo push notification""" + if not self.is_configured(): + return False + + if not self._pubkey and not self.load_rsa_key(): + print("Cannot approve push: RSA key not available") + return False + + print("Polling for Duo push notification...") + start_time = time.monotonic() + poll_interval = 2 # Poll every 2 seconds + + while time.monotonic() - start_time < timeout: + try: + # Poll for transactions + transactions = self._get_transactions() + if self._state.debug: + print(f"Transactions response: {transactions}") + response_data = transactions.get('response', {}) + pending = response_data.get('transactions', []) + current_time = response_data.get('current_time', 0) + + if self._state.debug: + print(f"Found {len(pending)} pending transactions") + + if pending: + for tx in pending: + tx_id = tx.get('urgid') + expiration = tx.get('expiration', 0) + + if self._state.debug: + print(f"Transaction: {tx}") + + # Skip expired transactions + if expiration and current_time and expiration <= current_time: + if self._state.debug: + print(f"Skipping expired transaction {tx_id}") + continue + + if tx_id: + tx_summary = tx.get('summary', 'N/A') + print(f"Approving Duo push {tx_id[:12]}... ({tx_summary})") + response = self._reply_transaction(tx_id, 'approve') + if response.get('stat') == 'OK': + print("Duo push approved successfully") + return True + else: + print(f"Push approval returned: {response}") + + time.sleep(poll_interval) + + except Exception as e: + print(f"Error during Duo push approval: {e}") + return False + + return False + + def _generate_signature(self, method, path, time_str, data): + """Generate RSA signature for Duo API request""" + encoded_data = urlencode(sorted(data.items())) if data else "" + message_parts = [ + time_str, + method.upper(), + self._state.duo_push_host.lower(), + path, + encoded_data, + ] + message = "\n".join(message_parts).encode('ascii') + h = SHA512.new(message) + signature = pkcs1_15.new(self._pubkey).sign(h) + auth_string = f"{self._state.duo_push_pkey}:{base64.b64encode(signature).decode('ascii')}" + return "Basic " + base64.b64encode(auth_string.encode('ascii')).decode('ascii') + + def _make_request(self, method, path, data): + """Make authenticated request to Duo device API""" + dt = datetime.now(UTC) + # Format as RFC 2822 date for HTTP header (e.g., "Mon, 04 Nov 2025 12:34:56 GMT") + time_str = dt.strftime('%a, %d %b %Y %H:%M:%S GMT') + signature = self._generate_signature(method, path, time_str, data) + + url = f"https://{self._state.duo_push_host}{path}" + headers = { + 'Authorization': signature, + 'x-duo-date': time_str, + 'Host': self._state.duo_push_host, + 'Content-Type': 'application/x-www-form-urlencoded', + } + + try: + if method.upper() == 'GET': + r = requests.get(url, params=data, headers=headers, timeout=10) + else: + r = requests.post(url, data=data, headers=headers, timeout=10) + + r.raise_for_status() + return r.json() + except Exception as e: + print(f"Duo API request failed: {e}") + raise + + def _get_transactions(self): + """Get pending Duo push transactions""" + path = "/push/v2/device/transactions" + params = { + 'akey': self._state.duo_push_akey, + 'fips_status': '1', + 'hsm_status': 'true', + 'pkpush': 'rsa-sha512', + } + return self._make_request('GET', path, params) + + def _reply_transaction(self, transaction_id, answer): + """Reply to a Duo push transaction (approve/deny)""" + path = f"/push/v2/device/transactions/{transaction_id}" + data = { + 'akey': self._state.duo_push_akey, + 'answer': answer, + 'fips_status': '1', + 'hsm_status': 'true', + 'pkpush': 'rsa-sha512', + } + return self._make_request('POST', path, data) From 2674241c67b84f4c14aee200faa9f575feed044a Mon Sep 17 00:00:00 2001 From: Derb237 <198724448+Derb237@users.noreply.github.com> Date: Tue, 19 Aug 2025 07:56:10 +0000 Subject: [PATCH 36/36] Add organization name field and additional target fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add name column to Organization model - Update database plugin to handle organization names - Add migration for organization name field - Add target fields: average_payout, start_date, end_date, is_updated - Update upsert logic to include new fields 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../8b478a84c1a6_add_organization_name.py | 26 ++++++++++++++++++ src/synack/db/models/organization.py | 1 + src/synack/plugins/db.py | 27 +++++++++++++++---- 3 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 src/synack/db/alembic/versions/8b478a84c1a6_add_organization_name.py diff --git a/src/synack/db/alembic/versions/8b478a84c1a6_add_organization_name.py b/src/synack/db/alembic/versions/8b478a84c1a6_add_organization_name.py new file mode 100644 index 0000000..1bf270f --- /dev/null +++ b/src/synack/db/alembic/versions/8b478a84c1a6_add_organization_name.py @@ -0,0 +1,26 @@ +"""add organization name + +Revision ID: 8b478a84c1a6 +Revises: 6f542023f57e +Create Date: 2025-02-11 13:05:40.939271 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8b478a84c1a6' +down_revision = '6f542023f57e' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('organizations') as batch_op: + batch_op.add_column(sa.Column('name', sa.VARCHAR(100))) + + +def downgrade(): + with op.batch_alter_table('organizations') as batch_op: + batch_op.drop_column('name') diff --git a/src/synack/db/models/organization.py b/src/synack/db/models/organization.py index c352174..2c67868 100644 --- a/src/synack/db/models/organization.py +++ b/src/synack/db/models/organization.py @@ -12,3 +12,4 @@ class Organization(Base): __tablename__ = 'organizations' slug = sa.Column(sa.VARCHAR(20), primary_key=True) + name = sa.Column(sa.VARCHAR(100)) diff --git a/src/synack/plugins/db.py b/src/synack/plugins/db.py index 21a828d..0e0a9d8 100644 --- a/src/synack/plugins/db.py +++ b/src/synack/plugins/db.py @@ -104,15 +104,24 @@ def add_organizations(self, targets, session=None): for target in targets: if isinstance(target.get('organization'), str): slug = target.get('organization') + name = None # No name available in this case else: - slug = target.get('organization_id', target.get('organization', {}).get('slug')) + org = target.get('organization', {}) + slug = target.get('organization_id', org.get('slug')) + name = org.get('name') if slug: - organizations_data.append({'slug': slug}) + organizations_data.append({ + 'slug': slug, + 'name': name + }) if organizations_data: stmt = sqlite_insert(Organization).values(organizations_data) - stmt = stmt.on_conflict_do_nothing( + stmt = stmt.on_conflict_do_update( index_elements=['slug'], + set_={ + 'name': stmt.excluded.name + } ) session.execute(stmt) @@ -200,7 +209,11 @@ def add_targets(self, targets, **kwargs): 'is_active': target.get('isActive', target.get('is_active')), 'is_new': target.get('isNew', target.get('is_new')), 'is_registered': target.get('isRegistered', target.get('is_registered')), - 'last_submitted': target.get('lastSubmitted', target.get('last_submitted')) + 'last_submitted': target.get('lastSubmitted', target.get('last_submitted')), + 'average_payout': target.get('averagePayout'), + 'start_date': target.get('start_date'), + 'end_date': target.get('end_date'), + 'is_updated': target.get('isUpdated', False) } target_data.update(kwargs) targets_data.append(target_data) @@ -218,6 +231,10 @@ def add_targets(self, targets, **kwargs): 'is_new': stmt.excluded.is_new, 'is_registered': stmt.excluded.is_registered, 'last_submitted': stmt.excluded.last_submitted, + 'average_payout': stmt.excluded.average_payout, + 'start_date': stmt.excluded.start_date, + 'end_date': stmt.excluded.end_date, + 'is_updated': stmt.excluded.is_updated } ) session.execute(stmt) @@ -378,7 +395,7 @@ def find_targets(self, **kwargs): query = query.filter(sa.or_(*filters)) else: query = query.filter(sa.and_(*filters)) - + targets = query.all() session.expunge_all()