diff --git a/flag_slurper/autolib/governor.py b/flag_slurper/autolib/governor.py
new file mode 100644
index 0000000..5ace3ae
--- /dev/null
+++ b/flag_slurper/autolib/governor.py
@@ -0,0 +1,64 @@
+import logging
+import socket
+import time
+from collections import defaultdict
+from datetime import datetime, timedelta
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+class Governor:
+    """
+    The governor tracks the number of connections over time
+    to a given ip address. This is used for fail2ban evasion.
+
+    This can be configured in the flagrc.
+
+    .. warning::
+
+       The governor only tracks withing it's worker process, for a single run.
+       It **DOES NOT** persist across invocations.
+
+    * ``autopwn->governor`` (default false) whether the governor is enabled.
+    * ``autopwn->delay`` (default 5m) how long to wait after limiting.
+    * ``autopwn->window`` (default 30m) the window to track attempts in.
+    * ``autopwn->times`` (default 3) max attempts inside of the window.
+    """
+    instance = None
+
+    def __init__(self, enabled: bool, delay: int = 5 * 60, window: int = 30 * 60, times: int = 3):
+        self.enabled = enabled
+        self.delay = delay
+        self.window = window
+        self.times = times
+
+        # Dict[IP, timestamp]
+        self.limits = defaultdict(list)
+
+    @classmethod
+    def get_instance(cls, enabled: Optional[bool] = False, delay: Optional[int] = 5 * 60,
+                     window: Optional[int] = 30 * 60, times: Optional[int] = 3) -> 'Governor':
+        if not Governor.instance:
+            Governor.instance = cls(enabled, delay, window, times)
+        return Governor.instance
+
+    def filter(self, ipaddr: str):
+        def _filter(x: datetime):
+            return x > datetime.now() - timedelta(seconds=self.window)
+
+        self.limits[ipaddr] = list(filter(_filter, self.limits[ipaddr]))
+
+    def attempt(self, ipaddr: str):
+        if not self.enabled:
+            return
+
+        self.filter(ipaddr)
+        self.limits[ipaddr].append(datetime.now())
+        if len(self.limits[ipaddr]) > self.times:
+            logger.info("Attempting %d second delay for %s", self.delay, ipaddr)
+            time.sleep(self.delay)
+
+    @staticmethod
+    def resolve_url(url: str) -> str:
+        return socket.gethostbyname(url)
diff --git a/flag_slurper/autolib/protocols.py b/flag_slurper/autolib/protocols.py
index 8abf1d9..a655a8e 100644
--- a/flag_slurper/autolib/protocols.py
+++ b/flag_slurper/autolib/protocols.py
@@ -5,6 +5,7 @@ from typing import Tuple
 import paramiko
 
 from flag_slurper.autolib.exploit import get_file_contents, get_system_info
+from flag_slurper.autolib.governor import Governor
 from .exploit import find_flags, FlagConf, can_sudo
 from .models import Service, CredentialBag, Credential, Flag, CaptureNote
 from .post import post_ssh
@@ -26,6 +27,10 @@ def pwn_ssh(url: str, port: int, service: Service, flag_conf: FlagConf) -> Tuple
     working = set()
     credentials = CredentialBag.select()
     for credential in credentials:
+        # Govern if necessary (and enabled)
+        gov = Governor.get_instance()
+        gov.attempt(gov.resolve_url(url))
+
         try:
             cred = credential.credentials.where(Credential.service == service).get()
         except Credential.DoesNotExist:
diff --git a/flag_slurper/autopwn.py b/flag_slurper/autopwn.py
index b0a625d..4d77997 100644
--- a/flag_slurper/autopwn.py
+++ b/flag_slurper/autopwn.py
@@ -4,6 +4,7 @@ from multiprocessing import Pool
 
 import click
 
+from flag_slurper.autolib.governor import Governor
 from flag_slurper.autolib.models import SUDO_FLAG
 from . import utils, autolib
 from .autolib import models
diff --git a/flag_slurper/config.py b/flag_slurper/config.py
index 2f43948..09c969a 100644
--- a/flag_slurper/config.py
+++ b/flag_slurper/config.py
@@ -7,6 +7,8 @@ import requests
 from click import echo, prompt
 from jinja2 import Environment
 
+from flag_slurper.autolib.governor import Governor
+
 ROOT = Path(__file__).parent
 
 
@@ -31,9 +33,19 @@ class Config(ConfigParser):
             conffiles.append(extra)
         conf.read(conffiles)
 
+        conf.configure_governor()
+
         cls.instance = conf
         return conf
 
+    def configure_governor(self):
+        from .utils import parse_duration
+        gov = Governor.get_instance()
+        gov.enabled = self.getboolean('autopwn', 'governor')
+        gov.delay = parse_duration(self['autopwn']['delay'])
+        gov.window = parse_duration(self['autopwn']['window'])
+        gov.times = self.getint('autopwn', 'times')
+
     @staticmethod
     def get_instance():
         if not Config.instance:
diff --git a/flag_slurper/default.ini b/flag_slurper/default.ini
index 5ab9cbd..72ae3f2 100644
--- a/flag_slurper/default.ini
+++ b/flag_slurper/default.ini
@@ -5,3 +5,9 @@ api_version=v1
 
 [database]
 url=sqlite:///{{ project }}/db.sqlite3
+
+[autopwn]
+governor=false
+delay=5m
+window=30m
+times=3
diff --git a/flag_slurper/utils.py b/flag_slurper/utils.py
index d44fa83..b06dd61 100644
--- a/flag_slurper/utils.py
+++ b/flag_slurper/utils.py
@@ -2,6 +2,7 @@ import os
 import shutil
 import zipfile
 from typing import Tuple, Dict, Union, Callable, Optional
+import string
 
 import click
 import requests
@@ -167,3 +168,43 @@ def conditional_page(output: str, size: int):
         click.echo_via_pager(output)
     else:
         click.echo(output)
+
+
+def parse_duration(duration: str) -> int:
+    """
+    Parses a duration of the form 10m or 5s and converts
+    them into seconds.
+
+    >>> parse_duration('5s')
+    ... 5
+    >>> parse_duration('10m')
+    ... 600
+
+    .. note::
+
+       Only one unit is supported. For example 1h30m is not supported, but 90m is.
+
+    Supported suffixes:
+
+    - ``h`` hours
+    - ``m`` minutes
+    - ``s`` seconds
+
+    An omitted suffix will be interpreted as seconds.
+
+    :param duration: The duration in string form
+    :return: The duration in seconds
+    """
+    if duration[-1] in string.digits:
+        return int(duration)
+
+    if duration[-1].lower() == 'h':
+        return int(duration[:-1]) * 60 * 60
+
+    if duration[-1].lower() == 'm':
+        return int(duration[:-1]) * 60
+
+    if duration[-1].lower() == 's':
+        return int(duration[:-1])
+
+    raise ValueError("Unable to parse {duration} as a duration".format(duration=duration))

Add delay to AutoPWN to avoid fail2ban
Fail2ban's default configuration bans an IP if it fails to login 3 times in a 10 minutes window. The default credential list, *in theory*, should not trip this since it only makes two attempts (`root:cdc` and `cdc:cdc`).

AutoPWN should either add a delay in between each attempt or add a delay after `maxretry - 1` attempts. Either way it should be configurable.
diff --git a/tests/autolib/test_governor.py b/tests/autolib/test_governor.py
new file mode 100644
index 0000000..f9812b5
--- /dev/null
+++ b/tests/autolib/test_governor.py
@@ -0,0 +1,64 @@
+from datetime import datetime, timedelta
+
+from flag_slurper.autolib.governor import Governor
+
+
+def test_governor_singleton():
+    Governor.instance = None
+    Governor.get_instance(True)
+    gov = Governor.get_instance()
+    assert gov.enabled
+    assert gov.delay == 5 * 60
+    assert gov.window == 30 * 60
+    assert gov.times == 3
+
+
+def test_governor_tracks(mock):
+    Governor.instance = None
+    gov = Governor.get_instance(True)
+    sleep = mock.patch('flag_slurper.autolib.governor.time.sleep')
+    gov.attempt('192.168.1.113')
+    assert not sleep.called
+
+
+def test_governor_filter():
+    Governor.instance = None
+    gov = Governor.get_instance(True)
+    gov.limits['192.168.1.113'] = [datetime.now() - timedelta(minutes=3),
+                                   datetime.now() - timedelta(minutes=29),
+                                   datetime.now() - timedelta(minutes=1),
+                                   datetime.now() - timedelta(minutes=35)]
+    gov.filter('192.168.1.113')
+    assert len(gov.limits['192.168.1.113']) == 3
+
+
+def test_governor_limits(mock):
+    Governor.instance = None
+    gov = Governor.get_instance(True)
+    gov.limits['192.168.1.113'] = [datetime.now() - timedelta(minutes=3),
+                                   datetime.now() - timedelta(minutes=29),
+                                   datetime.now() - timedelta(minutes=1),
+                                   datetime.now() - timedelta(minutes=35)]
+    sleep = mock.patch('flag_slurper.autolib.governor.time.sleep')
+    gov.attempt('192.168.1.113')
+    assert sleep.called
+
+
+def test_governer_doesnt_limit_when_disabled(mock):
+    Governor.instance = None
+    gov = Governor.get_instance(False)
+    gov.limits['192.168.1.113'] = [datetime.now() - timedelta(minutes=3),
+                                   datetime.now() - timedelta(minutes=29),
+                                   datetime.now() - timedelta(minutes=1),
+                                   datetime.now() - timedelta(minutes=35)]
+    sleep = mock.patch('flag_slurper.autolib.governor.time.sleep')
+    gov.attempt('192.168.1.113')
+    assert not sleep.called
+
+
+def test_governor_resolve(mock):
+    addrhost = mock.patch('flag_slurper.autolib.governor.socket.gethostbyname')
+    addrhost.return_value = '192.168.1.113'
+    ipaddr = Governor.resolve_url('example.com')
+    assert addrhost.called_with('example.com')
+    assert ipaddr == '192.168.1.113'
diff --git a/tests/test_config.py b/tests/test_config.py
index f142137..565e575 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -4,6 +4,7 @@ from unittest import mock
 
 from click.testing import CliRunner
 
+from flag_slurper.autolib.governor import Governor
 from flag_slurper.cli import cli
 from flag_slurper.config import Config
 from flag_slurper.models import User
@@ -97,3 +98,13 @@ def test_config_login(tmpdir):
     c.read_file(tmpdir.join('flagrc').open('r'))
     assert 'iscore' in c and 'api_token' in c['iscore']
     assert c['iscore']['api_token'] == 'abcdef'
+
+
+def test_config_governor(tmpdir):
+    rc = tmpdir.join("flagrc.ini")
+    rc.write("[autopwn]\ngovernor=yes\ndelay=6m\n")
+
+    Config.load(str(rc), noflagrc=True)
+    gov = Governor.get_instance()
+    assert gov.enabled
+    assert gov.delay == 6 * 60
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 14bd39a..0dd9ed6 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -168,3 +168,25 @@ def test_get_teams(config):
 def test_parse_creds(given, expected):
     result = utils.parse_creds(given)
     assert result == expected
+
+
+@pytest.mark.parametrize('given,expected', (
+    ('3', 3),
+    ('5s', 5),
+    ('10m', 10 * 60),
+    ('3h', 3 * 60 * 60),
+))
+def test_parse_duration(given, expected):
+    result = utils.parse_duration(given)
+    assert result == expected
+
+
+@pytest.mark.parametrize('given', (
+    'abc',
+    '30y',
+    '2d',
+))
+def test_parse_duration_invalid(given):
+    with pytest.raises(ValueError, match=r'Unable to parse \w+ as a duration$'):
+        utils.parse_duration(given)
+

pip install -e .[remote,parallel,tests]
parse_log_pytest
requirements.txt
pytest pytest-cov pytest-sugar pytest-mock tox vcrpy responses pretend
apt-get update apt-get install -y gcc
3.6
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs==22.2.0
bcrypt==4.0.1
certifi==2021.5.30
cffi==1.15.1
charset-normalizer==2.0.12
click==8.0.4
coverage==6.2
cryptography==40.0.2
distlib==0.3.9
filelock==3.4.1
-e git+https://github.com/mgerst/flag-slurper.git@1f40a7114313537d02472290381845841767d028#egg=flag_slurper
idna==3.10
importlib-metadata==4.8.3
importlib-resources==5.4.0
iniconfig==1.1.1
Jinja2==3.0.3
MarkupSafe==2.0.1
multidict==5.2.0
packaging==21.3
paramiko==3.5.1
peewee==3.17.9
platformdirs==2.4.0
pluggy==1.0.0
pretend==1.0.9
psycopg2-binary==2.9.5
py==1.11.0
pycparser==2.21
PyNaCl==1.5.0
pyparsing==3.1.4
pytest==7.0.1
pytest-cov==4.0.0
pytest-mock==3.6.1
pytest-sugar==0.9.6
PyYAML==6.0.1
requests==2.27.1
responses==0.17.0
schema==0.7.7
six==1.17.0
termcolor==1.1.0
terminaltables==3.1.10
toml==0.10.2
tomli==1.2.3
tox==3.28.0
typing_extensions==4.1.1
urllib3==1.26.20
vcrpy==4.1.1
virtualenv==20.17.1
wrapt==1.16.0
yarl==1.7.2
zipp==3.6.0

name: flag-slurper
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - pip=21.2.2=py36h06a4308_0
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - attrs==22.2.0
      - bcrypt==4.0.1
      - cffi==1.15.1
      - charset-normalizer==2.0.12
      - click==8.0.4
      - coverage==6.2
      - cryptography==40.0.2
      - distlib==0.3.9
      - filelock==3.4.1
      - idna==3.10
      - importlib-metadata==4.8.3
      - importlib-resources==5.4.0
      - iniconfig==1.1.1
      - jinja2==3.0.3
      - markupsafe==2.0.1
      - multidict==5.2.0
      - packaging==21.3
      - paramiko==3.5.1
      - peewee==3.17.9
      - platformdirs==2.4.0
      - pluggy==1.0.0
      - pretend==1.0.9
      - psycopg2-binary==2.9.5
      - py==1.11.0
      - pycparser==2.21
      - pynacl==1.5.0
      - pyparsing==3.1.4
      - pytest==7.0.1
      - pytest-cov==4.0.0
      - pytest-mock==3.6.1
      - pytest-sugar==0.9.6
      - pyyaml==6.0.1
      - requests==2.27.1
      - responses==0.17.0
      - schema==0.7.7
      - six==1.17.0
      - termcolor==1.1.0
      - terminaltables==3.1.10
      - toml==0.10.2
      - tomli==1.2.3
      - tox==3.28.0
      - typing-extensions==4.1.1
      - urllib3==1.26.20
      - vcrpy==4.1.1
      - virtualenv==20.17.1
      - wrapt==1.16.0
      - yarl==1.7.2
      - zipp==3.6.0
prefix: /opt/conda/envs/flag-slurper

tests/autolib/test_governor.py::test_governor_singleton tests/autolib/test_governor.py::test_governor_filter tests/test_config.py::test_config_singleton tests/test_config.py::test_config_creation tests/test_config.py::test_config_load_extra tests/test_config.py::test_config_cond_set tests/test_config.py::test_config_api_url tests/test_config.py::test_config_request_extras_token tests/test_config.py::test_config_request_extras_creds tests/test_config.py::test_config_user tests/test_config.py::test_config_prompt_token tests/test_config.py::test_config_prompt_creds tests/test_config.py::test_config_login tests/test_config.py::test_config_governor tests/test_utils.py::test_remote[shell.team1-expected0] tests/test_utils.py::test_remote[cdc@shell.team1-expected1] tests/test_utils.py::test_remote[shell.team1:2222-expected2] tests/test_utils.py::test_remote[cdc@shell.team1:2222-expected3] tests/test_utils.py::test_prompt_choice[options0-1. tests/test_utils.py::test_prompt_choice[options1-Test tests/test_utils.py::test_prompt_choice[options2-1. tests/test_utils.py::test_prompt_choice[options3-Test tests/test_utils.py::test_prompt_choice[options4-Test tests/test_utils.py::test_report_utils[report_success-It tests/test_utils.py::test_report_utils[report_status-Status tests/test_utils.py::test_report_utils[report_warning-Warning tests/test_utils.py::test_report_utils[report_error-Error tests/test_utils.py::test_team_map tests/test_utils.py::test_save_flags tests/test_utils.py::test_save_flags_all_teams tests/test_utils.py::test_check_user[user0-0] tests/test_utils.py::test_check_user[user1-0] tests/test_utils.py::test_check_user[user2-2] tests/test_utils.py::test_get_service_status tests/test_utils.py::test_get_teams tests/test_utils.py::test_parse_creds[root-expected0] tests/test_utils.py::test_parse_creds[root:cdc-expected1] tests/test_utils.py::test_parse_duration[3-3] tests/test_utils.py::test_parse_duration[5s-5] tests/test_utils.py::test_parse_duration[10m-600] tests/test_utils.py::test_parse_duration[3h-10800] tests/test_utils.py::test_parse_duration_invalid[abc] tests/test_utils.py::test_parse_duration_invalid[30y] tests/test_utils.py::test_parse_duration_invalid[2d]
flag_slurper/autolib/governor.py flag_slurper/default.ini flag_slurper/config.py flag_slurper/utils.py flag_slurper/autopwn.py flag_slurper/autolib/protocols.py
flag_slurper/autolib/governor.py flag_slurper/default.ini flag_slurper/config.py flag_slurper/utils.py flag_slurper/autopwn.py flag_slurper/autolib/protocols.py