diff --git a/integration_tests/test_django_json_response_type.py b/integration_tests/test_django_json_response_type.py index 59f751d7..11272878 100644 --- a/integration_tests/test_django_json_response_type.py +++ b/integration_tests/test_django_json_response_type.py @@ -1,4 +1,7 @@ -from core_codemods.django_json_response_type import DjangoJsonResponseType +from core_codemods.django_json_response_type import ( + DjangoJsonResponseType, + DjangoJsonResponseTypeTransformer, +) from integration_tests.base_test import ( BaseIntegrationTest, original_and_expected_from_code_path, @@ -32,5 +35,5 @@ class TestDjangoJsonResponseType(BaseIntegrationTest): # fmt: on expected_line_change = "6" - change_description = DjangoJsonResponseType.change_description + change_description = DjangoJsonResponseTypeTransformer.change_description num_changed_files = 1 diff --git a/integration_tests/test_sonar_django_json_response_type.py b/integration_tests/test_sonar_django_json_response_type.py new file mode 100644 index 00000000..d7b681a3 --- /dev/null +++ b/integration_tests/test_sonar_django_json_response_type.py @@ -0,0 +1,40 @@ +from core_codemods.django_json_response_type import DjangoJsonResponseTypeTransformer +from core_codemods.sonar.sonar_django_json_response_type import ( + SonarDjangoJsonResponseType, +) +from integration_tests.base_test import ( + BaseIntegrationTest, + original_and_expected_from_code_path, +) + + +class TestSonarDjangoJsonResponseType(BaseIntegrationTest): + codemod = SonarDjangoJsonResponseType + code_path = "tests/samples/django_json_response_type.py" + original_code, expected_new_code = original_and_expected_from_code_path( + code_path, + [ + ( + 5, + """ return HttpResponse(json_response, content_type="application/json")\n""", + ), + ], + ) + sonar_issues_json = "tests/samples/sonar_issues.json" + + # fmt: off + expected_diff =( + """--- \n""" + """+++ \n""" + """@@ -3,4 +3,4 @@\n""" + """ \n""" + """ def foo(request):\n""" + """ json_response = json.dumps({ "user_input": request.GET.get("input") })\n""" + """- return HttpResponse(json_response)\n""" + """+ return HttpResponse(json_response, content_type="application/json")\n""" + ) + # fmt: on + + expected_line_change = "6" + change_description = DjangoJsonResponseTypeTransformer.change_description + num_changed_files = 1 diff --git a/src/codemodder/scripts/generate_docs.py b/src/codemodder/scripts/generate_docs.py index 59ec45a7..70249544 100644 --- a/src/codemodder/scripts/generate_docs.py +++ b/src/codemodder/scripts/generate_docs.py @@ -264,6 +264,13 @@ class DocMetadata: guidance_explained=CORE_METADATA["flask-json-response-type"].guidance_explained, need_sarif="Yes (Sonar)", ), + "django-json-response-type-S5131": DocMetadata( + importance=CORE_METADATA["django-json-response-type"].importance, + guidance_explained=CORE_METADATA[ + "django-json-response-type" + ].guidance_explained, + need_sarif="Yes (Sonar)", + ), } diff --git a/src/core_codemods/__init__.py b/src/core_codemods/__init__.py index 548f25be..614501f6 100644 --- a/src/core_codemods/__init__.py +++ b/src/core_codemods/__init__.py @@ -59,6 +59,7 @@ SonarRemoveAssertionInPytestRaises, ) from .sonar.sonar_flask_json_response_type import SonarFlaskJsonResponseType +from .sonar.sonar_django_json_response_type import SonarDjangoJsonResponseType registry = CodemodCollection( origin="pixee", @@ -129,5 +130,6 @@ SonarFixAssertTuple, SonarRemoveAssertionInPytestRaises, SonarFlaskJsonResponseType, + SonarDjangoJsonResponseType, ], ) diff --git a/src/core_codemods/django_json_response_type.py b/src/core_codemods/django_json_response_type.py index a3337ed0..4693de6b 100644 --- a/src/core_codemods/django_json_response_type.py +++ b/src/core_codemods/django_json_response_type.py @@ -1,26 +1,18 @@ import libcst as cst +from codemodder.codemods.libcst_transformer import ( + LibcstResultTransformer, + LibcstTransformerPipeline, +) +from codemodder.codemods.semgrep import SemgrepRuleDetector from core_codemods.api import ( Metadata, Reference, ReviewGuidance, - SimpleCodemod, ) +from core_codemods.api.core_codemod import CoreCodemod -class DjangoJsonResponseType(SimpleCodemod): - metadata = Metadata( - name="django-json-response-type", - summary="Set content type to `application/json` for `django.http.HttpResponse` with JSON data", - review_guidance=ReviewGuidance.MERGE_WITHOUT_REVIEW, - references=[ - Reference( - url="https://docs.djangoproject.com/en/4.0/ref/request-response/#django.http.HttpResponse.__init__" - ), - Reference( - url="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#output-encoding-for-javascript-contexts" - ), - ], - ) +class DjangoJsonResponseTypeTransformer(LibcstResultTransformer): change_description = "Sets `content_type` to `application/json`." detector_pattern = """ rules: @@ -49,3 +41,34 @@ def on_result_found(self, _, updated_node): ), ], ) + + +semgrep_rule = """ + rules: + - id: django-json-response-type + mode: taint + pattern-sources: + - pattern: json.dumps(...) + pattern-sinks: + - patterns: + - pattern: django.http.HttpResponse(...) + - pattern-not: django.http.HttpResponse(...,content_type=...,...) + """ + +DjangoJsonResponseType = CoreCodemod( + metadata=Metadata( + name="django-json-response-type", + summary="Set content type to `application/json` for `django.http.HttpResponse` with JSON data", + review_guidance=ReviewGuidance.MERGE_WITHOUT_REVIEW, + references=[ + Reference( + url="https://docs.djangoproject.com/en/4.0/ref/request-response/#django.http.HttpResponse.__init__" + ), + Reference( + url="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#output-encoding-for-javascript-contexts" + ), + ], + ), + transformer=LibcstTransformerPipeline(DjangoJsonResponseTypeTransformer), + detector=SemgrepRuleDetector(rule=semgrep_rule), +) diff --git a/src/core_codemods/flask_json_response_type.py b/src/core_codemods/flask_json_response_type.py index 68c20bf0..1ee7bacc 100644 --- a/src/core_codemods/flask_json_response_type.py +++ b/src/core_codemods/flask_json_response_type.py @@ -44,7 +44,7 @@ class FlaskJsonResponseTypeVisitor( content_type_key = "Content-Type" json_content_type = "application/json" - def __init__( + def __init__( # pylint: disable=super-init-not-called self, context: CodemodContext, file_context: FileContext, diff --git a/src/core_codemods/sonar/sonar_django_json_response_type.py b/src/core_codemods/sonar/sonar_django_json_response_type.py new file mode 100644 index 00000000..6ad05f83 --- /dev/null +++ b/src/core_codemods/sonar/sonar_django_json_response_type.py @@ -0,0 +1,12 @@ +from codemodder.codemods.base_codemod import Reference +from codemodder.codemods.sonar import SonarCodemod +from core_codemods.django_json_response_type import DjangoJsonResponseType + +SonarDjangoJsonResponseType = SonarCodemod.from_core_codemod( + name="django-json-response-type-S5131", + other=DjangoJsonResponseType, + rules=["pythonsecurity:S5131"], + new_references=[ + Reference(url="https://rules.sonarsource.com/python/type/Bug/RSPEC-5131/"), + ], +) diff --git a/tests/codemods/test_sonar_django_json_response_type.py b/tests/codemods/test_sonar_django_json_response_type.py new file mode 100644 index 00000000..9d5f1581 --- /dev/null +++ b/tests/codemods/test_sonar_django_json_response_type.py @@ -0,0 +1,46 @@ +import json +from core_codemods.sonar.sonar_django_json_response_type import ( + SonarDjangoJsonResponseType, +) +from tests.codemods.base_codemod_test import BaseSASTCodemodTest + + +class TestDjangoJsonResponseType(BaseSASTCodemodTest): + codemod = SonarDjangoJsonResponseType + tool = "sonar" + + def test_name(self): + assert self.codemod.name == "django-json-response-type-S5131" + + def test_simple(self, tmpdir): + input_code = """\ + from django.http import HttpResponse + import json + + def foo(request): + json_response = json.dumps({ "user_input": request.GET.get("input") }) + return HttpResponse(json_response) + """ + expected = """\ + from django.http import HttpResponse + import json + + def foo(request): + json_response = json.dumps({ "user_input": request.GET.get("input") }) + return HttpResponse(json_response, content_type="application/json") + """ + issues = { + "issues": [ + { + "rule": "pythonsecurity:S5131", + "component": f"{tmpdir / 'code.py'}", + "textRange": { + "startLine": 6, + "endLine": 6, + "startOffset": 12, + "endOffset": 39, + }, + } + ] + } + self.run_and_assert(tmpdir, input_code, expected, results=json.dumps(issues))