diff --git a/.pylintrc b/.pylintrc index cd69526c3..851542275 100644 --- a/.pylintrc +++ b/.pylintrc @@ -4,5 +4,4 @@ disable = unused-argument ignore-patterns=(.)*_test\.py,test_(.)*\.py [MESSAGES CONTROL] -# Disable the no-member warning [Currently enabled] -#disable = no-member +disable=too-few-public-methods diff --git a/aikido_firewall/helpers/__init__.py b/aikido_firewall/helpers/__init__.py index e69de29bb..68ecf7c59 100644 --- a/aikido_firewall/helpers/__init__.py +++ b/aikido_firewall/helpers/__init__.py @@ -0,0 +1,8 @@ +""" +init file for the helpers/ folder, imports helper function for easier accessibility +""" + +from aikido_firewall.helpers.escape_string_regexp import escape_string_regexp +from aikido_firewall.helpers.get_current_and_next_segments import ( + get_current_and_next_segments, +) diff --git a/aikido_firewall/helpers/build_path_to_payload.py b/aikido_firewall/helpers/build_path_to_payload.py new file mode 100644 index 000000000..d8d4c2c28 --- /dev/null +++ b/aikido_firewall/helpers/build_path_to_payload.py @@ -0,0 +1,23 @@ +""" +Helper function file, see funtion definition +""" + + +def build_path_to_payload(path_to_payload): + """ + Create a string so people see the path to where + the injection took place + """ + if len(path_to_payload) == 0: + return "." + + result = "" + for part in path_to_payload: + if part["type"] == "object": + result += "." + part["key"] + elif part["type"] == "array": + result += f".[{part['index']}]" + elif part["type"] == "jwt": + result += "" + + return result diff --git a/aikido_firewall/helpers/build_path_to_payload_test.py b/aikido_firewall/helpers/build_path_to_payload_test.py new file mode 100644 index 000000000..952f4958b --- /dev/null +++ b/aikido_firewall/helpers/build_path_to_payload_test.py @@ -0,0 +1,41 @@ +import pytest +from aikido_firewall.helpers.build_path_to_payload import build_path_to_payload + + +def test_build_path_to_payload_empty(): + assert build_path_to_payload([]) == "." + + +def test_build_path_to_payload_single_object(): + path = [{"type": "object", "key": "name"}] + assert build_path_to_payload(path) == ".name" + + +def test_build_path_to_payload_single_array(): + path = [{"type": "array", "index": 0}] + assert build_path_to_payload(path) == ".[0]" + + +def test_build_path_to_payload_single_jwt(): + path = [{"type": "jwt"}] + assert build_path_to_payload(path) == "" + + +def test_build_path_to_payload_mixed_types(): + path = [ + {"type": "object", "key": "user"}, + {"type": "array", "index": 2}, + {"type": "jwt"}, + {"type": "object", "key": "details"}, + {"type": "array", "index": 1}, + ] + assert build_path_to_payload(path) == ".user.[2].details.[1]" + + +def test_build_path_to_payload_multiple_objects(): + path = [ + {"type": "object", "key": "user"}, + {"type": "object", "key": "details"}, + {"type": "object", "key": "address"}, + ] + assert build_path_to_payload(path) == ".user.details.address" diff --git a/aikido_firewall/helpers/escape_string_regexp.py b/aikido_firewall/helpers/escape_string_regexp.py new file mode 100644 index 000000000..af3b2b1ca --- /dev/null +++ b/aikido_firewall/helpers/escape_string_regexp.py @@ -0,0 +1,17 @@ +""" +Helper function file, see funtion definition +""" + +import re + + +def escape_string_regexp(string): + """ + Escape characters with special meaning either inside or outside character sets. + Use a simple backslash escape when it's always valid, and a '\\xnn' escape + when the simpler form would be disallowed by Unicode patterns' stricter grammar. + Taken from https://github.com/sindresorhus/escape-string-regexp/ + """ + pattern = re.compile(r"[|\\{}()[\]^$+*?.]") + replace1 = re.sub(pattern, r"\\\g<0>", string) + return re.sub(r"-", r"\\x2d", replace1) diff --git a/aikido_firewall/helpers/escape_string_regexp_test.py b/aikido_firewall/helpers/escape_string_regexp_test.py new file mode 100644 index 000000000..9adffad43 --- /dev/null +++ b/aikido_firewall/helpers/escape_string_regexp_test.py @@ -0,0 +1,18 @@ +import re +import pytest +from aikido_firewall.helpers import escape_string_regexp + + +def test_escape_string_regexp(): + assert ( + escape_string_regexp("\\ ^ $ * + ? . ( ) | { } [ ]") + == "\\\\ \\^ \\$ \\* \\+ \\? \\. \\( \\) \\| \\{ \\} \\[ \\]" + ) + + +def test_escape_hyphen_pcre(): + assert escape_string_regexp("foo - bar") == "foo \\x2d bar" + + +def test_escape_hyphen_unicode_flag(): + assert re.match(escape_string_regexp("-"), "-u") is not None diff --git a/aikido_firewall/helpers/extract_strings_from_user_input.py b/aikido_firewall/helpers/extract_strings_from_user_input.py new file mode 100644 index 000000000..492e0fe4b --- /dev/null +++ b/aikido_firewall/helpers/extract_strings_from_user_input.py @@ -0,0 +1,42 @@ +""" +Helper function file, see funtion definition +""" + +from aikido_firewall.helpers.try_decode_as_jwt import try_decode_as_jwt +from aikido_firewall.helpers.build_path_to_payload import build_path_to_payload + + +def extract_strings_from_user_input(obj, path_to_payload=None): + """ + Extracts strings from an object (user input) + """ + if path_to_payload is None: + path_to_payload = [] + + results = {} + + if isinstance(obj, dict): + for key, value in obj.items(): + results[key] = build_path_to_payload(path_to_payload) + for k, v in extract_strings_from_user_input( + value, path_to_payload + [{"type": "object", "key": key}] + ).items(): + results[k] = v + + if isinstance(obj, list): + for i, value in enumerate(obj): + for k, v in extract_strings_from_user_input( + value, path_to_payload + [{"type": "array", "index": i}] + ).items(): + results[k] = v + + if isinstance(obj, str): + results[obj] = build_path_to_payload(path_to_payload) + jwt = try_decode_as_jwt(obj) + if jwt[0]: + for k, v in extract_strings_from_user_input( + jwt[1], path_to_payload + [{"type": "jwt"}] + ).items(): + results[k] = v + + return results diff --git a/aikido_firewall/helpers/extract_strings_from_user_input_test.py b/aikido_firewall/helpers/extract_strings_from_user_input_test.py new file mode 100644 index 000000000..ff73b3761 --- /dev/null +++ b/aikido_firewall/helpers/extract_strings_from_user_input_test.py @@ -0,0 +1,96 @@ +import pytest +from aikido_firewall.helpers.extract_strings_from_user_input import ( + extract_strings_from_user_input, +) + + +def from_obj(obj): + return dict(obj) + + +def test_empty_object_returns_empty_dict(): + assert extract_strings_from_user_input({}) == from_obj({}) + + +def test_extract_query_objects(): + assert extract_strings_from_user_input({"age": {"$gt": "21"}}) == from_obj( + {"age": ".", "$gt": ".age", "21": ".age.$gt"} + ) + assert extract_strings_from_user_input({"title": {"$ne": "null"}}) == from_obj( + {"title": ".", "$ne": ".title", "null": ".title.$ne"} + ) + assert extract_strings_from_user_input( + {"age": "whaat", "user_input": ["whaat", "dangerous"]} + ) == from_obj( + { + "user_input": ".", + "age": ".", + "whaat": ".user_input.[0]", + "dangerous": ".user_input.[1]", + } + ) + + +def test_extract_cookie_objects(): + assert extract_strings_from_user_input( + {"session": "ABC", "session2": "DEF"} + ) == from_obj( + {"session2": ".", "session": ".", "ABC": ".session", "DEF": ".session2"} + ) + assert extract_strings_from_user_input( + {"session": "ABC", "session2": 1234} + ) == from_obj({"session2": ".", "session": ".", "ABC": ".session"}) + + +def test_extract_header_objects(): + assert extract_strings_from_user_input( + {"Content-Type": "application/json"} + ) == from_obj({"Content-Type": ".", "application/json": ".Content-Type"}) + assert extract_strings_from_user_input({"Content-Type": 54321}) == from_obj( + {"Content-Type": "."} + ) + assert extract_strings_from_user_input( + {"Content-Type": "application/json", "ExtraHeader": "value"} + ) == from_obj( + { + "Content-Type": ".", + "application/json": ".Content-Type", + "ExtraHeader": ".", + "value": ".ExtraHeader", + } + ) + + +def test_extract_body_objects(): + assert extract_strings_from_user_input( + {"nested": {"nested": {"$ne": None}}} + ) == from_obj({"nested": ".nested", "$ne": ".nested.nested"}) + assert extract_strings_from_user_input( + {"age": {"$gt": "21", "$lt": "100"}} + ) == from_obj( + {"age": ".", "$lt": ".age", "$gt": ".age", "21": ".age.$gt", "100": ".age.$lt"} + ) + + +def test_decodes_jwts(): + assert extract_strings_from_user_input( + { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcm5hbWUiOnsiJG5lIjpudWxsfSwiaWF0IjoxNTE2MjM5MDIyfQ._jhGJw9WzB6gHKPSozTFHDo9NOHs3CNOlvJ8rWy6VrQ" + } + ) == from_obj( + { + "token": ".", + "iat": ".token", + "username": ".token", + "sub": ".token", + "1234567890": ".token.sub", + "$ne": ".token.username", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcm5hbWUiOnsiJG5lIjpudWxsfSwiaWF0IjoxNTE2MjM5MDIyfQ._jhGJw9WzB6gHKPSozTFHDo9NOHs3CNOlvJ8rWy6VrQ": ".token", + } + ) + + +def test_jwt_as_string(): + assert extract_strings_from_user_input( + {"header": "/;ping%20localhost;.e30=."} + ) == from_obj({"header": ".", "/;ping%20localhost;.e30=.": ".header"}) diff --git a/aikido_firewall/helpers/get_current_and_next_segments.py b/aikido_firewall/helpers/get_current_and_next_segments.py new file mode 100644 index 000000000..e9e3d5a23 --- /dev/null +++ b/aikido_firewall/helpers/get_current_and_next_segments.py @@ -0,0 +1,8 @@ +""" +Helper function file, see function definition for more info +""" + + +def get_current_and_next_segments(array): + """Get the current and next segments of an array""" + return [(array[i], array[i + 1]) for i in range(len(array) - 1)] diff --git a/aikido_firewall/helpers/get_current_and_next_segments_test.py b/aikido_firewall/helpers/get_current_and_next_segments_test.py new file mode 100644 index 000000000..1880058cd --- /dev/null +++ b/aikido_firewall/helpers/get_current_and_next_segments_test.py @@ -0,0 +1,28 @@ +import pytest +from aikido_firewall.helpers.get_current_and_next_segments import ( + get_current_and_next_segments, +) + + +def test_empty_array(): + assert get_current_and_next_segments([]) == [] + + +def test_single_item_array(): + assert get_current_and_next_segments(["a"]) == [] + + +def test_two_item_array(): + assert get_current_and_next_segments(["a", "b"]) == [("a", "b")] + + +def test_three_item_array(): + assert get_current_and_next_segments(["a", "b", "c"]) == [("a", "b"), ("b", "c")] + + +def test_four_item_array(): + assert get_current_and_next_segments(["a", "b", "c", "d"]) == [ + ("a", "b"), + ("b", "c"), + ("c", "d"), + ] diff --git a/aikido_firewall/helpers/is_plain_object.py b/aikido_firewall/helpers/is_plain_object.py new file mode 100644 index 000000000..7b1e3b34c --- /dev/null +++ b/aikido_firewall/helpers/is_plain_object.py @@ -0,0 +1,11 @@ +""" +Helper function file, see funtion definition +""" + + +def is_plain_object(o): + """ + Checks if the object is a plain object, + i.e. a dictionary in Python + """ + return str(type(o)) == "" diff --git a/aikido_firewall/helpers/is_plain_object_test.py b/aikido_firewall/helpers/is_plain_object_test.py new file mode 100644 index 000000000..cc9d84b9a --- /dev/null +++ b/aikido_firewall/helpers/is_plain_object_test.py @@ -0,0 +1,34 @@ +import pytest +from aikido_firewall.helpers.is_plain_object import is_plain_object + + +def test_is_plain_object_true(): + assert is_plain_object({}) == True + assert is_plain_object({"foo": "bar"}) == True + + +def test_is_plain_object_false(): + class Foo: + def __init__(self): + self.abc = {} + + foo_instance = Foo() + + assert is_plain_object("/foo/") == False + assert is_plain_object(lambda: None) == False + assert is_plain_object(1) == False + assert is_plain_object(["foo", "bar"]) == False + assert is_plain_object([]) == False + assert is_plain_object(foo_instance) == False + assert is_plain_object(None) == False + + +def test_is_plain_object_modified_prototype(): + class CustomConstructor: + pass + + CustomConstructor.prototype = list + + instance = CustomConstructor() + + assert is_plain_object(instance) == False diff --git a/aikido_firewall/helpers/try_decode_as_jwt.py b/aikido_firewall/helpers/try_decode_as_jwt.py new file mode 100644 index 000000000..d793f0ac8 --- /dev/null +++ b/aikido_firewall/helpers/try_decode_as_jwt.py @@ -0,0 +1,25 @@ +""" +Helper function file, see function docstrin +""" + +import base64 +import json + + +def try_decode_as_jwt(jwt): + """ + This will try to decode a string as a JWT + """ + if "." not in jwt: + return (False, None) + + parts = jwt.split(".") + + if len(parts) != 3: + return (False, None) + + try: + jwt = json.loads(base64.b64decode(str.encode(parts[1]) + b"==").decode("utf-8")) + return (True, jwt) + except Exception: + return (False, None) diff --git a/aikido_firewall/helpers/try_decode_as_jwt_test.py b/aikido_firewall/helpers/try_decode_as_jwt_test.py new file mode 100644 index 000000000..2fe4d1d72 --- /dev/null +++ b/aikido_firewall/helpers/try_decode_as_jwt_test.py @@ -0,0 +1,30 @@ +import pytest +from aikido_firewall.helpers.try_decode_as_jwt import try_decode_as_jwt + + +def test_returns_false_for_empty_string(): + assert try_decode_as_jwt("") == (False, None) + + +def test_returns_false_for_invalid_JWT(): + assert try_decode_as_jwt("invalid") == (False, None) + assert try_decode_as_jwt("invalid.invalid") == (False, None) + assert try_decode_as_jwt("invalid.invalid.invalid") == (False, None) + assert try_decode_as_jwt("invalid.invalid.invalid.invalid") == (False, None) + + +def test_returns_payload_for_invalid_JWT(): + assert try_decode_as_jwt("/;ping%20localhost;.e30=.") == (True, {}) + assert try_decode_as_jwt("/;ping%20localhost;.W10=.") == (True, []) + + +def test_returns_decoded_JWT_for_valid_JWT(): + assert try_decode_as_jwt( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcm5hbWUiOnsiJG5lIjpudWxsfSwiaWF0IjoxNTE2MjM5MDIyfQ._jhGJw9WzB6gHKPSozTFHDo9NOHs3CNOlvJ8rWy6VrQ" + ) == (True, {"sub": "1234567890", "username": {"$ne": None}, "iat": 1516239022}) + + +def test_returns_decoded_JWT_for_valid_JWT_with_bearer_prefix(): + assert try_decode_as_jwt( + "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcm5hbWUiOnsiJG5lIjpudWxsfSwiaWF0IjoxNTE2MjM5MDIyfQ._jhGJw9WzB6gHKPSozTFHDo9NOHs3CNOlvJ8rWy6VrQ" + ) == (True, {"sub": "1234567890", "username": {"$ne": None}, "iat": 1516239022}) diff --git a/aikido_firewall/sinks/pymysql.py b/aikido_firewall/sinks/pymysql.py index 029e56a77..c474c82e5 100644 --- a/aikido_firewall/sinks/pymysql.py +++ b/aikido_firewall/sinks/pymysql.py @@ -4,9 +4,14 @@ import copy import logging +import json from importlib.metadata import version import importhook from aikido_firewall.context import get_current_context +from aikido_firewall.vulnerabilities.sql_injection.check_context_for_sql_injection import ( + check_context_for_sql_injection, +) +from aikido_firewall.vulnerabilities.sql_injection.dialects import MySQL logger = logging.getLogger("aikido_firewall") @@ -25,9 +30,13 @@ def on_flask_import(mysql): def aikido_new_query(_self, sql, unbuffered=False): logger.debug("Wrapper - `pymysql` version : %s", version("pymysql")) - logger.debug("Sql : %s", sql) + context = get_current_context() - logger.debug("Context according to MySQL wrapper : %s", context) + result = check_context_for_sql_injection(sql, "Test_op", context, MySQL()) + + logger.info("sql_injection results : %s", json.dumps(result)) + if result: + raise Exception("SQL Injection [aikido_firewall]") return prev_query_function(_self, sql, unbuffered=False) # pylint: disable=no-member diff --git a/aikido_firewall/vulnerabilities/__init__.py b/aikido_firewall/vulnerabilities/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/aikido_firewall/vulnerabilities/sql_injection/__init__.py b/aikido_firewall/vulnerabilities/sql_injection/__init__.py new file mode 100644 index 000000000..1730a4b96 --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/__init__.py @@ -0,0 +1,42 @@ +""" +SQL Injection algorithm +""" + +from aikido_firewall.vulnerabilities.sql_injection.dialects import MySQL +from aikido_firewall.vulnerabilities.sql_injection.query_contains_user_input import ( + query_contains_user_input, +) +from aikido_firewall.vulnerabilities.sql_injection.userinput_contains_sql_syntax import ( + userinput_contains_sqlsyntax, +) +from aikido_firewall.vulnerabilities.sql_injection.uinput_occ_safely_encapsulated import ( + uinput_occ_safely_encapsulated, +) +from aikido_firewall.helpers.logging import logger + + +def detect_sql_injection(query, user_input, dialect): + """ + Execute this to check if the query is actually a SQL injection + """ + if len(user_input) <= 1: + # We ignore single characters since they are only able to crash the SQL Server, + # And don't pose a big threat. + return False + + if len(user_input) > len(query): + # We ignore cases where the user input is longer than the query. + # Because the user input can't be part of the query. + return False + + if not query_contains_user_input(query, user_input): + # If the user input is not part of the query, return false (No need to check) + return False + + if uinput_occ_safely_encapsulated(query, user_input): + return False + # Executing our final check with the massive RegEx + logger.debug( + "detect_sql_injection : No reason to return false with : %s", user_input + ) + return userinput_contains_sqlsyntax(user_input, dialect) diff --git a/aikido_firewall/vulnerabilities/sql_injection/check_context_for_sql_injection.py b/aikido_firewall/vulnerabilities/sql_injection/check_context_for_sql_injection.py new file mode 100644 index 000000000..83ec77f38 --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/check_context_for_sql_injection.py @@ -0,0 +1,35 @@ +""" +This will check the context of the request for SQL Injections +""" + +import json +from aikido_firewall.helpers.extract_strings_from_user_input import ( + extract_strings_from_user_input, +) +from aikido_firewall.vulnerabilities.sql_injection import detect_sql_injection +from aikido_firewall.helpers.logging import logger + +SOURCES = ["body", "cookies", "query", "headers"] + + +def check_context_for_sql_injection(sql, operation, context, dialect): + """ + This will check the context of the request for SQL Injections + """ + for source in SOURCES: + logger.debug("Checking source %s for SQL Injection", source) + if hasattr(context, source): + user_inputs = extract_strings_from_user_input(getattr(context, source)) + logger.debug("User inputs : %s", json.dumps(user_inputs)) + for user_input, path in user_inputs.items(): + logger.debug("Checking user input %s", user_input) + if detect_sql_injection(sql, user_input, dialect): + return { + "operation": operation, + "kind": "sql_injection", + "source": source, + "pathToPayload": path, + "metadata": {}, + "payload": user_input, + } + return {} diff --git a/aikido_firewall/vulnerabilities/sql_injection/consts.py b/aikido_firewall/vulnerabilities/sql_injection/consts.py new file mode 100644 index 000000000..4075dbf84 --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/consts.py @@ -0,0 +1,149 @@ +""" +Includes all SQL consts : SQL_KEYWORDS, COMMON_SQL_KEYWORDS, SQL_OPERATORS, + SQL_STRING_CHARS, SQL_DANGEROUS_IN_STRING, SQL_ESCAPE_SEQUENCES +""" + +SQL_KEYWORDS = [ + "INSERT", + "SELECT", + "CREATE", + "DROP", + "DATABASE", + "UPDATE", + "DELETE", + "ALTER", + "GRANT", + "SAVEPOINT", + "COMMIT", + "ROLLBACK", + "TRUNCATE", + "OR", + "AND", + "UNION", + "AS", + "WHERE", + "DISTINCT", + "FROM", + "INTO", + "TOP", + "BETWEEN", + "LIKE", + "IN", + "NULL", + "NOT", + "TABLE", + "INDEX", + "VIEW", + "COUNT", + "SUM", + "AVG", + "MIN", + "MAX", + "GROUP", + "BY", + "HAVING", + "DESC", + "ASC", + "OFFSET", + "FETCH", + "LEFT", + "RIGHT", + "INNER", + "OUTER", + "JOIN", + "EXISTS", + "REVOKE", + "ALL", + "LIMIT", + "ORDER", + "ADD", + "CONSTRAINT", + "COLUMN", + "ANY", + "BACKUP", + "CASE", + "CHECK", + "REPLACE", + "DEFAULT", + "EXEC", + "FOREIGN", + "KEY", + "FULL", + "PROCEDURE", + "ROWNUM", + "SET", + "SESSION", + "GLOBAL", + "UNIQUE", + "VALUES", + "COLLATE", + "IS", +] +# This is a list of common SQL keywords that are not dangerous by themselves +# They will appear in almost any SQL query +# e.g. SELECT * FROM table WHERE column = 'value' LIMIT 1 +# If a query parameter is ?LIMIT=1 it would be blocked +# If the body contains "LIMIT" or "SELECT" it would be blocked +COMMON_SQL_KEYWORDS = [ + "SELECT", + "INSERT", + "FROM", + "WHERE", + "DELETE", + "GROUP", + "BY", + "ORDER", + "LIMIT", + "OFFSET", + "HAVING", + "COUNT", + "SUM", + "AVG", + "MIN", + "MAX", + "DISTINCT", + "AS", + "AND", + "OR", + "NOT", + "IN", + "LIKE", + "BETWEEN", + "IS", + "NULL", + "ALL", + "ANY", + "EXISTS", + "UNIQUE", + "UPDATE", + "INTO", +] +SQL_OPERATORS = [ + "=", + "!", + ";", + "+", + "-", + "*", + "/", + "%", + "&", + "|", + "^", + ">", + "<", + "#", + "::", +] +SQL_STRING_CHARS = ['"', "'", "`"] +SQL_DANGEROUS_IN_STRING = [ + '"', # Double quote + "'", # Single quote + "`", # Backtick + "\\", # Escape character + "/*", # Start of comment + "*/", # End of comment + "--", # Start of comment + "#", # Start of comment +] +SQL_ESCAPE_SEQUENCES = ["\\n", "\\r", "\\t"] diff --git a/aikido_firewall/vulnerabilities/sql_injection/consts_test.py b/aikido_firewall/vulnerabilities/sql_injection/consts_test.py new file mode 100644 index 000000000..ec08c32ee --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/consts_test.py @@ -0,0 +1,49 @@ +import pytest +from aikido_firewall.vulnerabilities.sql_injection.consts import ( + SQL_DANGEROUS_IN_STRING, + COMMON_SQL_KEYWORDS, + SQL_ESCAPE_SEQUENCES, + SQL_KEYWORDS, + SQL_OPERATORS, + SQL_STRING_CHARS, +) + + +def test_sql_keywords_not_empty(): + for keyword in SQL_KEYWORDS: + assert len(keyword) > 0 + + +def test_sql_keywords_uppercase(): + for keyword in SQL_KEYWORDS: + assert keyword == keyword.upper() + + +def test_common_sql_keywords_not_empty(): + for keyword in COMMON_SQL_KEYWORDS: + assert len(keyword) > 0 + + +def test_common_sql_keywords_uppercase(): + for keyword in COMMON_SQL_KEYWORDS: + assert keyword == keyword.upper() + + +def test_sql_operators_not_empty(): + for operator in SQL_OPERATORS: + assert len(operator) > 0 + + +def test_sql_string_chars_single_chars(): + for char in SQL_STRING_CHARS: + assert len(char) == 1 + + +def test_sql_dangerous_in_string_not_empty(): + for char in SQL_DANGEROUS_IN_STRING: + assert len(char) > 0 + + +def test_sql_escape_sequences_not_empty(): + for sequence in SQL_ESCAPE_SEQUENCES: + assert len(sequence) > 0 diff --git a/aikido_firewall/vulnerabilities/sql_injection/dialects/__init__.py b/aikido_firewall/vulnerabilities/sql_injection/dialects/__init__.py new file mode 100644 index 000000000..7a6f22399 --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/dialects/__init__.py @@ -0,0 +1,13 @@ +""" +Dialects __init__.py file, imports the different dialects for quick access +""" + +from aikido_firewall.vulnerabilities.sql_injection.dialects.mysql import ( + SQLDialectMySQL as MySQL, +) +from aikido_firewall.vulnerabilities.sql_injection.dialects.pg import ( + SQLDialectPostgres as Postgres, +) +from aikido_firewall.vulnerabilities.sql_injection.dialects.sqlite import ( + SQLDialectSQLite as SQLite, +) diff --git a/aikido_firewall/vulnerabilities/sql_injection/dialects/abstract.py b/aikido_firewall/vulnerabilities/sql_injection/dialects/abstract.py new file mode 100644 index 000000000..8f3619c28 --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/dialects/abstract.py @@ -0,0 +1,28 @@ +""" +The possible SQL Dialects (Characters that vary based on the language) +e.g. postgres dialect +""" + +from abc import ABC, abstractmethod + + +class SQLDialect(ABC): + """ + A subclass that provides a template to work with + """ + + @abstractmethod + def get_dangerous_strings(self): + """ + Use this to add dangerous strings that are specific to the SQL dialect + These are matched without surrounding spaces, + so if you add "SELECT" it will match "SELECT" and "SELECTED" + """ + + @abstractmethod + def get_keywords(self): + """ + Use this to add keywords that are specific to the SQL dialect + These are matched with surrounding spaces, + so if you add "SELECT" it will match "SELECT" but not "SELECTED" + """ diff --git a/aikido_firewall/vulnerabilities/sql_injection/dialects/dialects_test.py b/aikido_firewall/vulnerabilities/sql_injection/dialects/dialects_test.py new file mode 100644 index 000000000..fe143fd21 --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/dialects/dialects_test.py @@ -0,0 +1,37 @@ +import aikido_firewall.vulnerabilities.sql_injection.dialects as dialects +import pytest + + +@pytest.fixture +def test_dialects(): + return [dialects.MySQL(), dialects.Postgres(), dialects.SQLite()] + + +def test_unique(test_dialects): + """ + Making sure that there are no duplicates in the + get_keywords() or get_dangerous_strings() arrays + """ + for dialect in test_dialects: + dangerous_strings = dialect.get_dangerous_strings() + set_dangerous_strings = set(dangerous_strings) + assert len(set_dangerous_strings) == len(dangerous_strings) + + keywords = dialect.get_keywords() + set_keywords = set(keywords) + assert len(set_keywords) == len(keywords) + + +def test_no_empty_strings(test_dialects): + """ + Making sure that everything in the get_keywords() + or in the get_dangerous_strings() arrays has a length higher than zero + """ + for dialect in test_dialects: + dangerous_strings = dialect.get_dangerous_strings() + for keyword in dangerous_strings: + assert len(keyword) > 0 + + keywords = dialect.get_keywords() + for keyword in keywords: + assert len(keyword) > 0 diff --git a/aikido_firewall/vulnerabilities/sql_injection/dialects/mysql.py b/aikido_firewall/vulnerabilities/sql_injection/dialects/mysql.py new file mode 100644 index 000000000..b45f9bd77 --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/dialects/mysql.py @@ -0,0 +1,28 @@ +""" +File includes MySQL dialect +""" + +from aikido_firewall.vulnerabilities.sql_injection.dialects.abstract import SQLDialect + + +class SQLDialectMySQL(SQLDialect): + """ + This is the MySQL dialect, it includes strings specific to MySQL + """ + + def get_dangerous_strings(self): + return [] + + def get_keywords(self): + return [ + # https://dev.mysql.com/doc/refman/8.0/en/set-variable.html + "GLOBAL", + "SESSION", + "PERSIST", + "PERSIST_ONLY", + "@@GLOBAL", + "@@SESSION", + # https://dev.mysql.com/doc/refman/8.0/en/set-character-set.html + "CHARACTER SET", + "CHARSET", + ] diff --git a/aikido_firewall/vulnerabilities/sql_injection/dialects/pg.py b/aikido_firewall/vulnerabilities/sql_injection/dialects/pg.py new file mode 100644 index 000000000..1ac044f37 --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/dialects/pg.py @@ -0,0 +1,23 @@ +""" +File includes MySQL dialect +""" + +from aikido_firewall.vulnerabilities.sql_injection.dialects.abstract import SQLDialect + + +class SQLDialectPostgres(SQLDialect): + """ + This is the Postgresql dialect, it includes strings specific to PG + """ + + def get_dangerous_strings(self): + return [ + # https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-DOLLAR-QUOTING + "$", + ] + + def get_keywords(self): + return [ + # https://www.postgresql.org/docs/current/sql-set.html + "CLIENT_ENCODING", + ] diff --git a/aikido_firewall/vulnerabilities/sql_injection/dialects/sqlite.py b/aikido_firewall/vulnerabilities/sql_injection/dialects/sqlite.py new file mode 100644 index 000000000..652dfa86b --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/dialects/sqlite.py @@ -0,0 +1,17 @@ +""" +File includes SQLite dialect +""" + +from aikido_firewall.vulnerabilities.sql_injection.dialects.abstract import SQLDialect + + +class SQLDialectSQLite(SQLDialect): + """ + This is the SQLite dialect, it includes strings specific to SQLite + """ + + def get_dangerous_strings(self): + return [] + + def get_keywords(self): + return ["VACUUM", "ATTACH", "DETACH"] diff --git a/aikido_firewall/vulnerabilities/sql_injection/payloads/Auth_Bypass.txt b/aikido_firewall/vulnerabilities/sql_injection/payloads/Auth_Bypass.txt new file mode 100644 index 000000000..da70a9779 --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/payloads/Auth_Bypass.txt @@ -0,0 +1,196 @@ +'-' +' ' +'&' +'^' +'*' +' or ''-' +' or '' ' +' or ''&' +' or ''^' +' or ''*' +"-" +" " +"&" +"^" +"*" +" or ""-" +" or "" " +" or ""&" +" or ""^" +" or ""*" +or true-- +" or true-- +' or true-- +") or true-- +') or true-- +' or 'x'='x +') or ('x')=('x +')) or (('x'))=(('x +" or "x"="x +") or ("x")=("x +")) or (("x"))=(("x +or 1=1 +or 1=1-- +or 1=1# +or 1=1/* +admin' -- +admin' # +admin'/* +admin' or '1'='1 +admin' or '1'='1'-- +admin' or '1'='1'# +admin' or '1'='1'/* +admin'or 1=1 or ''=' +admin' or 1=1 +admin' or 1=1-- +admin' or 1=1# +admin' or 1=1/* +admin') or ('1'='1 +admin') or ('1'='1'-- +admin') or ('1'='1'# +admin') or ('1'='1'/* +admin') or '1'='1 +admin') or '1'='1'-- +admin') or '1'='1'# +admin') or '1'='1'/* +1234 ' AND 1=0 UNION ALL SELECT 'admin', '81dc9bdb52d04dc20036dbd8313ed055 +admin" -- +admin" # +admin"/* +admin" or "1"="1 +admin" or "1"="1"-- +admin" or "1"="1"# +admin" or "1"="1"/* +admin"or 1=1 or ""=" +admin" or 1=1 +admin" or 1=1-- +admin" or 1=1# +admin" or 1=1/* +admin") or ("1"="1 +admin") or ("1"="1"-- +admin") or ("1"="1"# +admin") or ("1"="1"/* +admin") or "1"="1 +admin") or "1"="1"-- +admin") or "1"="1"# +admin") or "1"="1"/* +1234 " AND 1=0 UNION ALL SELECT "admin", "81dc9bdb52d04dc20036dbd8313ed055 +== +' -- +' # +' – +'-- +'/* +'# +" -- +" # +"/* +' and 1='1 +' and a='a + or 1=1 + or true +' or ''=' +" or ""=" +1′) and '1′='1– +' AND 1=0 UNION ALL SELECT '', '81dc9bdb52d04dc20036dbd8313ed055 +" AND 1=0 UNION ALL SELECT "", "81dc9bdb52d04dc20036dbd8313ed055 + and 1=1 + and 1=1– +' and 'one'='one +' and 'one'='one– +' group by password having 1=1-- +' group by userid having 1=1-- +' group by username having 1=1-- + like '%' + or 0=0 -- + or 0=0 # + or 0=0 – +' or 0=0 # +' or 0=0 -- +' or 0=0 # +' or 0=0 – +" or 0=0 -- +" or 0=0 # +" or 0=0 – +%' or '0'='0 + or 1=1 + or 1=1-- + or 1=1/* + or 1=1# + or 1=1– +' or 1=1-- +' or '1'='1 +' or '1'='1'-- +' or '1'='1'/* +' or '1'='1'# +' or '1′='1 +' or 1=1 +' or 1=1 -- +' or 1=1 – +' or 1=1-- +' or 1=1;# +' or 1=1/* +' or 1=1# +' or 1=1– +') or '1'='1 +') or '1'='1-- +') or '1'='1'-- +') or '1'='1'/* +') or '1'='1'# +') or ('1'='1 +') or ('1'='1-- +') or ('1'='1'-- +') or ('1'='1'/* +') or ('1'='1'# +'or'1=1 +'or'1=1′ +" or "1"="1 +" or "1"="1"-- +" or "1"="1"/* +" or "1"="1"# +" or 1=1 +" or 1=1 -- +" or 1=1 – +" or 1=1-- +" or 1=1/* +" or 1=1# +" or 1=1– +") or "1"="1 +") or "1"="1"-- +") or "1"="1"/* +") or "1"="1"# +") or ("1"="1 +") or ("1"="1"-- +") or ("1"="1"/* +") or ("1"="1"# +) or '1′='1– +) or ('1′='1– +' or 1=1 LIMIT 1;# +'or 1=1 or ''=' +"or 1=1 or ""=" +' or 'a'='a +' or a=a-- +' or a=a– +') or ('a'='a +" or "a"="a +") or ("a"="a +') or ('a'='a and hi") or ("a"="a +' or 'one'='one +' or 'one'='one– +' or uid like '% +' or uname like '% +' or userid like '% +' or user like '% +' or username like '% +' or 'x'='x +') or ('x'='x +" or "x"="x +' OR 'x'='x'#; +'=' 'or' and '=' 'or' +' UNION ALL SELECT 1, @@version;# +' UNION ALL SELECT system_user(),user();# +' UNION select table_schema,table_name FROM information_Schema.tables;# +admin' and substring(password/text(),1,1)='7 +' and substring(password/text(),1,1)='7 +' or 1=1 limit 1 -- -+ +'="or' \ No newline at end of file diff --git a/aikido_firewall/vulnerabilities/sql_injection/payloads/README.md b/aikido_firewall/vulnerabilities/sql_injection/payloads/README.md new file mode 100644 index 000000000..273f7b0ab --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/payloads/README.md @@ -0,0 +1 @@ +These files originate from [https://github.com/payloadbox/sql-injection-payload-list](https://github.com/payloadbox/sql-injection-payload-list). diff --git a/aikido_firewall/vulnerabilities/sql_injection/payloads/mssql_and_db2.txt b/aikido_firewall/vulnerabilities/sql_injection/payloads/mssql_and_db2.txt new file mode 100644 index 000000000..37775eaea --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/payloads/mssql_and_db2.txt @@ -0,0 +1,23 @@ +select @@version +select @@servernamee +select @@microsoftversione +select * from master..sysserverse +select * from sysusers +exec master..xp_cmdshell 'ipconfig+/all' +exec master..xp_cmdshell 'net+view' +exec master..xp_cmdshell 'net+users' +exec master..xp_cmdshell 'ping+' +BACKUP database master to disks='\\\\backupdb.dat' +create table myfile (line varchar(8000))" bulk insert foo from 'c:\inetpub\wwwroot\auth.asp�'" select * from myfile"-- +select versionnumber, version_timestamp from sysibm.sysversions; +select user from sysibm.sysdummy1; +select session_user from sysibm.sysdummy1; +select system_user from sysibm.sysdummy1; +select current server from sysibm.sysdummy1; +select name from sysibm.systables; +select grantee from syscat.dbauth; +select * from syscat.tabauth; +select * from syscat.dbauth where grantee = current user; +select * from syscat.tabauth where grantee = current user; +select name, tbname, coltype from sysibm.syscolumns; +SELECT schemaname FROM syscat.schemata; \ No newline at end of file diff --git a/aikido_firewall/vulnerabilities/sql_injection/payloads/mysql.txt b/aikido_firewall/vulnerabilities/sql_injection/payloads/mysql.txt new file mode 100644 index 000000000..929309787 --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/payloads/mysql.txt @@ -0,0 +1,6 @@ +' OR 1=1-- +'OR '' = ' Allows authentication without a valid username. +'-- +' union select 1, '', '' 1-- +'OR 1=1-- +create table myfile (input TEXT); load data infile '' into table myfile; select * from myfile; \ No newline at end of file diff --git a/aikido_firewall/vulnerabilities/sql_injection/payloads/postgres.txt b/aikido_firewall/vulnerabilities/sql_injection/payloads/postgres.txt new file mode 100644 index 000000000..aebb46543 --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/payloads/postgres.txt @@ -0,0 +1,19 @@ +select version(); +select current_database(); +select current_user; +select session_user; +select current_setting('log_connections'); +select current_setting('log_statement'); +select current_setting('port'); +select current_setting('password_encryption'); +select current_setting('krb_server_keyfile'); +select current_setting('virtual_host'); +select current_setting('port'); +select current_setting('config_file'); +select current_setting('hba_file'); +select current_setting('data_directory'); +select * from pg_shadow; +select * from pg_group; +create table myfile (input TEXT); +copy myfile from '/etc/passwd'; +select * from myfile;copy myfile to /tmp/test; \ No newline at end of file diff --git a/aikido_firewall/vulnerabilities/sql_injection/query_contains_user_input.py b/aikido_firewall/vulnerabilities/sql_injection/query_contains_user_input.py new file mode 100644 index 000000000..f616c3502 --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/query_contains_user_input.py @@ -0,0 +1,16 @@ +""" +See function docstring +""" + + +def query_contains_user_input(query, user_input): + """ + This function is the first step to determine if an SQL Injection is happening, + If the sql statement contains user input, this function returns true (case-insensitive) + @param query The SQL Statement you want to check it against + @param user_input The user input you want to check + @returns True when the sql statement contains the input + """ + lw_query = query.lower() + lw_input = user_input.lower() + return lw_input in lw_query diff --git a/aikido_firewall/vulnerabilities/sql_injection/query_contains_user_input_test.py b/aikido_firewall/vulnerabilities/sql_injection/query_contains_user_input_test.py new file mode 100644 index 000000000..b6e8d4fec --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/query_contains_user_input_test.py @@ -0,0 +1,20 @@ +""" +Test file for __init__.py +""" + +# pylint: disable=unused-import +import pytest +from aikido_firewall.vulnerabilities.sql_injection.query_contains_user_input import ( + query_contains_user_input, +) + + +class TestInitSqlInjection: + """Testing class""" + + def test(self): + """it checks if query contains user input""" + assert query_contains_user_input("SELECT * FROM 'Jonas';", "Jonas") + assert query_contains_user_input("Hi I'm MJoNaSs", "jonas") + assert query_contains_user_input("Hiya, 123^&*( is a real string", "123^&*(") + assert not query_contains_user_input("Roses are red", "violet") diff --git a/aikido_firewall/vulnerabilities/sql_injection/uinput_occ_safely_encapsulated.py b/aikido_firewall/vulnerabilities/sql_injection/uinput_occ_safely_encapsulated.py new file mode 100644 index 000000000..e2103d4ef --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/uinput_occ_safely_encapsulated.py @@ -0,0 +1,96 @@ +""" +This module mainly provides the function uinput_occ_safely_encapsulated +""" + +import regex as re +from aikido_firewall.helpers.escape_string_regexp import escape_string_regexp +from aikido_firewall.helpers import get_current_and_next_segments +from aikido_firewall.vulnerabilities.sql_injection.consts import ( + SQL_STRING_CHARS, + SQL_ESCAPE_SEQUENCES, +) + +ESCAPE_SEQUENCES_PATTERN = "|".join(map(escape_string_regexp, SQL_ESCAPE_SEQUENCES)) +escape_sequences_regex = re.compile(ESCAPE_SEQUENCES_PATTERN, re.M) + + +def js_slice(arr, start=None, end=None): + """ + A more or less exact replica of the js slice function + """ + length = len(arr) + + # Handle start index + if start is None: + start = 0 + elif start < 0: + start = max(length + start, 0) + else: + start = min(start, length) + + # Handle end index + if end is None: + end = length + elif end < 0: + end = max(length + end, 0) + else: + end = min(end, length) + + # Perform slicing + return arr[start:end] + + +def uinput_occ_safely_encapsulated(query, user_input): + """ + This function will check if user input is actually just safely encapsulated in the query + """ + segments_in_between = get_current_and_next_segments(query.split(user_input)) + + for segment in segments_in_between: + current_seg, next_seg = segment + print(segments_in_between) + print(segment) + input_str = user_input + char_before_user_input = js_slice(current_seg, -1) + char_after_user_input = js_slice(next_seg, 0, 1) + quote_char = None + for char in SQL_STRING_CHARS: + if char == char_before_user_input: + quote_char = char + break + + # Special case for when the user input starts with a single quote + # If the user input is `'value` + # And the single quote is properly escaped with a backslash we split the following + # `SELECT * FROM table WHERE column = '\'value'` + # Into [`SELECT * FROM table WHERE column = '\`, `'`] + # The char before the user input will be `\` and the char after the user input will be `'` + for char in ['"', "'"]: + if ( + not quote_char + and input_str.startswith(char) + and js_slice(current_seg, -2) == f"{char}\\" + and char_after_user_input == char + ): + quote_char = char + char_before_user_input = js_slice(current_seg, -2, -1) + # Remove the escaped quote from the user input + # otherwise we'll flag the user input as NOT safely encapsulated + input_str = js_slice(input_str, 1) + break + + if not quote_char: + return False + + if char_before_user_input != char_after_user_input: + return False + + if char_before_user_input in input_str: + return False + + without_escape_sequences = escape_sequences_regex.sub("", input_str) + + if "\\" in without_escape_sequences: + return False + + return True diff --git a/aikido_firewall/vulnerabilities/sql_injection/uinput_occ_safely_encapsulated_test.py b/aikido_firewall/vulnerabilities/sql_injection/uinput_occ_safely_encapsulated_test.py new file mode 100644 index 000000000..8e561889f --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/uinput_occ_safely_encapsulated_test.py @@ -0,0 +1,199 @@ +import pytest +from aikido_firewall.vulnerabilities.sql_injection.uinput_occ_safely_encapsulated import ( + uinput_occ_safely_encapsulated, + js_slice, +) + + +def test_js_slice_no_start_no_end(): + arr = [1, 2, 3, 4, 5] + result = js_slice(arr) + assert result == [1, 2, 3, 4, 5] + + +def test_js_slice_with_start(): + arr = [1, 2, 3, 4, 5] + result = js_slice(arr, 2) + assert result == [3, 4, 5] + + +def test_js_slice_with_negative_start(): + arr = [1, 2, 3, 4, 5] + result = js_slice(arr, -3) + assert result == [3, 4, 5] + + +def test_js_slice_with_start_and_end(): + arr = [1, 2, 3, 4, 5] + result = js_slice(arr, 1, 4) + assert result == [2, 3, 4] + + +def test_js_slice_with_negative_end(): + arr = [1, 2, 3, 4, 5] + result = js_slice(arr, 1, -1) + assert result == [2, 3, 4] + + +def test_js_slice_with_end_out_of_range(): + arr = [1, 2, 3, 4, 5] + result = js_slice(arr, 1, 10) + assert result == [2, 3, 4, 5] + + +def test_user_input_occurrences_safely_encapsulated(): + assert ( + uinput_occ_safely_encapsulated( + " Hello Hello 'UNION'and also \"UNION\" ", "UNION" + ) + == True + ) + assert uinput_occ_safely_encapsulated('"UNION"', "UNION") == True + assert uinput_occ_safely_encapsulated("`UNION`", "UNION") == True + assert uinput_occ_safely_encapsulated("`U`NION`", "U`NION") == False + assert uinput_occ_safely_encapsulated(" 'UNION' ", "UNION") == True + assert uinput_occ_safely_encapsulated("\"UNION\"'UNION'", "UNION") == True + assert uinput_occ_safely_encapsulated("'UNION'\"UNION\"UNION", "UNION") == False + assert uinput_occ_safely_encapsulated("'UNION'UNION\"UNION\"", "UNION") == False + assert uinput_occ_safely_encapsulated("UNION", "UNION") == False + assert uinput_occ_safely_encapsulated('"UN\'ION"', "UN'ION") == True + assert uinput_occ_safely_encapsulated("'UN\"ION'", 'UN"ION') == True + assert ( + uinput_occ_safely_encapsulated( + "SELECT * FROM cats WHERE id = 'UN\"ION' AND id = \"UN'ION\"", 'UN"ION' + ) + == True + ) + assert ( + uinput_occ_safely_encapsulated( + "SELECT * FROM cats WHERE id = 'UN'ION' AND id = \"UN'ION\"", "UN'ION" + ) + == False + ) + assert ( + uinput_occ_safely_encapsulated( + "SELECT * FROM cats WHERE id = 'UNION\\'", "UNION\\" + ) + == False + ) + assert ( + uinput_occ_safely_encapsulated( + "SELECT * FROM cats WHERE id = 'UNION\\\\'", "UNION\\\\" + ) + == False + ) + assert ( + uinput_occ_safely_encapsulated( + "SELECT * FROM cats WHERE id = 'UNION\\\\\\'", "UNION\\\\\\" + ) + == False + ) + assert ( + uinput_occ_safely_encapsulated( + "SELECT * FROM cats WHERE id = 'UNION\\n'", "UNION\\n" + ) + == True + ) + assert ( + uinput_occ_safely_encapsulated( + "SELECT * FROM users WHERE id = '\\'hello'", "'hello'" + ) + == False + ) + assert ( + uinput_occ_safely_encapsulated( + 'SELECT * FROM users WHERE id = "\\"hello"', '"hello"' + ) + == False + ) + + +def test_surrounded_with_single_quotes(): + assert ( + uinput_occ_safely_encapsulated( + "SELECT * FROM users WHERE id = '\\'hello\\''", "'hello'" + ) + == True + ) + + +def test_surrounded_with_double_quotes(): + assert ( + uinput_occ_safely_encapsulated( + 'SELECT * FROM users WHERE id = "\\"hello\\""', '"hello"' + ) + == True + ) + + +def test_starts_with_single_quote(): + assert ( + uinput_occ_safely_encapsulated( + "SELECT * FROM users WHERE id = '\\' or true--'", "' or true--" + ) + == True + ) + + +def test_starts_with_double_quote(): + assert ( + uinput_occ_safely_encapsulated( + 'SELECT * FROM users WHERE id = "\\" or true--"', '" or true--' + ) + == True + ) + + +def test_starts_with_single_quote_without_sql_syntax(): + assert ( + uinput_occ_safely_encapsulated( + "SELECT * FROM users WHERE id = '\\' hello world'", "' hello world" + ) + == True + ) + + +def test_starts_with_double_quote_without_sql_syntax(): + assert ( + uinput_occ_safely_encapsulated( + 'SELECT * FROM users WHERE id = "\\" hello world"', '" hello world' + ) + == True + ) + + +def test_starts_with_single_quote_multiple_occurrences(): + assert ( + uinput_occ_safely_encapsulated( + "SELECT * FROM users WHERE id = '\\'hello' AND id = '\\'hello'", "'hello" + ) + == True + ) + assert ( + uinput_occ_safely_encapsulated( + "SELECT * FROM users WHERE id = 'hello' AND id = '\\'hello'", "'hello" + ) + == False + ) + + +def test_starts_with_double_quote_multiple_occurrences(): + assert ( + uinput_occ_safely_encapsulated( + 'SELECT * FROM users WHERE id = "\\"hello" AND id = "\\"hello"', '"hello' + ) + == True + ) + assert ( + uinput_occ_safely_encapsulated( + 'SELECT * FROM users WHERE id = "hello" AND id = "\\"hello"', '"hello' + ) + == False + ) + + +def test_single_quotes_escaped_with_single_quotes(): + assert ( + uinput_occ_safely_encapsulated("SELECT * FROM users WHERE id = '''&'''", "'&'") + == False + ) diff --git a/aikido_firewall/vulnerabilities/sql_injection/userinput_contains_sql_syntax.py b/aikido_firewall/vulnerabilities/sql_injection/userinput_contains_sql_syntax.py new file mode 100644 index 000000000..88313e0c4 --- /dev/null +++ b/aikido_firewall/vulnerabilities/sql_injection/userinput_contains_sql_syntax.py @@ -0,0 +1,108 @@ +""" +Still need to figure out what to put here lol +""" + +import regex as re +from aikido_firewall.vulnerabilities.sql_injection.consts import ( + SQL_DANGEROUS_IN_STRING, + COMMON_SQL_KEYWORDS, + SQL_KEYWORDS, + SQL_OPERATORS, +) +from aikido_firewall.helpers import escape_string_regexp +from aikido_firewall.helpers.logging import logger + +cached_regex = {} + + +def userinput_contains_sqlsyntax(user_input, dialect): + """ + This function is the first check in order to determine if a SQL injection is happening, + If the user input contains the necessary characters or words for a SQL injection, this + function returns true. + """ + + # e.g. SELECT * FROM table WHERE column = 'value' LIMIT 1 + # If a query parameter is ?LIMIT=1 it would be blocked + # If the body contains "LIMIT" or "SELECT" it would be blocked + # These are common SQL keywords and appear in almost any SQL query + if user_input.upper() in COMMON_SQL_KEYWORDS: + logger.debug("User input in upper, returning false for %s", user_input) + return False + regex = cached_regex.get(dialect.__class__.__name__) + + if not regex: + logger.debug("Regex not found in cache") + regex = build_regex(dialect) + cached_regex[dialect.__class__.__name__] = regex + return bool(regex.search(user_input)) + + +def build_regex(dialect): + """ + This function builds our regex that will test for sql syntax + """ + match_strings = [ + gen_match_sql_keywords(dialect), + gen_match_sql_operators(), + gen_match_sql_functions(), + gen_match_dangerous_strings(dialect), + ] + logger.debug(match_strings) + return re.compile("|".join(map(lambda x: x.pattern, match_strings)), re.I | re.M) + + +def gen_match_sql_keywords(dialect): + """ + Generate the string which matches sql keywords (dialect included) + """ + escaped_kw_with_dialect = map( + escape_string_regexp, SQL_KEYWORDS + dialect.get_keywords() + ) + match_sql_keywords = [ + # Lookbehind : if the keywords are preceded by one or more letters, it should not match + r"(?=5.0)"] +[[package]] +name = "regex" +version = "2024.5.15" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.8" +files = [ + {file = "regex-2024.5.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a81e3cfbae20378d75185171587cbf756015ccb14840702944f014e0d93ea09f"}, + {file = "regex-2024.5.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7b59138b219ffa8979013be7bc85bb60c6f7b7575df3d56dc1e403a438c7a3f6"}, + {file = "regex-2024.5.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0bd000c6e266927cb7a1bc39d55be95c4b4f65c5be53e659537537e019232b1"}, + {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5eaa7ddaf517aa095fa8da0b5015c44d03da83f5bd49c87961e3c997daed0de7"}, + {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba68168daedb2c0bab7fd7e00ced5ba90aebf91024dea3c88ad5063c2a562cca"}, + {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e8d717bca3a6e2064fc3a08df5cbe366369f4b052dcd21b7416e6d71620dca1"}, + {file = "regex-2024.5.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1337b7dbef9b2f71121cdbf1e97e40de33ff114801263b275aafd75303bd62b5"}, + {file = "regex-2024.5.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9ebd0a36102fcad2f03696e8af4ae682793a5d30b46c647eaf280d6cfb32796"}, + {file = "regex-2024.5.15-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9efa1a32ad3a3ea112224897cdaeb6aa00381627f567179c0314f7b65d354c62"}, + {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1595f2d10dff3d805e054ebdc41c124753631b6a471b976963c7b28543cf13b0"}, + {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b802512f3e1f480f41ab5f2cfc0e2f761f08a1f41092d6718868082fc0d27143"}, + {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a0981022dccabca811e8171f913de05720590c915b033b7e601f35ce4ea7019f"}, + {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:19068a6a79cf99a19ccefa44610491e9ca02c2be3305c7760d3831d38a467a6f"}, + {file = "regex-2024.5.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1b5269484f6126eee5e687785e83c6b60aad7663dafe842b34691157e5083e53"}, + {file = "regex-2024.5.15-cp310-cp310-win32.whl", hash = "sha256:ada150c5adfa8fbcbf321c30c751dc67d2f12f15bd183ffe4ec7cde351d945b3"}, + {file = "regex-2024.5.15-cp310-cp310-win_amd64.whl", hash = "sha256:ac394ff680fc46b97487941f5e6ae49a9f30ea41c6c6804832063f14b2a5a145"}, + {file = "regex-2024.5.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f5b1dff3ad008dccf18e652283f5e5339d70bf8ba7c98bf848ac33db10f7bc7a"}, + {file = "regex-2024.5.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c6a2b494a76983df8e3d3feea9b9ffdd558b247e60b92f877f93a1ff43d26656"}, + {file = "regex-2024.5.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a32b96f15c8ab2e7d27655969a23895eb799de3665fa94349f3b2fbfd547236f"}, + {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:10002e86e6068d9e1c91eae8295ef690f02f913c57db120b58fdd35a6bb1af35"}, + {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec54d5afa89c19c6dd8541a133be51ee1017a38b412b1321ccb8d6ddbeb4cf7d"}, + {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:10e4ce0dca9ae7a66e6089bb29355d4432caed736acae36fef0fdd7879f0b0cb"}, + {file = "regex-2024.5.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e507ff1e74373c4d3038195fdd2af30d297b4f0950eeda6f515ae3d84a1770f"}, + {file = "regex-2024.5.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1f059a4d795e646e1c37665b9d06062c62d0e8cc3c511fe01315973a6542e40"}, + {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0721931ad5fe0dda45d07f9820b90b2148ccdd8e45bb9e9b42a146cb4f695649"}, + {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:833616ddc75ad595dee848ad984d067f2f31be645d603e4d158bba656bbf516c"}, + {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:287eb7f54fc81546346207c533ad3c2c51a8d61075127d7f6d79aaf96cdee890"}, + {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:19dfb1c504781a136a80ecd1fff9f16dddf5bb43cec6871778c8a907a085bb3d"}, + {file = "regex-2024.5.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:119af6e56dce35e8dfb5222573b50c89e5508d94d55713c75126b753f834de68"}, + {file = "regex-2024.5.15-cp311-cp311-win32.whl", hash = "sha256:1c1c174d6ec38d6c8a7504087358ce9213d4332f6293a94fbf5249992ba54efa"}, + {file = "regex-2024.5.15-cp311-cp311-win_amd64.whl", hash = "sha256:9e717956dcfd656f5055cc70996ee2cc82ac5149517fc8e1b60261b907740201"}, + {file = "regex-2024.5.15-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:632b01153e5248c134007209b5c6348a544ce96c46005d8456de1d552455b014"}, + {file = "regex-2024.5.15-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e64198f6b856d48192bf921421fdd8ad8eb35e179086e99e99f711957ffedd6e"}, + {file = "regex-2024.5.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68811ab14087b2f6e0fc0c2bae9ad689ea3584cad6917fc57be6a48bbd012c49"}, + {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8ec0c2fea1e886a19c3bee0cd19d862b3aa75dcdfb42ebe8ed30708df64687a"}, + {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0c0c0003c10f54a591d220997dd27d953cd9ccc1a7294b40a4be5312be8797b"}, + {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2431b9e263af1953c55abbd3e2efca67ca80a3de8a0437cb58e2421f8184717a"}, + {file = "regex-2024.5.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a605586358893b483976cffc1723fb0f83e526e8f14c6e6614e75919d9862cf"}, + {file = "regex-2024.5.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391d7f7f1e409d192dba8bcd42d3e4cf9e598f3979cdaed6ab11288da88cb9f2"}, + {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9ff11639a8d98969c863d4617595eb5425fd12f7c5ef6621a4b74b71ed8726d5"}, + {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4eee78a04e6c67e8391edd4dad3279828dd66ac4b79570ec998e2155d2e59fd5"}, + {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8fe45aa3f4aa57faabbc9cb46a93363edd6197cbc43523daea044e9ff2fea83e"}, + {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d0a3d8d6acf0c78a1fff0e210d224b821081330b8524e3e2bc5a68ef6ab5803d"}, + {file = "regex-2024.5.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c486b4106066d502495b3025a0a7251bf37ea9540433940a23419461ab9f2a80"}, + {file = "regex-2024.5.15-cp312-cp312-win32.whl", hash = "sha256:c49e15eac7c149f3670b3e27f1f28a2c1ddeccd3a2812cba953e01be2ab9b5fe"}, + {file = "regex-2024.5.15-cp312-cp312-win_amd64.whl", hash = "sha256:673b5a6da4557b975c6c90198588181029c60793835ce02f497ea817ff647cb2"}, + {file = "regex-2024.5.15-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:87e2a9c29e672fc65523fb47a90d429b70ef72b901b4e4b1bd42387caf0d6835"}, + {file = "regex-2024.5.15-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c3bea0ba8b73b71b37ac833a7f3fd53825924165da6a924aec78c13032f20850"}, + {file = "regex-2024.5.15-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bfc4f82cabe54f1e7f206fd3d30fda143f84a63fe7d64a81558d6e5f2e5aaba9"}, + {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5bb9425fe881d578aeca0b2b4b3d314ec88738706f66f219c194d67179337cb"}, + {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64c65783e96e563103d641760664125e91bd85d8e49566ee560ded4da0d3e704"}, + {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf2430df4148b08fb4324b848672514b1385ae3807651f3567871f130a728cc3"}, + {file = "regex-2024.5.15-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5397de3219a8b08ae9540c48f602996aa6b0b65d5a61683e233af8605c42b0f2"}, + {file = "regex-2024.5.15-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:455705d34b4154a80ead722f4f185b04c4237e8e8e33f265cd0798d0e44825fa"}, + {file = "regex-2024.5.15-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b2b6f1b3bb6f640c1a92be3bbfbcb18657b125b99ecf141fb3310b5282c7d4ed"}, + {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3ad070b823ca5890cab606c940522d05d3d22395d432f4aaaf9d5b1653e47ced"}, + {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:5b5467acbfc153847d5adb21e21e29847bcb5870e65c94c9206d20eb4e99a384"}, + {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:e6662686aeb633ad65be2a42b4cb00178b3fbf7b91878f9446075c404ada552f"}, + {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:2b4c884767504c0e2401babe8b5b7aea9148680d2e157fa28f01529d1f7fcf67"}, + {file = "regex-2024.5.15-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3cd7874d57f13bf70078f1ff02b8b0aa48d5b9ed25fc48547516c6aba36f5741"}, + {file = "regex-2024.5.15-cp38-cp38-win32.whl", hash = "sha256:e4682f5ba31f475d58884045c1a97a860a007d44938c4c0895f41d64481edbc9"}, + {file = "regex-2024.5.15-cp38-cp38-win_amd64.whl", hash = "sha256:d99ceffa25ac45d150e30bd9ed14ec6039f2aad0ffa6bb87a5936f5782fc1569"}, + {file = "regex-2024.5.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13cdaf31bed30a1e1c2453ef6015aa0983e1366fad2667657dbcac7b02f67133"}, + {file = "regex-2024.5.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cac27dcaa821ca271855a32188aa61d12decb6fe45ffe3e722401fe61e323cd1"}, + {file = "regex-2024.5.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7dbe2467273b875ea2de38ded4eba86cbcbc9a1a6d0aa11dcf7bd2e67859c435"}, + {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f18a9a3513a99c4bef0e3efd4c4a5b11228b48aa80743be822b71e132ae4f5"}, + {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d347a741ea871c2e278fde6c48f85136c96b8659b632fb57a7d1ce1872547600"}, + {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1878b8301ed011704aea4c806a3cadbd76f84dece1ec09cc9e4dc934cfa5d4da"}, + {file = "regex-2024.5.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4babf07ad476aaf7830d77000874d7611704a7fcf68c9c2ad151f5d94ae4bfc4"}, + {file = "regex-2024.5.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:35cb514e137cb3488bce23352af3e12fb0dbedd1ee6e60da053c69fb1b29cc6c"}, + {file = "regex-2024.5.15-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cdd09d47c0b2efee9378679f8510ee6955d329424c659ab3c5e3a6edea696294"}, + {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:72d7a99cd6b8f958e85fc6ca5b37c4303294954eac1376535b03c2a43eb72629"}, + {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a094801d379ab20c2135529948cb84d417a2169b9bdceda2a36f5f10977ebc16"}, + {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c0c18345010870e58238790a6779a1219b4d97bd2e77e1140e8ee5d14df071aa"}, + {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:16093f563098448ff6b1fa68170e4acbef94e6b6a4e25e10eae8598bb1694b5d"}, + {file = "regex-2024.5.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e38a7d4e8f633a33b4c7350fbd8bad3b70bf81439ac67ac38916c4a86b465456"}, + {file = "regex-2024.5.15-cp39-cp39-win32.whl", hash = "sha256:71a455a3c584a88f654b64feccc1e25876066c4f5ef26cd6dd711308aa538694"}, + {file = "regex-2024.5.15-cp39-cp39-win_amd64.whl", hash = "sha256:cab12877a9bdafde5500206d1020a584355a97884dfd388af3699e9137bf7388"}, + {file = "regex-2024.5.15.tar.gz", hash = "sha256:d3ee02d9e5f482cc8309134a91eeaacbdd2261ba111b0fef3748eeb4913e6a2c"}, +] + [[package]] name = "tomlkit" version = "0.13.0" @@ -539,4 +627,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "94d48d4cdd270d7de265606c56fa5cd5c69076a84920c5071523b4be5128e955" +content-hash = "834b3f397a99c7e910e20f7f9904408c3a27141d136decaf4fb7233253984306" diff --git a/pyproject.toml b/pyproject.toml index ab78cdea0..7a7caa139 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ pytest-cov = "^5.0.0" pytest-mock = "^3.14.0" werkzeug = "^3.0.3" flask-http-middleware = "^0.4.2" +regex = "^2024.5.15" [tool.poetry.group.dev.dependencies] black = "^24.4.2"