diff --git a/detect_secrets/core/usage.py b/detect_secrets/core/usage.py index a4c2e6fff..fb797c1d5 100644 --- a/detect_secrets/core/usage.py +++ b/detect_secrets/core/usage.py @@ -51,14 +51,15 @@ def add_output_verified_false_flag(parser): class ParserBuilder(object): + def __init__(self): self.parser = argparse.ArgumentParser() - self.subparser = None self.add_default_arguments() def add_default_arguments(self): - self._add_verbosity_argument()._add_version_argument() + self._add_verbosity_argument()\ + ._add_version_argument() def add_pre_commit_arguments(self): self._add_filenames_argument()\ @@ -66,21 +67,21 @@ def add_pre_commit_arguments(self): ._add_exclude_lines_argument()\ ._add_word_list_argument()\ ._add_use_all_plugins_argument()\ - ._add_no_verify_flag()\ + ._add_no_verify_flag() \ ._add_output_verified_false_flag()\ - ._add_fail_on_unaudited_flag() + ._add_fail_on_non_audited_flag() PluginOptions(self.parser).add_arguments() return self def add_console_use_arguments(self): - self.subparser = self.parser.add_subparsers( + subparser = self.parser.add_subparsers( dest='action', ) for action_parser in (ScanOptions, AuditOptions): - action_parser(self.subparser).add_arguments() + action_parser(subparser).add_arguments() return self @@ -145,9 +146,9 @@ def _add_output_verified_false_flag(self): add_output_verified_false_flag(self.parser) return self - def _add_fail_on_unaudited_flag(self): + def _add_fail_on_non_audited_flag(self): self.parser.add_argument( - '--fail-on-unaudited', + '--fail-on-non-audited', action='store_true', help='Fail check if there are entries have not been audited in baseline.', ) @@ -155,8 +156,9 @@ def _add_fail_on_unaudited_flag(self): class ScanOptions: + def __init__(self, subparser): - self.parser: argparse.ArgumentParser = subparser.add_parser( + self.parser = subparser.add_parser( 'scan', ) @@ -227,7 +229,10 @@ def _add_adhoc_scanning_argument(self): '--string', nargs='?', const=True, - help=('Scans an individual string, and displays configured ' 'plugins\' verdict.'), + help=( + 'Scans an individual string, and displays configured ' + 'plugins\' verdict.' + ), ) return self @@ -245,78 +250,22 @@ def _add_output_raw_argument(self): class AuditOptions: + def __init__(self, subparser): - # Override the default audit parser usage message since the arguments within - # the _add_report_module group should only be permitted when the --report - # arg is included. argparse does not have built-in mutual inclusion functionality, - # so we had to add our own custom validation function, validate_args, - # in detect-secrets/core/report/report.py. - # docs: https://docs.python.org/3/library/argparse.html#usage - self.parser: argparse.ArgumentParser = subparser.add_parser( + self.parser = subparser.add_parser( 'audit', - usage='%(prog)s [-h] [--diff | --display-results | --report [--fail-on-unaudited]' - ' [--fail-on-live] [--fail-on-audited-real] [--json | --omit-instructions]]' - ' [filename ...]', ) - def _add_report_module(self): - report_parser = self.parser.add_argument_group( - title='reporting', - description=( - 'Displays a report with the secrets detected which fail certain conditions. ' - 'To be used with the report mode (--report).' - ), - ) - - report_parser.add_argument( - '--fail-on-unaudited', - action='store_true', - help=( - 'This condition is met when there are potential secrets' - ' in the baseline file which have not yet been audited.' - ' To pass this check, run detect-secrets audit to' - ' audit all unaudited secrets.' - ), - ) - - report_parser.add_argument( - '--fail-on-live', - action='store_true', - help=( - 'This condition is met when a secret has been verified' - ' to be live. To pass this check, make sure that any' - ' secrets in the baseline file with a property of' - ' is_verified: true have been remediated, afterwards re-scan.' - ), - ) - - report_parser.add_argument( - '--fail-on-audited-real', - action='store_true', + def add_arguments(self): + self.parser.add_argument( + 'filename', + nargs='+', help=( - 'This condition is met when the baseline file contains' - ' one or more secrets which have been marked as actual' - ' secrets during the auditing process. Secrets with a' - ' property of is_secret: true meet this condition.' - ' To pass this check, remove these secrets from your' - ' code and re-scan so that they will be removed from your baseline.' + 'Audit a given baseline file to distinguish the difference ' + 'between false and true positives.' ), ) - report_parser_exclusive = report_parser.add_mutually_exclusive_group() - - report_parser_exclusive.add_argument( - '--json', - action='store_true', - help=('Causes the report output to be formatted as JSON.'), - ) - report_parser_exclusive.add_argument( - '--omit-instructions', - action='store_true', - help=('Omits instructions from the report.'), - ) - - def add_arguments(self): action_parser = self.parser.add_mutually_exclusive_group() action_parser.add_argument( @@ -338,23 +287,6 @@ def add_arguments(self): ), ) - action_parser.add_argument( - '--report', - action='store_true', - help=('Displays a report with the secrets detected'), - ) - - self._add_report_module() - - self.parser.add_argument( - 'filename', - nargs='+', - help=( - 'Audit a given baseline file to distinguish the difference ' - 'between false and true positives.' - ), - ) - return self @@ -364,10 +296,13 @@ class PluginDescriptor( [ # Classname of plugin; used for initialization 'classname', + # Flag to disable plugin. e.g. `--no-hex-string-scan` 'flag_text', + # Description for disable flag. 'help_text', + # type: list # Allows the bundling of all related command line provided # arguments together, under one plugin name. @@ -380,13 +315,19 @@ class PluginDescriptor( # Therefore, only populate the default value upon consolidation # (rather than relying on argparse default). 'related_args', + # The name of the plugin file 'filename', ], ), ): + def __new__(cls, related_args=None, **kwargs): - return super(PluginDescriptor, cls).__new__(cls, related_args=related_args or [], **kwargs) + return super(PluginDescriptor, cls).__new__( + cls, + related_args=related_args or [], + **kwargs + ) @classmethod def from_plugin_class(cls, plugin, name): @@ -398,12 +339,10 @@ def from_plugin_class(cls, plugin, name): if plugin.default_options: related_args = [] for arg_name, value in plugin.default_options.items(): - related_args.append( - ( - '--{}'.format(arg_name.replace('_', '-')), - value, - ), - ) + related_args.append(( + '--{}'.format(arg_name.replace('_', '-')), + value, + )) return cls( classname=name, @@ -572,6 +511,12 @@ class PluginOptions: help_text='Disables scans for GitHub credentials', filename='github_token', ), + PluginDescriptor( + classname='ContentChecker', + flag_text='--no-contentchecker-scan', # todo, check + help_text='Disables scans for ContentChecker credentials', + filename='content_checker', + ), ] opt_in_plugins = [ PluginDescriptor( @@ -658,11 +603,9 @@ def consolidate_args(args): related_args[arg_name] = default_value is_using_default_value[arg_name] = True - active_plugins.update( - { - plugin.classname: related_args, - }, - ) + active_plugins.update({ + plugin.classname: related_args, + }) for plugin in PluginOptions.all_plugins: if getattr(plugin, 'classname') in list(active_plugins): diff --git a/detect_secrets/plugins/content_checker.py b/detect_secrets/plugins/content_checker.py new file mode 100644 index 000000000..c87dfbba6 --- /dev/null +++ b/detect_secrets/plugins/content_checker.py @@ -0,0 +1,232 @@ +""" +This plugin runs the CEDP content-checker + +Adaptation from : https://github.ibm.com/cognitive-data-platform/cognitive-data-platform/blob/master/tools/cedp_ci/check/passwords.go + +Searches for the following named patterns. +2. SecretFoundInString +3. SetSecret +4. AssignmentSecretFound +5. CommentedSecretFound +6. CommaDelimitedSecret +7. AuthorizationFound +8. BasicAuthorizationFound +9. UrlSecretFound +10. SuspiciousBase64 +11. Certificates + + +Each pattern is defined by a regex and a corresponding match function(matchFcn). + +""" + + + +import re +import os +from .base import BasePlugin +from .base import classproperty +from .common.filetype import determine_file_type +from .common.filetype import FileType +from .common.filters import get_aho_corasick_helper +from .common.filters import is_sequential_string +from detect_secrets.core.potential_secret import PotentialSecret + + +def default_match_fcn(filename,extension,matches): + return True + +class Pattern: + + def __init__(self,name,pattern,match_fcn=None, + extensions=None,excluded_extensions=[]): + self.name = name + self.pattern = pattern + self.match_fcn = match_fcn + self.extensions = extensions + self.excluded_extensions = excluded_extensions + self.regex = re.compile(self.pattern) + + +assignmentPattern = '(\S+)\s*(?:=|:=|:|<-)\s*(.+)' +quotedStringPattern = '^(?:"([^"]+)"|\'([^\']+)\'|\'\'\'([^\']+)\'\'\'|"""([^"]+)"""|([^\'"\s]+))$' + +#restrictedPatterns is an enumeration of each pattern that we are searching for +#in the code base. The matching of any one of these will cause a CEDP CI failure +#across all readiness levels. + +variableRegex = '[\'"(]*([\w\.]*(?:password|dburl|token|passwd|key|access[_-]?key[_-]?id|access[_-]?key|secret[_-]?key|secret|auth[a-zA-Z_]*|cred|pwd|xclientid))[\'")]*' + +#secretRegex should ideally span secrets which may or may not be embedded in quotes, hence they shouldn't contain space. +secretRegex = '[\\\(\'"]*(?:(?:basic|token)\s+)?([^\'";\(\),\s$]+)(?:())?[\'"\)\\;]?' + +#quotedSecretRegex spans secrets embedded in quotes, so they can contain space. +quotedSecretRegex = '[\\\(]*[\'"]+' + '[\\\(\'"]*(?:(?:basic|token)\s+)?([^\'";\(\),$]+)(?:())?[\'"\)\\;]?' + '[\'"\)\\;]*' + +secretFoundPattern = '(?i)' + variableRegex + '\s*[=:]{1,2}\s*' + secretRegex + +#varAndSecretInString spans strings like "otherProps=whatever;PWD=debTFvgjm579&*%gjvH;PORT=8080;" +varAndSecretInString = '(?:' + '"(?:[^"]*?)' + secretFoundPattern + '(?:[^"]*)"' + '|' +\ + '\'(?:[^\']*?)' + secretFoundPattern + '(?:[^\']*)\'' + '|' + \ + '\'\'\'(?:[^\']*?)' + secretFoundPattern + '(?:[^\']*)\'\'\'' + '|' + \ + '"""(?:[^"]*?)' + secretFoundPattern + '(?:[^"]*)"""' + ')' + +# Note: All values here should be lowercase +DENYLIST = [ + Pattern( + name="SecretFound", + pattern=secretFoundPattern, + match_fcn=default_match_fcn, + excluded_extensions=[".java", ".go", ".py", ".cpp", ".c", ".js", ".scala", ".ts", ".proto", ".yaml", ".yml", ".tpl"] + ), + Pattern( + name="SecretFoundInString", + pattern=varAndSecretInString, + match_fcn=default_match_fcn, + ), + Pattern( + name="SetSecret", + pattern='[\[]*[\'"]*(?i)' + variableRegex + '[\'"]*[\]]*\s*(?:=|=|:|-)\s*[\'"]*\s*[\+,]*\s*' + quotedSecretRegex + '((?:\s*(?:\+*)\s*' + quotedSecretRegex + ')*)', + match_fcn=default_match_fcn, + ), + Pattern( + name="AssignmentSecretFound", + pattern='[\[]*[\'"]*(?i)' + variableRegex + '[\'"]*[\]]*\s*(?:=|=|:|-)\s*[\'"]*\s*[\+,]*\s*' + quotedSecretRegex + '((?:\s*(?:\+*)\s*' + quotedSecretRegex + ')*)', + match_fcn=default_match_fcn, + ), + Pattern( + name="CommentedSecretFound", + pattern='(?:\*|#|//)(?:\s*\w*)*[\[]*[\'"]*(?i)' + variableRegex + '[\'"]*[\]]*\s*(?:=|=|:)\s*' + secretRegex + '((?:\s*(?:\+*)\s*' + secretRegex + ')*)', + match_fcn=default_match_fcn, + ), + Pattern( + name="CommaDelimitedSecret", + pattern='[\'"]?(?i)[\'"]' + variableRegex + '[\'"]\s*[\'"]?\s*[,=:]{1,2}\s*' + quotedSecretRegex, + match_fcn=default_match_fcn, + ), + Pattern( + name="CommaDelimitedSecret", + pattern='[\'"]?(?i)[\'"]' + variableRegex + '[\'"]\s*[\'"]?\s*[,=:]{1,2}\s*' + quotedSecretRegex, + match_fcn=default_match_fcn, + ), + +] + + + + + + + + + + + + + + +class ContentChecker(BasePlugin): + """ + Scans for secret-sounding variable names. + + This checks if denylisted keywords are present in the analyzed string. + """ + secret_type = 'Content Checker' + + @classproperty + def default_options(cls): + return {} + + @property + def __dict__(self): + output = {} + output.update(super(ContentChecker, self).__dict__) + + return output + + def __init__(self, keyword_exclude=None, exclude_lines_regex=None, automaton=None, **kwargs): + false_positive_heuristics = [] + + super(ContentChecker, self).__init__( + exclude_lines_regex=exclude_lines_regex, + false_positive_heuristics=false_positive_heuristics, + **kwargs + ) + + self.secret_type = 'Content Checker' + + #print('initialization') + + + def analyze_string_content(self, string, line_num, filename, output_raw=False): + print('cc ',filename,':',line_num,',',string) + output = {} + + for match_string,pattern_name in self.secret_generator( + string, + filename=filename, + ): + secret = PotentialSecret( + #pattern_name, + self.secret_type, + filename, + match_string, + line_num, + output_raw=output_raw, + ) + output[secret] = secret + print('found secret ',pattern_name,' : ','cc ',filename,':',line_num,',',match_string) + + return output + + def secret_generator(self, string, filename): + _, file_extension = os.path.splitext(filename) + for pattern in DENYLIST: + # print('pattern : ',pattern.name) + # print("pattern : ",pattern.pattern) + # print('string : ',string.strip()) + if pattern.extensions and (file_extension not in pattern.extensions): + # print('skipping no in extensions list') + # print() + # print() + continue + elif file_extension in pattern.excluded_extensions: + # print('in excluded extensions list') + # print() + # print() + continue + + match = pattern.regex.search(string) + # print('match : ',match) + # print() + # print() + if match: + if pattern.match_fcn(filename,file_extension,match): + yield match[0],pattern.name + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/regextest.py b/regextest.py new file mode 100644 index 000000000..2d51f00df --- /dev/null +++ b/regextest.py @@ -0,0 +1,41 @@ +import re + +assignmentPattern = '(\S+)\s*(?:=|:=|:|<-)\s*(.+)' +quotedStringPattern = '^(?:"([^"]+)"|\'([^\']+)\'|\'\'\'([^\']+)\'\'\'|"""([^"]+)"""|([^\'"\s]+))$' + +#restrictedPatterns is an enumeration of each pattern that we are searching for +#in the code base. The matching of any one of these will cause a CEDP CI failure +#across all readiness levels. + +variableRegex = '[\'"(]*([\w\.]*(?:password|dburl|token|passwd|key|access[_-]?key[_-]?id|access[_-]?key|secret[_-]?key|secret|auth[a-zA-Z_]*|cred|pwd|xclientid))[\'")]*' + +#secretRegex should ideally span secrets which may or may not be embedded in quotes, hence they shouldn't contain space. +secretRegex = '[\\\(\'"]*(?:(?:basic|token)\s+)?([^\'";\(\),\s$]+)(?:())?[\'"\)\\;]?' + +#quotedSecretRegex spans secrets embedded in quotes, so they can contain space. +quotedSecretRegex = '[\\\(]*[\'"]+' + '[\\\(\'"]*(?:(?:basic|token)\s+)?([^\'";\(\),$]+)(?:())?[\'"\)\\;]?' + '[\'"\)\\;]*' + +secretFoundPattern = '(?i)' + variableRegex + '\s*[=:]{1,2}\s*' + secretRegex + +#varAndSecretInString spans strings like "otherProps=whatever;PWD=debTFvgjm579&*%gjvH;PORT=8080;" +varAndSecretInString = '(?:' + '"(?:[^"]*?)' + secretFoundPattern + '(?:[^"]*)"' + '|' +\ + '\'(?:[^\']*?)' + secretFoundPattern + '(?:[^\']*)\'' + '|' + \ + '\'\'\'(?:[^\']*?)' + secretFoundPattern + '(?:[^\']*)\'\'\'' + '|' + \ + '"""(?:[^"]*?)' + secretFoundPattern + '(?:[^"]*)"""' + ')' + +assignment_secret_found = '[\[]*[\'"]*(?i)' + variableRegex+ '[\'"]*[\]]*\s*(?:=|=|:|-)\s*[\'"]*\s*[\+,]*\s*' + quotedSecretRegex + '((?:\s*(?:\+*)\s*' + quotedSecretRegex + ')*)' + + +# variableRegex = '[\'"(]*([\w\.]*(?:password|dburl|token|passwd|key|access[_-]?key[_-]?id|access[_-]?key|secret[_-]?key|secret|auth[a-zA-Z_]*|cred|pwd|xclientid))[\'")]*' +regex_string = quotedSecretRegex +regex_val = re.compile(regex_string) +print('regex : ',regex_val) +search_string = 'String jwtSecret = "dasdasdasdasdasdasdasdasdasddaasdasdasdasdsdhashdsahdhsadhhasdhahdhashdhahdhah";' +print("search_string : ",search_string) +match_obj = regex_val.findall(search_string) +index = 0 +for matchval in match_obj: + print('match '+str(index)+' : ',matchval) + index +=1 + + diff --git a/tests/main_test.py b/tests/main_test.py index a134484c3..fd96c8160 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -132,22 +132,25 @@ def test_scan_with_exclude_args(self, mock_baseline_initialize): ) @pytest.mark.parametrize( - 'string, expected_base64_result, expected_hex_result', + 'string, expected_base64_result, expected_hex_result, expected_content_checker_result', [ ( '012345678ab', 'False (3.459)', 'True (3.459)', + 'False', ), ( 'Benign', 'False (2.252)', 'False', + 'False', ), ( 'key: 012345678ab', 'False', 'True (3.459)', + 'True (unverified)', ), ], ) @@ -157,6 +160,7 @@ def test_scan_string_basic( string, expected_base64_result, expected_hex_result, + expected_content_checker_result, ): with mock_stdin( string, @@ -164,12 +168,14 @@ def test_scan_string_basic( main_module, ) as printer_shim: assert main('scan --string'.split()) == 0 - assert uncolor(printer_shim.message) == get_plugin_report( + expected = get_plugin_report( { 'Base64HighEntropyString': expected_base64_result, 'HexHighEntropyString': expected_hex_result, + 'ContentChecker': expected_content_checker_result, }, exclude=['Db2Detector'], ) + assert uncolor(printer_shim.message) == expected mock_baseline_initialize.assert_not_called() @@ -604,23 +610,31 @@ def test_audit_short_file(self, filename, expected_output): audit_module, ) as printer_shim: main('audit will_be_mocked'.split()) - - assert uncolor(printer_shim.message) == textwrap.dedent(""" - Secret: 1 of 1 - Filename: {} - Secret Type: {} - ---------- - {} - ---------- - {} - ---------- - Saving progress... - """)[1:].format( - filename, - baseline_dict['results'][filename][0]['type'], - expected_output, - POTENTIAL_SECRET_DETECTED_NOTE, + RHS = ''.join( + [ + ( + textwrap.dedent(""" + Secret: {} of {} + Filename: {} + Secret Type: {} + ---------- + {} + ---------- + {} + ---------- + """)[1:].format( + str(idx + 1), str(len(baseline_dict['results'][filename])), + filename, + baseline_dict['results'][filename][idx]['type'], + expected_output, + POTENTIAL_SECRET_DETECTED_NOTE, + ) + ) + for idx in range(len(baseline_dict['results'][filename])) + ] + + ['Saving progress...\n'], ) + assert uncolor(printer_shim.message) == RHS @pytest.mark.parametrize( 'filename, expected_output', @@ -628,6 +642,21 @@ def test_audit_short_file(self, filename, expected_output): ( 'test_data/short_files/first_line.php', { + 'ContentChecker': { + 'config': { + 'name': 'ContentChecker', + }, + 'results': { + 'false-positives': {}, + 'true-positives': {}, + 'unknowns': { + 'test_data/short_files/first_line.php': [{ + 'line': "secret = 'notHighEnoughEntropy'", + 'plaintext': "secret = 'notHighEnoughEntropy'", + }], + }, + }, + }, 'KeywordDetector': { 'config': { 'name': 'KeywordDetector', diff --git a/tests/plugins/content_checker_test.py b/tests/plugins/content_checker_test.py new file mode 100644 index 000000000..df8a4a48b --- /dev/null +++ b/tests/plugins/content_checker_test.py @@ -0,0 +1,134 @@ +import json +import pytest +from detect_secrets.core.potential_secret import PotentialSecret +from detect_secrets.plugins.content_checker import ContentChecker +from testing.mocks import mock_file_object + +secretExpected = True +noSecretExpected = False +java_source_file_tests = [ + { + "name": "Java assignment spanning newline", + "content": 'String jwtSecret = "dasdasdasdasdasdasdasdasdasddaasdasdasdasdsdhashdsahdhsadhhasdhahdhashdhahdhah";', + "want": secretExpected, + }, + # { + # "name": "Java string assignment, not a passowrd, but a general statement", + # "content": 'String jwtSecret = "This is a line with more than 2 words.";', + # "want": noSecretExpected, + # }, + { + "name": "Java assignment", + "content": 'String password = "xYzzYXxxY123";', + "want": secretExpected, + }, + { + "name": "Java constant", + "content": ' public static final String SSL_PASSWORD = "test1234";', + "want": secretExpected, + }, + { + "name": "Read map", + "content": 'String password = credentials.get("password");', + "want": noSecretExpected, + }, + { + "name": "Write map", + "content": 'credentials.put("password", "xYzzYXxxY123");', + "want": secretExpected, + }, + # { + # "name": "Write map alternate string", + # "content": 'credentials.put("password", new String("xYzzYXxxY123"));', + # "want": secretExpected, + # }, + # { + # "name": "Write to list", + # "content": 'List secrets = ImmutableList.builder().addSecret("ZZZzzzzz").build();', + # "want": secretExpected, + # }, + # { + # "name": "Set Access Key", + # "content": 'List secrets = ImmutableList.builder().add_access_key("ZZZzzXXXXzzz").build();', + # "want": secretExpected, + # }, +# { +# "name": "Set App Name", +# "content": '''List appProperties = ImmutableList.builder() +# .setAppName("BigSQL-SinkAdapter") +# .build();''', +# "want": noSecretExpected, +# }, +# { +# "name": "Variable assigment", +# "content": '''String secret = mySuperSecret; +# public static final String SWIFT_AUTH_PROPERTY = Constants.FS_SWIFT2D + AUTH_URL;''', +# "want": noSecretExpected, +# }, +# { +# "name": "Accessing properties", +# "content": '''// various ways of accessing properties +# previousAuthConfig = System.getProperty("java.security.auth.login.config"); +# MQEnvironment.password = mqproperties.getProperty("password");''', +# "want": noSecretExpected, +# }, +# { +# "name": "Secret in comment", +# "content": '''/* +# * secret: xYzzYXxxY123 +# */''', +# "want": secretExpected, +# }, +# { +# "name": "no secret in comments", +# "content": '''// Initial author: Randy Weinstein (randy.weinstein@ibm.com)''', +# "want": noSecretExpected, +# }, +# { +# "name": "Secret in url", +# "content": '''String url = "https://user"name":MySecretPass0rd!@mycloudant.org";''', +# "want": secretExpected, +# }, +# { +# "name": "Secret in url", +# "content": '''String url = System.out.format("https://user"name":MySecretPass0rd!@mycloudant.org:%d", port);''', +# "want": secretExpected, +# }, +# { +# "name": "Secret from variable in url", +# "content": 'String url = System.out.format("https://%s:%s@mycloudant.org:%d", user, mySecretPassword, port);', +# "want": noSecretExpected, +# }, +] + + + +class TestContentChecker: + + @pytest.mark.parametrize( + 'test_case', + java_source_file_tests, + ) + def test_java_source_files(self, test_case): + logic = ContentChecker() + print("abcse test_cases : ",test_case) + # for test_case in test_cases: + print('abcse test_case : ',test_case) + f = mock_file_object(test_case['content']) + output = logic.analyze(f, 'mock_filename.java') + if test_case['want']: + assert len(output) == 1 + else: + assert len(output) == 0 + + + + + + + + + + + +