diff --git a/python/ql/src/experimental/Security/Practical-CodeQL-Introduction/query.ql b/python/ql/src/experimental/Security/Practical-CodeQL-Introduction/query.ql
new file mode 100644
index 000000000000..f3e33b905085
--- /dev/null
+++ b/python/ql/src/experimental/Security/Practical-CodeQL-Introduction/query.ql
@@ -0,0 +1,3 @@
+import python
+
+select "Hello Wo... CodeQL!"
diff --git a/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Basic_approaches.py b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Basic_approaches.py
new file mode 100644
index 000000000000..1dcd3463368a
--- /dev/null
+++ b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Basic_approaches.py
@@ -0,0 +1,5 @@
+def basic():
+ text = "this is a demo"
+ eval(text)
+ tixt = "second demo"
+ eval(tixt)
diff --git a/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Code_Injection.py b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Code_Injection.py
new file mode 100644
index 000000000000..3839ea8ed399
--- /dev/null
+++ b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Code_Injection.py
@@ -0,0 +1,45 @@
+from flask import Flask, request
+
+app = Flask(__name__)
+
+
+@app.route("/flow1")
+def flow1():
+ code = request.args["code"]
+ eval(code)
+
+
+@app.route("/flow2")
+def flow2():
+ email = request.args["email"]
+ eval("./send_email {email}".format(email=email))
+
+
+def flow3_extra(text):
+ return text.split("\n")
+
+
+@app.route("/flow3")
+def flow3():
+ text = request.args["text"]
+ eval(flow3_extra(text))
+
+
+@app.route("/flow4")
+def flow4():
+ text = request.args["text"]
+ tixt = text
+ toxt = flow3_extra(tixt)
+ tuxt = toxt
+ eval(tuxt)
+
+
+@app.route("/flow1_good")
+def flow1_good():
+ code = request.args["code"]
+ if code == "print('Hello, Wo... CodeQL!')":
+ eval(code)
+
+
+# if __name__ == "__main__":
+# app.run(debug=True)
diff --git a/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/LDAP3_Injection_bad.py b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/LDAP3_Injection_bad.py
new file mode 100644
index 000000000000..2edb986fccfc
--- /dev/null
+++ b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/LDAP3_Injection_bad.py
@@ -0,0 +1,41 @@
+from flask import request, Flask
+import ldap3
+
+app = Flask(__name__)
+
+
+@app.route("/normal")
+def normal():
+ """
+ A RemoteFlowSource is used directly as DN and search filter
+ """
+
+ unsafe_dc = request.args['dc']
+ unsafe_filter = request.args['username']
+
+ dn = "dc={}".format(unsafe_dc)
+ search_filter = "(user={})".format(unsafe_filter)
+
+ srv = ldap3.Server('ldap://127.0.0.1')
+ conn = ldap3.Connection(srv, user=dn, auto_bind=True)
+ conn.search(dn, search_filter)
+
+
+@app.route("/direct")
+def direct():
+ """
+ A RemoteFlowSource is used directly as DN and search filter using a oneline call to .search
+ """
+
+ unsafe_dc = request.args['dc']
+ unsafe_filter = request.args['username']
+
+ dn = "dc={}".format(unsafe_dc)
+ search_filter = "(user={})".format(unsafe_filter)
+
+ srv = ldap3.Server('ldap://127.0.0.1')
+ conn = ldap3.Connection(srv, user=dn, auto_bind=True).search(
+ dn, search_filter)
+
+# if __name__ == "__main__":
+# app.run(debug=True)
diff --git a/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/LDAP3_Injection_good.py b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/LDAP3_Injection_good.py
new file mode 100644
index 000000000000..bb2e6d7af83e
--- /dev/null
+++ b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/LDAP3_Injection_good.py
@@ -0,0 +1,49 @@
+from flask import request, Flask
+import ldap3
+from ldap3.utils.dn import escape_rdn
+from ldap3.utils.conv import escape_filter_chars
+
+app = Flask(__name__)
+
+
+@app.route("/normal")
+def normal():
+ """
+ A RemoteFlowSource is sanitized and used as DN and search filter
+ """
+
+ unsafe_dc = request.args['dc']
+ unsafe_filter = request.args['username']
+
+ safe_dc = escape_rdn(unsafe_dc)
+ safe_filter = escape_filter_chars(unsafe_filter)
+
+ dn = "dc={}".format(safe_dc)
+ search_filter = "(user={})".format(safe_filter)
+
+ srv = ldap3.Server('ldap://127.0.0.1')
+ conn = ldap3.Connection(srv, user=dn, auto_bind=True)
+ conn.search(dn, search_filter)
+
+
+@app.route("/direct")
+def direct():
+ """
+ A RemoteFlowSource is sanitized and used as DN and search filter using a oneline call to .search
+ """
+
+ unsafe_dc = request.args['dc']
+ unsafe_filter = request.args['username']
+
+ safe_dc = escape_rdn(unsafe_dc)
+ safe_filter = escape_filter_chars(unsafe_filter)
+
+ dn = "dc={}".format(safe_dc)
+ search_filter = "(user={})".format(safe_filter)
+
+ srv = ldap3.Server('ldap://127.0.0.1')
+ conn = ldap3.Connection(srv, user=dn, auto_bind=True).search(
+ dn, search_filter)
+
+# if __name__ == "__main__":
+# app.run(debug=True)
diff --git a/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/LDAP_Injection_bad.py b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/LDAP_Injection_bad.py
new file mode 100644
index 000000000000..133b0baaf9c0
--- /dev/null
+++ b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/LDAP_Injection_bad.py
@@ -0,0 +1,59 @@
+from flask import request, Flask
+import ldap
+
+app = Flask(__name__)
+
+
+@app.route("/normal")
+def normal():
+ """
+ A RemoteFlowSource is used directly as DN and search filter
+ """
+
+ unsafe_dc = request.args['dc']
+ unsafe_filter = request.args['username']
+
+ dn = "dc={}".format(unsafe_dc)
+ search_filter = "(user={})".format(unsafe_filter)
+
+ ldap_connection = ldap.initialize("ldap://127.0.0.1")
+ user = ldap_connection.search_s(
+ dn, ldap.SCOPE_SUBTREE, search_filter)
+
+
+@app.route("/direct")
+def direct():
+ """
+ A RemoteFlowSource is used directly as DN and search filter using a oneline call to .search_s
+ """
+
+ unsafe_dc = request.args['dc']
+ unsafe_filter = request.args['username']
+
+ dn = "dc={}".format(unsafe_dc)
+ search_filter = "(user={})".format(unsafe_filter)
+
+ user = ldap.initialize("ldap://127.0.0.1").search_s(
+ dn, ldap.SCOPE_SUBTREE, search_filter)
+
+
+@app.route("/normal_argbyname")
+def normal_argbyname():
+ """
+ A RemoteFlowSource is used directly as DN and search filter, while the search filter is specified as
+ an argument by name
+ """
+
+ unsafe_dc = request.args['dc']
+ unsafe_filter = request.args['username']
+
+ dn = "dc={}".format(unsafe_dc)
+ search_filter = "(user={})".format(unsafe_filter)
+
+ ldap_connection = ldap.initialize("ldap://127.0.0.1")
+ user = ldap_connection.search_s(
+ dn, ldap.SCOPE_SUBTREE, filterstr=search_filter)
+
+
+# if __name__ == "__main__":
+# app.run(debug=True)
diff --git a/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/LDAP_Injection_good.py b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/LDAP_Injection_good.py
new file mode 100644
index 000000000000..dfc6f91d0455
--- /dev/null
+++ b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/LDAP_Injection_good.py
@@ -0,0 +1,70 @@
+from flask import request, Flask
+import ldap
+import ldap.filter
+import ldap.dn
+
+app = Flask(__name__)
+
+
+@app.route("/normal")
+def normal():
+ """
+ A RemoteFlowSource is sanitized and used as DN and search filter
+ """
+
+ unsafe_dc = request.args['dc']
+ unsafe_filter = request.args['username']
+
+ safe_dc = ldap.dn.escape_dn_chars(unsafe_dc)
+ safe_filter = ldap.filter.escape_filter_chars(unsafe_filter)
+
+ dn = "dc={}".format(safe_dc)
+ search_filter = "(user={})".format(safe_filter)
+
+ ldap_connection = ldap.initialize("ldap://127.0.0.1")
+ user = ldap_connection.search_s(
+ dn, ldap.SCOPE_SUBTREE, search_filter)
+
+
+@app.route("/direct")
+def direct():
+ """
+ A RemoteFlowSource is sanitized and used as DN and search filter using a oneline call to .search_s
+ """
+
+ unsafe_dc = request.args['dc']
+ unsafe_filter = request.args['username']
+
+ safe_dc = ldap.dn.escape_dn_chars(unsafe_dc)
+ safe_filter = ldap.filter.escape_filter_chars(unsafe_filter)
+
+ dn = "dc={}".format(safe_dc)
+ search_filter = "(user={})".format(safe_filter)
+
+ user = ldap.initialize("ldap://127.0.0.1").search_s(
+ dn, ldap.SCOPE_SUBTREE, search_filter, ["testAttr1", "testAttr2"])
+
+
+@app.route("/normal_argbyname")
+def normal_argbyname():
+ """
+ A RemoteFlowSource is sanitized and used as DN and search filter, while the search filter is specified as
+ an argument by name
+ """
+
+ unsafe_dc = request.args['dc']
+ unsafe_filter = request.args['username']
+
+ safe_dc = ldap.dn.escape_dn_chars(unsafe_dc)
+ safe_filter = ldap.filter.escape_filter_chars(unsafe_filter)
+
+ dn = "dc={}".format(safe_dc)
+ search_filter = "(user={})".format(safe_filter)
+
+ ldap_connection = ldap.initialize("ldap://127.0.0.1")
+ user = ldap_connection.search_s(
+ dn, ldap.SCOPE_SUBTREE, filterstr=search_filter)
+
+
+# if __name__ == "__main__":
+# app.run(debug=True)
diff --git a/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Practical-CodeQL-Introduction.expected b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Practical-CodeQL-Introduction.expected
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Practical-CodeQL-Introduction.qlref b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Practical-CodeQL-Introduction.qlref
new file mode 100644
index 000000000000..efdd25476224
--- /dev/null
+++ b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Practical-CodeQL-Introduction.qlref
@@ -0,0 +1 @@
+experimental/Security/Practical-CodeQL-Introduction/query.ql
\ No newline at end of file
diff --git a/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Regex_Injection_bad.py b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Regex_Injection_bad.py
new file mode 100644
index 000000000000..622eaf199f6b
--- /dev/null
+++ b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Regex_Injection_bad.py
@@ -0,0 +1,40 @@
+from flask import request, Flask
+import re
+
+app = Flask(__name__)
+
+
+@app.route("/direct")
+def direct():
+ """
+ A RemoteFlowSource is used directly as re.search's pattern
+ """
+
+ unsafe_pattern = request.args["pattern"]
+ re.search(unsafe_pattern, "")
+
+
+@app.route("/compile")
+def compile():
+ """
+ A RemoteFlowSource is used directly as re.compile's pattern
+ which also executes .search()
+ """
+
+ unsafe_pattern = request.args["pattern"]
+ compiled_pattern = re.compile(unsafe_pattern)
+ compiled_pattern.search("")
+
+
+@app.route("/compile_direct")
+def compile_direct():
+ """
+ A RemoteFlowSource is used directly as re.compile's pattern
+ which also executes .search() in the same line
+ """
+
+ unsafe_pattern = request.args["pattern"]
+ re.compile(unsafe_pattern).search("")
+
+# if __name__ == "__main__":
+# app.run(debug=True)
diff --git a/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Regex_Injection_good.py b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Regex_Injection_good.py
new file mode 100644
index 000000000000..cdc9a7ac158c
--- /dev/null
+++ b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/Regex_Injection_good.py
@@ -0,0 +1,17 @@
+from flask import request, Flask
+import re
+
+
+@app.route("/direct")
+def direct():
+ unsafe_pattern = request.args['pattern']
+ safe_pattern = re.escape(unsafe_pattern)
+ re.search(safe_pattern, "")
+
+
+@app.route("/compile")
+def compile():
+ unsafe_pattern = request.args['pattern']
+ safe_pattern = re.escape(unsafe_pattern)
+ compiled_pattern = re.compile(safe_pattern)
+ compiled_pattern.search("")
diff --git a/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/XXE_general.py b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/XXE_general.py
new file mode 100644
index 000000000000..c9f8cc984bda
--- /dev/null
+++ b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/XXE_general.py
@@ -0,0 +1,73 @@
+from flask import request, Flask
+from io import StringIO, BytesIO
+import xml.etree
+import xml.etree.ElementTree
+import lxml.etree
+import xml.dom.minidom
+import xml.dom.pulldom
+import xmltodict
+
+
+app = Flask(__name__)
+
+# xml_content = ']>&xxe;'
+
+
+@app.route("/lxml.etree.fromstring")
+def lxml_fromstring():
+ xml_content = request.args['xml_content']
+
+ return lxml.etree.fromstring(xml_content).text
+
+
+@app.route("/lxml.etree.XML")
+def lxml_XML():
+ xml_content = request.args['xml_content']
+
+ return lxml.etree.XML(xml_content).text
+
+
+@app.route("/lxml.etree.parse")
+def lxml_parse():
+ xml_content = request.args['xml_content']
+
+ return lxml.etree.parse(StringIO(xml_content)).text
+
+
+@app.route("/xmltodict.parse")
+def xmltodict_parse():
+ xml_content = request.args['xml_content']
+
+ return xmltodict.parse(xml_content, disable_entities=False)
+
+
+@app.route("/lxml.etree.XMLParser+lxml.etree.fromstring")
+def lxml_XMLParser_fromstring():
+ xml_content = request.args['xml_content']
+
+ parser = lxml.etree.XMLParser()
+ return lxml.etree.fromstring(xml_content, parser=parser).text
+
+
+@app.route("/lxml.etree.get_default_parser+lxml.etree.fromstring")
+def lxml_defaultParser_fromstring():
+ xml_content = request.args['xml_content']
+
+ parser = lxml.etree.get_default_parser()
+ return lxml.etree.fromstring(xml_content, parser=parser).text
+
+
+@app.route("/lxml.etree.XMLParser+xml.etree.ElementTree.fromstring")
+def lxml_XMLParser_xml_fromstring():
+ xml_content = request.args['xml_content']
+
+ parser = lxml.etree.XMLParser()
+ return xml.etree.ElementTree.fromstring(xml_content, parser=parser).text
+
+
+@app.route("/lxml.etree.XMLParser+xml.etree.ElementTree.parse")
+def lxml_XMLParser_xml_parse():
+ xml_content = request.args['xml_content']
+
+ parser = lxml.etree.XMLParser()
+ return xml.etree.ElementTree.parse(StringIO(xml_content), parser=parser).getroot().text
diff --git a/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/XXE_sax.py b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/XXE_sax.py
new file mode 100644
index 000000000000..9a7bc0050f7e
--- /dev/null
+++ b/python/ql/test/experimental/query-tests/Security/Practical-CodeQL-Introduction/XXE_sax.py
@@ -0,0 +1,75 @@
+from flask import request, Flask
+from io import StringIO
+import xml.sax
+
+# xml_content = ']>&xxe;'
+
+app = Flask(__name__)
+
+
+class MainHandler(xml.sax.ContentHandler):
+ def __init__(self):
+ self._result = []
+
+ def characters(self, data):
+ self._result.append(data)
+
+ def parse(self, f):
+ xml.sax.parse(f, self)
+ return self._result
+
+# GOOD
+
+
+@app.route("/MainHandler")
+def mainHandler():
+ xml_content = request.args['xml_content']
+
+ return MainHandler().parse(StringIO(xml_content))
+
+
+@app.route("/xml.sax.make_parser()+MainHandler")
+def xml_makeparser_MainHandler():
+ xml_content = request.args['xml_content']
+
+ GoodHandler = MainHandler()
+ parser = xml.sax.make_parser()
+ parser.setContentHandler(GoodHandler)
+ parser.parse(StringIO(xml_content))
+ return GoodHandler._result
+
+
+@app.route("/xml.sax.make_parser()+MainHandler-xml.sax.handler.feature_external_ges_False")
+def xml_makeparser_MainHandler_entitiesFalse():
+ xml_content = request.args['xml_content']
+
+ GoodHandler = MainHandler()
+ parser = xml.sax.make_parser()
+ parser.setContentHandler(GoodHandler)
+ # https://docs.python.org/3/library/xml.sax.handler.html#xml.sax.handler.feature_external_ges
+ parser.setFeature(xml.sax.handler.feature_external_ges, False)
+ parser.parse(StringIO(xml_content))
+ return GoodHandler._result
+
+# BAD
+
+
+@app.route("/xml.sax.make_parser()+MainHandler-xml.sax.handler.feature_external_ges_True")
+def xml_makeparser_MainHandler_entitiesTrue():
+ xml_content = request.args['xml_content']
+
+ BadHandler = MainHandler()
+ parser = xml.sax.make_parser()
+ parser.setContentHandler(BadHandler)
+ parser.setFeature(xml.sax.handler.feature_external_ges, True)
+ parser.parse(StringIO(xml_content))
+ return BadHandler._result
+
+
+@app.route("/xml.sax.make_parser()+xml.dom.minidom.parse-xml.sax.handler.feature_external_ges_True")
+def xml_makeparser_minidom_entitiesTrue():
+ xml_content = request.args['xml_content']
+
+ parser = xml.sax.make_parser()
+ parser.setFeature(xml.sax.handler.feature_external_ges, True)
+ return xml.dom.minidom.parse(StringIO(xml_content), parser=parser).documentElement.childNodes