diff --git a/rules/python/django/sql_injection.yml b/rules/python/django/sql_injection.yml new file mode 100644 index 00000000..4a6c1c17 --- /dev/null +++ b/rules/python/django/sql_injection.yml @@ -0,0 +1,49 @@ +imports: + - python_shared_common_sql_user_input +patterns: + - pattern: $<_>.objects.raw($$<...>) + filters: + - variable: USER_INPUT + detection: python_shared_common_sql_user_input + scope: result +languages: + - python +severity: critical +metadata: + description: Unsanitized external input in SQL query + remediation_message: |- + ## Description + + Using unsanitized data, such as user input or request data, or externally influenced data passed to a function, in SQL query exposes your application to SQL injection attacks. This vulnerability arises when externally controlled data is directly included in SQL statements without proper sanitation, allowing attackers to manipulate queries and access or modify data. + + ## Remediations + + - **Do not** include raw external input in SQL queries. This practice can lead to SQL injection vulnerabilities. + ```python + sorting_order = request.GET["untrusted"] + query = f"SELECT id, name FROM products ORDER BY name LIMIT 20 {sorting_order};"; # unsafe + ``` + - **Do** validate all external input to ensure it meets the expected format before including it in SQL queries. + ```python + sorting_order = "DESC" if request.GET["sortingOrder"] == "DESC" else "ASC" + ``` + - **Do** use parameters for database queries to separate SQL logic from external input, significantly reducing the risk of SQL injection. + ```python + Product.objects.raw("SELECT * FROM products WHERE id LIKE ?", [f"%{product_id}%"]) + ``` + - **Do** escape all external input using appropriate database-specific escaping functions before including it in SQL queries. + ```python + from mysql.connector.conversion import MySQLConverter + + converter = MySQLConverter(connection) + ok = converter.escape(request.GET["value"]) + ``` + + ## References + + - [OWASP SQL injection explained](https://owasp.org/www-community/attacks/SQL_Injection) + - [OWASP SQL injection prevention cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html) + cwe_id: + - 89 + id: python_django_sql_injection + documentation_url: https://docs.bearer.com/reference/rules/python_django_sql_injection diff --git a/rules/python/lang/sql_injection.yml b/rules/python/lang/sql_injection.yml new file mode 100644 index 00000000..668f2524 --- /dev/null +++ b/rules/python/lang/sql_injection.yml @@ -0,0 +1,75 @@ +imports: + - python_shared_common_sql_user_input + - python_shared_lang_import1 +patterns: + - pattern: $.$($$<...>) + filters: + - variable: CURSOR + detection: python_lang_sql_injection_cursor + scope: cursor + - variable: METHOD + values: + - callproc + - execute + - executemany + - variable: USER_INPUT + detection: python_shared_common_sql_user_input + scope: result + - pattern: $($) + filters: + - variable: TEXT + detection: python_shared_lang_import1 + scope: cursor + filters: + - variable: MODULE1 + values: [sqlalchemy] + - variable: NAME + values: [text] + - variable: USER_INPUT + detection: python_shared_common_sql_user_input + scope: result +auxiliary: + - id: python_lang_sql_injection_cursor + patterns: + - $<_>.cursor() +languages: + - python +severity: critical +metadata: + description: Unsanitized external input in SQL query + remediation_message: |- + ## Description + + Using unsanitized data, such as user input or request data, or externally influenced data passed to a function, in SQL query exposes your application to SQL injection attacks. This vulnerability arises when externally controlled data is directly included in SQL statements without proper sanitation, allowing attackers to manipulate queries and access or modify data. + + ## Remediations + + - **Do not** include raw external input in SQL queries. This practice can lead to SQL injection vulnerabilities. + ```python + sorting_order = request.GET["untrusted"] + query = f"SELECT id, name FROM products ORDER BY name LIMIT 20 {sorting_order};"; # unsafe + ``` + - **Do** validate all external input to ensure it meets the expected format before including it in SQL queries. + ```python + sorting_order = "DESC" if request.GET["sortingOrder"] == "DESC" else "ASC" + ``` + - **Do** use parameters for database queries to separate SQL logic from external input, significantly reducing the risk of SQL injection. + ```python + cursor.execute("SELECT * FROM products WHERE id LIKE ?", [f"%{product_id}%"]) + ``` + - **Do** escape all external input using appropriate database-specific escaping functions before including it in SQL queries. + ```python + from mysql.connector.conversion import MySQLConverter + + converter = MySQLConverter(connection) + ok = converter.escape(request.GET["value"]) + ``` + + ## References + + - [OWASP SQL injection explained](https://owasp.org/www-community/attacks/SQL_Injection) + - [OWASP SQL injection prevention cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html) + cwe_id: + - 89 + id: python_lang_sql_injection + documentation_url: https://docs.bearer.com/reference/rules/python_lang_sql_injection diff --git a/rules/python/shared/common/html_user_input.yml b/rules/python/shared/common/html_user_input.yml new file mode 100644 index 00000000..7c80acfd --- /dev/null +++ b/rules/python/shared/common/html_user_input.yml @@ -0,0 +1,40 @@ +type: shared +languages: + - python +imports: + - python_shared_lang_import1 + - python_shared_lang_instance + - python_shared_common_user_input + - python_shared_django_html_sanitizer +sanitizer: python_shared_common_html_user_input_sanitizer +patterns: + - pattern: $ + filters: + - variable: USER_INPUT + detection: python_shared_common_user_input + scope: cursor +auxiliary: + - id: python_shared_common_html_user_input_sanitizer + patterns: + - pattern: $ + filters: + - variable: DJANGO_SANITIZER + detection: python_shared_django_html_sanitizer + scope: cursor_strict + - pattern: $.sanitize($<_>) + filters: + - variable: SANITIZER + detection: python_shared_lang_instance + scope: cursor + filters: + - variable: CLASS + detection: python_shared_lang_import1 + scope: cursor + filters: + - variable: MODULE1 + values: [html_sanitizer] + - variable: NAME + values: [Sanitizer] +metadata: + description: "Python HTML user input." + id: python_shared_common_html_user_input diff --git a/rules/python/shared/common/sql_user_input.yml b/rules/python/shared/common/sql_user_input.yml new file mode 100644 index 00000000..50f05516 --- /dev/null +++ b/rules/python/shared/common/sql_user_input.yml @@ -0,0 +1,80 @@ +type: shared +languages: + - python +imports: + - python_shared_common_external_input + - python_shared_lang_instance + - python_shared_lang_import1 + - python_shared_lang_import2 + - python_shared_lang_import3 +sanitizer: python_shared_common_sql_user_input_sanitizer +patterns: + - pattern: $ + filters: + - variable: INPUT + detection: python_shared_common_external_input + scope: cursor +auxiliary: + - id: python_shared_common_sql_user_input_sanitizer + patterns: + - pattern: $.escape($<_>) + filters: + - variable: CONVERTER + detection: python_shared_lang_instance + scope: cursor + filters: + - variable: CLASS + detection: python_shared_lang_import3 + scope: cursor + filters: + - variable: MODULE1 + values: [mysql] + - variable: MODULE2 + values: [connector] + - variable: MODULE3 + values: [conversion] + - variable: NAME + values: [MySQLConverter] + - pattern: $.$($<...>) + filters: + - variable: CONNECTION + detection: python_shared_common_sql_user_input_pymysql_connection + scope: cursor + - variable: METHOD + values: + - escape + - escape_string + - pattern: $.getquoted() + filters: + - variable: ADAPTER + detection: python_shared_common_sql_user_input_psycopg_adapter + scope: cursor + - id: python_shared_common_sql_user_input_pymysql_connection + patterns: + - pattern: $($<...>) + filters: + - variable: CONNECT + detection: python_shared_lang_import1 + scope: cursor + filters: + - variable: MODULE1 + values: [pymysql] + - variable: NAME + values: [connect] + - id: python_shared_common_sql_user_input_psycopg_adapter + patterns: + - pattern: $($<...>) + filters: + - variable: ADAPT + detection: python_shared_lang_import2 + scope: cursor + filters: + - variable: MODULE1 + values: [psycopg2] + - variable: MODULE2 + values: [extensions] + - variable: NAME + values: [adapt] +metadata: + description: "Python SQL user input." + id: python_shared_common_sql_user_input diff --git a/tests/python/django/sql_injection/test.js b/tests/python/django/sql_injection/test.js new file mode 100644 index 00000000..b175f51c --- /dev/null +++ b/tests/python/django/sql_injection/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("sql_injection", () => { + 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/sql_injection/testdata/main.py b/tests/python/django/sql_injection/testdata/main.py new file mode 100644 index 00000000..62ea7a1b --- /dev/null +++ b/tests/python/django/sql_injection/testdata/main.py @@ -0,0 +1,10 @@ +user_input = input() + +User.objects.raw(f"SELECT * FROM x WHERE y = ?", [user_input]) +# bearer:expected python_django_sql_injection +User.objects.raw(f"SELECT * FROM x WHERE y = {user_input}", []) + +import pymysql +safe = pymysql.connect().escape_string(user_input) +User.objects.raw(f"SELECT * FROM x WHERE y = {safe}", []) + diff --git a/tests/python/lang/sql_injection/test.js b/tests/python/lang/sql_injection/test.js new file mode 100644 index 00000000..b175f51c --- /dev/null +++ b/tests/python/lang/sql_injection/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("sql_injection", () => { + 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/sql_injection/testdata/main.py b/tests/python/lang/sql_injection/testdata/main.py new file mode 100644 index 00000000..3e7dfe3c --- /dev/null +++ b/tests/python/lang/sql_injection/testdata/main.py @@ -0,0 +1,61 @@ +user_input = input() + +def generic(): + cursor = conn.cursor() + + cursor.callproc("SELECT * FROM x WHERE y = :foo", { "foo": user_input }) + # bearer:expected python_lang_sql_injection + cursor.callproc(user_input, { "foo": 42 }) + cursor.execute("SELECT * FROM x WHERE y = ?", [user_input]) + # bearer:expected python_lang_sql_injection + cursor.execute("SELECT * FROM " + user_input, { "foo": 42 }) + + with connection.cursor() as c: + c.executemany("UPDATE bar SET foo = 1 WHERE baz = :b", { "b": user_input }) + # bearer:expected python_lang_sql_injection + c.executemany(f"UPDATE bar SET foo = 1 WHERE baz = {user_input}", {}) + + +def sqlalchemy(): + from sqlalchemy import create_engine, text + + engine = create_engine('sqlite:///example.db') + + with engine.connect() as sqlalcon: + result = sqlalcon.execute(text("SELECT * FROM :x"), { "x": user_input }) + # bearer:expected python_lang_sql_injection + result = sqlalcon.execute(text(f"SELECT * FROM {user_input}")) + + +def mysql_connector_sanitizer(): + import mysql.connector + from mysql.connector.conversion import MySQLConverter + + conn = mysql.connector.connect() + converter = MySQLConverter(conn) + cursor = conn.cursor() + + # bearer:expected python_lang_sql_injection + cursor.execute(user_input) + cursor.execute(converter.escape(user_input)) + +def pymysql_sanitizer(): + import pymysql + + conn = pymysql.connect() + cursor = conn.cursor() + + # bearer:expected python_lang_sql_injection + cursor.execute(user_input) + cursor.execute(conn.escape_string(user_input)) + +def psycopg_sanitizer(): + import psycopg2 + import psycopg2.extensions + + conn = psycopg2.connect(database="your_database") + cursor = conn.cursor() + + # bearer:expected python_lang_sql_injection + cursor.execute(user_input) + cursor.execute(psycopg2.extensions.adapt(user_input).getquoted()) \ No newline at end of file