From c72d02df72189e90294389c7c05d6d1c66638055 Mon Sep 17 00:00:00 2001 From: David Roe Date: Tue, 21 May 2024 16:05:10 +0100 Subject: [PATCH] feat(python): add sql injection rules --- rules/python/django/sql_injection.yml | 49 ++++++++++++ rules/python/lang/sql_injection.yml | 75 +++++++++++++++++ ...ml_user_input.yaml => html_user_input.yml} | 0 rules/python/shared/common/sql_user_input.yml | 80 +++++++++++++++++++ tests/python/django/sql_injection/test.js | 20 +++++ .../django/sql_injection/testdata/main.py | 10 +++ tests/python/lang/sql_injection/test.js | 20 +++++ .../lang/sql_injection/testdata/main.py | 61 ++++++++++++++ 8 files changed, 315 insertions(+) create mode 100644 rules/python/django/sql_injection.yml create mode 100644 rules/python/lang/sql_injection.yml rename rules/python/shared/common/{html_user_input.yaml => html_user_input.yml} (100%) create mode 100644 rules/python/shared/common/sql_user_input.yml create mode 100644 tests/python/django/sql_injection/test.js create mode 100644 tests/python/django/sql_injection/testdata/main.py create mode 100644 tests/python/lang/sql_injection/test.js create mode 100644 tests/python/lang/sql_injection/testdata/main.py 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.yaml b/rules/python/shared/common/html_user_input.yml similarity index 100% rename from rules/python/shared/common/html_user_input.yaml rename to rules/python/shared/common/html_user_input.yml 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