From 9fe164665c59f84b9c879b5c827483cee69a9031 Mon Sep 17 00:00:00 2001 From: William Ballenthin Date: Tue, 26 Oct 2021 16:46:43 -0600 Subject: [PATCH 1/4] main: exit with unique error codes when bailing TODO: create an enum of all these things so they're easy for a human to read. closes #802 --- capa/main.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/capa/main.py b/capa/main.py index aeafa14d..ef20bb21 100644 --- a/capa/main.py +++ b/capa/main.py @@ -756,7 +756,7 @@ def handle_common_args(args): # so in this case, we require the user to use -r to specify the rule directory. logger.error("default embedded rules not found! (maybe you installed capa as a library?)") logger.error("provide your own rule set via the `-r` option.") - return -1 + return -10 else: rules_path = args.rules logger.debug("using rules path: %s", rules_path) @@ -822,7 +822,9 @@ def main(argv=None): install_common_args(parser, {"sample", "format", "backend", "signatures", "rules", "tag"}) parser.add_argument("-j", "--json", action="store_true", help="emit JSON instead of text") args = parser.parse_args(args=argv) - handle_common_args(args) + ret = handle_common_args(args) + if ret is not None and ret != 0: + return ret try: taste = get_file_taste(args.sample) @@ -830,7 +832,7 @@ def main(argv=None): # per our research there's not a programmatic way to render the IOError with non-ASCII filename unless we # handle the IOError separately and reach into the args logger.error("%s", e.args[0]) - return -1 + return -11 try: rules = get_rules(args.rules, disable_progress=args.quiet) @@ -850,7 +852,7 @@ def main(argv=None): logger.debug(" %d. %s", i, r) except (IOError, capa.rules.InvalidRule, capa.rules.InvalidRuleSet) as e: logger.error("%s", str(e)) - return -1 + return -12 file_extractor = None if args.format == "pe" or (args.format == "auto" and taste.startswith(b"MZ")): @@ -862,24 +864,24 @@ def main(argv=None): file_extractor = capa.features.extractors.pefile.PefileFeatureExtractor(args.sample) except PEFormatError as e: logger.error("Input file '%s' is not a valid PE file: %s", args.sample, str(e)) - return -1 + return -13 elif args.format == "elf" or (args.format == "auto" and taste.startswith(b"\x7fELF")): try: file_extractor = capa.features.extractors.elffile.ElfFeatureExtractor(args.sample) except (ELFError, OverflowError) as e: logger.error("Input file '%s' is not a valid ELF file: %s", args.sample, str(e)) - return -1 + return -13 if file_extractor: try: pure_file_capabilities, _ = find_file_capabilities(rules, file_extractor, {}) except PEFormatError as e: logger.error("Input file '%s' is not a valid PE file: %s", args.sample, str(e)) - return -1 + return -13 except (ELFError, OverflowError) as e: logger.error("Input file '%s' is not a valid ELF file: %s", args.sample, str(e)) - return -1 + return -13 # file limitations that rely on non-file scope won't be detected here. # nor on FunctionName features, because pefile doesn't support this. @@ -888,7 +890,7 @@ def main(argv=None): # do show the output in verbose mode, though. if not (args.verbose or args.vverbose or args.json): logger.debug("file limitation short circuit, won't analyze fully.") - return -1 + return -14 try: if args.format == "pe" or (args.format == "auto" and taste.startswith(b"MZ")): @@ -898,7 +900,7 @@ def main(argv=None): logger.debug("skipping library code matching: only have PE signatures") except (IOError) as e: logger.error("%s", str(e)) - return -1 + return -15 if (args.format == "freeze") or (args.format == "auto" and capa.features.freeze.is_freeze(taste)): format = "freeze" @@ -926,14 +928,14 @@ def main(argv=None): ) logger.error(" If you don't know the input file type, you can try using the `file` utility to guess it.") logger.error("-" * 80) - return -1 + return -16 except UnsupportedArchError: logger.error("-" * 80) logger.error(" Input file does not appear to target a supported architecture.") logger.error(" ") logger.error(" capa currently only supports analyzing x86 (32- and 64-bit).") logger.error("-" * 80) - return -1 + return -17 except UnsupportedOSError: logger.error("-" * 80) logger.error(" Input file does not appear to target a supported OS.") @@ -942,7 +944,7 @@ def main(argv=None): " capa currently only supports analyzing executables for some operating systems (including Windows and Linux)." ) logger.error("-" * 80) - return -1 + return -18 meta = collect_metadata(argv, args.sample, args.rules, extractor) @@ -953,7 +955,7 @@ def main(argv=None): # bail if capa encountered file limitation e.g. a packed binary # do show the output in verbose mode, though. if not (args.verbose or args.vverbose or args.json): - return -1 + return -14 if args.json: print(capa.render.json.render(meta, rules, capabilities)) @@ -980,10 +982,10 @@ def ida_main(): logging.getLogger().setLevel(logging.INFO) if not capa.ida.helpers.is_supported_ida_version(): - return -1 + return -19 if not capa.ida.helpers.is_supported_file_type(): - return -1 + return -16 logger.debug("-" * 80) logger.debug(" Using default embedded rules.") From 4f4e0881b5f3d3f41dd4a929644a2bed48db4f5b Mon Sep 17 00:00:00 2001 From: William Ballenthin Date: Tue, 26 Oct 2021 16:48:02 -0600 Subject: [PATCH 2/4] changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68cba301..de1533d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### New Features +- main: exit with unique error codes when bailing #802 @williballenthin + ### Breaking Changes ### New Rules (1) From 4cc496a8e5af84690cc518ae211ab8add8679839 Mon Sep 17 00:00:00 2001 From: William Ballenthin Date: Tue, 26 Oct 2021 16:57:33 -0600 Subject: [PATCH 3/4] main: use constants to represent error codes --- capa/main.py | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/capa/main.py b/capa/main.py index ef20bb21..ae652536 100644 --- a/capa/main.py +++ b/capa/main.py @@ -51,6 +51,16 @@ BACKEND_SMDA = "smda" EXTENSIONS_SHELLCODE_32 = ("sc32", "raw32") EXTENSIONS_SHELLCODE_64 = ("sc64", "raw64") +E_MISSING_RULES = -10 +E_MISSING_FILE = -11 +E_INVALID_RULE = -12 +E_CORRUPT_FILE = -13 +E_FILE_LIMITATION = -14 +E_INVALID_SIG = -15 +E_INVALID_FILE_TYPE = -16 +E_INVALID_FILE_ARCH = -17 +E_INVALID_FILE_OS = -18 +E_UNSUPPORTED_IDA_VERSION = -19 logger = logging.getLogger("capa") @@ -756,7 +766,7 @@ def handle_common_args(args): # so in this case, we require the user to use -r to specify the rule directory. logger.error("default embedded rules not found! (maybe you installed capa as a library?)") logger.error("provide your own rule set via the `-r` option.") - return -10 + return E_MISSING_RULES else: rules_path = args.rules logger.debug("using rules path: %s", rules_path) @@ -832,7 +842,7 @@ def main(argv=None): # per our research there's not a programmatic way to render the IOError with non-ASCII filename unless we # handle the IOError separately and reach into the args logger.error("%s", e.args[0]) - return -11 + return E_MISSING_FILE try: rules = get_rules(args.rules, disable_progress=args.quiet) @@ -852,7 +862,7 @@ def main(argv=None): logger.debug(" %d. %s", i, r) except (IOError, capa.rules.InvalidRule, capa.rules.InvalidRuleSet) as e: logger.error("%s", str(e)) - return -12 + return E_INVALID_RULE file_extractor = None if args.format == "pe" or (args.format == "auto" and taste.startswith(b"MZ")): @@ -864,24 +874,24 @@ def main(argv=None): file_extractor = capa.features.extractors.pefile.PefileFeatureExtractor(args.sample) except PEFormatError as e: logger.error("Input file '%s' is not a valid PE file: %s", args.sample, str(e)) - return -13 + return E_CORRUPT_FILE elif args.format == "elf" or (args.format == "auto" and taste.startswith(b"\x7fELF")): try: file_extractor = capa.features.extractors.elffile.ElfFeatureExtractor(args.sample) except (ELFError, OverflowError) as e: logger.error("Input file '%s' is not a valid ELF file: %s", args.sample, str(e)) - return -13 + return E_CORRUPT_FILE if file_extractor: try: pure_file_capabilities, _ = find_file_capabilities(rules, file_extractor, {}) except PEFormatError as e: logger.error("Input file '%s' is not a valid PE file: %s", args.sample, str(e)) - return -13 + return E_CORRUPT_FILE except (ELFError, OverflowError) as e: logger.error("Input file '%s' is not a valid ELF file: %s", args.sample, str(e)) - return -13 + return E_CORRUPT_FILE # file limitations that rely on non-file scope won't be detected here. # nor on FunctionName features, because pefile doesn't support this. @@ -890,7 +900,7 @@ def main(argv=None): # do show the output in verbose mode, though. if not (args.verbose or args.vverbose or args.json): logger.debug("file limitation short circuit, won't analyze fully.") - return -14 + return E_FILE_LIMITATION try: if args.format == "pe" or (args.format == "auto" and taste.startswith(b"MZ")): @@ -900,7 +910,7 @@ def main(argv=None): logger.debug("skipping library code matching: only have PE signatures") except (IOError) as e: logger.error("%s", str(e)) - return -15 + return E_INVALID_SIG if (args.format == "freeze") or (args.format == "auto" and capa.features.freeze.is_freeze(taste)): format = "freeze" @@ -928,14 +938,14 @@ def main(argv=None): ) logger.error(" If you don't know the input file type, you can try using the `file` utility to guess it.") logger.error("-" * 80) - return -16 + return E_INVALID_FILE_TYPE except UnsupportedArchError: logger.error("-" * 80) logger.error(" Input file does not appear to target a supported architecture.") logger.error(" ") logger.error(" capa currently only supports analyzing x86 (32- and 64-bit).") logger.error("-" * 80) - return -17 + return E_INVALID_FILE_ARCH except UnsupportedOSError: logger.error("-" * 80) logger.error(" Input file does not appear to target a supported OS.") @@ -944,7 +954,7 @@ def main(argv=None): " capa currently only supports analyzing executables for some operating systems (including Windows and Linux)." ) logger.error("-" * 80) - return -18 + return E_INVALID_FILE_OS meta = collect_metadata(argv, args.sample, args.rules, extractor) @@ -955,7 +965,7 @@ def main(argv=None): # bail if capa encountered file limitation e.g. a packed binary # do show the output in verbose mode, though. if not (args.verbose or args.vverbose or args.json): - return -14 + return E_FILE_LIMITATION if args.json: print(capa.render.json.render(meta, rules, capabilities)) @@ -982,10 +992,10 @@ def ida_main(): logging.getLogger().setLevel(logging.INFO) if not capa.ida.helpers.is_supported_ida_version(): - return -19 + return E_UNSUPPORTED_IDA_VERSION if not capa.ida.helpers.is_supported_file_type(): - return -16 + return E_INVALID_FILE_TYPE logger.debug("-" * 80) logger.debug(" Using default embedded rules.") From 07412f047d11b60990b2e98fecbffa2f9ff40e28 Mon Sep 17 00:00:00 2001 From: William Ballenthin Date: Wed, 27 Oct 2021 09:24:22 -0600 Subject: [PATCH 4/4] tests: fix check of status code E_MISSING_FILE --- tests/test_main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_main.py b/tests/test_main.py index aa74bed3..5d6ac853 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -70,7 +70,7 @@ def test_main_non_ascii_filename(pingtaest_extractor, tmpdir, capsys): def test_main_non_ascii_filename_nonexistent(tmpdir, caplog): NON_ASCII_FILENAME = "täst_not_there.exe" - assert capa.main.main(["-q", NON_ASCII_FILENAME]) == -1 + assert capa.main.main(["-q", NON_ASCII_FILENAME]) == capa.main.E_MISSING_FILE assert NON_ASCII_FILENAME in caplog.text