Skip to content

Commit b30b197

Browse files
authored
chore(iast): add sqli integration tests (#7992)
IAST: SQL injection integration tests with different ORMs ## Checklist - [x] Change(s) are motivated and described in the PR description. - [x] Testing strategy is described if automated tests are not included in the PR. - [x] Risk is outlined (performance impact, potential for breakage, maintainability, etc). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] [Library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) are followed. If no release note is required, add label `changelog/no-changelog`. - [x] Documentation is included (in-code, generated user docs, [public corp docs](https://github.com/DataDog/documentation/)). - [x] Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Title is accurate. - [x] No unnecessary changes are introduced. - [x] Description motivates each change. - [x] Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes unless absolutely necessary. - [x] Testing strategy adequately addresses listed risk(s). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] Release note makes sense to a user of the library. - [x] Reviewer has explicitly acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment. - [x] Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) - [x] If this PR touches code that signs or publishes builds or packages, or handles credentials of any kind, I've requested a review from `@DataDog/security-design-and-guidance`. - [x] This PR doesn't touch any of that.
1 parent 399c6ad commit b30b197

File tree

14 files changed

+430
-5
lines changed

14 files changed

+430
-5
lines changed

.circleci/config.templ.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,12 @@ jobs:
436436
snapshot: true
437437
docker_services: "postgres"
438438

439+
appsec_iast_tdd_propagation:
440+
<<: *machine_executor
441+
steps:
442+
- run_test:
443+
pattern: 'appsec_iast_tdd_propagation'
444+
439445
appsec_iast_memcheck:
440446
<<: *machine_executor
441447
steps:

.riot/requirements/1ce36b6.txt

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#
2+
# This file is autogenerated by pip-compile with Python 3.11
3+
# by the following command:
4+
#
5+
# pip-compile --no-annotate .riot/requirements/1ce36b6.in
6+
#
7+
aiosqlite==0.17.0
8+
attrs==23.2.0
9+
blinker==1.7.0
10+
bytecode==0.15.1
11+
cattrs==22.2.0
12+
certifi==2023.11.17
13+
charset-normalizer==3.3.2
14+
click==8.1.7
15+
coverage[toml]==7.4.0
16+
ddsketch==2.0.4
17+
deprecated==1.2.14
18+
envier==0.5.0
19+
flask==3.0.0
20+
greenlet==3.0.3
21+
hypothesis==6.45.0
22+
idna==3.6
23+
importlib-metadata==6.11.0
24+
iniconfig==2.0.0
25+
iso8601==1.1.0
26+
itsdangerous==2.1.2
27+
jinja2==3.1.2
28+
markupsafe==2.1.3
29+
mock==5.1.0
30+
opentelemetry-api==1.22.0
31+
opentracing==2.4.0
32+
packaging==23.2
33+
peewee==3.17.0
34+
pluggy==1.3.0
35+
pony==0.7.17
36+
protobuf==4.25.1
37+
pypika-tortoise==0.1.6
38+
pytest==7.4.4
39+
pytest-cov==4.1.0
40+
pytest-mock==3.12.0
41+
pytz==2023.3.post1
42+
requests==2.31.0
43+
six==1.16.0
44+
sortedcontainers==2.4.0
45+
sqlalchemy==2.0.25
46+
tortoise-orm==0.20.0
47+
typing-extensions==4.9.0
48+
urllib3==2.1.0
49+
werkzeug==3.0.1
50+
wrapt==1.16.0
51+
xmltodict==0.13.0
52+
zipp==3.17.0

riotfile.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,35 @@ def select_pys(min_version=MIN_PYTHON_VERSION, max_version=MAX_PYTHON_VERSION):
184184
"_DD_APPSEC_DEDUPLICATION_ENABLED": "false",
185185
},
186186
),
187+
Venv(
188+
name="appsec_iast_tdd_propagation",
189+
pys=select_pys(min_version="3.11", max_version="3.11"),
190+
command="pytest --no-cov tests/appsec/iast_tdd_propagation/",
191+
pkgs={
192+
"flask": "~=3.0",
193+
"sqlalchemy": "~=2.0.23",
194+
"pony": latest,
195+
"aiosqlite": latest,
196+
"tortoise-orm": latest,
197+
"peewee": latest,
198+
"requests": latest,
199+
"six": ">=1.12.0",
200+
"envier": "==0.5.0",
201+
"cattrs": "<23.1.1",
202+
"ddsketch": ">=2.0.1",
203+
"protobuf": ">=3",
204+
"attrs": ">=20",
205+
"typing_extensions": latest,
206+
"xmltodict": ">=0.12",
207+
"opentelemetry-api": ">=1",
208+
"opentracing": ">=2.0.0",
209+
"bytecode": latest,
210+
},
211+
env={
212+
"DD_IAST_REQUEST_SAMPLING": "100", # Override default 30% to analyze all IAST requests
213+
"_DD_APPSEC_DEDUPLICATION_ENABLED": "false",
214+
},
215+
),
187216
Venv(
188217
name="appsec_integrations",
189218
command="pytest {cmdargs} tests/appsec/integrations/",

tests/.suitespec.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,14 @@
407407
"@appsec_iast",
408408
"tests/appsec/iast/*"
409409
],
410+
"appsec_iast_tdd_propagation": [
411+
"@bootstrap",
412+
"@core",
413+
"@tracing",
414+
"@appsec",
415+
"@appsec_iast",
416+
"tests/appsec/iast_tdd_propagation/*"
417+
],
410418
"appsec_iast_memcheck": [
411419
"@bootstrap",
412420
"@core",

tests/appsec/appsec_utils.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import subprocess
55
import sys
66

7+
from requests.exceptions import ConnectionError
8+
79
from ddtrace.internal.utils.retry import RetryError
810
from ddtrace.vendor import psutil
911
from tests.webclient import Client
@@ -13,12 +15,15 @@
1315
ROOT_PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
1416

1517

16-
def _build_env():
18+
def _build_env(env=None):
1719
environ = dict(PATH="%s:%s" % (ROOT_PROJECT_DIR, ROOT_DIR), PYTHONPATH="%s:%s" % (ROOT_PROJECT_DIR, ROOT_DIR))
1820
if os.environ.get("PATH"):
1921
environ["PATH"] = "%s:%s" % (os.environ.get("PATH"), environ["PATH"])
2022
if os.environ.get("PYTHONPATH"):
2123
environ["PYTHONPATH"] = "%s:%s" % (os.environ.get("PYTHONPATH"), environ["PYTHONPATH"])
24+
if env:
25+
for k, v in env.items():
26+
environ[k] = v
2227
return environ
2328

2429

@@ -36,16 +41,23 @@ def gunicorn_server(appsec_enabled="true", remote_configuration_enabled="true",
3641

3742
@contextmanager
3843
def flask_server(
39-
appsec_enabled="true", remote_configuration_enabled="true", iast_enabled="false", tracer_enabled="true", token=None
44+
appsec_enabled="true",
45+
remote_configuration_enabled="true",
46+
iast_enabled="false",
47+
tracer_enabled="true",
48+
token=None,
49+
app="tests/appsec/app.py",
50+
env=None,
4051
):
41-
cmd = ["python", "tests/appsec/app.py", "--no-reload"]
52+
cmd = ["python", app, "--no-reload"]
4253
yield from appsec_application_server(
4354
cmd,
4455
appsec_enabled=appsec_enabled,
4556
remote_configuration_enabled=remote_configuration_enabled,
4657
iast_enabled=iast_enabled,
4758
tracer_enabled=tracer_enabled,
4859
token=token,
60+
env=env,
4961
)
5062

5163

@@ -56,8 +68,9 @@ def appsec_application_server(
5668
iast_enabled="false",
5769
tracer_enabled="true",
5870
token=None,
71+
env=None,
5972
):
60-
env = _build_env()
73+
env = _build_env(env)
6174
env["DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS"] = "0.5"
6275
env["DD_REMOTE_CONFIGURATION_ENABLED"] = remote_configuration_enabled
6376
if token:
@@ -77,7 +90,7 @@ def appsec_application_server(
7790
env=env,
7891
stdout=sys.stdout,
7992
stderr=sys.stderr,
80-
preexec_fn=os.setsid,
93+
start_new_session=True,
8194
)
8295
try:
8396
client = Client("http://0.0.0.0:8000")
@@ -107,6 +120,8 @@ def appsec_application_server(
107120
yield server_process, client, (children[1].pid if len(children) > 1 else None)
108121
try:
109122
client.get_ignored("/shutdown")
123+
except ConnectionError:
124+
pass
110125
except Exception:
111126
raise AssertionError(
112127
"\n=== Captured STDOUT ===\n%s=== End of captured STDOUT ==="
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
#!/usr/bin/env python3
2+
3+
""" This Flask application is imported on tests.appsec.appsec_utils.gunicorn_server
4+
"""
5+
6+
7+
import importlib
8+
import os
9+
import sys
10+
11+
from flask import Flask
12+
from flask import request
13+
14+
from ddtrace import tracer
15+
from ddtrace.appsec._constants import IAST
16+
from ddtrace.appsec._iast import ddtrace_iast_flask_patch
17+
from ddtrace.appsec._iast._taint_tracking import is_pyobject_tainted
18+
from ddtrace.internal import core
19+
20+
21+
import ddtrace.auto # noqa: F401 # isort: skip
22+
23+
orm = os.getenv("FLASK_ORM", "sqlite")
24+
25+
orm_impl = importlib.import_module(f"{orm}_impl")
26+
27+
28+
app = Flask(__name__)
29+
30+
31+
class ResultResponse:
32+
param = ""
33+
sources = ""
34+
vulnerabilities = ""
35+
36+
def __init__(self, param):
37+
self.param = param
38+
39+
def json(self):
40+
return {
41+
"param": self.param,
42+
"sources": self.sources,
43+
"vulnerabilities": self.vulnerabilities,
44+
"params_are_tainted": is_pyobject_tainted(self.param),
45+
}
46+
47+
48+
@app.route("/shutdown")
49+
def shutdown():
50+
tracer.shutdown()
51+
sys.exit(0)
52+
53+
54+
@app.route("/")
55+
def tainted_view():
56+
param = request.args.get("param", "param")
57+
58+
report = core.get_items([IAST.CONTEXT_KEY], tracer.current_root_span())
59+
60+
assert not (report and report[0])
61+
62+
orm_impl.execute_query("select * from User where name = '" + param + "'")
63+
64+
response = ResultResponse(param)
65+
report = core.get_items([IAST.CONTEXT_KEY], tracer.current_root_span())
66+
if report and report[0]:
67+
response.sources = report[0].sources[0].value
68+
response.vulnerabilities = list(report[0].vulnerabilities)[0].type
69+
70+
return response.json()
71+
72+
73+
@app.route("/untainted")
74+
def untainted_view():
75+
param = request.args.get("param", "param")
76+
77+
report = core.get_items([IAST.CONTEXT_KEY], tracer.current_root_span())
78+
79+
assert not (report and report[0])
80+
81+
orm_impl.execute_untainted_query("select * from User where name = '" + param + "'")
82+
83+
response = ResultResponse(param)
84+
report = core.get_items([IAST.CONTEXT_KEY], tracer.current_root_span())
85+
if report and report[0]:
86+
response.sources = report[0].sources[0].value
87+
response.vulnerabilities = list(report[0].vulnerabilities)[0].type
88+
89+
return response.json()
90+
91+
92+
if __name__ == "__main__":
93+
ddtrace_iast_flask_patch()
94+
app.run(debug=False, port=8000)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env python3
2+
3+
from peewee import CharField
4+
from peewee import IntegerField
5+
from peewee import Model
6+
from peewee import SqliteDatabase
7+
8+
9+
db = SqliteDatabase(":sharedmemory:")
10+
11+
12+
class User(Model):
13+
id = IntegerField(primary_key=True)
14+
name = CharField()
15+
16+
class Meta:
17+
database = db
18+
19+
20+
db.create_tables([User])
21+
22+
23+
def execute_query(param):
24+
db.execute_sql(param)
25+
26+
27+
def execute_untainted_query(_):
28+
db.execute_sql("SELECT * FROM User")
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/usr/bin/env python3
2+
3+
from pony import orm
4+
5+
6+
db = orm.Database()
7+
8+
9+
class User(db.Entity):
10+
name = orm.Required(str)
11+
12+
13+
db.bind(provider="sqlite", filename=":sharedmemory:", create_db=True)
14+
db.generate_mapping(create_tables=True)
15+
16+
17+
def execute_query(param):
18+
with orm.db_session:
19+
return User.select_by_sql(param)
20+
21+
22+
def execute_untainted_query(_):
23+
with orm.db_session:
24+
return User.select_by_sql("SELECT * from User")
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env python3
2+
3+
from sqlalchemy import Column
4+
from sqlalchemy import Integer
5+
from sqlalchemy import String
6+
from sqlalchemy import create_engine
7+
from sqlalchemy import text
8+
from sqlalchemy.orm import declarative_base
9+
from sqlalchemy.pool import StaticPool
10+
11+
12+
engine = create_engine("sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
13+
14+
Base = declarative_base()
15+
16+
17+
class User(Base):
18+
__tablename__ = "User"
19+
id = Column(Integer, primary_key=True)
20+
name = Column(String(30), nullable=True)
21+
22+
23+
User.metadata.create_all(engine)
24+
25+
26+
def execute_query(param):
27+
with engine.connect() as connection:
28+
connection.execute(text(param))
29+
30+
31+
def execute_untainted_query(_):
32+
with engine.connect() as connection:
33+
connection.execute(text("SELECT * FROM User"))
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/usr/bin/env python3
2+
3+
import sqlite3
4+
5+
6+
conn = sqlite3.connect(":memory:", check_same_thread=False)
7+
cursor = conn.cursor()
8+
9+
cursor.execute("CREATE TABLE User (id INTEGER PRIMARY KEY, name TEXT)")
10+
11+
12+
def execute_query(param):
13+
cursor.execute(param)
14+
15+
16+
def execute_untainted_query(_):
17+
cursor.execute("SELECT 1")

0 commit comments

Comments
 (0)