diff --git a/rules/python/django/cookie_missing_http_only.yml b/rules/python/django/cookie_missing_http_only.yml index 4d709310..0cb49317 100644 --- a/rules/python/django/cookie_missing_http_only.yml +++ b/rules/python/django/cookie_missing_http_only.yml @@ -37,14 +37,16 @@ auxiliary: values: [render] - id: python_django_cookie_missing_http_only_set_cookie_http_only patterns: - - pattern: $<_>($<...>httponly=$$<...>) + # ok if it is not False + - pattern: $<_>($<...>httponly=$$<...>) filters: - - variable: "TRUE" - detection: python_django_cookie_missing_http_only_true - scope: cursor - - id: python_django_cookie_missing_http_only_true + - not: + variable: "FALSE" + detection: python_django_cookie_missing_http_only_false + scope: cursor + - id: python_django_cookie_missing_http_only_false patterns: - - "True" + - "False" languages: - python severity: medium diff --git a/rules/python/django/cookie_missing_secure.yml b/rules/python/django/cookie_missing_secure.yml index 2c397934..1528d535 100644 --- a/rules/python/django/cookie_missing_secure.yml +++ b/rules/python/django/cookie_missing_secure.yml @@ -37,14 +37,16 @@ auxiliary: values: [render] - id: python_django_cookie_missing_secure_set_cookie_secure patterns: - - pattern: $<_>($<...>secure=$) + # ok if it is not False + - pattern: $<_>($<...>secure=$$<...>) filters: - - variable: "TRUE" - detection: python_django_cookie_missing_secure_true - scope: cursor - - id: python_django_cookie_missing_secure_true + - not: + variable: "FALSE" + detection: python_django_cookie_missing_secure_false + scope: cursor + - id: python_django_cookie_missing_secure_false patterns: - - "True" + - "False" languages: - python severity: medium diff --git a/rules/python/django/html_magic_method.yml b/rules/python/django/html_magic_method.yml new file mode 100644 index 00000000..470be401 --- /dev/null +++ b/rules/python/django/html_magic_method.yml @@ -0,0 +1,26 @@ +patterns: + - | + def __html__($<...>): +languages: + - python +severity: high +metadata: + description: Usage of __html__ magic method + remediation_message: | + ## Description + + The Django template engine considers values returned by the `__html__` method as "safe" for rendering and therefore no HTML escaping is applied (escaping special characters like ampersands or quotes). Using this method exposes your application to Cross-Site Scripting (XSS) vulnerabilities. + + ## Remediations + + - **Do not** use the `__html__` magic method + - **Do** use `format_html` instead to build up HTML fragments. This is more appropriate because it applies escaping to its arguments by default. + ```python + from django.utils.html import format_html + + format_html("{} {} {}", mark_safe(some_html), some text) + ``` + cwe_id: + - 79 + id: python_django_html_magic_method + documentation_url: https://docs.bearer.com/reference/rules/python_django_html_magic_method diff --git a/rules/python/django/insecure_allow_origin.yml b/rules/python/django/insecure_allow_origin.yml index 2be0d322..299877a7 100644 --- a/rules/python/django/insecure_allow_origin.yml +++ b/rules/python/django/insecure_allow_origin.yml @@ -14,7 +14,7 @@ patterns: detection: python_shared_common_user_input scope: result - pattern: | - $($<...> headers={$<...>$: $<...>$$<...>} $<...>) + $($<...>headers=$$<...>) filters: - variable: RESPONSE detection: python_shared_lang_import2 @@ -25,12 +25,23 @@ patterns: - variable: MODULE2 values: [http] - variable: NAME - values: [HttpResponse] - - variable: ALLOW_ORIGIN - string_regex: (?i)\Aaccess-control-allow-origin\z - - variable: USER_INPUT - detection: python_shared_common_user_input - scope: result + values: + - HttpResponse + - JsonResponse + - variable: HEADERS_HASH + detection: python_django_insecure_allow_origin_headers_hash + scope: cursor +auxiliary: + - id: python_django_insecure_allow_origin_headers_hash + patterns: + - pattern: | + {$<...>$: $$<...>} + filters: + - variable: ALLOW_ORIGIN + string_regex: (?i)\Aaccess-control-allow-origin\z + - variable: USER_INPUT + detection: python_shared_common_user_input + scope: result languages: - python severity: medium diff --git a/rules/python/django/mark_safe.yml b/rules/python/django/mark_safe.yml new file mode 100644 index 00000000..0ea2b77f --- /dev/null +++ b/rules/python/django/mark_safe.yml @@ -0,0 +1,58 @@ +imports: + - python_shared_lang_import3 +sanitizer: python_django_mark_safe_sanitizer +patterns: + - pattern: $($<...>) + filters: + - variable: MARK_SAFE + detection: python_shared_lang_import3 + scope: cursor + filters: + - variable: MODULE1 + values: [django] + - variable: MODULE2 + values: [utils] + - variable: MODULE3 + values: [safestring] + - variable: NAME + values: [mark_safe] +auxiliary: + - id: python_django_mark_safe_sanitizer + patterns: + - pattern: $($<...>) + filters: + - variable: FORMAT_HTML + detection: python_shared_lang_import3 + scope: cursor + filters: + - variable: MODULE1 + values: [django] + - variable: MODULE2 + values: [utils] + - variable: MODULE3 + values: [html] + - variable: NAME + values: [format_html] +languages: + - python +severity: high +metadata: + description: Usage of mark_safe + remediation_message: | + ## Description + + The Django utils method `mark_safe` is used to mark a string as "safe" for output as HTML, but it does not escape special characters like ampersands or quotes, and therefore could expose your application to XSS attacks if an external string is passed to it. + + ## Remediations + + - **Do not** use `mark_safe` wherever possible + - **Do** use `format_html` instead to build up HTML fragments. This is more appropriate because it applies escaping to its arguments by default. + ```python + from django.utils.html import format_html + + format_html("{} {} {}", mark_safe(some_html), some text) + ``` + cwe_id: + - 79 + id: python_django_mark_safe + documentation_url: https://docs.bearer.com/reference/rules/python_django_mark_safe diff --git a/rules/python/django/permissive_allow_origin.yml b/rules/python/django/permissive_allow_origin.yml index c7f5b9bb..6d728ba5 100644 --- a/rules/python/django/permissive_allow_origin.yml +++ b/rules/python/django/permissive_allow_origin.yml @@ -12,7 +12,7 @@ patterns: - variable: VALUE string_regex: \A\*\z - pattern: | - $($<...> headers={$<...>$: $<...>$$<...>} $<...>) + $($<...>headers=$$<...>) filters: - variable: RESPONSE detection: python_shared_lang_import2 @@ -23,11 +23,22 @@ patterns: - variable: MODULE2 values: [http] - variable: NAME - values: [HttpResponse] - - variable: ALLOW_ORIGIN - string_regex: (?i)\Aaccess-control-allow-origin\z - - variable: VALUE - string_regex: \A\*\z + values: + - HttpResponse + - JsonResponse + - variable: HEADERS_HASH + detection: python_django_permissive_allow_origin_headers_hash + scope: cursor +auxiliary: + - id: python_django_permissive_allow_origin_headers_hash + patterns: + - pattern: | + {$<...>$: $$<...>} + filters: + - variable: ALLOW_ORIGIN + string_regex: (?i)\Aaccess-control-allow-origin\z + - variable: VALUE + string_regex: \A\*\z languages: - python severity: medium diff --git a/rules/python/lang/avoid_pickle.yml b/rules/python/lang/avoid_pickle.yml new file mode 100644 index 00000000..f984ac2b --- /dev/null +++ b/rules/python/lang/avoid_pickle.yml @@ -0,0 +1,74 @@ +imports: + - python_shared_lang_import1 +patterns: + - pattern: $($<...>) + filters: + - variable: PICKLE + detection: python_shared_lang_import1 + scope: cursor + filters: + - variable: MODULE1 + values: + - pickle + - _pickle + - cPickle + - variable: NAME + values: + - load + - loads + - dump + - dumps + - pattern: $.$() + filters: + - variable: UNPICKLER + detection: python_lang_avoid_pickle_unpickler + scope: cursor + - variable: METHOD + values: + - load + - persistent_load +auxiliary: + - id: python_lang_avoid_pickle_unpickler + patterns: + - pattern: $($<...>) + filters: + - variable: UNPICKLER + detection: python_shared_lang_import1 + scope: cursor + filters: + - variable: MODULE1 + values: + - pickle + - _pickle + - cPickle + - variable: NAME + values: [Unpickler] +languages: + - python +severity: critical +metadata: + description: Usage of unsafe Pickle libraries + remediation_message: | + ## Description + + Using pickle, _pickle and cPickle can make your application vulnerable to unsafe code execution. This is because the deserialization logic of these libraries allows for arbitrary code execution. It is best practices to avoid these libraries and to use a safer serialization formats like JSON. + + ## Remediations + + - **Do not** use pickle or its derivatives for deserialization wherever possible. These libraries are open to security vulnerabilities. + - **Do** use recommended safer formats like JSON, Protocol Buffers (protobuf) and MessagePack. + ```python + import msgpack #MessagePack + + data = {'key': 'value'} + packed_data = msgpack.packb(data) + ``` + + ## References + + - [OWASP Deserialization cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Deserialization_Cheat_Sheet.html) + + cwe_id: + - 502 + id: python_lang_avoid_pickle + documentation_url: https://docs.bearer.com/reference/rules/python_lang_avoid_pickle diff --git a/rules/python/lang/code_injection.yml b/rules/python/lang/code_injection.yml index d5e75242..4b729b07 100644 --- a/rules/python/lang/code_injection.yml +++ b/rules/python/lang/code_injection.yml @@ -1,18 +1,46 @@ imports: - - python_shared_common_user_input + - python_shared_common_external_input - python_shared_lang_import1 patterns: - - pattern: exec($<...>$$<...>) + - pattern: exec($<...>$$<...>) filters: - - variable: USER_INPUT - detection: python_shared_common_user_input + - variable: EXTERNAL_INPUT + detection: python_shared_common_external_input scope: result - - pattern: setattr($<_>, $<_>, $<...>$$<...>) + - pattern: setattr($<_>, $<_>, $<...>$$<...>) filters: - - variable: USER_INPUT - detection: python_shared_common_user_input + - variable: EXTERNAL_INPUT + detection: python_shared_common_external_input scope: result - - pattern: $($<...>$$<...>) + - pattern: compile($<_>, $<_>, $<...>$$<...>) + filters: + - variable: EXTERNAL_INPUT + detection: python_shared_common_external_input + scope: result + - pattern: $($<...>$$<...>) + filters: + - variable: IMPORT_MODULE + detection: python_shared_lang_import1 + scope: cursor + filters: + - variable: MODULE1 + values: [importlib] + - variable: NAME + values: [import_module] + - variable: EXTERNAL_INPUT + detection: python_shared_common_external_input + scope: result + - pattern: __import__($<...>$$<...>) + filters: + - variable: EXTERNAL_INPUT + detection: python_shared_common_external_input + scope: result + - pattern: globals()[$<...>$$<...>] + filters: + - variable: EXTERNAL_INPUT + detection: python_shared_common_external_input + scope: result + - pattern: $($<...>$$<...>) filters: - variable: OS detection: python_shared_lang_import1 @@ -30,22 +58,22 @@ patterns: - execve - execvp - execvpe - - variable: USER_INPUT - detection: python_shared_common_user_input + - variable: EXTERNAL_INPUT + detection: python_shared_common_external_input scope: result languages: - python severity: critical metadata: - description: Unsanitized user input in code generation + description: Unsanitized external input in code generation remediation_message: |- ## Description - Allowing user input to directly influence code generation or scripting functions without proper sanitization can lead to code injection vulnerabilities. This occurs when an attacker is able to insert malicious code into your application, which is then executed, potentially leading to unauthorized actions or data access. + Allowing external input (dynamic or user-controlled) to directly influence code generation or scripting functions without proper sanitization can lead to code injection vulnerabilities. This occurs when an attacker is able to insert malicious code into your application, which is then executed, potentially leading to unauthorized actions or data access. ## Remediations - - **Do not** pass unsanitized user input to functions or methods that dynamically execute code. + - **Do not** pass unsanitized external input to functions or methods that dynamically execute code. - **Do** always validate or sanitize input to ensure it does not contain harmful code before using it in such contexts. ## References diff --git a/rules/python/lang/http_url_using_user_input.yml b/rules/python/lang/http_url_using_user_input.yml index 2baa25ea..f1fa85f3 100644 --- a/rules/python/lang/http_url_using_user_input.yml +++ b/rules/python/lang/http_url_using_user_input.yml @@ -2,12 +2,12 @@ languages: - python imports: - python_shared_common_user_input - - python_shared_lang_http_location + - python_shared_common_http_location patterns: - pattern: $ filters: - variable: USER_INPUT_LOCATION - detection: python_shared_lang_http_location + detection: python_shared_common_http_location scope: cursor_strict filters: - variable: LOCATION diff --git a/rules/python/lang/insecure_allow_origin.yml b/rules/python/lang/insecure_allow_origin.yml index 593cdff1..6abbd2a7 100644 --- a/rules/python/lang/insecure_allow_origin.yml +++ b/rules/python/lang/insecure_allow_origin.yml @@ -1,7 +1,32 @@ imports: - python_shared_common_user_input + - python_shared_lang_import1 + - python_shared_lang_import2 - python_shared_lang_instance patterns: + - pattern: $.set_header($, $<...>$$<...>) + filters: + - variable: ALLOW_ORIGIN + string_regex: (?i)\Aaccess-control-allow-origin\z + - variable: USER_INPUT + detection: python_shared_common_user_input + scope: result + - variable: HANDLER + detection: python_shared_lang_instance + scope: cursor + filters: + - variable: CLASS + detection: python_shared_lang_import2 + scope: cursor + filters: + - variable: MODULE1 + values: [tornado] + - variable: MODULE2 + values: [web] + - variable: NAME + values: + - RequestHandler + - StaticFileHandler - pattern: $.send_header($, $<...>$$<...>) filters: - variable: ALLOW_ORIGIN @@ -23,6 +48,49 @@ patterns: values: [server] - variable: NAME values: [BaseHTTPRequestHandler] + - pattern: | + $($<...>headers=$$<...>) + filters: + - variable: HEADERS_HASH + detection: python_lang_insecure_allow_origin_headers_hash + scope: cursor + - variable: AIOHTTP + detection: python_shared_lang_import2 + scope: cursor + filters: + - variable: MODULE1 + values: [aiohttp] + - variable: MODULE2 + values: [web] + - variable: NAME + values: + - json_response + - Response + - pattern: | + $($<...>headers=$$<...>) + filters: + - variable: HEADERS_HASH + detection: python_lang_insecure_allow_origin_headers_hash + scope: cursor + - variable: BOTTLE + detection: python_shared_lang_import1 + scope: cursor + filters: + - variable: MODULE1 + values: [bottle] + - variable: NAME + values: [HTTPResponse] +auxiliary: + - id: python_lang_insecure_allow_origin_headers_hash + patterns: + - pattern: | + {$<...>$: $$<...>} + filters: + - variable: ALLOW_ORIGIN + string_regex: (?i)\Aaccess-control-allow-origin\z + - variable: USER_INPUT + detection: python_shared_common_user_input + scope: result languages: - python severity: medium diff --git a/rules/python/lang/insecure_websocket.yml b/rules/python/lang/insecure_websocket.yml index 8b5ed42e..8b8c19ae 100644 --- a/rules/python/lang/insecure_websocket.yml +++ b/rules/python/lang/insecure_websocket.yml @@ -10,6 +10,14 @@ patterns: - pattern: $($$<...>) filters: - either: + - variable: CONNECT + detection: python_shared_lang_import1 + scope: cursor + filters: + - variable: MODULE1 + values: [websocket] + - variable: NAME + values: [WebSocketApp] - variable: CONNECT detection: python_shared_lang_import1 scope: cursor @@ -45,22 +53,36 @@ patterns: scope: cursor - pattern: $.connect($$<...>) filters: - - variable: CLIENT - detection: python_shared_lang_instance - scope: cursor - filters: - - variable: CLASS - detection: python_shared_lang_import1 + - either: + - variable: CLIENT + detection: python_shared_lang_instance scope: cursor filters: - - variable: MODULE1 - values: [socketio] - - variable: NAME - values: - - Client - - AsyncClient - - SimpleClient - - AsyncSimpleClient + - variable: CLASS + detection: python_shared_lang_import1 + scope: cursor + filters: + - variable: MODULE1 + values: [websocket] + - variable: NAME + values: + - WebSocket + - variable: CLIENT + detection: python_shared_lang_instance + scope: cursor + filters: + - variable: CLASS + detection: python_shared_lang_import1 + scope: cursor + filters: + - variable: MODULE1 + values: [socketio] + - variable: NAME + values: + - Client + - AsyncClient + - SimpleClient + - AsyncSimpleClient - variable: INSECURE_URL detection: python_shared_lang_insecure_url scope: cursor diff --git a/rules/python/lang/jwt_verification_bypass.yml b/rules/python/lang/jwt_verification_bypass.yml new file mode 100644 index 00000000..68ed2a65 --- /dev/null +++ b/rules/python/lang/jwt_verification_bypass.yml @@ -0,0 +1,49 @@ +imports: + - python_shared_lang_import1 +patterns: + - pattern: | + $($<...>options=$$<...>) + filters: + - variable: JWT + detection: python_shared_lang_import1 + scope: cursor + filters: + - variable: MODULE1 + values: [jwt] + - variable: NAME + values: [decode] + - variable: OPTS + detection: python_lang_jwt_verification_bypass_options +auxiliary: + - id: python_lang_jwt_verification_bypass_options + patterns: + - pattern: | + { $<...>"verify_signature": $$<...> } + filters: + - variable: "FALSE" + detection: python_lang_jwt_verification_bypass_false + scope: cursor + - id: python_lang_jwt_verification_bypass_false + patterns: + - "False" +languages: + - python +severity: critical +metadata: + description: Missing signature verification of JWT + remediation_message: |- + ## Description + + Failing to verify the signature of JSON Web Tokens (JWTs) compromises the security of an application. Signature verification is crucial for confirming the authenticity and integrity of JWTs. Without this verification, your application is open to token forgery and replay attacks, where attackers can manipulate or reuse tokens to gain unauthorized access. + + ## Remediations + + - **Do not** disable verification of the token's signature, because this leaves a significant security gap. + ```python + jwt.decode(token, options={"verify_signature": False}) # unsafe + ``` + + cwe_id: + - 347 + id: python_lang_jwt_verification_bypass + documentation_url: https://docs.bearer.com/reference/rules/python_lang_jwt_verification_bypass diff --git a/rules/python/lang/logger.yml b/rules/python/lang/logger.yml index 09166fd5..e7e3fe75 100644 --- a/rules/python/lang/logger.yml +++ b/rules/python/lang/logger.yml @@ -3,7 +3,7 @@ imports: - python_shared_lang_instance - python_shared_lang_import1 patterns: - - pattern: $.$($) + - pattern: $.$($<...>$$<...>) filters: - variable: LOGGER detection: python_lang_logger_init @@ -22,6 +22,8 @@ patterns: auxiliary: - id: python_lang_logger_init patterns: + - logger + - $<_>.get_logger() - pattern: $ filters: - variable: LOGGER diff --git a/rules/python/lang/permissive_allow_origin.yml b/rules/python/lang/permissive_allow_origin.yml index 3c4fd639..92af95f7 100644 --- a/rules/python/lang/permissive_allow_origin.yml +++ b/rules/python/lang/permissive_allow_origin.yml @@ -1,7 +1,28 @@ imports: - python_shared_lang_instance + - python_shared_lang_import1 - python_shared_lang_import2 patterns: + - pattern: $.set_header($, $<...>$$<...>) + filters: + - variable: ALLOW_ORIGIN + string_regex: (?i)\Aaccess-control-allow-origin\z + - variable: VALUE + string_regex: \A\*\z + - variable: HANDLER + detection: python_shared_lang_instance + scope: cursor + filters: + - variable: CLASS + detection: python_shared_lang_import2 + scope: cursor + filters: + - variable: MODULE1 + values: [tornado] + - variable: MODULE2 + values: [web] + - variable: NAME + values: [RequestHandler] - pattern: $.send_header($, $<...>$$<...>) filters: - variable: ALLOW_ORIGIN @@ -22,6 +43,48 @@ patterns: values: [server] - variable: NAME values: [BaseHTTPRequestHandler] + - pattern: | + $($<...>headers=$$<...>) + filters: + - variable: HEADERS_HASH + detection: python_lang_permissive_allow_origin_headers_hash + scope: cursor + - variable: AIOHTTP + detection: python_shared_lang_import2 + scope: cursor + filters: + - variable: MODULE1 + values: [aiohttp] + - variable: MODULE2 + values: [web] + - variable: NAME + values: + - json_response + - Response + - pattern: | + $($<...>headers=$$<...>) + filters: + - variable: HEADERS_HASH + detection: python_lang_permissive_allow_origin_headers_hash + scope: cursor + - variable: BOTTLE + detection: python_shared_lang_import1 + scope: cursor + filters: + - variable: MODULE1 + values: [bottle] + - variable: NAME + values: [HTTPResponse] +auxiliary: + - id: python_lang_permissive_allow_origin_headers_hash + patterns: + - pattern: | + {$<...>$: $$<...>} + filters: + - variable: ALLOW_ORIGIN + string_regex: (?i)\Aaccess-control-allow-origin\z + - variable: VALUE + string_regex: \A\*\z languages: - python severity: medium diff --git a/rules/python/lang/sql_injection.yml b/rules/python/lang/sql_injection.yml index 003122c6..f4183e9b 100644 --- a/rules/python/lang/sql_injection.yml +++ b/rules/python/lang/sql_injection.yml @@ -3,6 +3,14 @@ imports: - python_shared_lang_instance - python_shared_lang_import1 patterns: + - pattern: $.execute($$<...>) + filters: + - variable: CONN + detection: python_lang_sql_injection_conn + scope: cursor + - variable: USER_INPUT + detection: python_shared_common_sql_user_input + scope: result - pattern: $.$($$<...>) filters: - variable: CURSOR @@ -78,6 +86,10 @@ auxiliary: - id: python_lang_sql_injection_cursor patterns: - $<_>.cursor() + - id: python_lang_sql_injection_conn + patterns: + - pattern: | + def $<_>($<...>$conn$<...>): - id: python_lang_sql_injection_pg8000_conn patterns: - pattern: $ diff --git a/rules/python/shared/common/http_location.yml b/rules/python/shared/common/http_location.yml new file mode 100644 index 00000000..4ec0a545 --- /dev/null +++ b/rules/python/shared/common/http_location.yml @@ -0,0 +1,25 @@ +type: shared +languages: + - python +imports: + - python_shared_django_http_location + - python_shared_lang_http_location +patterns: + - pattern: $ + filters: + - either: + - variable: PYTHON_SHARED_COMMON_HTTP_LOCATION + detection: python_shared_lang_http_location + scope: cursor_strict + imports: + - variable: LOCATION + as: LOCATION + - variable: PYTHON_SHARED_COMMON_HTTP_LOCATION + detection: python_shared_django_http_location + scope: cursor_strict + imports: + - variable: LOCATION + as: LOCATION +metadata: + description: "Python HTTP location." + id: python_shared_common_http_location diff --git a/rules/python/shared/django/http_location.yml b/rules/python/shared/django/http_location.yml new file mode 100644 index 00000000..77d55b53 --- /dev/null +++ b/rules/python/shared/django/http_location.yml @@ -0,0 +1,23 @@ +type: shared +imports: + - python_shared_lang_import2 +patterns: + - pattern: $($$<...>) + filters: + - variable: CONNECTION_CLASS + detection: python_shared_lang_import2 + scope: cursor + filters: + - variable: MODULE1 + values: [django] + - variable: MODULE2 + values: [http] + - variable: NAME + values: + - HttpResponseRedirect + - HttpResponsePermanentRedirect +languages: + - python +metadata: + description: "Python Django HTTP request URL, redirect etc." + id: python_shared_django_http_location diff --git a/rules/python/shared/lang/dynamic_input.yml b/rules/python/shared/lang/dynamic_input.yml index 17bc9ed2..be3cdc86 100644 --- a/rules/python/shared/lang/dynamic_input.yml +++ b/rules/python/shared/lang/dynamic_input.yml @@ -4,7 +4,6 @@ languages: imports: - python_shared_lang_import1 patterns: - - sys.argv[$<_>] - pattern: $.parse_args($<...>) filters: - variable: PARSER @@ -44,6 +43,8 @@ auxiliary: def $<_>($<...>$$$<...>): - | def $<_>($<...>$$=$<_>$<...>): + - | + def $<_>($<...>$$:$<_>$<...>): metadata: description: "Python dynamic input." id: python_shared_lang_dynamic_input diff --git a/rules/python/shared/lang/user_input.yml b/rules/python/shared/lang/user_input.yml index dd85a141..e5f1490f 100644 --- a/rules/python/shared/lang/user_input.yml +++ b/rules/python/shared/lang/user_input.yml @@ -1,8 +1,36 @@ +imports: + - python_shared_lang_import1 + - python_shared_lang_import2 type: shared languages: - python patterns: - input($<...>) + - pattern: $($<...>) + filters: + - variable: SYS_STDIN + detection: python_shared_lang_import2 + scope: cursor + filters: + - variable: MODULE1 + values: [sys] + - variable: MODULE2 + values: [stdin] + - variable: NAME + values: + - read + - readline + - readlines + - pattern: $[$<_>] + filters: + - variable: SYS_ARGV + detection: python_shared_lang_import1 + scope: cursor + filters: + - variable: MODULE1 + values: [sys] + - variable: NAME + values: [argv] metadata: description: "Python lang user input." id: python_shared_lang_user_input diff --git a/tests/python/django/cookie_missing_http_only/testdata/main.py b/tests/python/django/cookie_missing_http_only/testdata/main.py index d022e204..41bc8bdf 100644 --- a/tests/python/django/cookie_missing_http_only/testdata/main.py +++ b/tests/python/django/cookie_missing_http_only/testdata/main.py @@ -6,4 +6,6 @@ def bad(request): def ok(request): cookie = jwt.encode(payload, 'csrf_vulneribility', algorithm='HS256') # ok - response.set_cookie('auth_cookie', cookie, httponly=True) \ No newline at end of file + response.set_cookie('auth_cookie', cookie, httponly=True) + response.set_cookie('auth_cookie', cookie, httponly=settings.config.HTTPONLY) + diff --git a/tests/python/django/cookie_missing_secure/testdata/main.py b/tests/python/django/cookie_missing_secure/testdata/main.py index b8d32773..70011dc7 100644 --- a/tests/python/django/cookie_missing_secure/testdata/main.py +++ b/tests/python/django/cookie_missing_secure/testdata/main.py @@ -6,4 +6,5 @@ def bad(request): def ok(request): cookie = jwt.encode(payload, 'csrf_vulneribility', algorithm='HS256') # ok - response.set_cookie('auth_cookie', cookie, secure=True) \ No newline at end of file + response.set_cookie('auth_cookie', cookie, secure=True) + response.set_cookie('auth_cookie', cookie, secure=settings.SECURE) \ No newline at end of file diff --git a/tests/python/django/html_magic_method/test.js b/tests/python/django/html_magic_method/test.js new file mode 100644 index 00000000..1a45de2a --- /dev/null +++ b/tests/python/django/html_magic_method/test.js @@ -0,0 +1,20 @@ +const { + createNewInvoker, + getEnvironment, +} = require("../../../helper.js") +const { ruleId, ruleFile, testBase } = getEnvironment(__dirname) + +describe(ruleId, () => { + const invoke = createNewInvoker(ruleId, ruleFile, testBase) + + test("html_magic_method", () => { + const testCase = "main.py" + + const results = invoke(testCase) + + expect(results).toEqual({ + Missing: [], + Extra: [] + }) + }) +}) \ No newline at end of file diff --git a/tests/python/django/html_magic_method/testdata/main.py b/tests/python/django/html_magic_method/testdata/main.py new file mode 100644 index 00000000..558f3292 --- /dev/null +++ b/tests/python/django/html_magic_method/testdata/main.py @@ -0,0 +1,6 @@ +# bearer:expected python_django_html_magic_method +def __html__(): + # something bad + +def __hello__(): + # no problem here \ No newline at end of file diff --git a/tests/python/django/mark_safe/test.js b/tests/python/django/mark_safe/test.js new file mode 100644 index 00000000..78d2f4d8 --- /dev/null +++ b/tests/python/django/mark_safe/test.js @@ -0,0 +1,20 @@ +const { + createNewInvoker, + getEnvironment, +} = require("../../../helper.js") +const { ruleId, ruleFile, testBase } = getEnvironment(__dirname) + +describe(ruleId, () => { + const invoke = createNewInvoker(ruleId, ruleFile, testBase) + + test("mark_safe", () => { + const testCase = "main.py" + + const results = invoke(testCase) + + expect(results).toEqual({ + Missing: [], + Extra: [] + }) + }) +}) \ No newline at end of file diff --git a/tests/python/django/mark_safe/testdata/main.py b/tests/python/django/mark_safe/testdata/main.py new file mode 100644 index 00000000..336d117f --- /dev/null +++ b/tests/python/django/mark_safe/testdata/main.py @@ -0,0 +1,13 @@ +from django.utils.safestring import mark_safe +from django.utils.html import format_html + +def bad(): + # bearer:expected python_django_mark_safe + return mark_safe("some HTML string") + +def ok(some_var): + return format_html( + "{} {} {}", + mark_safe("some HTML string"), + some_var + ) \ No newline at end of file diff --git a/tests/python/django/path_traversal/testdata/main.py b/tests/python/django/path_traversal/testdata/main.py index 58f3c79b..0e4afd88 100644 --- a/tests/python/django/path_traversal/testdata/main.py +++ b/tests/python/django/path_traversal/testdata/main.py @@ -1,13 +1,14 @@ from django.core.files.storage import FileSystemStorage as FSS -# bearer:expected python_django_path_traversal -fs = FSS(sys.argv[0]) -request_file = request.FILES['document'] -file = fs.save(request_file) +def bad(dynamic_input): + # bearer:expected python_django_path_traversal + fs = FSS(dynamic_input) + request_file = request.FILES['document'] + file = fs.save(request_file) from django.core.files.storage import default_storage import os.path - -default_storage.save(os.path.normpath(sys.argv[1])) -x = os.path.normpath(sys.argv[2]) -default_storage.save(x) +def ok(dynamic_input): + default_storage.save(os.path.normpath(dynamic_input)) + x = os.path.normpath(dynamic_input) + default_storage.save(x) diff --git a/tests/python/django/permissive_allow_origin/testdata/main.py b/tests/python/django/permissive_allow_origin/testdata/main.py index 848ffd63..730bd233 100644 --- a/tests/python/django/permissive_allow_origin/testdata/main.py +++ b/tests/python/django/permissive_allow_origin/testdata/main.py @@ -1,6 +1,6 @@ # Use bearer:expected python_django_permissive_allow_origin to flag expected findings -from django.http import HttpResponse +from django.http import HttpResponse, JsonResponse response = HttpResponse() # bearer:expected python_django_permissive_allow_origin @@ -9,6 +9,25 @@ # bearer:expected python_django_permissive_allow_origin HttpResponse(headers={"Access-Control-Allow-Origin": "*"}) +def bad(self, request, *args, **kwargs): + auth_header = request.headers.get('Authorization') + if auth_header: + method, token = auth_header.split(' ', 1) + if method != 'Bearer': + # bearer:expected python_django_permissive_allow_origin + return JsonResponse({ + "error": "invalid_request", + "error_description": "Unknown authorization method" + }, status=400, headers={ + 'Access-Control-Allow-Origin': '*', + }) + else: + # bearer:expected python_django_permissive_allow_origin + return HttpResponse(status=401, headers={ + 'WWW-Authenticate': 'Bearer realm="example"', + 'Access-Control-Allow-Origin': '*', + }) + # ok response = HttpResponse() response.headers['access-control-allow-origin'] = 'https://my-example-site.com' diff --git a/tests/python/lang/avoid_pickle/test.js b/tests/python/lang/avoid_pickle/test.js new file mode 100644 index 00000000..5b0326de --- /dev/null +++ b/tests/python/lang/avoid_pickle/test.js @@ -0,0 +1,20 @@ +const { + createNewInvoker, + getEnvironment, +} = require("../../../helper.js") +const { ruleId, ruleFile, testBase } = getEnvironment(__dirname) + +describe(ruleId, () => { + const invoke = createNewInvoker(ruleId, ruleFile, testBase) + + test("avoid_pickle", () => { + const testCase = "main.py" + + const results = invoke(testCase) + + expect(results).toEqual({ + Missing: [], + Extra: [] + }) + }) +}) \ No newline at end of file diff --git a/tests/python/lang/avoid_pickle/testdata/main.py b/tests/python/lang/avoid_pickle/testdata/main.py new file mode 100644 index 00000000..f340c182 --- /dev/null +++ b/tests/python/lang/avoid_pickle/testdata/main.py @@ -0,0 +1 @@ +# Use bearer:expected python_lang_avoid_pickle to flag expected findings \ No newline at end of file diff --git a/tests/python/lang/code_injection/testdata/main.py b/tests/python/lang/code_injection/testdata/main.py index fe56ca16..ed5fab61 100644 --- a/tests/python/lang/code_injection/testdata/main.py +++ b/tests/python/lang/code_injection/testdata/main.py @@ -13,4 +13,21 @@ def bad2(): def bad3(request): unsafe = request.GET.get("some_code") # bearer:expected python_lang_code_injection - os.execl("/bin/bash", "/bin/bash", "-c", unsafe) \ No newline at end of file + os.execl("/bin/bash", "/bin/bash", "-c", unsafe) + +import sys +def bad4(): + hook_name = sys.argv[1] + if hook_name not in HOOK_NAMES: + sys.exit("Unknown hook: %s" % hook_name) + + # bearer:expected python_lang_code_injection + hook = globals()[hook_name] + +from importlib import import_module +def bad4(request): + # bearer:expected python_lang_code_injection + module1 = __import__("user.commands.%s" % (request.GET.get("module1"))) + # bearer:expected python_lang_code_injection + module = import_module("user.commands.%s" % (request.GET.get("module_name"))) + return module.Command() \ No newline at end of file diff --git a/tests/python/lang/eval_using_user_input/testdata/main.py b/tests/python/lang/eval_using_user_input/testdata/main.py index b68697d6..bf1d16c5 100644 --- a/tests/python/lang/eval_using_user_input/testdata/main.py +++ b/tests/python/lang/eval_using_user_input/testdata/main.py @@ -5,10 +5,17 @@ def bad(request): # bearer:expected python_lang_eval_using_user_input eval(form.cleaned_data["bad_eval"]) +import sys def bad2(interpreter_id: int = 0): unsafe = sys.argv[2] # bearer:expected python_lang_eval_using_user_input subinterpreters.run_string(interpreter_id, unsafe) +from sys import stdin +def bad3(): + code = stdin.readlines() + # bearer:expected python_lang_eval_using_user_input + return eval(code, {}, {"datetime": datetime, "timezone": timezone}) + def ok(): subinterpreters.run_string(interpreter_id, "print 'hello world'") \ No newline at end of file diff --git a/tests/python/lang/http_url_using_user_input/testdata/main.py b/tests/python/lang/http_url_using_user_input/testdata/main.py index 9934a667..a7b09a05 100644 --- a/tests/python/lang/http_url_using_user_input/testdata/main.py +++ b/tests/python/lang/http_url_using_user_input/testdata/main.py @@ -20,6 +20,12 @@ def http_client(): conn.putrequest("GET", "ok") # bearer:expected python_lang_http_url_using_user_input conn.putrequest("GET", user_input) + + +from django.http import HttpResponseRedirect +def django(): + # bearer:expected python_lang_http_url_using_user_input + HttpResponseRedirect("%s://%s" % (user_input, user_input)) def urllib(): diff --git a/tests/python/lang/insecure_websocket/testdata/main.py b/tests/python/lang/insecure_websocket/testdata/main.py index cd71ad4d..8c7b1023 100644 --- a/tests/python/lang/insecure_websocket/testdata/main.py +++ b/tests/python/lang/insecure_websocket/testdata/main.py @@ -14,7 +14,14 @@ async def websockets(): connect_sync("ws://example.com") # bearer:expected python_lang_insecure_websocket await connect_async("ws://example.com") - + +def websocket(): + import websocket + # bearer:expected python_lang_insecure_websocket + websocket.WebSocketApp("ws://example.com") + ws = websocket.WebSocket() + # bearer:expected python_lang_insecure_websocket + ws.connect("ws://example.com") def socketio(): import socketio diff --git a/tests/python/lang/jwt_verification_bypass/test.js b/tests/python/lang/jwt_verification_bypass/test.js new file mode 100644 index 00000000..318a3dc5 --- /dev/null +++ b/tests/python/lang/jwt_verification_bypass/test.js @@ -0,0 +1,20 @@ +const { + createNewInvoker, + getEnvironment, +} = require("../../../helper.js") +const { ruleId, ruleFile, testBase } = getEnvironment(__dirname) + +describe(ruleId, () => { + const invoke = createNewInvoker(ruleId, ruleFile, testBase) + + test("jwt_verification_bypass", () => { + const testCase = "main.py" + + const results = invoke(testCase) + + expect(results).toEqual({ + Missing: [], + Extra: [] + }) + }) +}) \ No newline at end of file diff --git a/tests/python/lang/jwt_verification_bypass/testdata/main.py b/tests/python/lang/jwt_verification_bypass/testdata/main.py new file mode 100644 index 00000000..1db51096 --- /dev/null +++ b/tests/python/lang/jwt_verification_bypass/testdata/main.py @@ -0,0 +1,11 @@ +import jwt + +def bad(token: str) -> bool: + try: + # bearer:expected python_lang_jwt_verification_bypass + payload = jwt.decode(token, options={"verify_signature": False}) + +def ok(token: str) -> bool: + try: + payload = jwt.decode(token, options={"verify_signature": True}) + payload = jwt.decode(token, options={"hello_world": False}) \ No newline at end of file diff --git a/tests/python/lang/logger/testdata/main.py b/tests/python/lang/logger/testdata/main.py index ec9f153c..b7f4afcf 100644 --- a/tests/python/lang/logger/testdata/main.py +++ b/tests/python/lang/logger/testdata/main.py @@ -16,3 +16,11 @@ def bad3(user): myOtherLogger = something_else.getLogger(__name__) # bearer:expected python_lang_logger myOtherLogger.debug(f"User '{user.email}' logged") + + +import my_custom +def custom_bad(): + logger = my_custom.get_logger() + # bearer:expected python_lang_logger + logger.info("changing password for %s", user.username) + \ No newline at end of file diff --git a/tests/python/lang/os_command_injection/testdata/main.py b/tests/python/lang/os_command_injection/testdata/main.py index 3346977d..60d9751b 100644 --- a/tests/python/lang/os_command_injection/testdata/main.py +++ b/tests/python/lang/os_command_injection/testdata/main.py @@ -19,6 +19,7 @@ def bad(): stderr=subprocess.STDOUT, shell=True) +import sys def bad2(): unsafe = sys.argv[1] # bearer:expected python_lang_os_command_injection diff --git a/tests/python/lang/path_traversal/testdata/main.py b/tests/python/lang/path_traversal/testdata/main.py index 2bcab129..6fad0426 100644 --- a/tests/python/lang/path_traversal/testdata/main.py +++ b/tests/python/lang/path_traversal/testdata/main.py @@ -1,12 +1,12 @@ -# Use bearer:expected python_lang_path_traversal to flag expected findings - import os -# bearer:expected python_lang_path_traversal -os.mkdir(sys.argv[2]) +def bad(dynamic_input): + # bearer:expected python_lang_path_traversal + os.mkdir(dynamic_input) import os.path # ok (sanitized) -normalized = os.path.normpath(sys.argv[1]) -os.mkdir(normalized) \ No newline at end of file +def ok(dynamic_input): + normalized = os.path.normpath(dynamic_input) + os.mkdir(normalized) \ No newline at end of file diff --git a/tests/python/lang/permissive_allow_origin/testdata/main.py b/tests/python/lang/permissive_allow_origin/testdata/main.py index 7c63ffa5..02555187 100644 --- a/tests/python/lang/permissive_allow_origin/testdata/main.py +++ b/tests/python/lang/permissive_allow_origin/testdata/main.py @@ -8,9 +8,48 @@ def do_GET(self): self.send_header('Access-Control-Allow-Origin', '*') self.end_headers() +def aiohttp_bad()(success, message) -> aiohttp.web.Response: + from aiohttp.web import json_response, Response + headers = {"Access-Control-Allow-Origin": "*"} + + # bearer:expected python_lang_permissive_allow_origin + Response( + text=traceback.format_exc(), + status=aiohttp.web.HTTPInternalServerError.status_code, + headers=headers + ) + # bearer:expected python_lang_permissive_allow_origin + return json_response( + { + "result": success, + "msg": message + }, + headers=headers, + status=200 if success else 500, + ) + class OkClass(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) self.send_header('Content-type', 'application/json') self.send_header('Access-Control-Allow-Origin', 'https://my-example-site.com') - self.end_headers() \ No newline at end of file + self.end_headers() + +def aiohttp_ok()(success, message) -> aiohttp.web.Response: + from aiohttp.web import json_response, Response + headers = {"Access-Control-Allow-Origin": "https://example.com"} + # ok - headers not permissive + Response( + text=traceback.format_exc(), + status=aiohttp.web.HTTPInternalServerError.status_code, + headers=headers + ) + + # ok - no headers + return aiohttp.web.json_response( + { + "result": success, + "msg": message + }, + status=200 if success else 500, + ) \ No newline at end of file diff --git a/tests/python/lang/sql_injection/testdata/main.py b/tests/python/lang/sql_injection/testdata/main.py index 1e21ea4c..888b79b9 100644 --- a/tests/python/lang/sql_injection/testdata/main.py +++ b/tests/python/lang/sql_injection/testdata/main.py @@ -41,6 +41,12 @@ def sqlalchemy(): # bearer:expected python_lang_sql_injection result = sqlalcon.execute(text(f"SELECT * FROM {user_input}")) +def bad2(scenario: dict, conn): + query = f"INSERT INTO scenario (config_id) VALUES ({scenario['config_id']})" + # bearer:expected python_lang_sql_injection + conn.execute(query) + conn.commit() + def mysql_connector_sanitizer(): import mysql.connector