Skip to content

Commit 8d769d4

Browse files
authored
New Sonar codemode: use-secure-protocols (#973)
* Skeleton for use-secure-protocols codemod * Initial implementation of transformation * Unit test for use-secure-protocols * Integration tests for use-secure-protocols * Removed leftover print and reverted some changes * Fixed sonar results test
1 parent b7351cf commit 8d769d4

File tree

10 files changed

+446
-3
lines changed

10 files changed

+446
-3
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from codemodder.codemods.test import SonarIntegrationTest
2+
from core_codemods.sonar.sonar_use_secure_protocols import (
3+
SonarUseSecureProtocols,
4+
SonarUseSecureProtocolsTransformer,
5+
)
6+
7+
8+
class TestSonarUseSecureProtocols(SonarIntegrationTest):
9+
codemod = SonarUseSecureProtocols
10+
code_path = "tests/samples/use_secure_protocols.py"
11+
replacement_lines = [
12+
(
13+
5,
14+
"""url = "https://example.com"\n""",
15+
),
16+
]
17+
# fmt: off
18+
expected_diff = (
19+
"""--- \n"""
20+
"""+++ \n"""
21+
"""@@ -2,4 +2,4 @@\n"""
22+
''' import smtplib\n'''
23+
''' import telnetlib\n'''
24+
''' \n'''
25+
'''-url = "http://example.com"\n'''
26+
'''+url = "https://example.com"\n'''
27+
)
28+
# fmt: on
29+
expected_line_change = "5"
30+
change_description = SonarUseSecureProtocolsTransformer.change_description

src/codemodder/scripts/generate_docs.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,11 @@ class DocMetadata:
334334
guidance_explained="Our change provides the most secure way to create cookies in Flask. However, it's possible you have configured your Flask application configurations to use secure cookies. In these cases, using the default parameters for `set_cookie` is safe.",
335335
need_sarif="Yes (Sonar)",
336336
),
337+
"use-secure-protocols": DocMetadata(
338+
importance="High",
339+
guidance_explained="While secure protocols are widely supported by a variety of application and server software, it may require explicit configuration to support those protocols.",
340+
need_sarif="Yes (Sonar)",
341+
),
337342
}
338343

339344
SEMGREP_CODEMOD_NAMES = [

src/core_codemods/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
from .sonar.sonar_tempfile_mktemp import SonarTempfileMktemp
9696
from .sonar.sonar_timezone_aware_datetime import SonarTimezoneAwareDatetime
9797
from .sonar.sonar_url_sandbox import SonarUrlSandbox
98+
from .sonar.sonar_use_secure_protocols import SonarUseSecureProtocols
9899
from .sql_parameterization import SQLQueryParameterization
99100
from .str_concat_in_seq_literal import StrConcatInSeqLiteral
100101
from .subprocess_shell_false import SubprocessShellFalse
@@ -206,6 +207,7 @@
206207
SonarTimezoneAwareDatetime,
207208
SonarSandboxProcessCreation,
208209
SonarSecureCookie,
210+
SonarUseSecureProtocols,
209211
],
210212
)
211213

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
Communication using clear-text protocols allows an attacker to sniff or tamper with the transported data.
2+
3+
This codemod will replace any detected clear text protocol with their cryptographic enabled version.
4+
5+
Our changes look like the following:
6+
```diff
7+
- url = "http://example.com"
8+
+ url = "https://example.com"
9+
10+
- ftp_con = ftplib.FTP("ftp.example.com")
11+
+ ftp_con = ftplib.FTP_TLS("ftp.example.com")
12+
+ smtp_context = ssl.create_default_context()
13+
+ smtp_context.verify_mode = ssl.CERT_REQUIRED
14+
+ smtp_context.check_hostname = True
15+
smtp_con = smtplib.SMTP("smtp.example.com", port=587)
16+
+ smtp.starttls(context=smtp_context)
17+
18+
19+
+ smtp_context_1 = ssl.create_default_context()
20+
+ smtp_context_1.verify_mode = ssl.CERT_REQUIRED
21+
+ smtp_context_1.check_hostname = True
22+
- smtp_con_2 = smtplib.SMTP("smtp.gmail.com")
23+
+ smtp_con_2 = smtplib.SMTP_SSL("smtp.gmail.com", context=smtp_context_1)
24+
```

src/core_codemods/sonar/api.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,25 @@
1010

1111

1212
class SonarCodemod(SASTCodemod):
13+
14+
def __init__(
15+
self,
16+
*,
17+
metadata: Metadata,
18+
transformer: BaseTransformerPipeline,
19+
default_extensions: list[str] | None = None,
20+
requested_rules: list[str] | None = None,
21+
provider: str | None = None,
22+
):
23+
super().__init__(
24+
metadata=metadata,
25+
detector=SonarDetector(),
26+
transformer=transformer,
27+
default_extensions=default_extensions,
28+
requested_rules=requested_rules,
29+
provider=provider,
30+
)
31+
1332
@property
1433
def origin(self):
1534
return "sonar"
@@ -38,7 +57,6 @@ def from_core_codemod_with_multiple_rules(
3857
),
3958
),
4059
transformer=transformer if transformer else other.transformer,
41-
detector=SonarDetector(),
4260
default_extensions=other.default_extensions,
4361
requested_rules=[tr.id for tr in rules],
4462
)
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import libcst as cst
2+
from libcst.codemod import CodemodContext
3+
4+
from codemodder.codemods.base_codemod import (
5+
Metadata,
6+
ReviewGuidance,
7+
ToolMetadata,
8+
ToolRule,
9+
)
10+
from codemodder.codemods.libcst_transformer import (
11+
LibcstResultTransformer,
12+
LibcstTransformerPipeline,
13+
)
14+
from codemodder.codemods.utils_mixin import NameAndAncestorResolutionMixin
15+
from codemodder.codetf import Reference
16+
from codemodder.file_context import FileContext
17+
from codemodder.result import Result
18+
from core_codemods.sonar.api import SonarCodemod
19+
20+
rules = [
21+
ToolRule(
22+
id="python:S5332",
23+
name="Using clear-text protocols is security-sensitive",
24+
url="https://rules.sonarsource.com/python/RSPEC-5332/",
25+
),
26+
]
27+
28+
29+
class SonarUseSecureProtocolsTransformer(
30+
LibcstResultTransformer, NameAndAncestorResolutionMixin
31+
):
32+
change_description = "Modified URLs or calls to use secure protocols"
33+
34+
def __init__(
35+
self,
36+
context: CodemodContext,
37+
results: list[Result] | None,
38+
file_context: FileContext,
39+
_transformer: bool = False,
40+
):
41+
self.nodes_memory_with_context_name: dict[cst.CSTNode, str] = {}
42+
super().__init__(context, results, file_context, _transformer)
43+
44+
def _match_and_handle_statement(
45+
self, possible_smtp_call, original_node_statement, updated_node_statement
46+
):
47+
maybe_name = self.find_base_name(possible_smtp_call)
48+
match possible_smtp_call:
49+
case cst.Call() if maybe_name == "smtplib.SMTP":
50+
# get the stored context_name or create a new one:
51+
if possible_smtp_call in self.nodes_memory_with_context_name:
52+
context_name = self.nodes_memory_with_context_name[
53+
possible_smtp_call
54+
]
55+
else:
56+
context_name = self.generate_available_name(
57+
original_node_statement, ["smtp_context"]
58+
)
59+
60+
new_statements = []
61+
new_statements.append(
62+
cst.parse_statement(
63+
f"{context_name} = ssl.create_default_context()"
64+
)
65+
)
66+
new_statements.append(
67+
cst.parse_statement(
68+
f"{context_name}.verify_mode = ssl.CERT_REQUIRED"
69+
)
70+
)
71+
new_statements.append(
72+
cst.parse_statement(f"{context_name}.check_hostname = True")
73+
)
74+
new_statements.append(updated_node_statement)
75+
# don't append this if we changed the call to SSL version
76+
if possible_smtp_call in self.nodes_memory_with_context_name:
77+
self.nodes_memory_with_context_name.pop(possible_smtp_call)
78+
else:
79+
new_statements.append(
80+
cst.parse_statement(f"smtplib.starttls(context={context_name})")
81+
)
82+
self.add_needed_import("smtplib")
83+
self.add_needed_import("ssl")
84+
self.report_change(possible_smtp_call)
85+
return cst.FlattenSentinel(new_statements)
86+
return updated_node_statement
87+
88+
def leave_SimpleStatementLine(self, original_node, updated_node):
89+
match original_node.body:
90+
# match the first statement that is either selected or is an assignment whose value is selected
91+
case [cst.Assign() as a, *_] if self.node_is_selected(a.value):
92+
return self._match_and_handle_statement(
93+
a.value, original_node, updated_node
94+
)
95+
case [s, *_] if self.node_is_selected(s):
96+
return self._match_and_handle_statement(s, original_node, updated_node)
97+
return updated_node
98+
99+
def leave_Call(self, original_node, updated_node):
100+
if self.node_is_selected(original_node):
101+
match self.find_base_name(original_node):
102+
case "ftplib.FTP":
103+
new_func = cst.parse_expression("ftplib.FTP_TLS")
104+
self.report_change(original_node)
105+
self.add_needed_import("ftplib")
106+
return updated_node.with_changes(func=new_func)
107+
# Just using ssl.create_default_context() may not be enough for older python versions
108+
# See https://stackoverflow.com/questions/33857698/sending-email-from-python-using-starttls
109+
case "smtplib.SMTP":
110+
# port is the second positional, check that
111+
maybe_port_value = (
112+
original_node.args[1]
113+
if len(original_node.args) >= 2
114+
and original_node.args[1].keyword is None
115+
else None
116+
)
117+
# find port keyword, if any
118+
maybe_port_value = maybe_port_value or next(
119+
iter(
120+
[
121+
a
122+
for a in original_node.args
123+
if a.keyword and a.keyword.value == "port"
124+
]
125+
),
126+
None,
127+
)
128+
maybe_port_value = (
129+
maybe_port_value.value if maybe_port_value else None
130+
)
131+
match maybe_port_value:
132+
case None:
133+
return self._change_to_smtp_ssl(original_node, updated_node)
134+
case cst.Integer() if maybe_port_value.value == "0":
135+
return self._change_to_smtp_ssl(original_node, updated_node)
136+
return updated_node
137+
138+
def _change_to_smtp_ssl(self, original_node, updated_node):
139+
# remember this node so we don't add the starttls
140+
new_func = cst.parse_expression("smtplib.SMTP_SSL")
141+
142+
context_name = self.generate_available_name(original_node, ["smtp_context"])
143+
self.nodes_memory_with_context_name[original_node] = context_name
144+
145+
new_args = [
146+
*original_node.args,
147+
cst.Arg(
148+
keyword=cst.Name("context"),
149+
value=cst.Name(context_name),
150+
),
151+
]
152+
return updated_node.with_changes(func=new_func, args=new_args)
153+
154+
def leave_SimpleString(
155+
self, original_node: cst.SimpleString, updated_node: cst.SimpleString
156+
) -> cst.BaseExpression:
157+
if self.node_is_selected(original_node):
158+
match original_node.raw_value:
159+
case original_node.raw_value as s if s.startswith("http"):
160+
self.report_change(original_node)
161+
return updated_node.with_changes(
162+
value=original_node.prefix
163+
+ original_node.quote
164+
+ s.replace("http", "https", 1)
165+
+ original_node.quote
166+
)
167+
case original_node.raw_value as s if s.startswith("ftp"):
168+
self.report_change(original_node)
169+
return updated_node.with_changes(
170+
value=original_node.prefix
171+
+ original_node.quote
172+
+ s.replace("ftp", "sftp", 1)
173+
+ original_node.quote
174+
)
175+
return updated_node
176+
177+
178+
SonarUseSecureProtocols = SonarCodemod(
179+
metadata=Metadata(
180+
name="use-secure-protocols",
181+
summary="Use encrypted protocols instead of clear-text",
182+
review_guidance=ReviewGuidance.MERGE_AFTER_CURSORY_REVIEW,
183+
references=[
184+
Reference(
185+
url="https://docs.python.org/3/library/ftplib.html#ftplib.FTP_TLS"
186+
),
187+
Reference(
188+
url="https://docs.python.org/3/library/smtplib.html#smtplib.SMTP.starttls"
189+
),
190+
Reference(url="https://owasp.org/Top10/A02_2021-Cryptographic_Failures/"),
191+
Reference(
192+
url="https://owasp.org/www-project-top-ten/2017/A3_2017-Sensitive_Data_Exposure"
193+
),
194+
Reference(url="https://cwe.mitre.org/data/definitions/200"),
195+
Reference(url="https://cwe.mitre.org/data/definitions/319"),
196+
]
197+
+ [Reference(url=tr.url or "", description=tr.name) for tr in rules],
198+
tool=ToolMetadata(
199+
name="Sonar",
200+
rules=rules,
201+
),
202+
),
203+
transformer=LibcstTransformerPipeline(SonarUseSecureProtocolsTransformer),
204+
default_extensions=[".py"],
205+
requested_rules=[tr.id for tr in rules],
206+
)

0 commit comments

Comments
 (0)