Skip to content

Commit 826e82b

Browse files
authored
New Codemod: Sonar Flask Secure Cookie (#969)
* Initial version of SonarSecureCookie * Added unit test * Added integration test * Fixed missing file for integration test * Fixed tests and refactoring
1 parent 22bdada commit 826e82b

File tree

12 files changed

+282
-39
lines changed

12 files changed

+282
-39
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from codemodder.codemods.test import SonarIntegrationTest
2+
from core_codemods.sonar.sonar_secure_cookie import (
3+
SonarSecureCookie,
4+
SonarSecureCookieTransformer,
5+
)
6+
7+
8+
class TestSonarSecureCookie(SonarIntegrationTest):
9+
codemod = SonarSecureCookie
10+
code_path = "tests/samples/secure_cookie.py"
11+
replacement_lines = [
12+
(
13+
8,
14+
""" resp.set_cookie('custom_cookie', 'value', secure=True, httponly=True, samesite='Lax')\n""",
15+
),
16+
]
17+
expected_diff = "--- \n+++ \n@@ -5,5 +5,5 @@\n @app.route('/')\n def index():\n resp = make_response('Custom Cookie Set')\n- resp.set_cookie('custom_cookie', 'value')\n+ resp.set_cookie('custom_cookie', 'value', secure=True, httponly=True, samesite='Lax')\n return resp\n"
18+
expected_line_change = "8"
19+
change_description = SonarSecureCookieTransformer.change_description

integration_tests/test_secure_flask_cookie.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from codemodder.codemods.test import BaseIntegrationTest
2-
from core_codemods.secure_flask_cookie import SecureFlaskCookie
2+
from core_codemods.secure_flask_cookie import SecureCookieTransformer, SecureFlaskCookie
33

44

55
class TestSecureFlaskCookie(BaseIntegrationTest):
@@ -23,4 +23,4 @@ def index():
2323
]
2424
expected_diff = "--- \n+++ \n@@ -5,5 +5,5 @@\n @app.route('/')\n def index():\n resp = make_response('Custom Cookie Set')\n- resp.set_cookie('custom_cookie', 'value')\n+ resp.set_cookie('custom_cookie', 'value', secure=True, httponly=True, samesite='Lax')\n return resp\n"
2525
expected_line_change = "8"
26-
change_description = SecureFlaskCookie.change_description
26+
change_description = SecureCookieTransformer.change_description

src/codemodder/codemods/test/integration_utils.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,12 @@ def _assert_results_fields(self, results, output_path):
124124

125125
# TODO: if/when we add description for each url
126126
for reference in result["references"][
127-
# Last reference for Sonar has a different description
128-
: (-1 if self.sonar_issues_json or self.sonar_hotspots_json else None)
127+
# Last references for Sonar has a different description
128+
: (
129+
-len(self.codemod.requested_rules)
130+
if self.sonar_issues_json or self.sonar_hotspots_json
131+
else None
132+
)
129133
]:
130134
assert reference["url"] == reference["description"]
131135

@@ -288,21 +292,25 @@ def check_sonar_issues(cls):
288292
(cls.sonar_issues_json, cls.sonar_hotspots_json)
289293
)
290294

291-
assert (
292-
cls.codemod.requested_rules[-1] in sonar_results
295+
assert any(
296+
[x in sonar_results for x in cls.codemod.requested_rules]
293297
), f"Make sure to add a sonar issue/hotspot for {cls.codemod.rule_id} in {cls.sonar_issues_json} or {cls.sonar_hotspots_json}"
294298
results_for_codemod = sonar_results[cls.codemod.requested_rules[-1]]
295299
file_path = pathlib.Path(cls.code_filename)
296300
assert (
297301
file_path in results_for_codemod
298-
), f"Make sure to add a sonar issue/hotspot for file `{cls.code_filename}` under rule `{cls.codemod.rule_id}`in {cls.sonar_issues_json} or {cls.sonar_hotspots_json}"
302+
), f"Make sure to add a sonar issue/hotspot for file `{cls.code_filename}` under one of the rules `{cls.codemod.requested_rules}`in {cls.sonar_issues_json} or {cls.sonar_hotspots_json}"
299303

300304
def _assert_sonar_fields(self, result):
301305
assert self.codemod_instance._metadata.tool is not None
302-
assert (
303-
result["references"][-1]["description"]
304-
== self.codemod_instance._metadata.tool.rules[0].name
305-
)
306+
rules = self.codemod_instance._metadata.tool.rules
307+
for i in range(len(rules)):
308+
assert (
309+
result["references"][len(result["references"]) - len(rules) + i][
310+
"description"
311+
]
312+
== self.codemod_instance._metadata.tool.rules[i].name
313+
)
306314
assert result["detectionTool"]["name"] == "Sonar"
307315

308316

src/codemodder/scripts/generate_docs.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,12 @@ class DocMetadata:
328328
need_sarif="Yes (Sonar)",
329329
)
330330
for name in SONAR_CODEMOD_NAMES
331+
} | {
332+
"secure-cookie": DocMetadata(
333+
importance="Medium",
334+
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.",
335+
need_sarif="Yes (Sonar)",
336+
),
331337
}
332338

333339
SEMGREP_CODEMOD_NAMES = [

src/core_codemods/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
SonarRemoveAssertionInPytestRaises,
9090
)
9191
from .sonar.sonar_sandbox_process_creation import SonarSandboxProcessCreation
92+
from .sonar.sonar_secure_cookie import SonarSecureCookie
9293
from .sonar.sonar_secure_random import SonarSecureRandom
9394
from .sonar.sonar_sql_parameterization import SonarSQLParameterization
9495
from .sonar.sonar_tempfile_mktemp import SonarTempfileMktemp
@@ -204,6 +205,7 @@
204205
SonarInvertedBooleanCheck,
205206
SonarTimezoneAwareDatetime,
206207
SonarSandboxProcessCreation,
208+
SonarSecureCookie,
207209
],
208210
)
209211

src/core_codemods/secure_flask_cookie.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,25 @@
1-
from core_codemods.api import Metadata, Reference, ReviewGuidance, SimpleCodemod
1+
from codemodder.codemods.libcst_transformer import (
2+
LibcstResultTransformer,
3+
LibcstTransformerPipeline,
4+
)
5+
from codemodder.codemods.semgrep import SemgrepRuleDetector
6+
from core_codemods.api import Metadata, Reference, ReviewGuidance
7+
from core_codemods.api.core_codemod import CoreCodemod
28
from core_codemods.secure_cookie_mixin import SecureCookieMixin
39

410

5-
class SecureFlaskCookie(SimpleCodemod, SecureCookieMixin):
6-
metadata = Metadata(
11+
class SecureCookieTransformer(LibcstResultTransformer, SecureCookieMixin):
12+
change_description = "Flask response `set_cookie` call should be called with `secure=True`, `httponly=True`, and `samesite='Lax'`."
13+
14+
def on_result_found(self, original_node, updated_node):
15+
new_args = self.replace_args(
16+
original_node, self._choose_new_args(original_node)
17+
)
18+
return self.update_arg_target(updated_node, new_args)
19+
20+
21+
SecureFlaskCookie = CoreCodemod(
22+
metadata=Metadata(
723
name="secure-flask-cookie",
824
summary="Use Safe Parameters in `flask` Response `set_cookie` Call",
925
review_guidance=ReviewGuidance.MERGE_AFTER_CURSORY_REVIEW,
@@ -14,11 +30,14 @@ class SecureFlaskCookie(SimpleCodemod, SecureCookieMixin):
1430
Reference(
1531
url="https://owasp.org/www-community/controls/SecureCookieAttribute"
1632
),
33+
Reference(url="https://cwe.mitre.org/data/definitions/1004"),
34+
Reference(url="https://cwe.mitre.org/data/definitions/311"),
35+
Reference(url="https://cwe.mitre.org/data/definitions/315"),
1736
Reference(url="https://cwe.mitre.org/data/definitions/614"),
1837
],
19-
)
20-
change_description = "Flask response `set_cookie` call should be called with `secure=True`, `httponly=True`, and `samesite='Lax'`."
21-
detector_pattern = """
38+
),
39+
detector=SemgrepRuleDetector(
40+
"""
2241
rules:
2342
- id: secure-flask-cookie
2443
mode: taint
@@ -39,10 +58,7 @@ class SecureFlaskCookie(SimpleCodemod, SecureCookieMixin):
3958
- pattern: $SINK.set_cookie(...)
4059
- pattern-not: $SINK.set_cookie(..., secure=True, ..., httponly=True, ..., samesite="Lax", ...)
4160
- pattern-not: $SINK.set_cookie(..., secure=True, ..., httponly=True, ..., samesite="Strict", ...)
42-
"""
43-
44-
def on_result_found(self, original_node, updated_node):
45-
new_args = self.replace_args(
46-
original_node, self._choose_new_args(original_node)
47-
)
48-
return self.update_arg_target(updated_node, new_args)
61+
"""
62+
),
63+
transformer=LibcstTransformerPipeline(SecureCookieTransformer),
64+
)

src/core_codemods/sonar/api.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,39 +15,53 @@ def origin(self):
1515
return "sonar"
1616

1717
@classmethod
18-
def from_core_codemod(
18+
def from_core_codemod_with_multiple_rules(
1919
cls,
2020
name: str,
2121
other: CoreCodemod,
22-
rule_id: str,
23-
rule_name: str,
22+
rules: list[ToolRule],
2423
transformer: BaseTransformerPipeline | None = None,
2524
):
26-
rule_url = sonar_url_from_id(rule_id)
2725
return SonarCodemod(
2826
metadata=Metadata(
2927
name=name,
3028
summary=other.summary,
3129
review_guidance=other._metadata.review_guidance,
3230
references=(
33-
other.references + [Reference(url=rule_url, description=rule_name)]
31+
other.references
32+
+ [Reference(url=tr.url or "", description=tr.name) for tr in rules]
3433
),
3534
description=other.description,
3635
tool=ToolMetadata(
3736
name="Sonar",
38-
rules=[
39-
ToolRule(
40-
id=rule_id,
41-
name=rule_name,
42-
url=rule_url,
43-
)
44-
],
37+
rules=rules,
4538
),
4639
),
4740
transformer=transformer if transformer else other.transformer,
4841
detector=SonarDetector(),
4942
default_extensions=other.default_extensions,
50-
requested_rules=[rule_id],
43+
requested_rules=[tr.id for tr in rules],
44+
)
45+
46+
@classmethod
47+
def from_core_codemod(
48+
cls,
49+
name: str,
50+
other: CoreCodemod,
51+
rule_id: str,
52+
rule_name: str,
53+
transformer: BaseTransformerPipeline | None = None,
54+
):
55+
rule_url = sonar_url_from_id(rule_id)
56+
rules = [
57+
ToolRule(
58+
id=rule_id,
59+
name=rule_name,
60+
url=rule_url,
61+
),
62+
]
63+
return cls.from_core_codemod_with_multiple_rules(
64+
name, other, rules, transformer
5165
)
5266

5367

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from codemodder.codemods.base_codemod import ToolRule
2+
from codemodder.codemods.libcst_transformer import (
3+
LibcstResultTransformer,
4+
LibcstTransformerPipeline,
5+
)
6+
from core_codemods.secure_cookie_mixin import SecureCookieMixin
7+
from core_codemods.secure_flask_cookie import SecureFlaskCookie
8+
from core_codemods.sonar.api import SonarCodemod
9+
10+
rules = [
11+
ToolRule(
12+
id="python:S3330",
13+
name='Creating cookies without the "HttpOnly" flag is security-sensitive',
14+
url="https://rules.sonarsource.com/python/RSPEC-3330/",
15+
),
16+
ToolRule(
17+
id="python:S2092",
18+
name='Creating cookies without the "secure" flag is security-sensitive',
19+
url="https://rules.sonarsource.com/python/RSPEC-2092/",
20+
),
21+
]
22+
23+
24+
class SonarSecureCookieTransformer(LibcstResultTransformer, SecureCookieMixin):
25+
change_description = "Flask response `set_cookie` call should be called with `secure=True`, `httponly=True`, and `samesite='Lax'`."
26+
27+
def leave_Call(self, original_node, updated_node):
28+
if self.node_is_selected(original_node.func):
29+
self.report_change(original_node)
30+
new_args = self.replace_args(
31+
original_node, self._choose_new_args(original_node)
32+
)
33+
return self.update_arg_target(updated_node, new_args)
34+
return updated_node
35+
36+
37+
SonarSecureCookie = SonarCodemod.from_core_codemod_with_multiple_rules(
38+
name="secure-cookie",
39+
other=SecureFlaskCookie,
40+
rules=rules,
41+
transformer=LibcstTransformerPipeline(SonarSecureCookieTransformer),
42+
)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import json
2+
3+
from codemodder.codemods.test import BaseSASTCodemodTest
4+
from core_codemods.sonar.sonar_secure_cookie import SonarSecureCookie
5+
6+
7+
class TestSonarSecureCookie(BaseSASTCodemodTest):
8+
codemod = SonarSecureCookie
9+
tool = "sonar"
10+
11+
def test_name(self):
12+
assert self.codemod.name == "secure-cookie"
13+
14+
def test_simple(self, tmpdir):
15+
input_code = """
16+
import flask
17+
18+
response = flask.make_response()
19+
var = "hello"
20+
response.set_cookie("name", "value")
21+
22+
response2 = flask.Response()
23+
var = "hello"
24+
response2.set_cookie("name", "value")
25+
"""
26+
expected = """
27+
import flask
28+
29+
response = flask.make_response()
30+
var = "hello"
31+
response.set_cookie("name", "value", secure=True, httponly=True, samesite='Lax')
32+
33+
response2 = flask.Response()
34+
var = "hello"
35+
response2.set_cookie("name", "value", secure=True, httponly=True, samesite='Lax')
36+
"""
37+
issues = {
38+
"hotspots": [
39+
{
40+
"component": "code.py",
41+
"status": "TO_REVIEW",
42+
"textRange": {
43+
"startLine": 6,
44+
"endLine": 6,
45+
"startOffset": 0,
46+
"endOffset": 19,
47+
},
48+
"ruleKey": "python:S2092",
49+
},
50+
{
51+
"component": "code.py",
52+
"status": "TO_REVIEW",
53+
"textRange": {
54+
"startLine": 10,
55+
"endLine": 10,
56+
"startOffset": 0,
57+
"endOffset": 20,
58+
},
59+
"ruleKey": "python:S2092",
60+
},
61+
{
62+
"component": "code.py",
63+
"status": "TO_REVIEW",
64+
"textRange": {
65+
"startLine": 6,
66+
"endLine": 6,
67+
"startOffset": 0,
68+
"endOffset": 19,
69+
},
70+
"ruleKey": "python:S3330",
71+
},
72+
{
73+
"component": "code.py",
74+
"status": "TO_REVIEW",
75+
"textRange": {
76+
"startLine": 10,
77+
"endLine": 10,
78+
"startOffset": 0,
79+
"endOffset": 20,
80+
},
81+
"ruleKey": "python:S3330",
82+
},
83+
],
84+
}
85+
self.run_and_assert(
86+
tmpdir, input_code, expected, results=json.dumps(issues), num_changes=2
87+
)

tests/samples/secure_cookie.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from flask import Flask, session, make_response
2+
3+
app = Flask(__name__)
4+
5+
@app.route('/')
6+
def index():
7+
resp = make_response('Custom Cookie Set')
8+
resp.set_cookie('custom_cookie', 'value')
9+
return resp

0 commit comments

Comments
 (0)