Repository: aio-libs/aiosmtpd

Issue:
add STARTTLS support to aiosmtpd cli
It would be great to be able to have the aiosmtpd cli support offering STARTTLS.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/aio-libs/aiosmtpd/pull/252?src=pr&el=h1) Report
> Merging [#252](https://codecov.io/gh/aio-libs/aiosmtpd/pull/252?src=pr&el=desc) (68bc18f) into [master](https://codecov.io/gh/aio-libs/aiosmtpd/commit/cfd7cf6d4c897e5a75f9bf92b0791c3b5bca3dfc?el=desc) (cfd7cf6) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/aio-libs/aiosmtpd/pull/252/graphs/tree.svg?width=650&height=150&src=pr&token=k9dc7NVG69)](https://codecov.io/gh/aio-libs/aiosmtpd/pull/252?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##            master      #252   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            6         6           
  Lines         1206      1235   +29     
  Branches       220       228    +8     
=========================================
+ Hits          1206      1235   +29     
```


| [Impacted Files](https://codecov.io/gh/aio-libs/aiosmtpd/pull/252?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [aiosmtpd/\_\_init\_\_.py](https://codecov.io/gh/aio-libs/aiosmtpd/pull/252/diff?src=pr&el=tree#diff-YWlvc210cGQvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [aiosmtpd/main.py](https://codecov.io/gh/aio-libs/aiosmtpd/pull/252/diff?src=pr&el=tree#diff-YWlvc210cGQvbWFpbi5weQ==) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/aio-libs/aiosmtpd/pull/252?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/aio-libs/aiosmtpd/pull/252?src=pr&el=footer). Last update [cfd7cf6...68bc18f](https://codecov.io/gh/aio-libs/aiosmtpd/pull/252?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/README.rst b/README.rst
index b629ff4..53f2781 100644
--- a/README.rst
+++ b/README.rst
@@ -195,25 +195,41 @@ have been configured and tested:
   is pre-prepared, such as Travis CI or |GitHub Actions|_; this will definitely
   save some time during tox's testenv prepping.
 
+  For all testenv combinations except diffcov,
+  |bandit|_ security check will also be run prior to running pytest.
+
+.. _bandit: https://github.com/PyCQA/bandit
+.. |bandit| replace:: ``bandit``
+
+
 * ``qa``
 
-  Perform ``flake8`` code style checking
+  Performs |flake8|_ code style checking,
+  and |flake8-bugbear|_ design checking.
+
+  In addition, some tests to help ensure that ``aiosmtpd`` is *releasable* to PyPI are also run.
+
+.. _flake8: https://flake8.pycqa.org/en/latest/
+.. |flake8| replace:: ``flake8``
+.. _flake8-bugbear: https://github.com/PyCQA/flake8-bugbear
+.. |flake8-bugbear| replace:: ``flake8-bugbear``
 
 * ``docs``
 
-  Builds **HTML documentation** using Sphinx.
+  Builds **HTML documentation** and **manpage** using Sphinx.
   A `pytest doctest`_ will run prior to actual building of the documentation.
 
 * ``static``
 
   Performs a **static type checking** using ``pytype``.
-  Please ensure that `all its dependencies`_ have been installed before
+
+  **Note 1:** Please ensure that `all pytype dependencies`_ have been installed before
   executing this testenv.
 
-  **Note:** Because ``pytype`` does not run on Windows,
+  **Note 2:** Because ``pytype`` does not run on Windows,
   This testenv must be invoked explicitly; it will not automatically run.
 
-.. _`all its dependencies`: https://github.com/google/pytype/blob/2021.02.09/CONTRIBUTING.md#pytype-dependencies
+.. _`all pytype dependencies`: https://github.com/google/pytype/blob/2021.02.09/CONTRIBUTING.md#pytype-dependencies
 
 
 Environment Variables
diff --git a/aiosmtpd/__init__.py b/aiosmtpd/__init__.py
index b03ab91..53204da 100644
--- a/aiosmtpd/__init__.py
+++ b/aiosmtpd/__init__.py
@@ -1,4 +1,4 @@
 # Copyright 2014-2021 The aiosmtpd Developers
 # SPDX-License-Identifier: Apache-2.0
 
-__version__ = "1.4.0a2"
+__version__ = "1.4.0a3"
diff --git a/aiosmtpd/docs/NEWS.rst b/aiosmtpd/docs/NEWS.rst
index 0c3bfd6..1216ed9 100644
--- a/aiosmtpd/docs/NEWS.rst
+++ b/aiosmtpd/docs/NEWS.rst
@@ -10,6 +10,7 @@ Added
 -----
 * Support for |PROXY Protocol|_ (Closes #174)
 * Example for authentication
+* SSL Support for CLI. See :ref:`the man page <manpage>` for more info. (Closes #172)
 
 .. _`PROXY Protocol`: https://www.haproxy.com/blog/using-haproxy-with-the-proxy-protocol-to-better-secure-your-database/
 .. |PROXY Protocol| replace:: **PROXY Protocol**
diff --git a/aiosmtpd/docs/_exts/autoprogramm.py b/aiosmtpd/docs/_exts/autoprogramm.py
index 5705e70..69088be 100644
--- a/aiosmtpd/docs/_exts/autoprogramm.py
+++ b/aiosmtpd/docs/_exts/autoprogramm.py
@@ -151,7 +151,7 @@ def import_object(import_name):
                 with open(f[0]) as fobj:
                     codestring = fobj.read()
                 foo = imp.new_module("foo")
-                exec(codestring, foo.__dict__)
+                exec(codestring, foo.__dict__)  # nosec
 
                 sys.modules["foo"] = foo
                 mod = __import__("foo")
@@ -163,7 +163,7 @@ def import_object(import_name):
     globals_ = builtins
     if not isinstance(globals_, dict):
         globals_ = globals_.__dict__
-    return eval(expr, globals_, mod.__dict__)
+    return eval(expr, globals_, mod.__dict__)  # nosec
 
 
 class AutoprogrammDirective(Directive):
diff --git a/aiosmtpd/docs/conf.py b/aiosmtpd/docs/conf.py
index 03100d2..c8104e6 100644
--- a/aiosmtpd/docs/conf.py
+++ b/aiosmtpd/docs/conf.py
@@ -101,6 +101,7 @@ language = "en"
 # today = ''
 # Else, today_fmt is used as the format for a strftime call.
 # today_fmt = '%B %d, %Y'
+today_fmt = "%Y-%m-%d"
 
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
@@ -282,7 +283,7 @@ latex_documents = [
 # One entry per manual page. List of tuples
 # (source start file, name, description, authors, manual section).
 man_pages = [
-    ("manpage", "aiosmtpd", "aiosmtpd Documentation", ["aiosmtpd hackers"], 1),
+    ("manpage", "aiosmtpd", "asyncio based SMTP server", [author], 1),
 ]
 
 # If true, show URL addresses after external links.
diff --git a/aiosmtpd/docs/manpage.rst b/aiosmtpd/docs/manpage.rst
index 4f1cc6d..0a90213 100644
--- a/aiosmtpd/docs/manpage.rst
+++ b/aiosmtpd/docs/manpage.rst
@@ -8,7 +8,7 @@ Provides an asynchronous, RFC 5321 compliant Simple Mail Transfer Protocol (SMTP
 extensions.
 
 :Author: |author|
-:Date: 2020-11-20
+:Date: |today|
 :Copyright: |copyright|
 :Version: |version|
 :Manual section: 1
@@ -30,6 +30,5 @@ ENVIRONMENT
 
 .. envvar:: AIOSMTPD_CONTROLLER_TIMEOUT
 
-    How long the main thread will wait (in seconds) until the SMTP thread is ready.
-
-    Default: ``1.0``
+    | How long the main thread will wait (in seconds) until the SMTP thread is ready.
+    | Default: ``1.0``
diff --git a/aiosmtpd/main.py b/aiosmtpd/main.py
index 9644d33..e978c60 100644
--- a/aiosmtpd/main.py
+++ b/aiosmtpd/main.py
@@ -5,11 +5,13 @@ import asyncio
 import logging
 import os
 import signal
+import ssl
 import sys
 from argparse import ArgumentParser
 from contextlib import suppress
 from functools import partial
 from importlib import import_module
+from pathlib import Path
 
 from public import public
 
@@ -87,7 +89,9 @@ def _parser() -> ArgumentParser:
         "--debug",
         default=0,
         action="count",
-        help="""Increase debugging output.""",
+        help=(
+            "Increase debugging output. Every ``-d`` increases debugging level by one."
+        )
     )
     parser.add_argument(
         "-l",
@@ -102,6 +106,57 @@ def _parser() -> ArgumentParser:
             "``{host}:{port}`` is used.".format(host=DEFAULT_HOST, port=DEFAULT_PORT)
         ),
     )
+    parser.add_argument(
+        "--smtpscert",
+        metavar="CERTFILE",
+        type=Path,
+        default=None,
+        help=(
+            "The certificate file for implementing **SMTPS**. If given, the parameter "
+            "``--smtpskey`` must also be specified."
+        ),
+    )
+    parser.add_argument(
+        "--smtpskey",
+        metavar="KEYFILE",
+        type=Path,
+        default=None,
+        help=(
+            "The key file for implementing **SMTPS**. If given, the parameter "
+            "``--smtpscert`` must also be specified."
+        ),
+    )
+    parser.add_argument(
+        "--tlscert",
+        metavar="CERTFILE",
+        type=Path,
+        default=None,
+        help=(
+            "The certificate file for implementing **STARTTLS**. If given, the "
+            "parameter ``--tlskey`` must also be specified."
+        ),
+    )
+    parser.add_argument(
+        "--tlskey",
+        metavar="KEYFILE",
+        type=Path,
+        default=None,
+        help=(
+            "The key file for implementing **STARTTLS**. If given, the parameter "
+            "``--tlscert`` must also be specified."
+        ),
+    )
+    parser.add_argument(
+        "--no-requiretls",
+        dest="requiretls",
+        default=True,
+        action="store_false",
+        help=(
+            "If specified, disables ``require_starttls`` of the SMTP class. "
+            "(By default, ``require_starttls`` is True.) "
+            "Has no effect if ``--tlscert`` and ``--tlskey`` are not specified."
+        ),
+    )
     parser.add_argument(
         "classargs",
         metavar="CLASSARGS",
@@ -114,33 +169,48 @@ def _parser() -> ArgumentParser:
 
 def parseargs(args=None):
     parser = _parser()
-    args = parser.parse_args(args)
+    parsed = parser.parse_args(args)
     # Find the handler class.
-    path, dot, name = args.classpath.rpartition(".")
+    path, dot, name = parsed.classpath.rpartition(".")
     module = import_module(path)
     handler_class = getattr(module, name)
     if hasattr(handler_class, "from_cli"):
-        args.handler = handler_class.from_cli(parser, *args.classargs)
+        parsed.handler = handler_class.from_cli(parser, *parsed.classargs)
     else:
-        if len(args.classargs) > 0:
+        if len(parsed.classargs) > 0:
             parser.error(f"Handler class {path} takes no arguments")
-        args.handler = handler_class()
+        parsed.handler = handler_class()
     # Parse the host:port argument.
-    if args.listen is None:
-        args.host = DEFAULT_HOST
-        args.port = DEFAULT_PORT
+    if parsed.listen is None:
+        parsed.host = DEFAULT_HOST
+        parsed.port = DEFAULT_PORT
     else:
-        host, colon, port = args.listen.rpartition(":")
+        host, colon, port = parsed.listen.rpartition(":")
         if len(colon) == 0:
-            args.host = port
-            args.port = DEFAULT_PORT
+            parsed.host = port
+            parsed.port = DEFAULT_PORT
         else:
-            args.host = DEFAULT_HOST if len(host) == 0 else host
+            parsed.host = DEFAULT_HOST if len(host) == 0 else host
             try:
-                args.port = int(DEFAULT_PORT if len(port) == 0 else port)
+                parsed.port = int(DEFAULT_PORT if len(port) == 0 else port)
             except ValueError:
                 parser.error("Invalid port number: {}".format(port))
-    return parser, args
+
+    if bool(parsed.smtpscert) ^ bool(parsed.smtpskey):
+        parser.error("--smtpscert and --smtpskey must be specified together")
+    if parsed.smtpscert and not parsed.smtpscert.exists():
+        parser.error(f"Cert file {parsed.smtpscert} not found")
+    if parsed.smtpskey and not parsed.smtpskey.exists():
+        parser.error(f"Key file {parsed.smtpskey} not found")
+
+    if bool(parsed.tlscert) ^ bool(parsed.tlskey):
+        parser.error("--tlscert and --tlskey must be specified together")
+    if parsed.tlscert and not parsed.tlscert.exists():
+        parser.error(f"Cert file {parsed.tlscert} not found")
+    if parsed.tlskey and not parsed.tlskey.exists():
+        parser.error(f"Key file {parsed.tlskey} not found")
+
+    return parser, parsed
 
 
 @public
@@ -163,8 +233,20 @@ def main(args=None):
             )
             sys.exit(1)
 
+    if args.tlscert and args.tlskey:
+        tls_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+        tls_context.check_hostname = False
+        tls_context.load_cert_chain(str(args.tlscert), str(args.tlskey))
+    else:
+        tls_context = None
+
     factory = partial(
-        SMTP, args.handler, data_size_limit=args.size, enable_SMTPUTF8=args.smtputf8
+        SMTP,
+        args.handler,
+        data_size_limit=args.size,
+        enable_SMTPUTF8=args.smtputf8,
+        tls_context=tls_context,
+        require_starttls=args.requiretls,
     )
 
     logging.basicConfig(level=logging.ERROR)
@@ -178,10 +260,19 @@ def main(args=None):
     if args.debug > 2:
         loop.set_debug(enabled=True)
 
+    if args.smtpscert and args.smtpskey:
+        smtps_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+        smtps_context.check_hostname = False
+        smtps_context.load_cert_chain(str(args.smtpscert), str(args.smtpskey))
+    else:
+        smtps_context = None
+
     log.debug("Attempting to start server on %s:%s", args.host, args.port)
     server = server_loop = None
     try:
-        server = loop.create_server(factory, host=args.host, port=args.port)
+        server = loop.create_server(
+            factory, host=args.host, port=args.port, ssl=smtps_context
+        )
         server_loop = loop.run_until_complete(server)
     except RuntimeError:  # pragma: nocover
         raise
diff --git a/bandit.yml b/bandit.yml
new file mode 100644
index 0000000..312031a
--- /dev/null
+++ b/bandit.yml
@@ -0,0 +1,398 @@
+
+### Bandit config file generated from:
+# 'C:\Repos\Venvs\aiosmtpd-13-cp38\Scripts\bandit-config-generator -o bandit.rc -s B101'
+
+### This config may optionally select a subset of tests to run or skip by
+### filling out the 'tests' and 'skips' lists given below. If no tests are
+### specified for inclusion then it is assumed all tests are desired. The skips
+### set will remove specific tests from the include set. This can be controlled
+### using the -t/-s CLI options. Note that the same test ID should not appear
+### in both 'tests' and 'skips', this would be nonsensical and is detected by
+### Bandit at runtime.
+
+# Available tests:
+# B101 : assert_used
+# B102 : exec_used
+# B103 : set_bad_file_permissions
+# B104 : hardcoded_bind_all_interfaces
+# B105 : hardcoded_password_string
+# B106 : hardcoded_password_funcarg
+# B107 : hardcoded_password_default
+# B108 : hardcoded_tmp_directory
+# B110 : try_except_pass
+# B112 : try_except_continue
+# B201 : flask_debug_true
+# B301 : pickle
+# B302 : marshal
+# B303 : md5
+# B304 : ciphers
+# B305 : cipher_modes
+# B306 : mktemp_q
+# B307 : eval
+# B308 : mark_safe
+# B309 : httpsconnection
+# B310 : urllib_urlopen
+# B311 : random
+# B312 : telnetlib
+# B313 : xml_bad_cElementTree
+# B314 : xml_bad_ElementTree
+# B315 : xml_bad_expatreader
+# B316 : xml_bad_expatbuilder
+# B317 : xml_bad_sax
+# B318 : xml_bad_minidom
+# B319 : xml_bad_pulldom
+# B320 : xml_bad_etree
+# B321 : ftplib
+# B323 : unverified_context
+# B324 : hashlib_new_insecure_functions
+# B325 : tempnam
+# B401 : import_telnetlib
+# B402 : import_ftplib
+# B403 : import_pickle
+# B404 : import_subprocess
+# B405 : import_xml_etree
+# B406 : import_xml_sax
+# B407 : import_xml_expat
+# B408 : import_xml_minidom
+# B409 : import_xml_pulldom
+# B410 : import_lxml
+# B411 : import_xmlrpclib
+# B412 : import_httpoxy
+# B413 : import_pycrypto
+# B501 : request_with_no_cert_validation
+# B502 : ssl_with_bad_version
+# B503 : ssl_with_bad_defaults
+# B504 : ssl_with_no_version
+# B505 : weak_cryptographic_key
+# B506 : yaml_load
+# B507 : ssh_no_host_key_verification
+# B601 : paramiko_calls
+# B602 : subprocess_popen_with_shell_equals_true
+# B603 : subprocess_without_shell_equals_true
+# B604 : any_other_function_with_shell_equals_true
+# B605 : start_process_with_a_shell
+# B606 : start_process_with_no_shell
+# B607 : start_process_with_partial_path
+# B608 : hardcoded_sql_expressions
+# B609 : linux_commands_wildcard_injection
+# B610 : django_extra_used
+# B611 : django_rawsql_used
+# B701 : jinja2_autoescape_false
+# B702 : use_of_mako_templates
+# B703 : django_mark_safe
+
+# (optional) list included test IDs here, eg '[B101, B406]':
+tests:
+
+# (optional) list skipped test IDs here, eg '[B101, B406]':
+skips: ['B101', 'B404']
+
+### (optional) plugin settings - some test plugins require configuration data
+### that may be given here, per-plugin. All bandit test plugins have a built in
+### set of sensible defaults and these will be used if no configuration is
+### provided. It is not necessary to provide settings for every (or any) plugin
+### if the defaults are acceptable.
+
+any_other_function_with_shell_equals_true:
+  no_shell:
+  - os.execl
+  - os.execle
+  - os.execlp
+  - os.execlpe
+  - os.execv
+  - os.execve
+  - os.execvp
+  - os.execvpe
+  - os.spawnl
+  - os.spawnle
+  - os.spawnlp
+  - os.spawnlpe
+  - os.spawnv
+  - os.spawnve
+  - os.spawnvp
+  - os.spawnvpe
+  - os.startfile
+  shell:
+  - os.system
+  - os.popen
+  - os.popen2
+  - os.popen3
+  - os.popen4
+  - popen2.popen2
+  - popen2.popen3
+  - popen2.popen4
+  - popen2.Popen3
+  - popen2.Popen4
+  - commands.getoutput
+  - commands.getstatusoutput
+  subprocess:
+  - subprocess.Popen
+  - subprocess.call
+  - subprocess.check_call
+  - subprocess.check_output
+  - subprocess.run
+assert_used:
+  skips: []
+hardcoded_tmp_directory:
+  tmp_dirs:
+  - /tmp
+  - /var/tmp
+  - /dev/shm
+linux_commands_wildcard_injection:
+  no_shell:
+  - os.execl
+  - os.execle
+  - os.execlp
+  - os.execlpe
+  - os.execv
+  - os.execve
+  - os.execvp
+  - os.execvpe
+  - os.spawnl
+  - os.spawnle
+  - os.spawnlp
+  - os.spawnlpe
+  - os.spawnv
+  - os.spawnve
+  - os.spawnvp
+  - os.spawnvpe
+  - os.startfile
+  shell:
+  - os.system
+  - os.popen
+  - os.popen2
+  - os.popen3
+  - os.popen4
+  - popen2.popen2
+  - popen2.popen3
+  - popen2.popen4
+  - popen2.Popen3
+  - popen2.Popen4
+  - commands.getoutput
+  - commands.getstatusoutput
+  subprocess:
+  - subprocess.Popen
+  - subprocess.call
+  - subprocess.check_call
+  - subprocess.check_output
+  - subprocess.run
+ssl_with_bad_defaults:
+  bad_protocol_versions:
+  - PROTOCOL_SSLv2
+  - SSLv2_METHOD
+  - SSLv23_METHOD
+  - PROTOCOL_SSLv3
+  - PROTOCOL_TLSv1
+  - SSLv3_METHOD
+  - TLSv1_METHOD
+ssl_with_bad_version:
+  bad_protocol_versions:
+  - PROTOCOL_SSLv2
+  - SSLv2_METHOD
+  - SSLv23_METHOD
+  - PROTOCOL_SSLv3
+  - PROTOCOL_TLSv1
+  - SSLv3_METHOD
+  - TLSv1_METHOD
+start_process_with_a_shell:
+  no_shell:
+  - os.execl
+  - os.execle
+  - os.execlp
+  - os.execlpe
+  - os.execv
+  - os.execve
+  - os.execvp
+  - os.execvpe
+  - os.spawnl
+  - os.spawnle
+  - os.spawnlp
+  - os.spawnlpe
+  - os.spawnv
+  - os.spawnve
+  - os.spawnvp
+  - os.spawnvpe
+  - os.startfile
+  shell:
+  - os.system
+  - os.popen
+  - os.popen2
+  - os.popen3
+  - os.popen4
+  - popen2.popen2
+  - popen2.popen3
+  - popen2.popen4
+  - popen2.Popen3
+  - popen2.Popen4
+  - commands.getoutput
+  - commands.getstatusoutput
+  subprocess:
+  - subprocess.Popen
+  - subprocess.call
+  - subprocess.check_call
+  - subprocess.check_output
+  - subprocess.run
+start_process_with_no_shell:
+  no_shell:
+  - os.execl
+  - os.execle
+  - os.execlp
+  - os.execlpe
+  - os.execv
+  - os.execve
+  - os.execvp
+  - os.execvpe
+  - os.spawnl
+  - os.spawnle
+  - os.spawnlp
+  - os.spawnlpe
+  - os.spawnv
+  - os.spawnve
+  - os.spawnvp
+  - os.spawnvpe
+  - os.startfile
+  shell:
+  - os.system
+  - os.popen
+  - os.popen2
+  - os.popen3
+  - os.popen4
+  - popen2.popen2
+  - popen2.popen3
+  - popen2.popen4
+  - popen2.Popen3
+  - popen2.Popen4
+  - commands.getoutput
+  - commands.getstatusoutput
+  subprocess:
+  - subprocess.Popen
+  - subprocess.call
+  - subprocess.check_call
+  - subprocess.check_output
+  - subprocess.run
+start_process_with_partial_path:
+  no_shell:
+  - os.execl
+  - os.execle
+  - os.execlp
+  - os.execlpe
+  - os.execv
+  - os.execve
+  - os.execvp
+  - os.execvpe
+  - os.spawnl
+  - os.spawnle
+  - os.spawnlp
+  - os.spawnlpe
+  - os.spawnv
+  - os.spawnve
+  - os.spawnvp
+  - os.spawnvpe
+  - os.startfile
+  shell:
+  - os.system
+  - os.popen
+  - os.popen2
+  - os.popen3
+  - os.popen4
+  - popen2.popen2
+  - popen2.popen3
+  - popen2.popen4
+  - popen2.Popen3
+  - popen2.Popen4
+  - commands.getoutput
+  - commands.getstatusoutput
+  subprocess:
+  - subprocess.Popen
+  - subprocess.call
+  - subprocess.check_call
+  - subprocess.check_output
+  - subprocess.run
+subprocess_popen_with_shell_equals_true:
+  no_shell:
+  - os.execl
+  - os.execle
+  - os.execlp
+  - os.execlpe
+  - os.execv
+  - os.execve
+  - os.execvp
+  - os.execvpe
+  - os.spawnl
+  - os.spawnle
+  - os.spawnlp
+  - os.spawnlpe
+  - os.spawnv
+  - os.spawnve
+  - os.spawnvp
+  - os.spawnvpe
+  - os.startfile
+  shell:
+  - os.system
+  - os.popen
+  - os.popen2
+  - os.popen3
+  - os.popen4
+  - popen2.popen2
+  - popen2.popen3
+  - popen2.popen4
+  - popen2.Popen3
+  - popen2.Popen4
+  - commands.getoutput
+  - commands.getstatusoutput
+  subprocess:
+  - subprocess.Popen
+  - subprocess.call
+  - subprocess.check_call
+  - subprocess.check_output
+  - subprocess.run
+subprocess_without_shell_equals_true:
+  no_shell:
+  - os.execl
+  - os.execle
+  - os.execlp
+  - os.execlpe
+  - os.execv
+  - os.execve
+  - os.execvp
+  - os.execvpe
+  - os.spawnl
+  - os.spawnle
+  - os.spawnlp
+  - os.spawnlpe
+  - os.spawnv
+  - os.spawnve
+  - os.spawnvp
+  - os.spawnvpe
+  - os.startfile
+  shell:
+  - os.system
+  - os.popen
+  - os.popen2
+  - os.popen3
+  - os.popen4
+  - popen2.popen2
+  - popen2.popen3
+  - popen2.popen4
+  - popen2.Popen3
+  - popen2.Popen4
+  - commands.getoutput
+  - commands.getstatusoutput
+  subprocess:
+  - subprocess.Popen
+  - subprocess.call
+  - subprocess.check_call
+  - subprocess.check_output
+  - subprocess.run
+try_except_continue:
+  check_typed_exception: false
+try_except_pass:
+  check_typed_exception: false
+weak_cryptographic_key:
+  weak_key_size_dsa_high: 1024
+  weak_key_size_dsa_medium: 2048
+  weak_key_size_ec_high: 160
+  weak_key_size_ec_medium: 224
+  weak_key_size_rsa_high: 1024
+  weak_key_size_rsa_medium: 2048
+
diff --git a/housekeep.py b/housekeep.py
index 32cb168..aeea7b7 100644
--- a/housekeep.py
+++ b/housekeep.py
@@ -75,7 +75,7 @@ def deldir(targ: Path, verbose: bool = True):
             pp.rmdir()
         else:
             raise RuntimeError(f"Don't know how to handle '{pp}'")
-        if verbose and (i & 0x1FF) == 0:
+        if verbose and ((i & 0x3FF) == 0):
             print(".", end="", flush=True)
     targ.rmdir()
 
@@ -122,11 +122,11 @@ def pycache_clean(verbose=False):
     """Cleanup __pycache__ dirs & bytecode files (if any)"""
     aiosmtpdpath = Path(".")
     for i, f in enumerate(aiosmtpdpath.rglob("*.py[co]"), start=1):
-        if verbose and (i % 0x3FF) == 0:
+        if verbose and ((i & 0xFF) == 0):
             print(".", end="", flush=True)
         f.unlink()
-    for d in aiosmtpdpath.rglob("__pycache__"):
-        if verbose:
+    for i, d in enumerate(aiosmtpdpath.rglob("__pycache__"), start=1):
+        if verbose and ((i & 0x7) == 0):
             print(".", end="", flush=True)
         deldir(d, verbose)
     if verbose:
@@ -258,7 +258,7 @@ if __name__ == "__main__":
         else:
             print(f"{Fore.YELLOW}We're in CI but --force is specified")
     print(
-        f"{Fore.GREEN}\u25B7\u25B7\u25B7 "
+        f"{Fore.GREEN}>>> "
         f"{Path(__file__).name} {opts.cmd}{Style.RESET_ALL}",
         flush=True,
     )
diff --git a/tox.ini b/tox.ini
index 173f021..10c9263 100644
--- a/tox.ini
+++ b/tox.ini
@@ -14,6 +14,7 @@ envdir =
     py: {toxworkdir}/py
 commands =
     python housekeep.py prep
+    !diffcov: bandit -c bandit.yml -r aiosmtpd
     nocov: pytest --verbose -p no:cov --tb=short {posargs}
     cov: pytest --cov --cov-report=xml --cov-report=html --cov-report=term --tb=short {posargs}
     diffcov: diff-cover coverage-{env:INTERP}.xml --html-report diffcov-{env:INTERP}.html
@@ -23,6 +24,7 @@ commands =
 #sitepackages = True
 usedevelop = True
 deps =
+    bandit
     colorama
     coverage[toml]
     coverage-conditional-plugin
@@ -55,8 +57,8 @@ envdir = {toxworkdir}/qa
 commands =
     python housekeep.py prep
     python -m flake8 aiosmtpd setup.py housekeep.py release.py
-    pytest -v --tb=short aiosmtpd/qa
     check-manifest -v
+    pytest -v --tb=short aiosmtpd/qa
 deps =
     colorama
     flake8
@@ -70,7 +72,8 @@ envdir = {toxworkdir}/docs
 commands =
     python housekeep.py prep
     pytest -v aiosmtpd/docs
-    python setup.py build_sphinx
+    sphinx-build --color -b html -d build/.doctree aiosmtpd/docs build/html
+    sphinx-build --color -b man  -d build/.doctree aiosmtpd/docs build/man
 deps:
     # IMPORTANT: If you edit this, also edit the files:
     #   - .github/workflows/unit-testing-and-coverage.yml


Test patch:
diff --git a/.github/workflows/unit-testing-and-coverage.yml b/.github/workflows/unit-testing-and-coverage.yml
index a1d79df..2afcf03 100644
--- a/.github/workflows/unit-testing-and-coverage.yml
+++ b/.github/workflows/unit-testing-and-coverage.yml
@@ -47,8 +47,9 @@ jobs:
         run: |
           pip install colorama pytest pytest-mock sphinx sphinx-autofixture sphinx_rtd_theme
           pytest -v aiosmtpd/docs
-          python setup.py build_sphinx
-      - name: "Static Type Checking"
+          sphinx-build --color -b html -d build/.doctree aiosmtpd/docs build/html
+          sphinx-build --color -b man  -d build/.doctree aiosmtpd/docs build/man
+      - name: "Static Code Checking"
         # language=bash
         run: |
           # Required by examples
@@ -93,6 +94,11 @@ jobs:
         pip install colorama coverage[toml] coverage-conditional-plugin packaging pytest pytest-cov pytest-mock
         # Package deps
         python setup.py develop
+    - name: "Security checking"
+      # language=bash
+      run: |
+        pip install bandit
+        bandit -c bandit.yml -r aiosmtpd
     - name: "Execute testing"
       shell: bash
       # language=bash
diff --git a/aiosmtpd/qa/test_0packaging.py b/aiosmtpd/qa/test_0packaging.py
index ef0fc3f..2240762 100644
--- a/aiosmtpd/qa/test_0packaging.py
+++ b/aiosmtpd/qa/test_0packaging.py
@@ -36,7 +36,7 @@ class TestVersion:
         cmd = f"git show {reference}".split()
         try:
             with capsys.disabled():
-                master_smtp = subprocess.check_output(cmd).decode()
+                master_smtp = subprocess.check_output(cmd).decode()  # nosec
         except subprocess.CalledProcessError:
             pytest.skip("Skipping due to git error")
             return
diff --git a/aiosmtpd/tests/conftest.py b/aiosmtpd/tests/conftest.py
index eae4b2d..08fc0e8 100644
--- a/aiosmtpd/tests/conftest.py
+++ b/aiosmtpd/tests/conftest.py
@@ -25,6 +25,15 @@ except ImportError:
     HAS_PROACTOR = False
 
 
+__all__ = [
+    "controller_data",
+    "handler_data",
+    "Global",
+    "SERVER_CRT",
+    "SERVER_KEY",
+]
+
+
 # region #### Aliases #################################################################
 
 controller_data = pytest.mark.controller_data
@@ -55,6 +64,9 @@ class Global:
         cls.SrvAddr = HostPort(contr.hostname, contr.port)
 
 
+SERVER_CRT = resource_filename("aiosmtpd.tests.certs", "server.crt")
+SERVER_KEY = resource_filename("aiosmtpd.tests.certs", "server.key")
+
 # endregion
 
 
@@ -281,10 +293,7 @@ def ssl_context_server() -> Generator[ssl.SSLContext, None, None]:
     """
     context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
     context.check_hostname = False
-    context.load_cert_chain(
-        resource_filename("aiosmtpd.tests.certs", "server.crt"),
-        resource_filename("aiosmtpd.tests.certs", "server.key"),
-    )
+    context.load_cert_chain(SERVER_CRT, SERVER_KEY)
     #
     yield context
 
@@ -296,9 +305,7 @@ def ssl_context_client() -> Generator[ssl.SSLContext, None, None]:
     """
     context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
     context.check_hostname = False
-    context.load_verify_locations(
-        resource_filename("aiosmtpd.tests.certs", "server.crt")
-    )
+    context.load_verify_locations(SERVER_CRT)
     #
     yield context
 
diff --git a/aiosmtpd/tests/test_main.py b/aiosmtpd/tests/test_main.py
index 8025fd1..1ec4079 100644
--- a/aiosmtpd/tests/test_main.py
+++ b/aiosmtpd/tests/test_main.py
@@ -3,13 +3,21 @@
 
 import asyncio
 import logging
+import multiprocessing as MP
 import os
+import time
+from ctypes import c_bool
+from smtplib import SMTP as SMTPClient
+from smtplib import SMTP_SSL
 from typing import Generator
 
 import pytest
 
 from aiosmtpd import __version__
+from aiosmtpd.handlers import Debugging
 from aiosmtpd.main import main, parseargs
+from aiosmtpd.testing.statuscodes import SMTP_STATUS_CODES as S
+from aiosmtpd.tests.conftest import SERVER_CRT, SERVER_KEY
 
 try:
     import pwd
@@ -19,6 +27,13 @@ except ImportError:
 HAS_SETUID = hasattr(os, "setuid")
 MAIL_LOG = logging.getLogger("mail.log")
 
+# If less than 1.0, might cause intermittent error if test system
+# is too busy/overloaded.
+AUTOSTOP_DELAY = 1.0
+
+
+# region ##### Custom Handlers ########################################################
+
 
 class FromCliHandler:
     def __init__(self, called):
@@ -33,7 +48,9 @@ class NullHandler:
     pass
 
 
-# region ##### Fixtures #######################################################
+# endregion
+
+# region ##### Fixtures ###############################################################
 
 
 @pytest.fixture
@@ -42,10 +59,7 @@ def autostop_loop(temp_event_loop) -> Generator[asyncio.AbstractEventLoop, None,
     # immediately.  This will allow the calls to main() in these tests to
     # also exit almost immediately.  Otherwise, the foreground test
     # process will hang.
-    #
-    # If less than 1.0, might cause intermittent error if test system
-    # is too busy/overloaded.
-    temp_event_loop.call_later(1.0, temp_event_loop.stop)
+    temp_event_loop.call_later(AUTOSTOP_DELAY, temp_event_loop.stop)
     #
     yield temp_event_loop
 
@@ -73,6 +87,48 @@ def setuid(mocker):
     yield
 
 
+# endregion
+
+# region ##### Helper Funcs ###########################################################
+
+
+def watch_for_tls(ready_flag, has_tls, req_tls):
+    has_tls.value = False
+    req_tls.value = False
+    ready_flag.set()
+    start = time.monotonic()
+    while (time.monotonic() - start) <= AUTOSTOP_DELAY:
+        try:
+            with SMTPClient("localhost", 8025) as client:
+                resp = client.docmd("HELP", "HELO")
+                if resp == S.S530_STARTTLS_FIRST:
+                    req_tls.value = True
+                client.ehlo("exemple.org")
+                if "starttls" in client.esmtp_features:
+                    has_tls.value = True
+                return
+        except Exception:
+            time.sleep(0.05)
+
+
+def watch_for_smtps(ready_flag, has_smtps):
+    has_smtps.value = False
+    ready_flag.set()
+    start = time.monotonic()
+    while (time.monotonic() - start) <= AUTOSTOP_DELAY:
+        try:
+            with SMTP_SSL("localhost", 8025) as client:
+                client.ehlo("exemple.org")
+                has_smtps.value = True
+                return
+        except Exception:
+            time.sleep(0.05)
+
+
+def main_n(*args):
+    main(("-n",) + args)
+
+
 # endregion
 
 
@@ -111,7 +167,7 @@ class TestMain:
 
     def test_n(self, setuid):
         with pytest.raises(RuntimeError):
-            main(("-n",))
+            main_n()
 
     def test_nosetuid(self, setuid):
         with pytest.raises(RuntimeError):
@@ -121,24 +177,87 @@ class TestMain:
         # For this test, the test runner likely has already set the log level
         # so it may not be logging.ERROR.
         default_level = MAIL_LOG.getEffectiveLevel()
-        main(("-n",))
+        main_n()
         assert MAIL_LOG.getEffectiveLevel() == default_level
 
     def test_debug_1(self):
-        main(("-n", "-d"))
+        main_n("-d")
         assert MAIL_LOG.getEffectiveLevel() == logging.INFO
 
     def test_debug_2(self):
-        main(("-n", "-dd"))
+        main_n("-dd")
         assert MAIL_LOG.getEffectiveLevel() == logging.DEBUG
 
     def test_debug_3(self):
-        main(("-n", "-ddd"))
+        main_n("-ddd")
         assert MAIL_LOG.getEffectiveLevel() == logging.DEBUG
         assert asyncio.get_event_loop().get_debug()
 
 
+class TestMainByWatcher:
+    def test_tls(self, temp_event_loop):
+        ready_flag = MP.Event()
+        has_starttls = MP.Value(c_bool)
+        require_tls = MP.Value(c_bool)
+        p = MP.Process(
+            target=watch_for_tls, args=(ready_flag, has_starttls, require_tls)
+        )
+        p.start()
+        ready_flag.wait()
+        temp_event_loop.call_later(AUTOSTOP_DELAY, temp_event_loop.stop)
+        main_n("--tlscert", str(SERVER_CRT), "--tlskey", str(SERVER_KEY))
+        p.join()
+        assert has_starttls.value is True
+        assert require_tls.value is True
+
+    def test_tls_noreq(self, temp_event_loop):
+        ready_flag = MP.Event()
+        has_starttls = MP.Value(c_bool)
+        require_tls = MP.Value(c_bool)
+        p = MP.Process(
+            target=watch_for_tls, args=(ready_flag, has_starttls, require_tls)
+        )
+        p.start()
+        ready_flag.wait()
+        temp_event_loop.call_later(AUTOSTOP_DELAY, temp_event_loop.stop)
+        main_n(
+            "--tlscert", str(SERVER_CRT), "--tlskey", str(SERVER_KEY), "--no-requiretls"
+        )
+        p.join()
+        assert has_starttls.value is True
+        assert require_tls.value is False
+
+    def test_smtps(self, temp_event_loop):
+        ready_flag = MP.Event()
+        has_smtps = MP.Value(c_bool)
+        p = MP.Process(target=watch_for_smtps, args=(ready_flag, has_smtps))
+        p.start()
+        ready_flag.wait()
+        temp_event_loop.call_later(AUTOSTOP_DELAY, temp_event_loop.stop)
+        main_n("--smtpscert", str(SERVER_CRT), "--smtpskey", str(SERVER_KEY))
+        p.join()
+        assert has_smtps.value is True
+
+
 class TestParseArgs:
+    def test_defaults(self):
+        parser, args = parseargs(tuple())
+        assert args.classargs == tuple()
+        assert args.classpath == "aiosmtpd.handlers.Debugging"
+        assert args.debug == 0
+        assert isinstance(args.handler, Debugging)
+        assert args.host == "localhost"
+        assert args.listen is None
+        assert args.port == 8025
+        assert args.setuid is True
+        assert args.size is None
+        assert args.smtputf8 is False
+        assert args.smtpscert is None
+        assert args.smtpskey is None
+        assert args.tlscert is None
+        assert args.tlskey is None
+        assert args.requiretls is True
+
     def test_handler_from_cli(self):
         parser, args = parseargs(
             ("-c", "aiosmtpd.tests.test_main.FromCliHandler", "--", "FOO")
@@ -193,6 +312,50 @@ class TestParseArgs:
         assert excinfo.value.code == 0
         assert capsys.readouterr().out == f"smtpd {__version__}\n"
 
+    @pytest.mark.parametrize("args", [("--smtpscert", "x"), ("--smtpskey", "x")])
+    def test_smtps(self, capsys, mocker, args):
+        mocker.patch("aiosmtpd.main.PROGRAM", "smtpd")
+        with pytest.raises(SystemExit) as exc:
+            parseargs(args)
+        assert exc.value.code == 2
+        assert (
+            "--smtpscert and --smtpskey must be specified together"
+            in capsys.readouterr().err
+        )
+
+    @pytest.mark.parametrize("args", [("--tlscert", "x"), ("--tlskey", "x")])
+    def test_tls(self, capsys, mocker, args):
+        mocker.patch("aiosmtpd.main.PROGRAM", "smtpd")
+        with pytest.raises(SystemExit) as exc:
+            parseargs(args)
+        assert exc.value.code == 2
+        assert (
+            "--tlscert and --tlskey must be specified together"
+            in capsys.readouterr().err
+        )
+
+    def test_norequiretls(self, capsys, mocker):
+        mocker.patch("aiosmtpd.main.PROGRAM", "smtpd")
+        parser, args = parseargs(("--no-requiretls",))
+        assert args.requiretls is False
+
+    @pytest.mark.parametrize(
+        "certfile, keyfile, expect",
+        [
+            ("x", "x", "Cert file x not found"),
+            (SERVER_CRT, "x", "Key file x not found"),
+            ("x", SERVER_KEY, "Cert file x not found"),
+        ],
+        ids=["x-x", "cert-x", "x-key"],
+    )
+    @pytest.mark.parametrize("meth", ["smtps", "tls"])
+    def test_ssl_files_err(self, capsys, mocker, meth, certfile, keyfile, expect):
+        mocker.patch("aiosmtpd.main.PROGRAM", "smtpd")
+        with pytest.raises(SystemExit) as exc:
+            parseargs((f"--{meth}cert", certfile, f"--{meth}key", keyfile))
+        assert exc.value.code == 2
+        assert expect in capsys.readouterr().err
+
 
 class TestSigint:
     def test_keyboard_interrupt(self, temp_event_loop):
@@ -203,7 +366,7 @@ class TestSigint:
 
         temp_event_loop.call_later(1.0, interrupt)
         try:
-            main(("-n",))
+            main_n()
         except Exception:
             pytest.fail("main() should've closed cleanly without exceptions!")
         else:
diff --git a/aiosmtpd/tests/test_server.py b/aiosmtpd/tests/test_server.py
index 05674c6..f572680 100644
--- a/aiosmtpd/tests/test_server.py
+++ b/aiosmtpd/tests/test_server.py
@@ -26,10 +26,7 @@ class SlowStartController(Controller):
 
     def _run(self, ready_event):
         time.sleep(self.ready_timeout * 1.5)
-        try:
-            super()._run(ready_event)
-        except Exception:
-            pass
+        super()._run(ready_event)
 
 
 class SlowFactoryController(Controller):