Skip to content

Commit

Permalink
New Python rule to check for imaplib use without a timeout
Browse files Browse the repository at this point in the history
New rule PY041.

When imaplib.IMAP4 or IMAP4_SSL are used without specifiying a
timeout, the connections have the potential to block forever.
The default timeout is the global socket timeout setting which
is None by default (effectively no timeout).

Signed-off-by: Eric Brown <[email protected]>
  • Loading branch information
ericwb committed Sep 29, 2024
1 parent cf0984b commit 4fd41c5
Show file tree
Hide file tree
Showing 21 changed files with 244 additions and 14 deletions.
1 change: 1 addition & 0 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,4 @@
| PY038 | [os — unnecessary privileges](rules/python/stdlib/os-setuid-root.md) | Execution with Unnecessary Privileges using `os` Module |
| PY039 | [socket — no timeout](rules/python/stdlib/socket-no-timeout.md) | Synchronous Access of `socket` without Timeout |
| PY040 | [smtplib — no timeout](rules/python/stdlib/smtplib-no-timeout.md) | Synchronous Access of `SMTP` without Timeout |
| PY041 | [imaplib — no timeout](rules/python/stdlib/imaplib-no-timeout.md) | Synchronous Access of `IMAP4` without Timeout |
10 changes: 10 additions & 0 deletions docs/rules/python/stdlib/imaplib-no-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
id: PY041
title: imaplib — no timeout
hide_title: true
pagination_prev: null
pagination_next: null
slug: /rules/PY041
---

::: precli.rules.python.imaplib.imaplib_no_timeout
130 changes: 130 additions & 0 deletions precli/rules/python/stdlib/imaplib_no_timeout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Copyright 2024 Secure Sauce LLC
r"""
# Synchronous Access of `IMAP4` without Timeout
The `imaplib.IMAP4` and `imaplib.IMAP4_SSL` classes are used to connect to
IMAP servers for retrieving emails over the Internet Message Access Protocol
(IMAP). By default, these classes do not specify a timeout, which can result
in the application blocking indefinitely while trying to communicate with an
unresponsive server. This can lead to resource exhaustion, Denial of Service
(DoS), or system instability, particularly in production environments where
resilience is critical.
This rule enforces the use of a timeout parameter when creating instances
of `imaplib.IMAP4` and `imaplib.IMAP4_SSL` to avoid the risk of indefinite
blocking and ensure graceful handling of network delays or failures.
Failing to specify a timeout in these classes may cause the application to
block indefinitely while waiting for a response from the mail server. This can
lead to Denial of Service (DoS) vulnerabilities or cause the application to
become unresponsive.
# Example
```python linenums="1" hl_lines="5" title="imaplib_imap_no_timeout.py"
import imaplib
import ssl
imap = imaplib.IMAP4("imap.example.com")
imap.starttls(ssl.create_default_context())
```
??? example "Example Output"
```
> precli tests/unit/rules/python/stdlib/imaplib/examples/imaplib_imap_no_timeout.py
⚠️ Warning on line 10 in tests/unit/rules/python/stdlib/imaplib/examples/imaplib_imap_no_timeout.py
PY041: Synchronous Access of Remote Resource without Timeout
The class 'imaplib.IMAP4' is used without a timeout, which may cause the application to block indefinitely if the remote server does not respond.
```
# Remediation
Always provide a timeout parameter when using `imaplib.IMAP4` or
`imaplib.IMAP4_SSL`. This ensures that if the mail server is unreachable or
unresponsive, the connection attempt will fail after a set period, preventing
indefinite blocking and resource exhaustion.
```python linenums="1" hl_lines="5" title="imaplib_imap_no_timeout.py"
import imaplib
import ssl
imap = imaplib.IMAP4("imap.example.com", timeout=5)
imap.starttls(ssl.create_default_context())
```
# See also
!!! info
- [imaplib.IMAP4 — imaplib — IMAP4 protocol client](https://docs.python.org/3/library/imaplib.html#imaplib.IMAP4)
- [imaplib.IMAP4_SSL — imaplib — IMAP4 protocol client](https://docs.python.org/3/library/imaplib.html#imaplib.IMAP4_SSL)
- [CWE-1088: Synchronous Access of Remote Resource without Timeout](https://cwe.mitre.org/data/definitions/1088.html)
_New in version 0.6.7_
""" # noqa: E501
from precli.core.call import Call
from precli.core.location import Location
from precli.core.result import Result
from precli.rules import Rule


class ImaplibNoTimeout(Rule):
def __init__(self, id: str):
super().__init__(
id=id,
name="no_timeout",
description=__doc__,
cwe_id=1088,
message="The class '{0}' is used without a timeout, which may "
"cause the application to block indefinitely if the remote server "
"does not respond.",
)

def analyze_call(self, context: dict, call: Call) -> Result | None:
if call.name_qualified not in (
"imaplib.IMAP4",
"imaplib.IMAP4_SSL",
):
return

if call.name_qualified == "imaplib.IMAP4":
# IMAP4(host='', port=143, timeout=None)
argument = call.get_argument(position=2, name="timeout")
elif call.name_qualified == "imaplib.IMAP4_SSL":
# IMAP4_SSL(host='', port=993, *, ssl_context=None, timeout=None)
argument = call.get_argument(name="timeout")

timeout = argument.value

if argument.node is None:
arg_list_node = call.arg_list_node
fix_node = arg_list_node
args = [child.string for child in arg_list_node.named_children]
args.append("timeout=5")
content = f"({', '.join(args)})"
result_node = call.arg_list_node
elif timeout is None:
fix_node = argument.node
result_node = argument.node
content = "5"
else:
# If the timeout parameter is set to be zero, the class will raise
# a ValueError to prevent the creation of a non-blocking socket. A
# negative value also raises ValueError. So there is no need to
# check for these values.
return

fixes = Rule.get_fixes(
context=context,
deleted_location=Location(fix_node),
description="Set timeout parameter to a small number of seconds.",
inserted_content=content,
)
return Result(
rule_id=self.id,
location=Location(node=result_node),
message=self.message.format(call.name_qualified),
fixes=fixes,
)
4 changes: 2 additions & 2 deletions precli/rules/python/stdlib/smtplib_no_timeout.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
server = smtplib.SMTP("smtp.example.com", 587)
server.starttls(ssl.create_default_context())
server.starttls(context=ssl.create_default_context())
```
??? example "Example Output"
Expand All @@ -51,7 +51,7 @@
server = smtplib.SMTP("smtp.example.com", 587, timeout=10)
server.starttls(ssl.create_default_context())
server.starttls(context=ssl.create_default_context())
```
# See also
Expand Down
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,6 @@ precli.rules.python =

# precli/rules/python/stdlib/smtplib_no_timeout.py
PY040 = precli.rules.python.stdlib.smtplib_no_timeout:SmtplibNoTimeout

# precli/rules/python/stdlib/imaplib_no_timeout.py
PY041 = precli.rules.python.stdlib.imaplib_no_timeout:ImaplibNoTimeout
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import imaplib


imap4 = imaplib.IMAP4()
imap4 = imaplib.IMAP4(timeout=5)
authobject = object()
imap4.authenticate("SKEY", authobject)
imap4.select()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
import imaplib


with imaplib.IMAP4("domain.org") as imap4:
with imaplib.IMAP4("domain.org", timeout=5) as imap4:
imap4.noop()
imap4.login("user", "password")
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import imaplib


imap4 = imaplib.IMAP4()
imap4 = imaplib.IMAP4(timeout=5)
imap4.login(getpass.getuser(), getpass.getpass())
imap4.select()
typ, data = imap4.search(None, "ALL")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import imaplib


imap4 = imaplib.IMAP4()
imap4 = imaplib.IMAP4(timeout=5)
imap4.login_cram_md5(getpass.getuser(), getpass.getpass())
imap4.select()
typ, data = imap4.search(None, "ALL")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# level: WARNING
# start_line: 10
# end_line: 10
# start_column: 20
# end_column: 40
import imaplib
import ssl


imap = imaplib.IMAP4("imap.example.com")
imap.starttls(ssl.create_default_context())
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import ssl


imap4 = imaplib.IMAP4_SSL(ssl_context=ssl.create_default_context())
imap4 = imaplib.IMAP4_SSL(ssl_context=ssl.create_default_context(), timeout=5)
imap4.login(getpass.getuser(), getpass.getpass())
imap4.select()
typ, data = imap4.search(None, "ALL")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@


ssl_context = None
imap4 = imaplib.IMAP4_SSL(ssl_context=ssl_context)
imap4 = imaplib.IMAP4_SSL(ssl_context=ssl_context, timeout=5)
imap4.login(getpass.getuser(), getpass.getpass())
imap4.select()
typ, data = imap4.search(None, "ALL")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import imaplib


imap4 = imaplib.IMAP4_SSL(ssl_context=None)
imap4 = imaplib.IMAP4_SSL(ssl_context=None, timeout=5)
imap4.login(getpass.getuser(), getpass.getpass())
imap4.select()
typ, data = imap4.search(None, "ALL")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import imaplib


imap4 = imaplib.IMAP4_SSL()
imap4 = imaplib.IMAP4_SSL(timeout=5)
imap4.login(getpass.getuser(), getpass.getpass())
imap4.select()
typ, data = imap4.search(None, "ALL")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# level: WARNING
# start_line: 11
# end_line: 11
# start_column: 76
# end_column: 80
import getpass
import imaplib
import ssl


imap4 = imaplib.IMAP4_SSL(ssl_context=ssl.create_default_context(), timeout=None)
imap4.login(getpass.getuser(), getpass.getpass())
imap4.select()
typ, data = imap4.search(None, "ALL")
for num in data[0].split():
typ, data = imap4.fetch(num, "(RFC822)")
print(f"Message {num}\n{data[0][1]}\n")
imap4.close()
imap4.logout()
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import ssl


imap4 = imaplib.IMAP4()
imap4 = imaplib.IMAP4(timeout=5)
imap4.starttls(ssl_context=ssl.create_default_context())
imap4.login(getpass.getuser(), getpass.getpass())
imap4.select()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@


ssl_context = None
imap4 = imaplib.IMAP4()
imap4 = imaplib.IMAP4(timeout=5)
imap4.starttls(ssl_context=ssl_context)
imap4.login(getpass.getuser(), getpass.getpass())
imap4.select()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import imaplib


imap4 = imaplib.IMAP4()
imap4 = imaplib.IMAP4(timeout=5)
imap4.starttls(ssl_context=None)
imap4.login(getpass.getuser(), getpass.getpass())
imap4.select()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import imaplib


imap4 = imaplib.IMAP4()
imap4 = imaplib.IMAP4(timeout=5)
imap4.starttls()
imap4.login(getpass.getuser(), getpass.getpass())
imap4.select()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# level: NONE
import imaplib
import ssl


imap = imaplib.IMAP4("imap.example.com", timeout=5)
imap.starttls(ssl.create_default_context())
49 changes: 49 additions & 0 deletions tests/unit/rules/python/stdlib/imaplib/test_imaplib_no_timeout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Copyright 2024 Secure Sauce LLC
import os

import pytest

from precli.core.level import Level
from precli.parsers import python
from precli.rules import Rule
from tests.unit.rules import test_case


class TestImaplibNoTimeout(test_case.TestCase):
@classmethod
def setup_class(cls):
cls.rule_id = "PY041"
cls.parser = python.Python()
cls.base_path = os.path.join(
"tests",
"unit",
"rules",
"python",
"stdlib",
"imaplib",
"examples",
)

def test_rule_meta(self):
rule = Rule.get_by_id(self.rule_id)
assert rule.id == self.rule_id
assert rule.name == "no_timeout"
assert (
rule.help_url
== f"https://docs.securesauce.dev/rules/{self.rule_id}"
)
assert rule.default_config.enabled is True
assert rule.default_config.level == Level.WARNING
assert rule.default_config.rank == -1.0
assert rule.cwe.id == 1088

@pytest.mark.parametrize(
"filename",
[
"imaplib_imap4_no_timeout.py",
"imaplib_imap4_ssl_timeout_none.py",
"imaplib_imap4_timeout_5.py",
],
)
def test(self, filename):
self.check(filename)

0 comments on commit 4fd41c5

Please sign in to comment.