diff --git a/.github/workflows/end2end.yml b/.github/workflows/end2end.yml index b0aa349bb..da9c0fb22 100644 --- a/.github/workflows/end2end.yml +++ b/.github/workflows/end2end.yml @@ -25,16 +25,15 @@ jobs: strategy: matrix: app: - - { name: django-mysql, testfile: end2end/django_mysql_test.py } - - { name: django-mysql-gunicorn, testfile: end2end/django_mysql_gunicorn_test.py } - - { name: django-postgres-gunicorn, testfile: end2end/django_postgres_gunicorn_test.py } - - { name: flask-mongo, testfile: end2end/flask_mongo_test.py } + - { name: django-mysql, testfile: end2end/django_mysql.py } + - { name: django-mysql-gunicorn, testfile: end2end/django_mysql_gunicorn.py } + - { name: django-postgres-gunicorn, testfile: end2end/django_postgres_gunicorn.py } + - { name: flask-mongo, testfile: end2end/flask_mongo.py } - { name: flask-mysql, testfile: end2end/flask_mysql_test.py } - - { name: flask-mysql-uwsgi, testfile: end2end/flask_mysql_uwsgi_test.py } - - { name: flask-postgres, testfile: end2end/flask_postgres_test.py } - - { name: flask-postgres-xml, testfile: end2end/flask_postgres_xml_test.py } - - { name: flask-postgres-xml, testfile: end2end/flask_postgres_xml_lxml_test.py } - - { name: quart-postgres-uvicorn, testfile: end2end/quart_postgres_uvicorn_test.py } + - { name: flask-mysql-uwsgi, testfile: end2end/flask_mysql_uwsgi.py } + - { name: flask-postgres, testfile: end2end/flask_postgres.py } + - { name: flask-postgres-xml, testfile: end2end/flask_postgres_xml.py } + - { name: quart-postgres-uvicorn, testfile: end2end/quart_postgres_uvicorn.py } - { name: starlette-postgres-uvicorn, testfile: end2end/starlette_postgres_uvicorn_test.py } python-version: ["3.10", "3.11", "3.12", "3.13"] steps: @@ -65,7 +64,7 @@ jobs: - name: Start application working-directory: ./sample-apps/${{ matrix.app.name }} run: | - nohup make run > output.log & tail -f output.log & sleep 20 - nohup make runZenDisabled & sleep 20 + nohup make run > output.log & sleep 1 + nohup make runZenDisabled & sleep 1 - name: Run end2end tests for application - run: tail -f ./sample-apps/${{ matrix.app.name }}/output.log & poetry run pytest ./${{ matrix.app.testfile }} + run: tail -f ./sample-apps/${{ matrix.app.name }}/output.log & poetry run python ./${{ matrix.app.testfile }} diff --git a/end2end/django_mysql.py b/end2end/django_mysql.py new file mode 100644 index 000000000..05ace97b5 --- /dev/null +++ b/end2end/django_mysql.py @@ -0,0 +1,80 @@ +from end2end.utils import assert_eq +from utils import App, Request + +django_mysql_app = App(8080) + +django_mysql_app.add_payload( + "test_sql_injection", + safe_request=Request("/app/create", data_type="form", body={"dog_name": "Bobby Tables"}), + unsafe_request=Request("/app/create", data_type="form", body={"dog_name": 'Dangerous bobby", 1); -- '}), + test_event={ + "blocked": True, + "kind": "sql_injection", + 'metadata': { + 'dialect': 'mysql', + 'sql': 'INSERT INTO sample_app_dogs (dog_name, dog_boss) VALUES ("Dangerous bobby", 1); -- ", "N/A")' + }, + 'operation': 'MySQLdb.Cursor.execute', + 'pathToPayload': '.dog_name.[0]', + 'payload': '"Dangerous bobby\\", 1); -- "', + 'source': "body", + } +) + +django_mysql_app.add_payload( + "test_shell_injection", + safe_request=None, # Don't test safeness + unsafe_request=Request("/app/shell/ls -la", "GET"), + test_event={ + "blocked": True, + "kind": "shell_injection", + 'metadata': {'command': 'ls -la'}, + 'operation': 'subprocess.Popen', + 'pathToPayload': '.[0]', + 'payload': '"ls -la"', + 'source': "route_params", + } +) + + +def test_heartbeat(app): + heartbeat = app.get_heartbeat() + route1 = heartbeat["routes"][0] + stats = heartbeat["stats"] + packages = set(map(lambda x: x["name"], heartbeat["packages"])) + + # Validate routes + assert_eq(route1["path"], "/app/create") + assert_eq(route1["method"], "POST") + assert_eq(route1["hits"], 1) + + assert_eq(route1["apispec"]["body"]["type"], "form-urlencoded") + assert_eq(route1["apispec"]["body"]["schema"], { + 'type': 'object', + 'properties': { + 'dog_name': { + 'items': {'type': 'string'}, + 'type': 'array' + } + } + }) + assert_eq(route1["apispec"]["query"], None) + assert_eq(route1["apispec"]["auth"], None) + + # Validate stats + assert_eq(stats["requests"]["attacksDetected"]["blocked"], 2) + assert_eq(stats["requests"]["attacksDetected"]["total"], 2) + # There are 3-4 requests : + # 1. is website live request, first request not always counted + # 2. /app/create safe + # 3. /app/create sql inj + # 4. /app/shell/ls -la shell inj + total = stats["requests"]["total"] + assert 3 <= total <= 4, f"Unexpected amount of total requests {total}" + + # Validate packages + assert_eq(packages, {'wrapt', 'asgiref', 'aikido_zen', 'django', 'sqlparse', 'regex', 'mysqlclient'}) + + +django_mysql_app.test_all_payloads() +test_heartbeat(django_mysql_app) diff --git a/end2end/django_mysql_gunicorn.py b/end2end/django_mysql_gunicorn.py new file mode 100644 index 000000000..79f37557d --- /dev/null +++ b/end2end/django_mysql_gunicorn.py @@ -0,0 +1,23 @@ +from utils import App, Request + +django_mysql_gunicorn_app = App(8082) + +django_mysql_gunicorn_app.add_payload( + "test_sql_injection", + safe_request=Request("/app/create/", data_type="form", body={"dog_name": "Bobby Tables"}), + unsafe_request=Request("/app/create/", data_type="form", body={"dog_name": 'Dangerous bobby", 1); -- '}), + test_event={ + "blocked": True, + "kind": "sql_injection", + 'metadata': { + 'dialect': 'mysql', + 'sql': 'INSERT INTO sample_app_dogs (dog_name, dog_boss) VALUES ("Dangerous bobby", 1); -- ", "N/A")' + }, + 'operation': 'MySQLdb.Cursor.execute', + 'pathToPayload': '.dog_name.[0]', + 'payload': '"Dangerous bobby\\", 1); -- "', + 'source': "body", + } +) + +django_mysql_gunicorn_app.test_all_payloads() diff --git a/end2end/django_mysql_gunicorn_test.py b/end2end/django_mysql_gunicorn_test.py deleted file mode 100644 index 8b68f1978..000000000 --- a/end2end/django_mysql_gunicorn_test.py +++ /dev/null @@ -1,61 +0,0 @@ -import pytest -import requests -import time -from .server.check_events_from_mock import fetch_events_from_mock, validate_started_event, filter_on_event_type - -# e2e tests for django_mysql_gunicorn sample app -post_url_fw = "http://localhost:8082/app/create/" -post_url_nofw = "http://localhost:8083/app/create/" - -def test_firewall_started_okay(): - events = fetch_events_from_mock("http://localhost:5000") - started_events = filter_on_event_type(events, "started") - assert len(started_events) == 1 - validate_started_event(started_events[0], ["gunicorn", "django"]) - -def test_safe_response_with_firewall(): - dog_name = "Bobby Tables" - res = requests.post(post_url_fw, data={'dog_name': dog_name}) - assert res.status_code == 200 - - -def test_safe_response_without_firewall(): - dog_name = "Bobby Tables" - res = requests.post(post_url_nofw, data={'dog_name': dog_name}) - assert res.status_code == 200 - - -def test_dangerous_response_with_firewall(): - dog_name = 'Dangerous bobby", 1); -- ' - res = requests.post(post_url_fw, data={'dog_name': dog_name}) - assert res.status_code == 500 - - time.sleep(5) # Wait for attack to be reported - events = fetch_events_from_mock("http://localhost:5000") - attacks = filter_on_event_type(events, "detected_attack") - - assert len(attacks) == 1 - del attacks[0]["attack"]["stack"] - assert attacks[0]["attack"] == { - "blocked": True, - "kind": "sql_injection", - 'metadata': { - 'dialect': 'mysql', - 'sql': 'INSERT INTO sample_app_dogs (dog_name, dog_boss) VALUES ("Dangerous bobby", 1); -- ", "N/A")' - }, - 'operation': 'MySQLdb.Cursor.execute', - 'pathToPayload': '.dog_name.[0]', - 'payload': '"Dangerous bobby\\", 1); -- "', - 'source': "body", - 'user': None - } - - assert attacks[0]["request"]["source"] == "django" - assert attacks[0]["request"]["route"] == "/app/create" - assert attacks[0]["request"]["userAgent"] == "python-requests/2.32.3" - -def test_dangerous_response_without_firewall(): - dog_name = 'Dangerous bobby", 1); -- ' - res = requests.post(post_url_nofw, data={'dog_name': dog_name}) - assert res.status_code == 200 - diff --git a/end2end/django_mysql_test.py b/end2end/django_mysql_test.py deleted file mode 100644 index 4dab9ec02..000000000 --- a/end2end/django_mysql_test.py +++ /dev/null @@ -1,114 +0,0 @@ -import time -import pytest -import requests -from .server.check_events_from_mock import fetch_events_from_mock, validate_started_event, filter_on_event_type, validate_heartbeat - -# e2e tests for django_mysql sample app -base_url_fw = "http://localhost:8080/app" -base_url_nofw = "http://localhost:8081/app" - -def test_firewall_started_okay(): - events = fetch_events_from_mock("http://localhost:5000") - started_events = filter_on_event_type(events, "started") - assert len(started_events) == 1 - validate_started_event(started_events[0], ["django", "mysqlclient"]) - -def test_safe_response_with_firewall(): - dog_name = "Bobby Tables" - res = requests.post(base_url_fw + "/create", data={'dog_name': dog_name}) - assert res.status_code == 200 - -def test_safe_response_without_firewall(): - dog_name = "Bobby Tables" - res = requests.post(base_url_nofw + "/create", data={'dog_name': dog_name}) - assert res.status_code == 200 - - -def test_dangerous_response_with_firewall(): - dog_name = 'Dangerous bobby", 1); -- ' - res = requests.post(base_url_fw + "/create", data={'dog_name': dog_name}) - assert res.status_code == 500 - time.sleep(5) # Wait for attack to be reported - events = fetch_events_from_mock("http://localhost:5000") - attacks = filter_on_event_type(events, "detected_attack") - - assert len(attacks) == 1 - del attacks[0]["attack"]["stack"] - assert attacks[0]["attack"] == { - "blocked": True, - "kind": "sql_injection", - 'metadata': { - 'dialect': 'mysql', - 'sql': 'INSERT INTO sample_app_dogs (dog_name, dog_boss) VALUES ("Dangerous bobby", 1); -- ", "N/A")' - }, - 'operation': 'MySQLdb.Cursor.execute', - 'pathToPayload': '.dog_name.[0]', - 'payload': '"Dangerous bobby\\", 1); -- "', - 'source': "body", - 'user': None - } - -def test_dangerous_response_with_firewall_shell(): - dog_name = 'Dangerous bobby", 1); -- ' - res = requests.get(base_url_fw + "/shell/ls -la") - assert res.status_code == 500 - time.sleep(5) # Wait for attack to be reported - events = fetch_events_from_mock("http://localhost:5000") - attacks = filter_on_event_type(events, "detected_attack") - - assert len(attacks) == 2 - del attacks[0] # Previous attack - del attacks[0]["attack"]["stack"] - assert attacks[0]["attack"] == { - "blocked": True, - "kind": "shell_injection", - 'metadata': {'command': 'ls -la'}, - 'operation': 'subprocess.Popen', - 'pathToPayload': '.[0]', - 'payload': '"ls -la"', - 'source': "route_params", - 'user': None - } - -def test_dangerous_response_without_firewall(): - dog_name = 'Dangerous bobby", 1); -- ' - res = requests.post(base_url_nofw + "/create", data={'dog_name': dog_name}) - assert res.status_code == 200 - -def test_initial_heartbeat(): - time.sleep(55) # Sleep 5 + 55 seconds for heartbeat - events = fetch_events_from_mock("http://localhost:5000") - heartbeat_events = filter_on_event_type(events, "heartbeat") - assert len(heartbeat_events) == 1 - validate_heartbeat( - heartbeat_events[0], - [{ - "apispec": { - 'body': { - 'type': 'form-urlencoded', - 'schema': { - 'type': 'object', - 'properties': { - 'dog_name': { - 'items': {'type': 'string'}, - 'type': 'array' - } - } - } - }, - 'query': None, - 'auth': None - }, - "hits": 1, - "hits_delta_since_sync": 0, - "method": "POST", - "path": "/app/create" - }], - { - "aborted": 0, - "attacksDetected": {"blocked": 2, "total": 2}, - "total": 3, - 'rateLimited': 0 - }, - {'wrapt', 'asgiref', 'aikido_zen', 'django', 'sqlparse', 'regex', 'mysqlclient'} - ) diff --git a/end2end/django_postgres_gunicorn.py b/end2end/django_postgres_gunicorn.py new file mode 100644 index 000000000..88f423814 --- /dev/null +++ b/end2end/django_postgres_gunicorn.py @@ -0,0 +1,45 @@ +from utils import App, Request + +django_postgres_gunicorn_app = App(8100) + +django_postgres_gunicorn_app.add_payload( + "test_sql_injection", + safe_request=Request("/app/create", data_type="form", body={"dog_name": "Bobby Tables"}), + unsafe_request=Request("/app/create", data_type="form", body={"dog_name": "Dangerous bobby', TRUE); -- "}), + test_event={ + "blocked": True, + "kind": "sql_injection", + 'metadata': { + 'dialect': "postgres", + 'sql': "INSERT INTO sample_app_Dogs (dog_name, is_admin) VALUES ('Dangerous bobby', TRUE); -- ', FALSE)" + }, + 'operation': "psycopg2.Connection.Cursor.execute", + 'pathToPayload': '.dog_name.[0]', + 'payload': "\"Dangerous bobby', TRUE); -- \"", + 'source': "body", + } +) + +django_postgres_gunicorn_app.add_payload( + "test_sql_injection_via_cookies", + safe_request=Request("/app/create/via_cookies", "GET", headers={ + "Cookie": "dog_name=Safe Dog" + }), + unsafe_request=Request("/app/create/via_cookies", "GET", headers={ + "Cookie": "dog_name=Dangerous bobby', TRUE) --; ,2=2" + }), + test_event={ + "blocked": True, + "kind": "sql_injection", + 'metadata': { + 'dialect': "postgres", + 'sql': "INSERT INTO sample_app_Dogs (dog_name, is_admin) VALUES ('Dangerous bobby', TRUE) --', FALSE)" + }, + 'operation': "psycopg2.Connection.Cursor.execute", + 'pathToPayload': '.dog_name', + 'payload': "\"Dangerous bobby', TRUE) --\"", + 'source': "cookies", + } +) + +django_postgres_gunicorn_app.test_all_payloads() diff --git a/end2end/django_postgres_gunicorn_test.py b/end2end/django_postgres_gunicorn_test.py deleted file mode 100644 index 9317775c0..000000000 --- a/end2end/django_postgres_gunicorn_test.py +++ /dev/null @@ -1,84 +0,0 @@ -import pytest -import requests -import time -from .server.check_events_from_mock import fetch_events_from_mock, validate_started_event, filter_on_event_type, \ - clear_events_from_mock - -# e2e tests for django_postgres_gunicorn sample app -post_url_fw = "http://localhost:8100/app/create" -post_url_nofw = "http://localhost:8101/app/create" - -def test_firewall_started_okay(): - events = fetch_events_from_mock("http://localhost:5000") - started_events = filter_on_event_type(events, "started") - assert len(started_events) == 1 - validate_started_event(started_events[0], ["gunicorn", "django", "psycopg2-binary"]) - -def test_safe_response_with_firewall(): - dog_name = "Bobby Tables" - res = requests.post(post_url_fw, data={'dog_name': dog_name}) - assert res.status_code == 200 - - -def test_safe_response_without_firewall(): - dog_name = "Bobby Tables" - res = requests.post(post_url_nofw, data={'dog_name': dog_name}) - assert res.status_code == 200 - - -def test_dangerous_response_with_firewall(): - clear_events_from_mock("http://localhost:5000") - dog_name = "Dangerous bobby', TRUE); -- " - res = requests.post(post_url_fw, data={'dog_name': dog_name}) - assert res.status_code == 500 - - time.sleep(5) # Wait for attack to be reported - events = fetch_events_from_mock("http://localhost:5000") - attacks = filter_on_event_type(events, "detected_attack") - - assert len(attacks) == 1 - del attacks[0]["attack"]["stack"] - assert attacks[0]["attack"] == { - "blocked": True, - "kind": "sql_injection", - 'metadata': { - 'dialect': "postgres", - 'sql': "INSERT INTO sample_app_Dogs (dog_name, is_admin) VALUES ('Dangerous bobby', TRUE); -- ', FALSE)" - }, - 'operation': "psycopg2.Connection.Cursor.execute", - 'pathToPayload': '.dog_name.[0]', - 'payload': "\"Dangerous bobby', TRUE); -- \"", - 'source': "body", - 'user': None - } - - -def test_dangerous_response_with_firewall(): - clear_events_from_mock("http://localhost:5000") - cookie_header = "dog_name=Dangerous bobby', TRUE) --; ,2=2" - res = requests.get(f"{post_url_fw}/via_cookies", headers={ - "Cookie": cookie_header - }) - assert res.status_code == 500 - - time.sleep(5) # Wait for attack to be reported - events = fetch_events_from_mock("http://localhost:5000") - attacks = filter_on_event_type(events, "detected_attack") - - assert len(attacks) == 1 - assert attacks[0]["attack"]["blocked"] - assert attacks[0]["attack"]["kind"] == "sql_injection" - assert attacks[0]["attack"]["metadata"] == { - 'dialect': "postgres", - 'sql': "INSERT INTO sample_app_Dogs (dog_name, is_admin) VALUES ('Dangerous bobby', TRUE) --', FALSE)" - } - assert attacks[0]["attack"]["pathToPayload"] == ".dog_name" - assert attacks[0]["attack"]["source"] == "cookies" - assert attacks[0]["attack"]["payload"] == "\"Dangerous bobby', TRUE) --\"" - - -def test_dangerous_response_without_firewall(): - dog_name = "Dangerous bobby', TRUE); -- " - res = requests.post(post_url_nofw, data={'dog_name': dog_name}) - assert res.status_code == 200 - diff --git a/end2end/flask_mongo.py b/end2end/flask_mongo.py new file mode 100644 index 000000000..8669b955a --- /dev/null +++ b/end2end/flask_mongo.py @@ -0,0 +1,32 @@ +import json + +from utils import App, Request + +flask_mongo_app = App(8094) + +def create_test_dog(): + Request("/create", data_type="form", body=json.dumps({ + {"dog_name": "bobby", "pswd": "1234"} + })).execute(flask_mongo_app.urls["enabled"]) + +flask_mongo_app.add_payload( + "test_nosql_injection", + safe_request=Request("/auth", data_type="form", body=json.dumps( + {"dog_name": "bobby", "pswd": "1234"} + )), + unsafe_request=Request("/auth", data_type="form", body=json.dumps( + {"dog_name": "bobby", "pswd": { "$ne": ""}} + )), + test_event={ + "blocked": True, + "kind": "nosql_injection", + 'metadata': {'filter': '{"dog_name": "bobby_tables", "pswd": {"$ne": ""}}'}, + 'operation': "pymongo.collection.Collection.find", + 'pathToPayload': ".pswd", + 'payload': '{"$ne": ""}', + 'source': "body", + } +) + +create_test_dog() +flask_mongo_app.test_all_payloads() diff --git a/end2end/flask_mysql_uwsgi.py b/end2end/flask_mysql_uwsgi.py new file mode 100644 index 000000000..45e2c0a06 --- /dev/null +++ b/end2end/flask_mysql_uwsgi.py @@ -0,0 +1,23 @@ +from utils import App, Request + +flask_mysql_uwsgi_app = App(8088) + +flask_mysql_uwsgi_app.add_payload( + "test_sql_injection", + safe_request=Request("/create", data_type="form", body={"dog_name": "Bobby Tables"}), + unsafe_request=Request("/create", data_type="form", body={"dog_name": 'Dangerous bobby", 1); -- '}), + test_event={ + "blocked": True, + "kind": "sql_injection", + 'metadata': { + 'dialect': 'mysql', + 'sql': 'INSERT INTO dogs (dog_name, isAdmin) VALUES ("Dangerous bobby", 1); -- ", 0)', + }, + 'operation': 'MySQLdb.Cursor.execute', + 'pathToPayload': '.dog_name', + 'payload': '"Dangerous bobby\\", 1); -- "', + 'source': "body", + } +) + +flask_mysql_uwsgi_app.test_all_payloads() diff --git a/end2end/flask_mysql_uwsgi_test.py b/end2end/flask_mysql_uwsgi_test.py deleted file mode 100644 index c3ce0c1ae..000000000 --- a/end2end/flask_mysql_uwsgi_test.py +++ /dev/null @@ -1,61 +0,0 @@ -import time -import pytest -import requests -from .server.check_events_from_mock import fetch_events_from_mock, validate_started_event, filter_on_event_type - -# e2e tests for flask_mysql_uwsgi sample app -post_url_fw = "http://localhost:8088/create" -post_url_nofw = "http://localhost:8089/create" - -def test_firewall_started_okay(): - events = fetch_events_from_mock("http://localhost:5000") - started_events = filter_on_event_type(events, "started") - assert len(started_events) == 1 - validate_started_event(started_events[0], ["flask", "pymysql", "uwsgi"]) - -def test_safe_response_with_firewall(): - dog_name = "Bobby Tables" - res = requests.post(post_url_fw, data={'dog_name': dog_name}) - assert res.status_code == 200 - - -def test_safe_response_without_firewall(): - dog_name = "Bobby Tables" - res = requests.post(post_url_nofw, data={'dog_name': dog_name}) - assert res.status_code == 200 - - -def test_dangerous_response_with_firewall(): - dog_name = 'Dangerous bobby", 1); -- ' - res = requests.post(post_url_fw, data={'dog_name': dog_name}) - assert res.status_code == 500 - - time.sleep(5) # Wait for attack to be reported - events = fetch_events_from_mock("http://localhost:5000") - attacks = filter_on_event_type(events, "detected_attack") - - assert len(attacks) == 1 - del attacks[0]["attack"]["stack"] - assert attacks[0]["attack"] == { - "blocked": True, - "kind": "sql_injection", - 'metadata': { - 'dialect': 'mysql', - 'sql': 'INSERT INTO dogs (dog_name, isAdmin) VALUES ("Dangerous bobby", 1); -- ", 0)' - }, - 'operation': 'pymysql.Cursor.execute', - 'pathToPayload': '.dog_name', - 'payload': '"Dangerous bobby\\", 1); -- "', - 'source': "body", - 'user': None - } - - assert attacks[0]["request"]["source"] == "flask" - assert attacks[0]["request"]["route"] == "/create" - assert attacks[0]["request"]["userAgent"] == "python-requests/2.32.3" - -def test_dangerous_response_without_firewall(): - dog_name = 'Dangerous bobby", 1); -- ' - res = requests.post(post_url_nofw, data={'dog_name': dog_name}) - assert res.status_code == 200 - diff --git a/end2end/flask_postgres.py b/end2end/flask_postgres.py new file mode 100644 index 000000000..24175af94 --- /dev/null +++ b/end2end/flask_postgres.py @@ -0,0 +1,45 @@ +from utils import App, Request + +flask_postgres_app = App(8090) + +flask_postgres_app.add_payload( + "test_sql_injection", + safe_request=Request("/create", data_type="form", body={"dog_name": "Bobby Tables"}), + unsafe_request=Request("/create", data_type="form", body={"dog_name": "Dangerous Bobby', TRUE); -- "}), + test_event={ + "blocked": True, + "kind": "sql_injection", + "metadata": { + "dialect": "postgres", + "sql": "INSERT INTO dogs (dog_name, isAdmin) VALUES ('Dangerous Bobby', TRUE); -- ', FALSE)" + }, + "operation": "psycopg2.Connection.Cursor.execute", + "pathToPayload": '.dog_name', + "payload": '"Dangerous Bobby\', TRUE); -- "', + "source": "body", + } +) + +flask_postgres_app.add_payload( + "test_sql_injection_via_cookies", + safe_request=Request("/create_with_cookie", "GET", headers={ + "Cookie": "dog_name=Safe Dog; ,2=2" + }), + unsafe_request=Request("/create_with_cookie", "GET", headers={ + "Cookie": "dog_name=Dangerous bobby', TRUE) --; ,2=2" + }), + test_event={ + "blocked": True, + "kind": "sql_injection", + 'metadata': { + 'dialect': "postgres", + 'sql': "INSERT INTO sample_app_Dogs (dog_name, is_admin) VALUES ('Dangerous bobby', TRUE) --', FALSE)" + }, + 'operation': "psycopg2.Connection.Cursor.execute", + 'pathToPayload': '.dog_name', + 'payload': "\"Dangerous bobby', TRUE) --\"", + 'source': "cookies", + } +) + +flask_postgres_app.test_all_payloads() diff --git a/end2end/flask_postgres_test.py b/end2end/flask_postgres_test.py deleted file mode 100644 index 72d668da3..000000000 --- a/end2end/flask_postgres_test.py +++ /dev/null @@ -1,110 +0,0 @@ -import time -import pytest -import json -import requests -from .server.check_events_from_mock import fetch_events_from_mock, validate_started_event, filter_on_event_type - -# e2e tests for flask_postgres sample app -post_url_fw = "http://localhost:8090/create" -post_url_nofw = "http://localhost:8091/create" -get_url_cookie_fw = "http://localhost:8090/create_with_cookie" -get_url_cookie_nofw = "http://localhost:8091/create_with_cookie" - -def test_firewall_started_okay(): - events = fetch_events_from_mock("http://localhost:5000") - started_events = filter_on_event_type(events, "started") - assert len(started_events) == 1 - validate_started_event(started_events[0], ["flask", "psycopg2-binary"]) - -def test_safe_response_with_firewall(): - dog_name = "Bobby Tables" - res = requests.post(post_url_fw, data={'dog_name': dog_name}) - assert res.status_code == 200 - - -def test_safe_response_without_firewall(): - dog_name = "Bobby Tables" - res = requests.post(post_url_nofw, data={'dog_name': dog_name}) - assert res.status_code == 200 - - -def test_dangerous_response_with_firewall(): - dog_name = "Dangerous Bobby', TRUE); -- " - res = requests.post(post_url_fw, data={'dog_name': dog_name}) - assert res.status_code == 500 - -def test_dangerous_response_without_firewall(): - dog_name = "Dangerous Bobby', TRUE); -- " - res = requests.post(post_url_nofw, data={'dog_name': dog_name}) - assert res.status_code == 200 - - -def test_safe_cookie_creation_with_firewall(): - cookies = { - "dog_name": "Bobby Tables", - "corrupt_data": ";;;;;;;;;;;;;" - } - res = requests.get(get_url_cookie_fw, cookies=cookies) - assert res.status_code == 200 - -def test_safe_cookie_creation_without_firewall(): - cookies = { - "dog_name": "Bobby Tables", - "corrupt_data": ";;;;;;;;;;;;;" - - } - res = requests.get(get_url_cookie_nofw, cookies=cookies) - assert res.status_code == 200 - - -def test_dangerous_cookie_creation_with_firewall(): - cookies = { - "dog_name": "Bobby', TRUE) -- ", - "corrupt_data": ";;;;;;;;;;;;;" - } - res = requests.get(get_url_cookie_fw, cookies=cookies) - assert res.status_code == 500 - -def test_dangerous_cookie_creation_without_firewall(): - cookies = { - "dog_name": "Bobby', TRUE) -- ", - "corrupt_data": ";;;;;;;;;;;;;" - } - res = requests.get(get_url_cookie_nofw, cookies=cookies) - assert res.status_code == 200 - -def test_attacks_detected(): - time.sleep(5) # Wait for attack to be reported - events = fetch_events_from_mock("http://localhost:5000") - attacks = filter_on_event_type(events, "detected_attack") - - assert len(attacks) == 2 - del attacks[0]["attack"]["stack"] - del attacks[1]["attack"]["stack"] - - assert attacks[0]["attack"] == { - "blocked": True, - "kind": "sql_injection", - 'metadata': { - 'dialect': "postgres", - 'sql': "INSERT INTO dogs (dog_name, isAdmin) VALUES ('Dangerous Bobby', TRUE); -- ', FALSE)" - }, - 'operation': "psycopg2.Connection.Cursor.execute", - 'pathToPayload': '.dog_name', - 'payload': '"Dangerous Bobby\', TRUE); -- "', - 'source': "body", - 'user': None - } - assert attacks[1]["attack"] == { - "blocked": True, - "kind": "sql_injection", - 'metadata': { - 'dialect': "postgres", - 'sql': "INSERT INTO dogs (dog_name, isAdmin) VALUES ('Bobby', TRUE) --', FALSE)" - }, - 'operation': "psycopg2.Connection.Cursor.execute", - 'pathToPayload': '.dog_name', - 'payload': "\"Bobby', TRUE) --\"", - 'source': "cookies", - 'user': None - } diff --git a/end2end/flask_postgres_xml.py b/end2end/flask_postgres_xml.py new file mode 100644 index 000000000..f902422cb --- /dev/null +++ b/end2end/flask_postgres_xml.py @@ -0,0 +1,32 @@ +from utils import App, Request + +flask_postgres_xml_app = App(8092) + +SQL_INJ_ATTACK_EVENT = { + "blocked": True, + "kind": "sql_injection", + "metadata": { + "dialect": "postgres", + "sql": "INSERT INTO dogs (dog_name, isAdmin) VALUES ('Malicious dog', TRUE); -- ', FALSE)" + }, + "operation": "psycopg2.Connection.Cursor.execute", + "pathToPayload": ".dog_name.[0]", + "payload": "\"Malicious dog', TRUE); -- \"", + "source": "xml", +} + +# Test both xml and lxml +flask_postgres_xml_app.add_payload( + "test_xml_sql_inj", + safe_request=Request("/xml_post", data_type="form", body=''), + unsafe_request=Request("/xml_post", data_type="form", body=''), + test_event=SQL_INJ_ATTACK_EVENT +) +flask_postgres_xml_app.add_payload( + "test_xml_sql_inj_(lxml)", + safe_request=Request("/xml_post_lxml", data_type="form", body=''), + unsafe_request=Request("/xml_post_lxml", data_type="form", body=''), + test_event=SQL_INJ_ATTACK_EVENT +) + +flask_postgres_xml_app.test_all_payloads() diff --git a/end2end/flask_postgres_xml_lxml_test.py b/end2end/flask_postgres_xml_lxml_test.py deleted file mode 100644 index 4f471b86e..000000000 --- a/end2end/flask_postgres_xml_lxml_test.py +++ /dev/null @@ -1,51 +0,0 @@ -import time -import pytest -import requests -from .server.check_events_from_mock import fetch_events_from_mock, validate_started_event, filter_on_event_type - -# e2e tests for flask_postgres sample app -post_url_fw = "http://localhost:8092/xml_post_lxml" -post_url_nofw = "http://localhost:8093/xml_post_lxml" - -def test_safe_response_with_firewall(): - xml_data = '' - res = requests.post(post_url_fw, data=xml_data) - assert res.status_code == 200 - -def test_safe_response_without_firewall(): - xml_data = '' - res = requests.post(post_url_nofw, data=xml_data) - assert res.status_code == 200 - - -def test_dangerous_response_with_firewall(): - xml_data = '' - res = requests.post(post_url_fw, data=xml_data) - assert res.status_code == 500 - - time.sleep(5) # Wait for attack to be reported - events = fetch_events_from_mock("http://localhost:5000") - attacks = filter_on_event_type(events, "detected_attack") - - assert len(attacks) == 1 - del attacks[0]["attack"]["stack"] - - assert attacks[0]["attack"] == { - "blocked": True, - "kind": "sql_injection", - 'metadata': { - 'dialect': "postgres", - 'sql': "INSERT INTO dogs (dog_name, isAdmin) VALUES ('Malicious dog', TRUE); -- ', FALSE)" - }, - 'operation': "psycopg2.Connection.Cursor.execute", - 'pathToPayload': ".dog_name.[0]", - 'payload': "\"Malicious dog', TRUE); -- \"", - 'source': "xml", - 'user': None - } - -def test_dangerous_response_without_firewall(): - xml_data = '' - res = requests.post(post_url_nofw, data=xml_data) - assert res.status_code == 200 - diff --git a/end2end/flask_postgres_xml_test.py b/end2end/flask_postgres_xml_test.py deleted file mode 100644 index 1fceb92fe..000000000 --- a/end2end/flask_postgres_xml_test.py +++ /dev/null @@ -1,56 +0,0 @@ -import time -import pytest -import requests -from .server.check_events_from_mock import fetch_events_from_mock, validate_started_event, filter_on_event_type - -# e2e tests for flask_postgres sample app -post_url_fw = "http://localhost:8092/xml_post" -post_url_nofw = "http://localhost:8093/xml_post" - -def test_firewall_started_okay(): - events = fetch_events_from_mock("http://localhost:5000") - started_events = filter_on_event_type(events, "started") - assert len(started_events) == 1 - validate_started_event(started_events[0], ["flask", "psycopg2-binary", "lxml"]) - -def test_safe_response_with_firewall(): - xml_data = '' - res = requests.post(post_url_fw, data=xml_data) - assert res.status_code == 200 - -def test_safe_response_without_firewall(): - xml_data = '' - res = requests.post(post_url_nofw, data=xml_data) - assert res.status_code == 200 - - -def test_dangerous_response_with_firewall(): - xml_data = '' - res = requests.post(post_url_fw, data=xml_data) - assert res.status_code == 500 - - time.sleep(5) # Wait for attack to be reported - events = fetch_events_from_mock("http://localhost:5000") - attacks = filter_on_event_type(events, "detected_attack") - - assert len(attacks) == 1 - del attacks[0]["attack"]["stack"] - - assert attacks[0]["attack"] == { - "blocked": True, - "kind": "sql_injection", - 'metadata': { - 'dialect': "postgres", - 'sql': "INSERT INTO dogs (dog_name, isAdmin) VALUES ('Malicious dog', TRUE); -- ', FALSE)" - }, - 'operation': "psycopg2.Connection.Cursor.execute", - 'pathToPayload': ".dog_name.[0]", - 'payload': "\"Malicious dog', TRUE); -- \"", - 'source': "xml", - 'user': None - } -def test_dangerous_response_without_firewall(): - xml_data = '' - res = requests.post(post_url_nofw, data=xml_data) - assert res.status_code == 200 - diff --git a/end2end/quart_postgres_uvicorn.py b/end2end/quart_postgres_uvicorn.py new file mode 100644 index 000000000..1263f0348 --- /dev/null +++ b/end2end/quart_postgres_uvicorn.py @@ -0,0 +1,24 @@ +from utils import App, Request + +quart_postgres_uvicorn_app = App(8096) + +quart_postgres_uvicorn_app.add_payload( + "test_sql_injection", + safe_request=Request("/create", data_type="form", body={"dog_name": "Bobby Tables"}), + unsafe_request=Request("/create", data_type="form", body={"dog_name": "Dangerous Bobby', TRUE); -- "}), + test_event={ + "blocked": True, + "kind": "sql_injection", + "metadata": { + 'dialect': "postgres", + 'sql': "INSERT INTO dogs (dog_name, isAdmin) VALUES ('Dangerous Bobby', TRUE); -- ', FALSE)" + }, + "operation": "asyncpg.connection.Connection.execute", + "pathToPayload": ".dog_name", + "payload": "\"Dangerous Bobby', TRUE); -- \"", + "source": "body", + "user_id": "user123" + } +) + +quart_postgres_uvicorn_app.test_all_payloads() diff --git a/end2end/quart_postgres_uvicorn_test.py b/end2end/quart_postgres_uvicorn_test.py deleted file mode 100644 index cef39f695..000000000 --- a/end2end/quart_postgres_uvicorn_test.py +++ /dev/null @@ -1,58 +0,0 @@ -import time -import pytest -import requests -from .server.check_events_from_mock import fetch_events_from_mock, validate_started_event, filter_on_event_type - -# e2e tests for flask_postgres sample app -post_url_fw = "http://localhost:8096/create" -post_url_nofw = "http://localhost:8097/create" - -def test_firewall_started_okay(): - events = fetch_events_from_mock("http://localhost:5000") - started_events = filter_on_event_type(events, "started") - assert len(started_events) == 1 - validate_started_event(started_events[0], None) - -def test_safe_response_with_firewall(): - dog_name = "Bobby Tables" - res = requests.post(post_url_fw, data={'dog_name': dog_name}) - assert res.status_code == 201 - -def test_safe_response_without_firewall(): - dog_name = "Bobby Tables" - res = requests.post(post_url_nofw, data={'dog_name': dog_name}) - assert res.status_code == 201 - - -def test_dangerous_response_with_firewall(): - dog_name = "Dangerous Bobby', TRUE); -- " - res = requests.post(post_url_fw, data={'dog_name': dog_name}) - assert res.status_code == 500 - - time.sleep(5) # Wait for attack to be reported - events = fetch_events_from_mock("http://localhost:5000") - attacks = filter_on_event_type(events, "detected_attack") - - assert len(attacks) == 1 - del attacks[0]["attack"]["stack"] - assert attacks[0]["attack"]["blocked"] == True - assert attacks[0]["attack"]["kind"] == "sql_injection" - assert attacks[0]["attack"]["metadata"]["sql"] == "INSERT INTO dogs (dog_name, isAdmin) VALUES ('Dangerous Bobby', TRUE); -- ', FALSE)" - assert attacks[0]["attack"]["metadata"]["dialect"] == "postgres" - assert attacks[0]["attack"]["operation"] == "asyncpg.connection.Connection.execute" - assert attacks[0]["attack"]["pathToPayload"] == '.dog_name' - assert attacks[0]["attack"]["payload"] == "\"Dangerous Bobby', TRUE); -- \"" - assert attacks[0]["attack"]["source"] == "body" - assert attacks[0]["attack"]["user"]["id"] == "user123" - assert attacks[0]["attack"]["user"]["name"] == "John Doe" - - assert attacks[0]["request"]["source"] == "quart" - assert attacks[0]["request"]["route"] == "/create" - assert attacks[0]["request"]["userAgent"] == "python-requests/2.32.3" - - -def test_dangerous_response_without_firewall(): - dog_name = "Dangerous Bobby', TRUE); -- " - res = requests.post(post_url_nofw, data={'dog_name': dog_name}) - assert res.status_code == 201 - diff --git a/end2end/utils/__init__.py b/end2end/utils/__init__.py new file mode 100644 index 000000000..c32becde1 --- /dev/null +++ b/end2end/utils/__init__.py @@ -0,0 +1,84 @@ +import time +import requests + +from .event_handler import EventHandler +from .assert_equals import assert_eq +from .request import Request +from .test_payloads_safe_vs_unsafe import test_payloads_safe_vs_unsafe + +class App: + def __init__(self, port): + self.urls = { + "enabled": f"http://localhost:{port}", + "disabled": f"http://localhost:{port + 1}" + } + self.payloads = {} + self.event_handler = EventHandler() + if not wait_until_live(self.urls["enabled"]): + raise Exception(self.urls["enabled"] + " is not turning on.") + if not wait_until_live(self.urls["disabled"]): + raise Exception(self.urls["disabled"] + " is not turning on.") + + def add_payload(self,key, safe_request, unsafe_request=None, test_event=None): + self.payloads[key] = { + "safe": safe_request, + "unsafe": unsafe_request, + "test_event": test_event + } + + def test_payload(self, key): + if key not in self.payloads: + raise Exception("Payload " + key + " not found.") + payload = self.payloads.get(key) + + self.event_handler.reset() + test_payloads_safe_vs_unsafe(payload, self.urls) + print("✅ Tested payload: " + key) + + if not payload["test_event"]: + return # Finished tests. + + time.sleep(5) + attacks = self.event_handler.fetch_attacks() + assert_eq(len(attacks), equals=1) + if isinstance(payload["test_event"], dict): + for k, v in payload["test_event"].items(): + if k == "user_id": # exemption rule for user ids + assert_eq(attacks[0]["attack"]["user"]["id"], v) + else: + assert_eq(attacks[0]["attack"][k], equals=v) + print("✅ Tested accurate event reporting for: " + key) + + def test_all_payloads(self): + for key in self.payloads.keys(): + self.test_payload(key) + + def get_heartbeat(self): + print("↺ Fetching latest heartbeat") + + start_time = time.time() + + heartbeats = self.event_handler.fetch_heartbeats() + while len(heartbeats) == 0: + heartbeats = self.event_handler.fetch_heartbeats() + time.sleep(2) + assert_eq(len(heartbeats), equals=1) + + delta = time.time() - start_time + print(f"✅ Fetched heartbeat, time spent: {delta:.2f}s") + + return heartbeats[0] + +def wait_until_live(url): + for i in range(10): + try: + res = requests.get(url, timeout=5) + if res.status_code in [200, 404]: + print("Server is live: " + url) + return True + else: + print("Status code " + str(res.status_code) + " for " + url) + except requests.RequestException as e: + print(f"Request failed: {e}") + time.sleep(5) + return False diff --git a/end2end/utils/assert_equals.py b/end2end/utils/assert_equals.py new file mode 100644 index 000000000..b9cc41ad9 --- /dev/null +++ b/end2end/utils/assert_equals.py @@ -0,0 +1,8 @@ +def assert_eq(val1, equals=None, val2=None, inside=None): + if inside: + assert val1 in inside, f"Assertion failed: Expected {val1} in {inside}" + else: + assert val1 == equals, f"Assertion failed: Expected {equals} != {val1}" + if val2 is not None: + # Pass along val2 as a val1 + assert_eq(val2, equals=equals, inside=inside) diff --git a/end2end/utils/event_handler.py b/end2end/utils/event_handler.py new file mode 100644 index 000000000..7f6832a8c --- /dev/null +++ b/end2end/utils/event_handler.py @@ -0,0 +1,25 @@ +import time +import requests +import json + +class EventHandler: + def __init__(self, url="http://localhost:5000"): + self.url = url + def reset(self): + print("Resetting stored events on mock server") + res = requests.get(self.url + "/mock/reset", timeout=5) + time.sleep(1) + def fetch_events_from_mock(self): + res = requests.get(self.url + "/mock/events", timeout=5) + json_events = json.loads(res.content.decode("utf-8")) + return json_events + + def fetch_attacks(self): + return filter_on_event_type(self.fetch_events_from_mock(), "detected_attack") + + def fetch_heartbeats(self): + return filter_on_event_type(self.fetch_events_from_mock(), "heartbeat") + + +def filter_on_event_type(events, type): + return [event for event in events if event["type"] == type] diff --git a/end2end/utils/request.py b/end2end/utils/request.py new file mode 100644 index 000000000..4a4eff6a8 --- /dev/null +++ b/end2end/utils/request.py @@ -0,0 +1,30 @@ +import requests + +class Request: + def __init__(self, route, method='POST', headers=None, data_type='json', body=None): + self.method = method + self.route = route + self.headers = headers if headers is not None else {} + self.data_type = data_type # 'json' or 'form' + self.body = body + + def execute(self, base_url): + """Execute the request and return the status code.""" + url = f"{base_url}{self.route}" + + if self.method.upper() == 'POST': + if self.data_type == 'json': + response = requests.post(url, json=self.body, headers=self.headers) + elif self.data_type == 'form': + response = requests.post(url, data=self.body, headers=self.headers) + else: + raise ValueError("Unsupported data type. Use 'json' or 'form'.") + elif self.method.upper() == 'GET': + response = requests.get(url, headers=self.headers) + else: + raise ValueError("Unsupported HTTP method. Use 'GET' or 'POST'.") + + return response.status_code + + def __str__(self): + return f"Request(method={self.method}, route={self.route}, headers={self.headers}, data_type={self.data_type}, body={self.body})" diff --git a/end2end/utils/test_payloads_safe_vs_unsafe.py b/end2end/utils/test_payloads_safe_vs_unsafe.py new file mode 100644 index 000000000..aaa497db7 --- /dev/null +++ b/end2end/utils/test_payloads_safe_vs_unsafe.py @@ -0,0 +1,13 @@ +from . import assert_eq + +def test_payloads_safe_vs_unsafe(payloads, urls): + if payloads["safe"]: + print("Safe req to : (1) " + urls["enabled"] + payloads["safe"].route) + assert_eq(val1=payloads["safe"].execute(urls["enabled"]), inside=[200, 201]) + print("Safe req to : (0) " + urls["disabled"] + payloads["safe"].route) + assert_eq(val1=payloads["safe"].execute(urls["disabled"]), inside=[200, 201]) + if payloads["unsafe"]: + print("Unsafe req to : (1) " + urls["enabled"] + payloads["unsafe"].route) + assert_eq(val1=payloads["unsafe"].execute(urls["enabled"]), inside=[200, 201]) + print("Unsafe req to : (0) " + urls["disabled"] + payloads["unsafe"].route) + assert_eq(val1=payloads["unsafe"].execute(urls["disabled"]), inside=[200, 201])