Skip to content

Commit badb2da

Browse files
authored
jwt.decode sonar codemod (#326)
* split jwt decode codemod into transformer * add jwt sonar codemod * refactor to result check * document code path * fix integration test * add integration tests * update based on review
1 parent 72a2b2a commit badb2da

10 files changed

+212
-40
lines changed

integration_tests/test_jwt_decode_verify.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from core_codemods.jwt_decode_verify import JwtDecodeVerify
1+
from core_codemods.jwt_decode_verify import JwtDecodeVerify, JwtDecodeVerifyTransformer
22
from codemodder.codemods.test import (
33
BaseIntegrationTest,
44
original_and_expected_from_code_path,
@@ -24,4 +24,4 @@ class TestJwtDecodeVerify(BaseIntegrationTest):
2424
expected_diff = '--- \n+++ \n@@ -8,7 +8,7 @@\n \n encoded_jwt = jwt.encode(payload, SECRET_KEY, algorithm="HS256")\n \n-decoded_payload = jwt.decode(encoded_jwt, SECRET_KEY, algorithms=["HS256"], verify=False)\n-decoded_payload = jwt.decode(encoded_jwt, SECRET_KEY, algorithms=["HS256"], options={"verify_signature": False})\n+decoded_payload = jwt.decode(encoded_jwt, SECRET_KEY, algorithms=["HS256"], verify=True)\n+decoded_payload = jwt.decode(encoded_jwt, SECRET_KEY, algorithms=["HS256"], options={"verify_signature": True})\n \n var = "something"\n'
2525
expected_line_change = "11"
2626
num_changes = 2
27-
change_description = JwtDecodeVerify.change_description
27+
change_description = JwtDecodeVerifyTransformer.change_description

integration_tests/test_sonar_exception_without_raise.py

+3-5
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
from core_codemods.exception_without_raise import (
2-
ExceptionWithoutRaise,
3-
ExceptionWithoutRaiseTransformer,
4-
)
1+
from core_codemods.exception_without_raise import ExceptionWithoutRaiseTransformer
2+
from core_codemods.sonar.sonar_exception_without_raise import SonarExceptionWithoutRaise
53
from codemodder.codemods.test import (
64
BaseIntegrationTest,
75
original_and_expected_from_code_path,
86
)
97

108

119
class TestSonarExceptionWithoutRaise(BaseIntegrationTest):
12-
codemod = ExceptionWithoutRaise
10+
codemod = SonarExceptionWithoutRaise
1311
code_path = "tests/samples/exception_without_raise.py"
1412
original_code, expected_new_code = original_and_expected_from_code_path(
1513
code_path,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from core_codemods.sonar.sonar_jwt_decode_verify import (
2+
SonarJwtDecodeVerify,
3+
JwtDecodeVerifySonarTransformer,
4+
)
5+
from codemodder.codemods.test import (
6+
BaseIntegrationTest,
7+
original_and_expected_from_code_path,
8+
)
9+
10+
11+
class TestJwtDecodeVerify(BaseIntegrationTest):
12+
codemod = SonarJwtDecodeVerify
13+
code_path = "tests/samples/jwt_decode_verify.py"
14+
original_code, expected_new_code = original_and_expected_from_code_path(
15+
code_path,
16+
[
17+
(
18+
10,
19+
"""decoded_payload = jwt.decode(encoded_jwt, SECRET_KEY, algorithms=["HS256"], verify=True)\n""",
20+
),
21+
(
22+
11,
23+
"""decoded_payload = jwt.decode(encoded_jwt, SECRET_KEY, algorithms=["HS256"], options={"verify_signature": True})\n""",
24+
),
25+
],
26+
)
27+
sonar_issues_json = "tests/samples/sonar_issues.json"
28+
29+
expected_diff = '--- \n+++ \n@@ -8,7 +8,7 @@\n \n encoded_jwt = jwt.encode(payload, SECRET_KEY, algorithm="HS256")\n \n-decoded_payload = jwt.decode(encoded_jwt, SECRET_KEY, algorithms=["HS256"], verify=False)\n-decoded_payload = jwt.decode(encoded_jwt, SECRET_KEY, algorithms=["HS256"], options={"verify_signature": False})\n+decoded_payload = jwt.decode(encoded_jwt, SECRET_KEY, algorithms=["HS256"], verify=True)\n+decoded_payload = jwt.decode(encoded_jwt, SECRET_KEY, algorithms=["HS256"], options={"verify_signature": True})\n \n var = "something"\n'
30+
expected_line_change = "11"
31+
num_changes = 2
32+
change_description = JwtDecodeVerifySonarTransformer.change_description

src/codemodder/codemods/base_visitor.py

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ def __init__(
2323
def filter_by_result(self, node):
2424
pos_to_match = self.node_position(node)
2525
if self.results is None:
26+
# Returning True here means codemods without detectors (and results)
27+
# will still run their transformations.
2628
return True
2729
return any(result.match_location(pos_to_match, node) for result in self.results)
2830

src/codemodder/result.py

+13-2
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,30 @@ class Result(ABCDataclass):
3434
def match_location(self, pos: CodeRange, node: cst.CSTNode) -> bool:
3535
del node
3636
return any(
37-
pos.start.line == location.start.line
37+
same_line(pos, location)
3838
and (
3939
pos.start.column
4040
in ((start_column := location.start.column) - 1, start_column)
4141
)
42-
and pos.end.line == location.end.line
4342
and (
4443
pos.end.column in ((end_column := location.end.column) - 1, end_column)
4544
)
4645
for location in self.locations
4746
)
4847

4948

49+
def same_line(pos: CodeRange, location: Location) -> bool:
50+
return pos.start.line == location.start.line and pos.end.line == location.end.line
51+
52+
53+
def fuzzy_column_match(pos: CodeRange, location: Location) -> bool:
54+
"""Checks that a result location is within the range of node's `pos` position"""
55+
return (
56+
pos.start.column <= location.start.column <= pos.end.column + 1
57+
and pos.start.column <= location.end.column <= pos.end.column + 1
58+
)
59+
60+
5061
class ResultSet(dict[str, dict[Path, list[Result]]]):
5162
def add_result(self, result: Result):
5263
for loc in result.locations:

src/codemodder/scripts/generate_docs.py

+5
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,11 @@ class DocMetadata:
287287
].guidance_explained,
288288
need_sarif="Yes (Sonar)",
289289
),
290+
"jwt-decode-verify-S5659": DocMetadata(
291+
importance=CORE_METADATA["jwt-decode-verify"].importance,
292+
guidance_explained=CORE_METADATA["jwt-decode-verify"].guidance_explained,
293+
need_sarif="Yes (Sonar)",
294+
),
290295
}
291296

292297

src/core_codemods/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
from .str_concat_in_seq_literal import StrConcatInSeqLiteral
6464
from .fix_async_task_instantiation import FixAsyncTaskInstantiation
6565
from .django_model_without_dunder_str import DjangoModelWithoutDunderStr
66+
from .sonar.sonar_jwt_decode_verify import SonarJwtDecodeVerify
6667

6768
registry = CodemodCollection(
6869
origin="pixee",
@@ -134,5 +135,6 @@
134135
SonarRemoveAssertionInPytestRaises,
135136
SonarFlaskJsonResponseType,
136137
SonarDjangoJsonResponseType,
138+
SonarJwtDecodeVerify,
137139
],
138140
)

src/core_codemods/jwt_decode_verify.py

+43-31
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,17 @@
55
Metadata,
66
Reference,
77
ReviewGuidance,
8-
SimpleCodemod,
98
)
9+
from core_codemods.api.core_codemod import CoreCodemod
10+
from codemodder.codemods.libcst_transformer import (
11+
LibcstTransformerPipeline,
12+
LibcstResultTransformer,
13+
)
14+
from codemodder.codemods.semgrep import SemgrepRuleDetector
1015

1116

12-
class JwtDecodeVerify(SimpleCodemod):
13-
metadata = Metadata(
14-
name="jwt-decode-verify",
15-
summary="Verify JWT Decode",
16-
review_guidance=ReviewGuidance.MERGE_WITHOUT_REVIEW,
17-
references=[
18-
Reference(url="https://pyjwt.readthedocs.io/en/stable/api.html"),
19-
Reference(
20-
url="https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/06-Session_Management_Testing/10-Testing_JSON_Web_Tokens"
21-
),
22-
],
23-
)
17+
class JwtDecodeVerifyTransformer(LibcstResultTransformer):
2418
change_description = "Enable all verifications in `jwt.decode` call."
25-
detector_pattern = r"""
26-
rules:
27-
- pattern-either:
28-
- patterns:
29-
- pattern: jwt.decode(..., verify=False, ...)
30-
- pattern-inside: |
31-
import jwt
32-
...
33-
- patterns:
34-
- pattern: |
35-
jwt.decode(..., options={..., "$KEY": False, ...}, ...)
36-
- metavariable-regex:
37-
metavariable: $KEY
38-
regex: verify_
39-
- pattern-inside: |
40-
import jwt
41-
...
42-
"""
4319

4420
def _replace_opts_dict(self, opts_dict):
4521
new_dict_elements = []
@@ -100,3 +76,39 @@ def is_verify_keyword(element: cst.DictElement) -> bool:
10076
matchers.matches(element.key, matchers.SimpleString())
10177
and "verify" in element.key.value
10278
)
79+
80+
81+
JwtDecodeVerify = CoreCodemod(
82+
metadata=Metadata(
83+
name="jwt-decode-verify",
84+
summary="Verify JWT Decode",
85+
review_guidance=ReviewGuidance.MERGE_WITHOUT_REVIEW,
86+
references=[
87+
Reference(url="https://pyjwt.readthedocs.io/en/stable/api.html"),
88+
Reference(
89+
url="https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/06-Session_Management_Testing/10-Testing_JSON_Web_Tokens"
90+
),
91+
],
92+
),
93+
transformer=LibcstTransformerPipeline(JwtDecodeVerifyTransformer),
94+
detector=SemgrepRuleDetector(
95+
r"""
96+
rules:
97+
- pattern-either:
98+
- patterns:
99+
- pattern: jwt.decode(..., verify=False, ...)
100+
- pattern-inside: |
101+
import jwt
102+
...
103+
- patterns:
104+
- pattern: |
105+
jwt.decode(..., options={..., "$KEY": False, ...}, ...)
106+
- metavariable-regex:
107+
metavariable: $KEY
108+
regex: verify_
109+
- pattern-inside: |
110+
import jwt
111+
...
112+
"""
113+
),
114+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import libcst as cst
2+
from codemodder.codemods.base_codemod import Reference
3+
from codemodder.result import same_line, fuzzy_column_match
4+
from codemodder.codemods.sonar import SonarCodemod
5+
from codemodder.codemods.libcst_transformer import (
6+
LibcstTransformerPipeline,
7+
)
8+
from core_codemods.jwt_decode_verify import JwtDecodeVerify, JwtDecodeVerifyTransformer
9+
10+
11+
class JwtDecodeVerifySonarTransformer(JwtDecodeVerifyTransformer):
12+
def filter_by_result(self, node) -> bool:
13+
"""
14+
Special case result-matching for this rule because the sonar
15+
results returned have a start/end column for the verify keyword
16+
within the `decode` call, not for the entire call like semgrep returns.
17+
"""
18+
match node:
19+
case cst.Call():
20+
pos_to_match = self.node_position(node)
21+
return any(
22+
self.match_location(pos_to_match, result)
23+
for result in self.results or []
24+
)
25+
return False
26+
27+
def match_location(self, pos, result):
28+
return any(
29+
same_line(pos, location) and fuzzy_column_match(pos, location)
30+
for location in result.locations
31+
)
32+
33+
34+
SonarJwtDecodeVerify = SonarCodemod.from_core_codemod(
35+
name="jwt-decode-verify-S5659",
36+
other=JwtDecodeVerify,
37+
rules=["python:S5659"],
38+
new_references=[
39+
Reference(url="https://rules.sonarsource.com/python/RSPEC-5659/"),
40+
],
41+
transformer=LibcstTransformerPipeline(JwtDecodeVerifySonarTransformer),
42+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import json
2+
from core_codemods.sonar.sonar_jwt_decode_verify import SonarJwtDecodeVerify
3+
from codemodder.codemods.test import BaseSASTCodemodTest
4+
5+
6+
class TestSonarJwtDecodeVerify(BaseSASTCodemodTest):
7+
codemod = SonarJwtDecodeVerify
8+
tool = "sonar"
9+
10+
def test_name(self):
11+
assert self.codemod.name == "jwt-decode-verify-S5659"
12+
13+
def test_simple(self, tmpdir):
14+
input_code = """
15+
import jwt
16+
17+
SECRET_KEY = "mysecretkey"
18+
payload = {
19+
"user_id": 123,
20+
"username": "john",
21+
}
22+
23+
encoded_jwt = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
24+
decoded_payload = jwt.decode(encoded_jwt, SECRET_KEY, algorithms=["HS256"], verify=False)
25+
decoded_payload = jwt.decode(encoded_jwt, SECRET_KEY, algorithms=["HS256"], options={"verify_signature": False})
26+
"""
27+
expected = """
28+
import jwt
29+
30+
SECRET_KEY = "mysecretkey"
31+
payload = {
32+
"user_id": 123,
33+
"username": "john",
34+
}
35+
36+
encoded_jwt = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
37+
decoded_payload = jwt.decode(encoded_jwt, SECRET_KEY, algorithms=["HS256"], verify=True)
38+
decoded_payload = jwt.decode(encoded_jwt, SECRET_KEY, algorithms=["HS256"], options={"verify_signature": True})
39+
"""
40+
issues = {
41+
"issues": [
42+
{
43+
"rule": "python:S5659",
44+
"status": "OPEN",
45+
"component": f"{tmpdir / 'code.py'}",
46+
"textRange": {
47+
"startLine": 11,
48+
"endLine": 11,
49+
"startOffset": 76,
50+
"endOffset": 88,
51+
},
52+
},
53+
{
54+
"rule": "python:S5659",
55+
"status": "OPEN",
56+
"component": f"{tmpdir / 'code.py'}",
57+
"textRange": {
58+
"startLine": 12,
59+
"endLine": 12,
60+
"startOffset": 84,
61+
"endOffset": 111,
62+
},
63+
},
64+
]
65+
}
66+
self.run_and_assert(
67+
tmpdir, input_code, expected, results=json.dumps(issues), num_changes=2
68+
)

0 commit comments

Comments
 (0)