diff --git a/.travis.yml b/.travis.yml index 0638ada6..fe655eef 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,8 @@ language: python +dist: xenial python: - "3.6" + - "3.7" install: - pip install codeclimate-test-reporter 'coverage>=4.0,<4.4' flake8 before_script: diff --git a/CHANGELOG.md b/CHANGELOG.md index 928f88cf..3d626450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,38 +22,81 @@ If you love PyT, please star our project on GitHub to show your support! :star: [#xxxx]: https://github.com/python-security/pyt/pull/xxxx [@xxxx]: https://github.com/xxxx --> + +# Unreleased + +#### :tada: New Features + +* Added visting functions in the tests of `while` nodes ([#186], thanks [@adrianbn]) + +[@adrianbn]: https://github.com/adrianbn +[#186]: https://github.com/python-security/pyt/pull/186 + + # 0.42 ##### November 1st, 2018 #### :boom: Breaking Changes -* Changed trigger file format when specifying specific tainted args ([#182]) +* Changed trigger file format when specifying specific tainted args ([#182], thanks [@bcaller]) #### :tada: New Features -* Function calls such as `list.append` and `dict.update` now propagate taint to the list or dict ([#181]) +* Function calls such as `list.append` and `dict.update` now propagate taint to the list or dict ([#181], thanks [@bcaller]) #### :bug: Bugfixes -* IfExp (or ternary) expression handling improved ([#179]) +* IfExp (or ternary) expression handling improved ([#179], thanks [@bcaller]) + +[#179]: https://github.com/python-security/pyt/pull/179 +[#181]: https://github.com/python-security/pyt/pull/181 +[#182]: https://github.com/python-security/pyt/pull/182 + # 0.40 ##### September 11th, 2018 #### :mega: Release Highlights -* Logging changes. Logging verbosity can be changed with `-v` to `-vvv` ([#172]) +* Logging changes. Logging verbosity can be changed with `-v` to `-vvv` ([#172], thanks [@bcaller]) #### :boom: Breaking Changes * Removed `--trim` option ([#169]) #### :tada: New Features -* Added `--only-unsanitised` flag to not print sanitised vulnerabilities ([#172]) +* Added `--only-unsanitised` flag to not print sanitised vulnerabilities ([#172], thanks [@bcaller]) #### :bug: Bugfixes -* Recursive functions don't cause `RecursionError` ([#173]) -* Handling of chained functions improved ([#171]) +* Recursive functions don't cause `RecursionError` ([#173], thanks [@bcaller]) +* Handling of chained functions improved ([#171], thanks [@bcaller]) + +[#169]: https://github.com/python-security/pyt/pull/169 +[#171]: https://github.com/python-security/pyt/pull/171 +[#172]: https://github.com/python-security/pyt/pull/172 +[#173]: https://github.com/python-security/pyt/pull/173 + # 0.39 ##### August 21st, 2018 -... +#### :tada: New Features + +* Added handling of assignment unpacking e.g. `a, b, c = d` ([#164], thanks [@bcaller]) +* Made file loading and vulnerability order deterministic ([#165], thanks [@bcaller]) + +#### :bug: Bugfixes +* Fixed VarsVisitor RuntimeError on code like `f(g(a)(b)(c))` ([#163], thanks [@bcaller]) + +#### :telescope: Precision + +* Taint propagates from methods of tainted objects ([#167], thanks [@bcaller]) + +#### :snake: Miscellaneous + +* Cleaned test cases of extraneous reassignments ([#166], thanks [@bcaller]) + +[#163]: https://github.com/python-security/pyt/pull/163 +[#164]: https://github.com/python-security/pyt/pull/164 +[#165]: https://github.com/python-security/pyt/pull/165 +[#166]: https://github.com/python-security/pyt/pull/166 +[#167]: https://github.com/python-security/pyt/pull/167 + # 0.38 ##### August 2nd, 2018 @@ -144,6 +187,7 @@ If you love PyT, please star our project on GitHub to show your support! :star: [#152]: https://github.com/python-security/pyt/pull/152 [#156]: https://github.com/python-security/pyt/pull/156 + # 0.34 ##### April 24th, 2018 diff --git a/README.rst b/README.rst index ad79c794..815b430b 100644 --- a/README.rst +++ b/README.rst @@ -11,11 +11,37 @@ :target: https://badge.fury.io/py/python-taint .. image:: https://img.shields.io/badge/PRs-welcome-ff69b4.svg - :target: https://github.com/python-security/pyt/issues?q=is%3Aopen+is%3Aissue+label%3Agood-first-issue + :target: https://github.com/python-security/pyt/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22+ .. image:: https://img.shields.io/badge/python-v3.6-blue.svg :target: https://pypi.org/project/python-taint/ +.. image:: https://img.shields.io/badge/Donate-Charity-orange.svg + :target: https://www.againstmalaria.com/donation.aspx + +This project is no longer maintained +==================================== + +**March 2020 Update**: Please go see the amazing `Pysa tutorial`_ that should get you up to speed finding security vulnerabilities in your Python codebase. + +`Pyre`_ from Facebook is an amazing project that has a bright future and many smart people working on it. +I would suggest, if you don't know that much about program analysis, that you understand how PyT works before diving into Pyre. Along with the `README's in most directories`_, there are the original `Master's Thesis`_ and `some slides`_. +With that said, **I am happy to review pull requests and give you write permissions if you make more than a few.** + +There were a lot of great contributors to this project, I plan on working on other projects like `detect-secrets`_ and others (e.g. Pyre eventually) in the future if you'd like to work together more :) + +If you are a security engineer with e.g. a Python codebase without type annotations, that Pyre won't handle, I would suggest you replace your sinks with a secure wrapper (something like `defusedxml`_), and alert off any uses of the standard sink. You can use `Bandit`_ to do this since dataflow analysis is not required, but you will have to trim it a lot, due to the high false-positive rate. + +.. _Pysa tutorial: https://github.com/facebook/pyre-check/tree/master/pysa_tutorial#pysa-tutorial +.. _Pyre: https://github.com/facebook/pyre-check +.. _README's in most directories: https://github.com/python-security/pyt/tree/master/pyt#how-it-works +.. _Master's Thesis: https://projekter.aau.dk/projekter/files/239563289/final.pdf +.. _some slides: https://docs.google.com/presentation/d/1JfAykAxR0DcJwwGfHmhrz1RhhKqYsnt5x_GY8CbTp7s +.. _detect-secrets: https://github.com/Yelp/detect-secrets/blob/master/CHANGELOG.md#whats-new +.. _defusedxml: https://pypi.org/project/defusedxml/ +.. _Bandit: https://github.com/PyCQA/bandit + + Python Taint ============ @@ -40,6 +66,8 @@ Example usage and output: Install ======= +Before continuing, make sure you have python3.6 or 3.7 installed. + .. code-block:: python pip install python-taint diff --git a/examples/example_inputs/while_func_comparator.py b/examples/example_inputs/while_func_comparator.py new file mode 100644 index 00000000..8c775f72 --- /dev/null +++ b/examples/example_inputs/while_func_comparator.py @@ -0,0 +1,6 @@ +def foo(): + return True + +while foo(): + print(x) + x += 1 diff --git a/examples/example_inputs/while_func_comparator_lhs.py b/examples/example_inputs/while_func_comparator_lhs.py new file mode 100644 index 00000000..1904e8e7 --- /dev/null +++ b/examples/example_inputs/while_func_comparator_lhs.py @@ -0,0 +1,6 @@ +def foo(): + return 6 + +while foo() > x: + print(x) + x += 1 diff --git a/examples/example_inputs/while_func_comparator_rhs.py b/examples/example_inputs/while_func_comparator_rhs.py new file mode 100644 index 00000000..6aafc2b6 --- /dev/null +++ b/examples/example_inputs/while_func_comparator_rhs.py @@ -0,0 +1,6 @@ +def foo(): + return 6 + +while x < foo(): + print(x) + x += 1 diff --git a/examples/vulnerable_code/command_injection_with_aliases.py b/examples/vulnerable_code/command_injection_with_aliases.py new file mode 100644 index 00000000..4e409c52 --- /dev/null +++ b/examples/vulnerable_code/command_injection_with_aliases.py @@ -0,0 +1,31 @@ +import os +import os as myos +from os import system +from os import system as mysystem +from subprocess import call as mycall, Popen as mypopen + +from flask import Flask, render_template, request + +app = Flask(__name__) + + +@app.route('/menu', methods=['POST']) +def menu(): + param = request.form['suggestion'] + command = 'echo ' + param + ' >> ' + 'menu.txt' + + os.system(command) + myos.system(command) + system(command) + mysystem(command) + mycall(command) + mypopen(command) + + with open('menu.txt', 'r') as f: + menu_ctx = f.read() + + return render_template('command_injection.html', menu=menu_ctx) + + +if __name__ == '__main__': + app.run(debug=True) diff --git a/pyt/cfg/alias_helper.py b/pyt/cfg/alias_helper.py index a1c83ab0..920af3f5 100644 --- a/pyt/cfg/alias_helper.py +++ b/pyt/cfg/alias_helper.py @@ -74,3 +74,21 @@ def retrieve_import_alias_mapping(names_list): if alias.asname: import_alias_names[alias.asname] = alias.name return import_alias_names + + +def fully_qualify_alias_labels(label, aliases): + """Replace any aliases in label with the fully qualified name. + + Args: + label -- A label : str representing a name (e.g. myos.system) + aliases -- A dict of {alias: real_name} (e.g. {'myos': 'os'}) + + >>> fully_qualify_alias_labels('myos.mycall', {'myos':'os'}) + 'os.mycall' + """ + for alias, full_name in aliases.items(): + if label == alias: + return full_name + elif label.startswith(alias+'.'): + return full_name + label[len(alias):] + return label diff --git a/pyt/cfg/stmt_visitor.py b/pyt/cfg/stmt_visitor.py index 95913211..16da1bb0 100644 --- a/pyt/cfg/stmt_visitor.py +++ b/pyt/cfg/stmt_visitor.py @@ -6,6 +6,7 @@ from .alias_helper import ( as_alias_handler, + fully_qualify_alias_labels, handle_aliases_in_init_files, handle_fdid_aliases, not_as_alias_handler, @@ -555,23 +556,44 @@ def visit_For(self, node): path=self.filenames[-1] )) - if isinstance(node.iter, ast.Call) and get_call_names_as_string(node.iter.func) in self.function_names: - last_node = self.visit(node.iter) - last_node.connect(for_node) + self.process_loop_funcs(node.iter, for_node) return self.loop_node_skeleton(for_node, node) + def process_loop_funcs(self, comp_n, loop_node): + """ + If the loop test node contains function calls, it connects the loop node to the nodes of + those function calls. + + :param comp_n: The test node of a loop that may contain functions. + :param loop_node: The loop node itself to connect to the new function nodes if any + :return: None + """ + if isinstance(comp_n, ast.Call) and get_call_names_as_string(comp_n.func) in self.function_names: + last_node = self.visit(comp_n) + last_node.connect(loop_node) + def visit_While(self, node): label_visitor = LabelVisitor() - label_visitor.visit(node.test) + test = node.test # the test condition of the while loop + label_visitor.visit(test) - test = self.append_node(Node( + while_node = self.append_node(Node( 'while ' + label_visitor.result + ':', node, path=self.filenames[-1] )) - return self.loop_node_skeleton(test, node) + if isinstance(test, ast.Compare): + # quirk. See https://greentreesnakes.readthedocs.io/en/latest/nodes.html#Compare + self.process_loop_funcs(test.left, while_node) + + for comp in test.comparators: + self.process_loop_funcs(comp, while_node) + else: # while foo(): + self.process_loop_funcs(test, while_node) + + return self.loop_node_skeleton(while_node, node) def add_blackbox_or_builtin_call(self, node, blackbox): # noqa: C901 """Processes a blackbox or builtin function when it is called. @@ -603,6 +625,11 @@ def add_blackbox_or_builtin_call(self, node, blackbox): # noqa: C901 call_function_label = call_label_visitor.result[:call_label_visitor.result.find('(')] + # Check if function call matches a blackbox/built-in alias and if so, resolve it + # This resolves aliases like "from os import system as mysys" as: mysys -> os.system + local_definitions = self.module_definitions_stack[-1] + call_function_label = fully_qualify_alias_labels(call_function_label, local_definitions.import_alias_mapping) + # Create e.g. ~call_1 = ret_func_foo LHS = CALL_IDENTIFIER + 'call_' + str(saved_function_call_index) RHS = 'ret_' + call_function_label + '(' @@ -789,7 +816,7 @@ def add_module( # noqa: C901 module_path = module[1] parent_definitions = self.module_definitions_stack[-1] - # The only place the import_alias_mapping is updated + # Here, in `visit_Import` and in `visit_ImportFrom` are the only places the `import_alias_mapping` is updated parent_definitions.import_alias_mapping.update(import_alias_mapping) parent_definitions.import_names = local_names @@ -898,10 +925,10 @@ def from_directory_import( if init_exists and not skip_init: package_name = os.path.split(module_path)[1] return self.add_module( - (module[0], init_file_location), - package_name, - local_names, - import_alias_mapping, + module=(module[0], init_file_location), + module_or_package_name=package_name, + local_names=local_names, + import_alias_mapping=import_alias_mapping, is_init=True, from_from=True ) @@ -911,10 +938,10 @@ def from_directory_import( new_init_file_location = os.path.join(full_name, '__init__.py') if os.path.isfile(new_init_file_location): self.add_module( - (real_name, new_init_file_location), - real_name, - local_names, - import_alias_mapping, + module=(real_name, new_init_file_location), + module_or_package_name=real_name, + local_names=local_names, + import_alias_mapping=import_alias_mapping, is_init=True, from_from=True, from_fdid=True @@ -924,10 +951,10 @@ def from_directory_import( else: file_module = (real_name, full_name + '.py') self.add_module( - file_module, - real_name, - local_names, - import_alias_mapping, + module=file_module, + module_or_package_name=real_name, + local_names=local_names, + import_alias_mapping=import_alias_mapping, from_from=True ) return IgnoredNode() @@ -938,10 +965,10 @@ def import_package(self, module, module_name, local_name, import_alias_mapping): init_exists = os.path.isfile(init_file_location) if init_exists: return self.add_module( - (module[0], init_file_location), - module_name, - local_name, - import_alias_mapping, + module=(module[0], init_file_location), + module_or_package_name=module_name, + local_names=local_name, + import_alias_mapping=import_alias_mapping, is_init=True ) else: @@ -984,10 +1011,10 @@ def handle_relative_import(self, node): # Is it a file? if name_with_dir.endswith('.py'): return self.add_module( - (node.module, name_with_dir), - None, - as_alias_handler(node.names), - retrieve_import_alias_mapping(node.names), + module=(node.module, name_with_dir), + module_or_package_name=None, + local_names=as_alias_handler(node.names), + import_alias_mapping=retrieve_import_alias_mapping(node.names), from_from=True ) return self.from_directory_import( @@ -1010,10 +1037,10 @@ def visit_Import(self, node): retrieve_import_alias_mapping(node.names) ) return self.add_module( - module, - name.name, - name.asname, - retrieve_import_alias_mapping(node.names) + module=module, + module_or_package_name=name.name, + local_names=name.asname, + import_alias_mapping=retrieve_import_alias_mapping(node.names) ) for module in self.project_modules: if name.name == module[0]: @@ -1025,14 +1052,20 @@ def visit_Import(self, node): retrieve_import_alias_mapping(node.names) ) return self.add_module( - module, - name.name, - name.asname, - retrieve_import_alias_mapping(node.names) + module=module, + module_or_package_name=name.name, + local_names=name.asname, + import_alias_mapping=retrieve_import_alias_mapping(node.names) ) for alias in node.names: + # The module is uninspectable (so blackbox or built-in). If it has an alias, we remember + # the alias so we can do fully qualified name resolution for blackbox- and built-in trigger words + # e.g. we want a call to "os.system" be recognised, even if we do "import os as myos" + if alias.asname is not None and alias.asname != alias.name: + local_definitions = self.module_definitions_stack[-1] + local_definitions.import_alias_mapping[name.asname] = alias.name if alias.name not in uninspectable_modules: - log.warn("Cannot inspect module %s", alias.name) + log.warning("Cannot inspect module %s", alias.name) uninspectable_modules.add(alias.name) # Don't repeatedly warn about this return IgnoredNode() @@ -1040,40 +1073,48 @@ def visit_ImportFrom(self, node): # Is it relative? if node.level > 0: return self.handle_relative_import(node) - else: - for module in self.local_modules: - if node.module == module[0]: - if os.path.isdir(module[1]): - return self.from_directory_import( - module, - not_as_alias_handler(node.names), - as_alias_handler(node.names) - ) - return self.add_module( + # not relative + for module in self.local_modules: + if node.module == module[0]: + if os.path.isdir(module[1]): + return self.from_directory_import( module, - None, - as_alias_handler(node.names), - retrieve_import_alias_mapping(node.names), - from_from=True + not_as_alias_handler(node.names), + as_alias_handler(node.names) ) - for module in self.project_modules: - name = module[0] - if node.module == name: - if os.path.isdir(module[1]): - return self.from_directory_import( - module, - not_as_alias_handler(node.names), - as_alias_handler(node.names), - retrieve_import_alias_mapping(node.names) - ) - return self.add_module( + return self.add_module( + module=module, + module_or_package_name=None, + local_names=as_alias_handler(node.names), + import_alias_mapping=retrieve_import_alias_mapping(node.names), + from_from=True + ) + for module in self.project_modules: + name = module[0] + if node.module == name: + if os.path.isdir(module[1]): + return self.from_directory_import( module, - None, + not_as_alias_handler(node.names), as_alias_handler(node.names), - retrieve_import_alias_mapping(node.names), - from_from=True + retrieve_import_alias_mapping(node.names) ) + return self.add_module( + module=module, + module_or_package_name=None, + local_names=as_alias_handler(node.names), + import_alias_mapping=retrieve_import_alias_mapping(node.names), + from_from=True + ) + + # Remember aliases for uninspectable modules such that we can label them fully qualified + # e.g. we want a call to "os.system" be recognised, even if we do "from os import system" + # from os import system as mysystem -> module=os, name=system, asname=mysystem + for name in node.names: + local_definitions = self.module_definitions_stack[-1] + local_definitions.import_alias_mapping[name.asname or name.name] = "{}.{}".format(node.module, name.name) + if node.module not in uninspectable_modules: - log.warn("Cannot inspect module %s", node.module) + log.warning("Cannot inspect module %s", node.module) uninspectable_modules.add(node.module) return IgnoredNode() diff --git a/pyt/vulnerabilities/vulnerabilities.py b/pyt/vulnerabilities/vulnerabilities.py index 0daca2cd..2dba0078 100644 --- a/pyt/vulnerabilities/vulnerabilities.py +++ b/pyt/vulnerabilities/vulnerabilities.py @@ -327,44 +327,48 @@ def how_vulnerable( if current_node in sanitiser_nodes: vuln_deets['sanitiser'] = current_node vuln_deets['confident'] = True - return VulnerabilityType.SANITISED + return VulnerabilityType.SANITISED, interactive if isinstance(current_node, BBorBInode): if current_node.func_name in blackbox_mapping['propagates']: continue elif current_node.func_name in blackbox_mapping['does_not_propagate']: - return VulnerabilityType.FALSE + return VulnerabilityType.FALSE, interactive elif interactive: user_says = input( - 'Is the return value of {} with tainted argument "{}" vulnerable? (Y/n)'.format( + 'Is the return value of {} with tainted argument "{}" vulnerable? ([Y]es/[N]o/[S]top asking)'.format( current_node.label, chain[i - 1].left_hand_side ) ).lower() + if user_says.startswith('s'): + interactive = False + vuln_deets['unknown_assignment'] = current_node + return VulnerabilityType.UNKNOWN, interactive if user_says.startswith('n'): blackbox_mapping['does_not_propagate'].append(current_node.func_name) - return VulnerabilityType.FALSE + return VulnerabilityType.FALSE, interactive blackbox_mapping['propagates'].append(current_node.func_name) else: vuln_deets['unknown_assignment'] = current_node - return VulnerabilityType.UNKNOWN + return VulnerabilityType.UNKNOWN, interactive if potential_sanitiser: vuln_deets['sanitiser'] = potential_sanitiser vuln_deets['confident'] = False - return VulnerabilityType.SANITISED + return VulnerabilityType.SANITISED, interactive - return VulnerabilityType.TRUE + return VulnerabilityType.TRUE, interactive def get_tainted_node_in_sink_args( sink_args, - nodes_in_constaint + nodes_in_constraint ): if not sink_args: return None # Starts with the node closest to the sink - for node in nodes_in_constaint: + for node in nodes_in_constraint: if node.left_hand_side in sink_args: return node @@ -398,11 +402,15 @@ def get_vulnerability( Returns: A Vulnerability if it exists, else None """ - nodes_in_constaint = [secondary for secondary in reversed(source.secondary_nodes) - if lattice.in_constraint(secondary, - sink.cfg_node)] - nodes_in_constaint.append(source.cfg_node) - + nodes_in_constraint = [ + secondary + for secondary in reversed(source.secondary_nodes) + if lattice.in_constraint( + secondary, + sink.cfg_node + ) + ] + nodes_in_constraint.append(source.cfg_node) if sink.trigger.all_arguments_propagate_taint: sink_args = get_sink_args(sink.cfg_node) else: @@ -410,7 +418,7 @@ def get_vulnerability( tainted_node_in_sink_arg = get_tainted_node_in_sink_args( sink_args, - nodes_in_constaint, + nodes_in_constraint, ) if tainted_node_in_sink_arg: @@ -435,12 +443,13 @@ def get_vulnerability( cfg.nodes, lattice ) + for chain in get_vulnerability_chains( source.cfg_node, sink.cfg_node, def_use ): - vulnerability_type = how_vulnerable( + vulnerability_type, interactive = how_vulnerable( chain, blackbox_mapping, sanitiser_nodes, @@ -454,9 +463,9 @@ def get_vulnerability( vuln_deets['reassignment_nodes'] = chain - return vuln_factory(vulnerability_type)(**vuln_deets) + return vuln_factory(vulnerability_type)(**vuln_deets), interactive - return None + return None, interactive def find_vulnerabilities_in_cfg( @@ -487,7 +496,7 @@ def find_vulnerabilities_in_cfg( ) for sink in triggers.sinks: for source in triggers.sources: - vulnerability = get_vulnerability( + vulnerability, interactive = get_vulnerability( source, sink, triggers, diff --git a/pyt/vulnerability_definitions/all_trigger_words.pyt b/pyt/vulnerability_definitions/all_trigger_words.pyt index 5642db5c..7d7d2999 100644 --- a/pyt/vulnerability_definitions/all_trigger_words.pyt +++ b/pyt/vulnerability_definitions/all_trigger_words.pyt @@ -30,18 +30,51 @@ "'..' in" ] }, + "commands.getoutput(": {}, + "commands.getstatusoutput(": {}, + "eval(": {}, + "exec(": {}, "execute(": {}, - "system(": {}, "filter(": {}, - "subprocess.call(": {}, - "render_template(": {}, - "set_cookie(": {}, - "redirect(": {}, - "url_for(": {}, "flash(": {}, "jsonify(": {}, + "os.execl(": {}, + "os.execle(": {}, + "os.execlp(": {}, + "os.execlpe(": {}, + "os.execv(": {}, + "os.execve(": {}, + "os.execvp(": {}, + "os.execvpe(": {}, + "os.popen(": {}, + "os.popen2(": {}, + "os.popen3(": {}, + "os.popen4(": {}, + "os.spawnl(": {}, + "os.spawnle(": {}, + "os.spawnlp(": {}, + "os.spawnlpe(": {}, + "os.spawnv(": {}, + "os.spawnve(": {}, + "os.spawnvp(": {}, + "os.spawnvpe(": {}, + "os.startfile(": {}, + "os.system(": {}, + "popen2.Popen3(": {}, + "popen2.Popen4(": {}, + "popen2.popen2(": {}, + "popen2.popen3(": {}, + "popen2.popen4(": {}, + "redirect(": {}, "render(": {}, + "render_template(": {}, "render_to_response(": {}, - "Popen(": {} + "set_cookie(": {}, + "subprocess.Popen(": {}, + "subprocess.call(": {}, + "subprocess.check_call(": {}, + "subprocess.check_output(": {}, + "subprocess.run(": {}, + "url_for(": {} } } diff --git a/tests/cfg/cfg_test.py b/tests/cfg/cfg_test.py index a4c24ba5..3af37942 100644 --- a/tests/cfg/cfg_test.py +++ b/tests/cfg/cfg_test.py @@ -684,6 +684,96 @@ def test_while_line_numbers(self): self.assertLineNumber(else_body_2, 6) self.assertLineNumber(next_stmt, 7) + def test_while_func_comparator(self): + self.cfg_create_from_file('examples/example_inputs/while_func_comparator.py') + + self.assert_length(self.cfg.nodes, expected_length=9) + + entry = 0 + test = 1 + entry_foo = 2 + ret_foo = 3 + exit_foo = 4 + call_foo = 5 + _print = 6 + body_1 = 7 + _exit = 8 + + self.assertEqual(self.cfg.nodes[test].label, 'while foo():') + + self.assertInCfg([ + (test, entry), + (entry_foo, test), + (_print, test), + (_exit, test), + (body_1, _print), + (test, body_1), + (test, call_foo), + (ret_foo, entry_foo), + (exit_foo, ret_foo), + (call_foo, exit_foo) + ]) + + def test_while_func_comparator_rhs(self): + self.cfg_create_from_file('examples/example_inputs/while_func_comparator_rhs.py') + + self.assert_length(self.cfg.nodes, expected_length=9) + + entry = 0 + test = 1 + entry_foo = 2 + ret_foo = 3 + exit_foo = 4 + call_foo = 5 + _print = 6 + body_1 = 7 + _exit = 8 + + self.assertEqual(self.cfg.nodes[test].label, 'while x < foo():') + + self.assertInCfg([ + (test, entry), + (entry_foo, test), + (_print, test), + (_exit, test), + (body_1, _print), + (test, body_1), + (test, call_foo), + (ret_foo, entry_foo), + (exit_foo, ret_foo), + (call_foo, exit_foo) + ]) + + def test_while_func_comparator_lhs(self): + self.cfg_create_from_file('examples/example_inputs/while_func_comparator_lhs.py') + + self.assert_length(self.cfg.nodes, expected_length=9) + + entry = 0 + test = 1 + entry_foo = 2 + ret_foo = 3 + exit_foo = 4 + call_foo = 5 + _print = 6 + body_1 = 7 + _exit = 8 + + self.assertEqual(self.cfg.nodes[test].label, 'while foo() > x:') + + self.assertInCfg([ + (test, entry), + (entry_foo, test), + (_print, test), + (_exit, test), + (body_1, _print), + (test, body_1), + (test, call_foo), + (ret_foo, entry_foo), + (exit_foo, ret_foo), + (call_foo, exit_foo) + ]) + class CFGAssignmentMultiTest(CFGBaseTestCase): def test_assignment_multi_target(self): diff --git a/tests/main_test.py b/tests/main_test.py index 561d8bd1..fef1e124 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -108,11 +108,11 @@ def test_targets_with_recursive(self): excluded_files = "" included_files = discover_files(targets, excluded_files, True) - self.assertEqual(len(included_files), 33) + self.assertEqual(len(included_files), 34) def test_targets_with_recursive_and_excluded(self): targets = ["examples/vulnerable_code/"] excluded_files = "inter_command_injection.py" included_files = discover_files(targets, excluded_files, True) - self.assertEqual(len(included_files), 32) + self.assertEqual(len(included_files), 33) diff --git a/tests/vulnerabilities/vulnerabilities_across_files_test.py b/tests/vulnerabilities/vulnerabilities_across_files_test.py index bd63b190..d7b8f0d1 100644 --- a/tests/vulnerabilities/vulnerabilities_across_files_test.py +++ b/tests/vulnerabilities/vulnerabilities_across_files_test.py @@ -62,7 +62,7 @@ def test_blackbox_library_call(self): EXPECTED_VULNERABILITY_DESCRIPTION = """ File: examples/vulnerable_code_across_files/blackbox_library_call.py > User input at line 12, source "request.args.get(": - ~call_1 = ret_request.args.get('suggestion') + ~call_1 = ret_flask.request.args.get('suggestion') Reassigned in: File: examples/vulnerable_code_across_files/blackbox_library_call.py > Line 12: param = ~call_1 diff --git a/tests/vulnerabilities/vulnerabilities_base_test_case.py b/tests/vulnerabilities/vulnerabilities_base_test_case.py index c21f81ed..a7e86121 100644 --- a/tests/vulnerabilities/vulnerabilities_base_test_case.py +++ b/tests/vulnerabilities/vulnerabilities_base_test_case.py @@ -11,7 +11,7 @@ def string_compare_alpha(self, output, expected_string): def assertAlphaEqual(self, output, expected_string): self.assertEqual( - [char for char in output if char.isalpha()], - [char for char in expected_string if char.isalpha()] + ''.join(char for char in output if char.isalpha()), + ''.join(char for char in expected_string if char.isalpha()) ) return True diff --git a/tests/vulnerabilities/vulnerabilities_test.py b/tests/vulnerabilities/vulnerabilities_test.py index 5a28aa03..893ec70a 100644 --- a/tests/vulnerabilities/vulnerabilities_test.py +++ b/tests/vulnerabilities/vulnerabilities_test.py @@ -150,7 +150,7 @@ def test_XSS_result(self): EXPECTED_VULNERABILITY_DESCRIPTION = """ File: examples/vulnerable_code/XSS.py > User input at line 6, source "request.args.get(": - ~call_1 = ret_request.args.get('param', 'not set') + ~call_1 = ret_flask.request.args.get('param', 'not set') Reassigned in: File: examples/vulnerable_code/XSS.py > Line 6: param = ~call_1 @@ -186,7 +186,7 @@ def test_path_traversal_result(self): EXPECTED_VULNERABILITY_DESCRIPTION = """ File: examples/vulnerable_code/path_traversal.py > User input at line 15, source "request.args.get(": - ~call_1 = ret_request.args.get('image_name') + ~call_1 = ret_flask.request.args.get('image_name') Reassigned in: File: examples/vulnerable_code/path_traversal.py > Line 15: image_name = ~call_1 @@ -210,7 +210,7 @@ def test_path_traversal_result(self): > Line 19: foo = ~call_2 File: examples/vulnerable_code/path_traversal.py > reaches line 20, sink "send_file(": - ~call_4 = ret_send_file(foo) + ~call_4 = ret_flask.send_file(foo) """ self.assertAlphaEqual(vulnerability_description, EXPECTED_VULNERABILITY_DESCRIPTION) @@ -222,7 +222,7 @@ def test_ensure_saved_scope(self): EXPECTED_VULNERABILITY_DESCRIPTION = """ File: examples/vulnerable_code/ensure_saved_scope.py > User input at line 15, source "request.args.get(": - ~call_1 = ret_request.args.get('image_name') + ~call_1 = ret_flask.request.args.get('image_name') Reassigned in: File: examples/vulnerable_code/ensure_saved_scope.py > Line 15: image_name = ~call_1 @@ -232,7 +232,7 @@ def test_ensure_saved_scope(self): > Line 10: save_3_image_name = image_name File: examples/vulnerable_code/ensure_saved_scope.py > reaches line 20, sink "send_file(": - ~call_4 = ret_send_file(image_name) + ~call_4 = ret_flask.send_file(image_name) """ self.assertAlphaEqual( vulnerability_description, @@ -246,7 +246,7 @@ def test_path_traversal_sanitised_result(self): EXPECTED_VULNERABILITY_DESCRIPTION = """ File: examples/vulnerable_code/path_traversal_sanitised.py > User input at line 8, source "request.args.get(": - ~call_1 = ret_request.args.get('image_name') + ~call_1 = ret_flask.request.args.get('image_name') Reassigned in: File: examples/vulnerable_code/path_traversal_sanitised.py > Line 8: image_name = ~call_1 @@ -258,7 +258,7 @@ def test_path_traversal_sanitised_result(self): > Line 12: ~call_4 = ret_os.path.join(~call_5, image_name) File: examples/vulnerable_code/path_traversal_sanitised.py > reaches line 12, sink "send_file(": - ~call_3 = ret_send_file(~call_4) + ~call_3 = ret_flask.send_file(~call_4) This vulnerability is sanitised by: Label: ~call_2 = ret_image_name.replace('..', '') """ @@ -271,7 +271,7 @@ def test_path_traversal_sanitised_2_result(self): EXPECTED_VULNERABILITY_DESCRIPTION = """ File: examples/vulnerable_code/path_traversal_sanitised_2.py > User input at line 8, source "request.args.get(": - ~call_1 = ret_request.args.get('image_name') + ~call_1 = ret_flask.request.args.get('image_name') Reassigned in: File: examples/vulnerable_code/path_traversal_sanitised_2.py > Line 8: image_name = ~call_1 @@ -279,7 +279,7 @@ def test_path_traversal_sanitised_2_result(self): > Line 12: ~call_3 = ret_os.path.join(~call_4, image_name) File: examples/vulnerable_code/path_traversal_sanitised_2.py > reaches line 12, sink "send_file(": - ~call_2 = ret_send_file(~call_3) + ~call_2 = ret_flask.send_file(~call_3) This vulnerability is potentially sanitised by: Label: if '..' in image_name: """ @@ -292,7 +292,7 @@ def test_sql_result(self): EXPECTED_VULNERABILITY_DESCRIPTION = """ File: examples/vulnerable_code/sql/sqli.py > User input at line 26, source "request.args.get(": - ~call_1 = ret_request.args.get('param', 'not set') + ~call_1 = ret_flask.request.args.get('param', 'not set') Reassigned in: File: examples/vulnerable_code/sql/sqli.py > Line 26: param = ~call_1 @@ -347,7 +347,7 @@ def test_XSS_reassign_result(self): EXPECTED_VULNERABILITY_DESCRIPTION = """ File: examples/vulnerable_code/XSS_reassign.py > User input at line 6, source "request.args.get(": - ~call_1 = ret_request.args.get('param', 'not set') + ~call_1 = ret_flask.request.args.get('param', 'not set') Reassigned in: File: examples/vulnerable_code/XSS_reassign.py > Line 6: param = ~call_1 @@ -367,18 +367,18 @@ def test_XSS_sanitised_result(self): EXPECTED_VULNERABILITY_DESCRIPTION = """ File: examples/vulnerable_code/XSS_sanitised.py > User input at line 7, source "request.args.get(": - ~call_1 = ret_request.args.get('param', 'not set') + ~call_1 = ret_flask.request.args.get('param', 'not set') Reassigned in: File: examples/vulnerable_code/XSS_sanitised.py > Line 7: param = ~call_1 File: examples/vulnerable_code/XSS_sanitised.py - > Line 9: ~call_2 = ret_Markup.escape(param) + > Line 9: ~call_2 = ret_flask.Markup.escape(param) File: examples/vulnerable_code/XSS_sanitised.py > Line 9: param = ~call_2 File: examples/vulnerable_code/XSS_sanitised.py > reaches line 12, sink "replace(": ~call_5 = ret_html.replace('{{ param }}', param) - This vulnerability is sanitised by: Label: ~call_2 = ret_Markup.escape(param) + This vulnerability is sanitised by: Label: ~call_2 = ret_flask.Markup.escape(param) """ self.assertAlphaEqual(vulnerability_description, EXPECTED_VULNERABILITY_DESCRIPTION) @@ -394,7 +394,7 @@ def test_XSS_variable_assign_result(self): EXPECTED_VULNERABILITY_DESCRIPTION = """ File: examples/vulnerable_code/XSS_variable_assign.py > User input at line 6, source "request.args.get(": - ~call_1 = ret_request.args.get('param', 'not set') + ~call_1 = ret_flask.request.args.get('param', 'not set') Reassigned in: File: examples/vulnerable_code/XSS_variable_assign.py > Line 6: param = ~call_1 @@ -414,7 +414,7 @@ def test_XSS_variable_multiple_assign_result(self): EXPECTED_VULNERABILITY_DESCRIPTION = """ File: examples/vulnerable_code/XSS_variable_multiple_assign.py > User input at line 6, source "request.args.get(": - ~call_1 = ret_request.args.get('param', 'not set') + ~call_1 = ret_flask.request.args.get('param', 'not set') Reassigned in: File: examples/vulnerable_code/XSS_variable_multiple_assign.py > Line 6: param = ~call_1 @@ -482,6 +482,21 @@ def test_list_append_taints_list(self): ) self.assert_length(vulnerabilities, expected_length=1) + def test_import_bb_or_bi_with_alias(self): + vulnerabilities = self.run_analysis('examples/vulnerable_code/command_injection_with_aliases.py') + + EXPECTED_SINK_TRIGGER_WORDS = [ + 'os.system(', + 'os.system(', + 'os.system(', + 'os.system(', + 'subprocess.call(', + 'subprocess.Popen(' + ] + + for vuln, expected_sink_trigger_word in zip(vulnerabilities, EXPECTED_SINK_TRIGGER_WORDS): + self.assertEqual(vuln.sink_trigger_word, expected_sink_trigger_word) + class EngineDjangoTest(VulnerabilitiesBaseTestCase): def run_analysis(self, path): @@ -516,7 +531,7 @@ def test_django_view_param(self): param File: examples/vulnerable_code/django_XSS.py > reaches line 5, sink "render(": - ~call_1 = ret_render(request, 'templates/xss.html', 'param'param) + ~call_1 = ret_django.shortcuts.render(request, 'templates/xss.html', 'param'param) """ self.assertAlphaEqual(vulnerability_description, EXPECTED_VULNERABILITY_DESCRIPTION) diff --git a/tox.ini b/tox.ini index c9b4b248..424c51ef 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,11 @@ [tox] -envlist = py36,cover,lint +envlist = py36,py37,cover,lint [testenv] commands = python -m tests [testenv:cover] -whitelist_externals = coverage deps = coverage>=4.0,<4.4 commands =