Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions docs/src/usage/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
6 changes: 5 additions & 1 deletion docs/src/usage/main-components/state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions docs/src/usage/plugins/duo.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
>> ```
60 changes: 60 additions & 0 deletions src/synack/_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
38 changes: 38 additions & 0 deletions src/synack/db/alembic/versions/6f542023f57e_add_duo_push_method.py
Original file line number Diff line number Diff line change
@@ -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 ###
7 changes: 7 additions & 0 deletions src/synack/db/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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='')
16 changes: 11 additions & 5 deletions src/synack/plugins/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,17 @@ 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 == 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:
Expand All @@ -165,6 +169,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, 423]:
self._debug.log(reason_failed, f'({res.status_code} - {res.reason}) {res.url}')

return res
8 changes: 5 additions & 3 deletions src/synack/plugins/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
83 changes: 80 additions & 3 deletions src/synack/plugins/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -478,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')
Expand All @@ -498,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()
Expand Down
Loading