Skip to content

Commit 56e0518

Browse files
committed
initial commit
0 parents  commit 56e0518

File tree

11 files changed

+251
-0
lines changed

11 files changed

+251
-0
lines changed

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
*.pyc
2+
*.egg-info
3+
*.egg_info
4+
build/
5+
dist/
6+
sdist/
7+
env/

LICENSE.txt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
py-find-injection
2+
Copyright (c) 2013 Uber Technologies, Inc.
3+
The MIT License
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to
7+
deal in the Software without restriction, including without limitation the
8+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
9+
sell copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in
13+
all copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21+
IN THE SOFTWARE.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`py_find_injection` uses various heuristics to look for SQL injection vulnerabilities in python source code.

py_find_injection/__init__.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
#!/usr/bin/env python
2+
3+
import argparse
4+
import ast
5+
import sys
6+
7+
8+
def stringify(node):
9+
if isinstance(node, ast.Name):
10+
return node.id
11+
elif isinstance(node, ast.Attribute):
12+
return '%s.%s' % (stringify(node.value), node.attr)
13+
elif isinstance(node, ast.Subscript):
14+
return '%s[%s]' % (stringify(node.value), stringify(node.slice))
15+
elif isinstance(node, ast.Index):
16+
return stringify(node.value)
17+
elif isinstance(node, ast.Call):
18+
return '%s(%s, %s)' % (stringify(node.func), stringify(node.args), stringify(node.keywords))
19+
elif isinstance(node, list):
20+
return '[%s]' % (', '.join(stringify(n) for n in node))
21+
elif isinstance(node, ast.Str):
22+
return node.s
23+
else:
24+
return ast.dump(node)
25+
26+
27+
class IllegalLine(object):
28+
def __init__(self, reason, node, filename):
29+
self.reason = reason
30+
self.lineno = node.lineno
31+
self.filename = filename
32+
self.node = node
33+
34+
def __str__(self):
35+
return "%s:%d\t%s" % (self.filename, self.lineno, self.reason)
36+
37+
def __repr__(self):
38+
return "IllegalLine<%s, %s:%s>" % (self.reason, self.filename, self.lineno)
39+
40+
41+
def find_assignment_in_context(variable, context):
42+
if isinstance(context, (ast.FunctionDef, ast.Module, ast.For, ast.While, ast.With, ast.If)):
43+
for node in reversed(list(ast.iter_child_nodes(context))):
44+
if isinstance(node, ast.Assign):
45+
if variable in (stringify(c) for c in node.targets):
46+
return node
47+
if getattr(context, 'parent', None):
48+
return find_assignment_in_context(variable, context.parent)
49+
else:
50+
return None
51+
52+
53+
class Checker(ast.NodeVisitor):
54+
def __init__(self, filename, *args, **kwargs):
55+
self.filename = filename
56+
self.errors = []
57+
super(Checker, self).__init__(*args, **kwargs)
58+
59+
def check_execute(self, node):
60+
if isinstance(node, ast.BinOp):
61+
if isinstance(node.op, ast.Mod):
62+
return IllegalLine('string interpolation of SQL query', node, self.filename)
63+
elif isinstance(node.op, ast.Add):
64+
return IllegalLine('string concatenation of SQL query', node, self.filename)
65+
elif isinstance(node, ast.Call):
66+
if isinstance(node.func, ast.Attribute):
67+
if node.func.attr == 'format':
68+
return IllegalLine('str.format called on SQL query', node, self.filename)
69+
elif isinstance(node, ast.Name):
70+
# now we need to figure out where that query is assigned. blargh.
71+
assignment = find_assignment_in_context(node.id, node)
72+
if assignment is not None:
73+
return self.check_execute(assignment.value)
74+
75+
def visit_Call(self, node):
76+
function_name = stringify(node.func)
77+
if function_name.lower() in ('session.execute', 'cursor.execute'):
78+
node.args[0].parent = node
79+
node_error = self.check_execute(node.args[0])
80+
if node_error:
81+
self.errors.append(node_error)
82+
elif function_name.lower() == 'eval':
83+
self.errors.append(IllegalLine('eval() is just generally evil', node, self.filename))
84+
self.generic_visit(node)
85+
86+
def visit(self, node):
87+
"""Visit a node."""
88+
method = 'visit_' + node.__class__.__name__
89+
visitor = getattr(self, method, self.generic_visit)
90+
return visitor(node)
91+
92+
def generic_visit(self, node):
93+
"""Called if no explicit visitor function exists for a node."""
94+
for field, value in ast.iter_fields(node):
95+
if isinstance(value, list):
96+
for item in value:
97+
if isinstance(item, ast.AST):
98+
item.parent = node
99+
self.visit(item,)
100+
elif isinstance(value, ast.AST):
101+
value.parent = node
102+
self.visit(value)
103+
104+
105+
def check(filename):
106+
c = Checker(filename=filename)
107+
with open(filename, 'r') as fobj:
108+
try:
109+
parsed = ast.parse(fobj.read(), filename)
110+
c.visit(parsed)
111+
except Exception:
112+
raise
113+
return c.errors
114+
115+
116+
def main():
117+
parser = argparse.ArgumentParser(
118+
description='Look for patterns in python source files that might indicate SQL injection vulnerabilities',
119+
epilog='Exit status is 0 if all files are okay, 1 if any files have an error. Errors are printed to stdout'
120+
)
121+
parser.add_argument('files', nargs='+', help='Files to check')
122+
args = parser.parse_args()
123+
124+
errors = []
125+
for fname in args.files:
126+
these_errors = check(fname)
127+
if these_errors:
128+
print '\n'.join(str(e) for e in these_errors)
129+
errors.extend(these_errors)
130+
if errors:
131+
print '%d total errors' % len(errors)
132+
return 1
133+
else:
134+
return 0
135+
136+
137+
if __name__ == '__main__':
138+
sys.exit(main())

requirements-tests.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
nose==1.3.0
2+
mock==1.0.1

setup.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from setuptools import setup, find_packages
2+
3+
4+
setup(
5+
name="py_find_injection",
6+
version="0.1",
7+
author="James Brown",
8+
author_email="jbrown@uber.com",
9+
url="http://github.com/uber/py_find_injection",
10+
description="simple python ast consumer which searches for common SQL injection attacks",
11+
classifiers=[
12+
"Programming Language :: Python",
13+
"Operating System :: OS Independent",
14+
"Topic :: Security",
15+
"Topic :: Security",
16+
"Intended Audience :: Developers",
17+
"Development Status :: 3 - Alpha",
18+
"Programming Language :: Python :: 2.7",
19+
"License :: OSI Approved :: MIT License",
20+
],
21+
packages=find_packages(exclude=["tests"]),
22+
entry_points={
23+
"console_scripts": [
24+
"py-find-injection = py_find_injection:main",
25+
]
26+
},
27+
tests_require=["nose==1.3.0", "mock==1.0.1"],
28+
test_suite="nose.collector",
29+
long_description="""py_find_injection
30+
31+
Walks the AST and looks for arguments to cursor.execute or session.execute; then
32+
determines whether string interpolation, concatenation or the .format() call is used
33+
on those arguments. Not at all comprehensive, but better than nothing.
34+
"""
35+
)

tests/__init__.py

Whitespace-only changes.

tests/samples/bad_script.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import mock
2+
3+
session = mock.Mock()
4+
5+
6+
session.execute("SELECT foo WHERE id=%s" % 2)

tests/samples/bad_script_2.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import mock
2+
3+
session = mock.Mock()
4+
5+
query = 1 + 2
6+
query = "SELECT foo FROM bar WHERE id="
7+
query = query + "2"
8+
cursor = session.cursor()
9+
cursor.execute(query)

tests/samples/good_script.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import mock
2+
3+
session = mock.Mock()
4+
uid = 1
5+
6+
session.execute("SELECT foo FROM bar")
7+
session.execute("SELECT foo FROM bar WHERE id=%s", uid)

tests/test_simple.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import os.path
2+
from unittest import TestCase
3+
4+
import py_find_injection
5+
6+
7+
SAMPLE_PATH = os.path.realpath(os.path.join(os.path.dirname(__file__), 'samples'))
8+
9+
10+
class TestSimple(TestCase):
11+
def test_good_file(self):
12+
errors = py_find_injection.check(os.path.join(SAMPLE_PATH, 'good_script.py'))
13+
self.assertEqual(errors, [])
14+
15+
def test_bad_file(self):
16+
errors = py_find_injection.check(os.path.join(SAMPLE_PATH, 'bad_script.py'))
17+
self.assertEqual(1, len(errors))
18+
self.assertEqual(errors[0].lineno, 6)
19+
self.assertEqual(errors[0].reason, 'string interpolation of SQL query')
20+
21+
def test_interpolation_not_inline(self):
22+
errors = py_find_injection.check(os.path.join(SAMPLE_PATH, 'bad_script_2.py'))
23+
self.assertEqual(1, len(errors))
24+
self.assertEqual(errors[0].lineno, 7)
25+
self.assertEqual(errors[0].reason, 'string concatenation of SQL query')

0 commit comments

Comments
 (0)