Skip to content

Commit

Permalink
Add ability to analyze call history
Browse files Browse the repository at this point in the history
This change adds a call history as part of the symbol table of
identifiers. This enables checks for libraries such as imaplib
that initialize an insecure class and is only secure if starttls
is later called.

* Symbol has push_call and call_history to track history
* Adds 4 new rules for modules imaplib, nntplib, poplib, smtplib

Signed-off-by: Eric Brown <[email protected]>
  • Loading branch information
ericwb committed Aug 22, 2023
1 parent 51e2281 commit 6f880b2
Show file tree
Hide file tree
Showing 26 changed files with 749 additions and 161 deletions.
17 changes: 12 additions & 5 deletions examples/complex.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import getpass
import imaplib


def init_nntp(n):
n.starttls()
M = imaplib.IMAP4()

#M.starttls()

nntp = nntplib.NNTP('news.gmane.io')
init_nntp(nntp)
nntp.login()
M.login(getpass.getuser(), getpass.getpass())
M.select()
typ, data = M.search(None, 'ALL')
for num in data[0].split():
typ, data = M.fetch(num, '(RFC822)')
print('Message %s\n%s\n' % (num, data[0][1]))
M.close()
M.logout()
37 changes: 33 additions & 4 deletions precli/core/call.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ def __init__(
node: Node,
name: str,
name_qual: str,
args: list,
kwargs: dict,
args: list = None,
kwargs: dict = None,
):
self._node = node
self._name = name
Expand All @@ -24,8 +24,20 @@ def __init__(
# list
self._func_node = node.children[0]
self._arg_list_node = node.children[1]
self._var_node = Call._get_var_node(self._func_node)
self._ident_node = Call._get_func_ident(self._func_node)

@staticmethod
def _get_var_node(node: Node) -> Node:
if (
node.named_children
and node.named_children[0].type in ("identifier", "attribute")
and node.named_children[1].type == "identifier"
):
return node.named_children[0]
elif node.type == "attribute":
return Call._get_var_node(node.named_children[0])

@staticmethod
def _get_func_ident(node: Node) -> Node:
# TODO(ericwb): does this function fail with nested calls?
Expand All @@ -44,12 +56,26 @@ def node(self) -> Node:
"""
return self._node

@property
def var_node(self) -> Node:
"""
The node representing the variable part of a function call.
For example, if the function call is:
a.b.c()
The function node would be a.b
:return: function for the call
:rtype: Node
"""
return self._var_node

@property
def function_node(self) -> Node:
"""
The node representing the entire function of the call.
For example, if the function is:
For example, if the function call is:
a.b.c()
The function node would be a.b.c
Expand All @@ -63,7 +89,7 @@ def identifier_node(self) -> Node:
"""
The node representing just the identifier of the function.
For example, if the function is:
For example, if the function call is:
a.b.c()
The identifier node would be c
Expand Down Expand Up @@ -116,3 +142,6 @@ def get_argument(
name=name,
)
return default if default else Argument(node=None, value=None)

def __repr__(self) -> str:
return self._node.text.decode()
10 changes: 10 additions & 0 deletions precli/core/symtab.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Copyright 2023 Secure Saurce LLC
from typing import Self

from precli.core.call import Call


class SymbolTable:
def __init__(self, name, parent=None):
Expand Down Expand Up @@ -38,6 +40,7 @@ def __init__(self, name, type, value):
self._name = name
self._type = type
self._value = value
self._call_history = []

@property
def name(self) -> str:
Expand All @@ -51,5 +54,12 @@ def type(self) -> str:
def value(self) -> str:
return self._value

def push_call(self, call: Call):
self._call_history.append(call)

@property
def call_history(self) -> list[Call]:
return self._call_history

def __repr__(self) -> str:
return f"Symbol (type: {self._type}, value: {self._value})"
18 changes: 18 additions & 0 deletions precli/parsers/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,15 @@ def visit_assignment(self, nodes: list[Node]):
left_hand = self.literal_value(nodes[0], default=nodes[0])
right_hand = self.literal_value(nodes[2], default=nodes[2])
self.current_symtab.put(left_hand, "identifier", right_hand)
if nodes[2].type == "call":
call = Call(
node=nodes[2],
name=right_hand,
name_qual=right_hand,
)
symbol = self.current_symtab.get(left_hand)
symbol.push_call(call)

self.visit(nodes)

def visit_call(self, nodes: list[Node]):
Expand All @@ -140,6 +149,15 @@ def visit_call(self, nodes: list[Node]):
self.current_symtab.put(identifier, "import", module)

self.process_rules("call", call=call)

if call.var_node is not None:
symbol = self.current_symtab.get(call.var_node.text.decode())
if symbol is not None and symbol.type == "identifier":
symbol.push_call(call)
else:
# TODO: why is var_node None?
pass

self.visit(nodes)

def visit_with_item(self, nodes: list[Node]):
Expand Down
8 changes: 1 addition & 7 deletions precli/rules/python/stdlib/ftplib/ftp_cleartext.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,19 +90,13 @@


class FtpCleartext(Rule):
"""
.. seealso::
- https://docs.python.org/3/library/ftplib.html
"""

def __init__(self, id: str):
super().__init__(
id=id,
name="cleartext_transmission",
full_descr=__doc__,
cwe_id=319,
message="The FTP protocol transmits data in cleartext without "
message="The FTP protocol can transmit data in cleartext without "
"encryption.",
targets=("call"),
wildcards={
Expand Down
128 changes: 128 additions & 0 deletions precli/rules/python/stdlib/imaplib/imap_cleartext.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Copyright 2023 Secure Saurce LLC
r"""
=====================================================================
Cleartext Transmission of Sensitive Information in the Imaplib Module
=====================================================================
The Python module ``imaplib`` provides a number of functions for accessing
IMAP servers. However, the default behavior of the module does not provide
utilize secure connections. This means that data transmitted over the network,
including passwords, is sent in cleartext. This makes it possible for attackers
to intercept and read this data.
The Python module imaplib should only in a secure mannner to protect sensitive
data when accessing IMAP servers.
-------
Example
-------
.. code-block:: python
:linenos:
:emphasize-lines: 5
import getpass
import imaplib
M = imaplib.IMAP4()
M.login(getpass.getuser(), getpass.getpass())
M.select()
typ, data = M.search(None, 'ALL')
for num in data[0].split():
typ, data = M.fetch(num, '(RFC822)')
print('Message %s\n%s\n' % (num, data[0][1]))
M.close()
M.logout()
-----------
Remediation
-----------
If the IMAP protocol must be used and sensitive data will be transferred, it
is recommended to secure the connection using ``IMAP4_SSL`` class.
Alternatively, the ``starttls`` function can be used to enter a secure session.
.. code-block:: python
:linenos:
:emphasize-lines: 5
import getpass
import imaplib
M = imaplib.IMAP4_SSL()
M.login(getpass.getuser(), getpass.getpass())
M.select()
typ, data = M.search(None, 'ALL')
for num in data[0].split():
typ, data = M.fetch(num, '(RFC822)')
print('Message %s\n%s\n' % (num, data[0][1]))
M.close()
M.logout()
.. seealso::
- `imaplib — IMAP4 protocol client <https://docs.python.org/3/library/imaplib.html>`_
- `CWE-319: Cleartext Transmission of Sensitive Information <https://cwe.mitre.org/data/definitions/319.html>`_
.. versionadded:: 1.0.0
""" # noqa: E501
from precli.core.level import Level
from precli.core.location import Location
from precli.core.result import Result
from precli.rules import Rule


class ImapCleartext(Rule):
def __init__(self, id: str):
super().__init__(
id=id,
name="cleartext_transmission",
full_descr=__doc__,
cwe_id=319,
message="The IMAP protocol can transmit data in cleartext without "
"encryption.",
targets=("call"),
wildcards={
"imaplib.*": [
"IMAP4",
]
},
)

def analyze(self, context: dict, **kwargs: dict) -> Result:
call = kwargs.get("call")

if call.name_qualified in [
"imaplib.IMAP4.authenticate",
"imaplib.IMAP4.login",
"imaplib.IMAP4.login_cram_md5",
]:
symbol = context["symtab"].get(call.var_node.text.decode())

if "starttls" not in [
x.identifier_node.text.decode() for x in symbol.call_history
]:
init_call = symbol.call_history[0]
fixes = Rule.get_fixes(
context=context,
deleted_location=Location(node=init_call.identifier_node),
description="Use the 'IMAP4_SSL' module to secure the "
"connection.",
inserted_content="IMAP4_SSL",
)

return Result(
rule_id=self.id,
location=Location(
file_name=context["file_name"],
node=call.identifier_node,
),
level=Level.ERROR,
message=f"The '{call.name_qualified}' function will "
f"transmit authentication information such as a user, "
"password in cleartext.",
fixes=fixes,
)
Loading

0 comments on commit 6f880b2

Please sign in to comment.