class CorsHeader(base.BaseTestCase): """Test to check if CORS header variables are set to wild characters.""" test_name = "CORS_HEADER" test_type = "headers" client = client() failures = [] @classmethod def get_test_cases(cls, filename, file_content): request_obj = parser.create_request( file_content, os.environ.get("SYNTRIBOS_ENDPOINT")) request_obj.headers['Origin'] = 'http://example.com' cls.resp = cls.client.send_request(request_obj) yield cls def test_case(self): if 'Access-Control-Allow-Origin' in self.resp.headers: if self.resp.headers['Access-Control-Allow-Origin'] == "*": self.register_issue( Issue( test="CORS_HEADER", severity="Medium", confidence="High", text=("CORS header `Access-Control-Allow-Origin` set" " to a wild character, this header should" " always be set to a white listed set of URIs"))) if 'Access-Control-Allow-Methods' in self.resp.headers: if self.resp.headers['Access-Control-Allow-Methods'] == "*": self.register_issue( Issue(test="CORS_HEADER", severity="Low", confidence="High", text=("CORS header `Access-Control-Allow-Methods`" " set to a wild character,it is a good" " practice to give a white list of allowed" " methods."))) if 'Access-Control-Allow-Headers' in self.resp.headers: if self.resp.headers['Access-Control-Allow-Headers'] == "*": self.register_issue( Issue(test="CORS_HEADER", severity="Low", confidence="High", text=("CORS header `Access-Control-Allow-Headers`" " set to a wild character,it is a good" " practice to give a white list of allowed" " headers")))
class XstHeader(base.BaseTestCase): """Test for Cross Site Tracing vulnerabilities. A TRACE request with a fake request is sent to the server, if the server responds back with the entire request flow as a reponse, this can be termed a vulnerability. All TRACE requests should be vetted and filtered by the server to prevent accidental leakage of cookies, etc. If an app is already vulnerable to XSS attacks, then this can enable an attacker to steal session cookies. :more: https://www.owasp.org/index.php/Cross_Site_Tracing """ test_name = "XST_HEADERS" test_type = "headers" client = client() failures = [] @classmethod def get_test_cases(cls, filename, file_content): xst_header = {"TRACE_THIS": "XST_Vuln"} request_obj = parser.create_request(file_content, CONF.syntribos.endpoint, meta_vars=None) prepared_copy = request_obj.get_prepared_copy() prepared_copy.method = "TRACE" prepared_copy.headers.update(xst_header) cls.test_resp, cls.test_signals = cls.client.send_request( prepared_copy) yield cls def test_case(self): self.test_signals.register(xst(self)) xst_slugs = [ slugs for slugs in self.test_signals.all_slugs if "HEADER_XST" in slugs ] for i in xst_slugs: # noqa test_severity = syntribos.LOW self.register_issue(defect_type="XST_HEADER", severity=test_severity, confidence=syntribos.HIGH, description=(_("XST vulnerability found.\n" "Make sure that response to a " "TRACE request is filtered.")))
class CorsHeader(base.BaseTestCase): """Test for CORS wild character vulnerabilities in HTTP header.""" test_name = "CORS_WILDCARD_HEADERS" parameter_location = "headers" client = client() failures = [] @classmethod def get_test_cases(cls, filename, file_content, meta_vars): request_obj = parser.create_request( file_content, CONF.syntribos.endpoint, meta_vars ) prepared_copy = request_obj.get_prepared_copy() cls.test_resp, cls.test_signals = cls.client.send_request( prepared_copy) cls.test_req = request_obj.get_prepared_copy() yield cls def test_case(self): self.test_signals.register(cors(self)) cors_slugs = [ slugs for slugs in self.test_signals.all_slugs if "HEADER_CORS" in slugs] for slug in cors_slugs: if "ORIGIN" in slug: test_severity = syntribos.HIGH else: test_severity = syntribos.MEDIUM self.register_issue( defect_type="CORS_HEADER", severity=test_severity, confidence=syntribos.HIGH, description=( _("CORS header vulnerability found.\n" "Make sure that the header is not assigned " "a wildcard character.")))
class BaseTestCase(unittest.TestCase): """Base class for building new tests :attribute str test_name: A name like ``XML_EXTERNAL_ENTITY_BODY``, containing the test type and the portion of the request template being tested :attribute list failures: A collection of "failures" raised by tests :attribute bool dead: Flip this if one of the requests doesn't return a response object :attribute client: HTTP client to be used by the test :attribute init_req: Initial request (loaded from request template) :attribute init_resp: Response to the initial request :attribute test_req: Request sent by the test for analysis :attribute test_resp: Response to the test request :attribute init_signals: Holder for signals on `init_req` :attribute test_signals: Holder for signals on `test_req` :attribute diff_signals: Holder for signals between `init_req` and `test_req` """ test_name = None failures = [] errors = [] dead = False client = client() init_req = None init_resp = None test_req = None test_resp = None init_signals = SignalHolder() test_signals = SignalHolder() diff_signals = SignalHolder() @classmethod def register_opts(cls): pass @classmethod def get_test_cases(cls, filename, file_content, meta_vars): """Returns tests for given TestCase class (overwritten by children).""" yield cls @classmethod def create_init_request(cls, filename, file_content, meta_vars): """Parses template and creates init request object This method does not send the initial request, instead, it only creates the object for use in the debug test :param str filename: name of template file :param str file_content: content of template file as string """ request_obj = parser.create_request(file_content, CONF.syntribos.endpoint, meta_vars) cls.init_req = request_obj cls.init_resp = None cls.init_signals = None cls.template_path = filename @classmethod def send_init_request(cls, filename, file_content, meta_vars): """Parses template, creates init request object, and sends init request This method sends the initial request, which is the request created after parsing the template file. This request will not be modified any further by the test cases themselves. :param str filename: name of template file :param str file_content: content of template file as string """ if not cls.init_req: cls.init_req = parser.create_request(file_content, CONF.syntribos.endpoint, meta_vars) prepared_copy = cls.init_req.get_prepared_copy() cls.prepared_init_req = prepared_copy cls.init_resp, cls.init_signals = cls.client.send_request( prepared_copy) if cls.init_resp is not None: # Get the computed body and add it to our RequestObject # TODO(cneill): Figure out a better way to handle this discrepancy cls.init_req.body = cls.init_resp.request.body else: cls.dead = True @classmethod def extend_class(cls, new_name, kwargs): """Creates an extension for the class Each TestCase class created is added to the `test_table`, which is then read in by the test runner as the master list of tests to be run. :param str new_name: Name of new class to be created :param dict kwargs: Keyword arguments to pass to the new class :rtype: class :returns: A TestCase class extending :class:`BaseTestCase` """ new_name = replace_invalid_characters(new_name) if not isinstance(kwargs, dict): raise Exception("kwargs must be a dictionary") new_cls = type(new_name, (cls, ), kwargs) new_cls.__module__ = cls.__module__ return new_cls @classmethod def tearDownClass(cls): super(BaseTestCase, cls).tearDownClass() if not cls.failures: if "EXCEPTION_RAISED" in cls.test_signals: sig = cls.test_signals.find(tags="EXCEPTION_RAISED")[0] exc_name = type(sig.data["exception"]).__name__ if ("CONNECTION_FAIL" in sig.tags): six.raise_from( FatalHTTPError( "The remote target has forcibly closed the connection " "with Syntribos and resulted in exception '{}'. This " "could potentially mean that a fatal error was " "encountered within the target application or server" " itself.".format(exc_name)), sig.data["exception"]) else: raise sig.data["exception"] @classmethod def tearDown(cls): get_slugs = [sig.slug for sig in cls.test_signals] get_checks = [sig.check_name for sig in cls.test_signals] test_signals_used = "Signals: " + str(get_slugs) LOG.debug(test_signals_used) test_checks_used = "Checks used: " + str(get_checks) LOG.debug(test_checks_used) def run_test_case(self): """This kicks off the test(s) for a given TestCase class After running the tests, an `AssertionError` is raised if any tests were added to self.failures. :raises: :exc:`AssertionError` """ if not self.dead: try: self.test_case() except Exception as e: self.errors += e raise if self.failures: raise AssertionError def test_case(self): """This method is overwritten by individual TestCase classes It represents the actual test that is called in :func:`run_test_case`, and handles populating `self.failures` """ pass def register_issue(self, defect_type, severity, confidence, description): """Adds an issue to the test's list of issues Creates a :class:`syntribos.issue.Issue` object, with given function parameters as instances variables, and registers the issue as a failure and associates the test's metadata to it. :param defect_type: The type of vulnerability that Syntribos believes it has found. This may be something like 500 error or DoS, regardless tof whathe Test Type is. :param severity: "Low", "Medium", or "High", depending on the defect :param description: Description of the defect :param confidence: The confidence of the defect :returns: new issue object with metadata associated :rtype: Issue """ issue = syntribos.Issue(defect_type=defect_type, severity=severity, confidence=confidence, description=description) issue.request = self.test_req if self.test_req else self.init_req issue.response = self.test_resp if self.test_resp else self.init_resp issue.template_path = self.template_path issue.parameter_location = self.parameter_location issue.test_type = self.test_name url_components = urlparse(self.init_resp.url) issue.target = url_components.netloc issue.path = url_components.path issue.init_signals = self.init_signals issue.test_signals = self.test_signals issue.diff_signals = self.diff_signals self.failures.append(issue) return issue
# # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # import requests import requests.exceptions as rex import requests_mock import testtools import syntribos.checks.http as http_checks import syntribos.clients.http.client as client import syntribos.signal client = client() class HTTPCheckUnittest(testtools.TestCase): def _get_one_signal(self, signals, slug=None, tags=None): to_search = None if isinstance(signals, syntribos.signal.SynSignal): to_search = signals elif isinstance(signals, syntribos.signal.SignalHolder): slugs = [slug] if slug else None matching = signals.find(slugs=slugs, tags=tags) self.assertEqual(1, len(matching)) to_search = matching[0]
class BaseFuzzTestCase(base.BaseTestCase): config = syntribos.tests.fuzz.config.BaseFuzzConfig() client = client() failure_keys = None success_keys = None @classmethod def validate_length(cls): if getattr(cls, "init_response", False) is False: raise NotImplemented init_req_len = len(cls.init_response.request.body or "") init_resp_len = len(cls.init_response.content or "") req_len = len(cls.resp.request.body or "") resp_len = len(cls.resp.content or "") request_diff = req_len - init_req_len response_diff = resp_len - init_resp_len percent_diff = abs(float(response_diff) / (init_resp_len + 1)) * 100 msg = ("Validate Length:\n" "\tInitial request length: {0}\n" "\tInitial response length: {1}\n" "\tRequest length: {2}\n" "\tResponse length: {3}\n" "\tRequest difference: {4}\n" "\tResponse difference: {5}\n" "\tPercent difference: {6}\n" "\tConfig percent: {7}\n").format(init_req_len, init_resp_len, req_len, resp_len, request_diff, response_diff, percent_diff, cls.config.percent) cls.fixture_log.debug(msg) if request_diff == response_diff: return True elif resp_len == init_resp_len: return True elif cls.config.percent: if percent_diff <= cls.config.percent: return True return False @classmethod def _get_strings(cls, file_name=None): path = os.path.join(data_dir, file_name or cls.data_key) with open(path, "rb") as fp: return fp.read().splitlines() @classmethod def data_driven_failure_cases(cls): failure_assertions = [] if cls.failure_keys is None: return [] for line in cls.failure_keys: failure_assertions.append([(cls.assertNotIn, line, cls.resp.content)]) return failure_assertions @classmethod def data_driven_pass_cases(cls): if cls.success_keys is None: return True for s in cls.success_keys: if s in cls.resp.content: return True return False @classmethod def setUpClass(cls): """being used as a setup test not.""" super(BaseFuzzTestCase, cls).setUpClass() cls.issues = [] cls.failures = [] cls.resp = cls.client.request(method=cls.request.method, url=cls.request.url, headers=cls.request.headers, params=cls.request.params, data=cls.request.data) @classmethod def tearDownClass(cls): super(BaseFuzzTestCase, cls).tearDownClass() for issue in cls.issues: if issue.failure: cls.failures.append(issue.as_dict()) def test_case(self): self.register_issue( Issue(test="500_errors", severity="Low", text="This request generates a 500 error", assertions=[(self.assertTrue, self.resp.status_code < 500)])) self.register_issue( Issue(test="length_diff", severity="Low", text=("The difference in length between the response to the" "baseline request and the request returned when" "sending an attack string exceeds {0} percent, which" "could indicate a vulnerability to injection attacks" ).format(self.config.percent), assertions=[(self.assertTrue, self.validate_length())])) self.register_issue( Issue(test="injection_strings", severity="Medium", text=("A known attack string was included in the response." "This could indicate a vulnerability to injection" "attacks."), assertions=self.data_driven_failure_cases())) self.register_issue( Issue(test="success_strings", severity="Low", text=("None of the expected strings in [{0}] can be found in" "the response").format(self.success_keys), assertions=[(self.assertTrue, self.data_driven_pass_cases()) ])) self.test_issues() def register_issue(self, issue=None): """Adds an issue to the test's list of issues Creates a new issue object, and associates the test's request and response to it. In addition, adds the issue to the test's list of issues """ if not issue: issue = Issue() issue.request = self.resp.request issue.response = self.resp self.issues.append(issue) return issue def test_issues(self): for issue in self.issues: issue.run_tests() @classmethod def get_test_cases(cls, filename, file_content): # maybe move this block to base.py request_obj = syntribos.tests.fuzz.datagen.FuzzParser.create_request( file_content, os.environ.get("SYNTRIBOS_ENDPOINT")) prepared_copy = request_obj.get_prepared_copy() cls.init_response = cls.client.send_request(prepared_copy) # end block prefix_name = "{filename}_{test_name}_{fuzz_file}_".format( filename=filename, test_name=cls.test_name, fuzz_file=cls.data_key) for fuzz_name, request in request_obj.fuzz_request( cls._get_strings(), cls.test_type, prefix_name): yield cls.extend_class(fuzz_name, {"request": request})
class BaseFuzzTestCase(base.BaseTestCase): config = syntribos.tests.fuzz.config.BaseFuzzConfig() client = client() failure_keys = None success_keys = None @classmethod def validate_length(cls): """Validates length of response Compares the length of a fuzzed response with a response to the baseline request. If the response is longer than expected, returns false :returns: boolean - whether the response is longer than expected """ if getattr(cls, "init_response", False) is False: raise NotImplemented init_req_len = len(cls.init_response.request.body or "") init_resp_len = len(cls.init_response.content or "") req_len = len(cls.resp.request.body or "") resp_len = len(cls.resp.content or "") request_diff = req_len - init_req_len response_diff = resp_len - init_resp_len percent_diff = abs(float(response_diff) / (init_resp_len + 1)) * 100 msg = ( "Validate Length:\n" "\tInitial request length: {0}\n" "\tInitial response length: {1}\n" "\tRequest length: {2}\n" "\tResponse length: {3}\n" "\tRequest difference: {4}\n" "\tResponse difference: {5}\n" "\tPercent difference: {6}\n" "\tConfig percent: {7}\n").format( init_req_len, init_resp_len, req_len, resp_len, request_diff, response_diff, percent_diff, cls.config.percent) cls.fixture_log.debug(msg) if request_diff == response_diff: return True elif resp_len == init_resp_len: return True elif cls.config.percent: if percent_diff <= cls.config.percent: return True return False @classmethod def _get_strings(cls, file_name=None): path = os.path.join(data_dir, file_name or cls.data_key) with open(path, "rb") as fp: return fp.read().splitlines() @classmethod def data_driven_failure_cases(cls): """Checks if response contains known bad strings :returns: a list of strings that show up in the response that are also defined in cls.failure_strings. failed_strings = [] """ failed_strings = [] if cls.failure_keys is None: return [] for line in cls.failure_keys: if line in cls.resp.content: failed_strings.append(line) return failed_strings @classmethod def data_driven_pass_cases(cls): """Checks if response contains expected strings :returns: a list of assertions that fail if the response doesn't contain a string defined in cls.success_keys as a string expected in the response. """ if cls.success_keys is None: return True for s in cls.success_keys: if s in cls.resp.content: return True return False @classmethod def setUpClass(cls): """being used as a setup test not.""" super(BaseFuzzTestCase, cls).setUpClass() cls.failures = [] cls.resp = cls.client.request( method=cls.request.method, url=cls.request.url, headers=cls.request.headers, params=cls.request.params, data=cls.request.data) @classmethod def tearDownClass(cls): super(BaseFuzzTestCase, cls).tearDownClass() def test_default_issues(self): """Tests for some default issues These issues are not specific to any test type, and can be raised as a result of many different types of attacks. Therefore, they're defined separately from the test_case method so that they are not overwritten by test cases that inherit from BaseFuzzTestCase. Any extension to this class should call self.test_default_issues() in order to test for the Issues defined here """ target = self.init_request.url domain = urlparse(target).hostname regex = r"\bhttp://{0}".format(domain) response_text = self.resp.text if re.search(regex, response_text): self.register_issue( Issue(test="SSL_ERROR", severity="Medium", confidence="High", text=("Make sure that all the returned endpoint URIs" " use 'https://' and not 'http://'" ) ) ) if self.resp.status_code >= 500: self.register_issue( Issue(test="500_errors", severity="Low", confidence="High", text=("This request returns an error with status code " "{0}, which might indicate some server-side fault " "that could lead to further vulnerabilities" ).format(self.resp.status_code) ) ) if (not self.validate_length() and self.resp.status_code == self.init_response.status_code): self.register_issue( Issue(test="length_diff", severity="Low", confidence="Low", text=("The difference in length between the response to " "the baseline request and the request returned " "when sending an attack string exceeds {0} " "percent, which could indicate a vulnerability " "to injection attacks") .format(self.config.percent) ) ) def test_case(self): """Performs the test The test runner will call test_case on every TestCase class, and will report any AssertionError raised by this method to the results. """ self.test_default_issues() @classmethod def get_test_cases(cls, filename, file_content): """Generates new TestCases for each fuzz string First, sends a baseline (non-fuzzed) request, storing it in cls.init_response. For each string returned by cls._get_strings(), yield a TestCase class for the string as an extension to the current TestCase class. Every string used as a fuzz test payload entails the generation of a new subclass for each parameter fuzzed. See :func:`base.extend_class`. """ # maybe move this block to base.py request_obj = syntribos.tests.fuzz.datagen.FuzzParser.create_request( file_content, os.environ.get("SYNTRIBOS_ENDPOINT")) prepared_copy = request_obj.get_prepared_copy() cls.init_response = cls.client.send_request(prepared_copy) cls.init_request = cls.init_response.request # end block prefix_name = "{filename}_{test_name}_{fuzz_file}_".format( filename=filename, test_name=cls.test_name, fuzz_file=cls.data_key) fr = request_obj.fuzz_request( cls._get_strings(), cls.test_type, prefix_name) for fuzz_name, request, fuzz_string, param_path in fr: yield cls.extend_class(fuzz_name, fuzz_string, param_path, {"request": request}) @classmethod def extend_class(cls, new_name, fuzz_string, param_path, kwargs): """Creates an extension for the class Each TestCase class created is added to the `test_table`, which is then read in by the test runner as the master list of tests to be run. :param str new_name: Name of new class to be created :param str fuzz_string: Fuzz string to insert :param str param_path: String tracing location of the ImpactedParameter :param dict kwargs: Keyword arguments to pass to the new class :rtype: class :returns: A TestCase class extending :class:`BaseTestCase` """ new_cls = super(BaseFuzzTestCase, cls).extend_class(new_name, kwargs) new_cls.fuzz_string = fuzz_string new_cls.param_path = param_path return new_cls def register_issue(self, issue): """Adds an issue to the test's list of issues Registers a :class:`syntribos.issue.Issue` object as a failure and associates the test's metadata to it, including the :class:`syntribos.tests.fuzz.base_fuzz.ImpactedParameter` object that encapsulates the details of the fuzz test. :param Issue issue: issue object to update :returns: new issue object with metadata associated :rtype: Issue """ # Still associating request and response objects with issue in event of # debug log req = self.resp.request issue.request = req issue.response = self.resp issue.test_type = self.test_name url_components = urlparse(self.init_response.url) issue.target = url_components.netloc issue.path = url_components.path if 'content-type' in self.init_request.headers: issue.content_type = self.init_request.headers['content-type'] else: issue.content_type = None issue.impacted_parameter = ImpactedParameter(method=req.method, location=self.test_type, name=self.param_path, value=self.fuzz_string) self.failures.append(issue) return issue