diff --git a/build.sh b/build.sh index 7659b46..5a1d063 100755 --- a/build.sh +++ b/build.sh @@ -385,12 +385,13 @@ if $run_gui; then eval "$cmd" fi -echo -e "\n${blueBold}#### Results ####${reset}" +line=$(printf '#%.0s' $(eval echo {1..$(( ($COLUMNS - 10) / 2))})) +echo -e "\n${blueBold}$line RESULTS $line${reset}" cmd="cat $TYPE_CHECK_OUT_FILE $LINT_OUT_FILE $COVERAGE_OUT_FILE" if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" -echo -e "\n${greenBold}Sucessful${reset}" +echo -e "\n${greenBold}SUCCESS${reset}" quit diff --git a/src/extract_otp_secrets.py b/src/extract_otp_secrets.py index a654900..1ed933e 100644 --- a/src/extract_otp_secrets.py +++ b/src/extract_otp_secrets.py @@ -171,7 +171,7 @@ def main(sys_args: list[str]) -> None: def parse_args(sys_args: list[str]) -> Args: global verbose, quiet, colored - description_text = "Extracts one time password (OTP) secrets from export QR codes from two-factor authentication (2FA) apps" + description_text = "Extracts one time password (OTP) secrets from QR codes exported by two-factor authentication (2FA) apps" if qreader_available: description_text += "\nIf no infiles are provided, a GUI window starts and QR codes are captured from the camera." example_text = """examples: @@ -186,18 +186,19 @@ python extract_otp_secrets.py = < example_export.png""" epilog=example_text) arg_parser.add_argument('infile', help="""a) file or - for stdin with 'otpauth-migration://...' URLs separated by newlines, lines starting with # are ignored; b) image file containing a QR code or = for stdin for an image containing a QR code""", nargs='*' if qreader_available else '+') + arg_parser.add_argument('--csv', '-c', help='export csv file or - for stdout', metavar=('FILE')) + arg_parser.add_argument('--keepass', '-k', help='export totp/hotp csv file(s) for KeePass, - for stdout', metavar=('FILE')) + arg_parser.add_argument('--json', '-j', help='export json file or - for stdout', metavar=('FILE')) + arg_parser.add_argument('--printqr', '-p', help='print QR code(s) as text to the terminal (requires qrcode module)', action='store_true') + arg_parser.add_argument('--saveqr', '-s', help='save QR code(s) as images to the given folder (requires qrcode module)', metavar=('DIR')) if qreader_available: arg_parser.add_argument('--camera', '-C', help='camera number of system (default camera: 0)', default=0, type=int, metavar=('NUMBER')) arg_parser.add_argument('--qr', '-Q', help=f'QR reader (default: {QRMode.ZBAR.name})', type=str, choices=[mode.name for mode in QRMode], default=QRMode.ZBAR.name) - arg_parser.add_argument('--json', '-j', help='export json file or - for stdout', metavar=('FILE')) - arg_parser.add_argument('--csv', '-c', help='export csv file or - for stdout', metavar=('FILE')) - arg_parser.add_argument('--keepass', '-k', help='export totp/hotp csv file(s) for KeePass, - for stdout', metavar=('FILE')) - arg_parser.add_argument('--printqr', '-p', help='print QR code(s) as text to the terminal (requires qrcode module)', action='store_true') - arg_parser.add_argument('--saveqr', '-s', help='save QR code(s) as images to the given folder (requires qrcode module)', metavar=('DIR')) + arg_parser.add_argument('-i', '--ignore', help='ignore duplicate otps', action='store_true') arg_parser.add_argument('--no-color', '-n', help='do not use ANSI colors in console output', action='store_true') output_group = arg_parser.add_mutually_exclusive_group() - output_group.add_argument('--verbose', '-v', help='verbose output', action='count') - output_group.add_argument('--quiet', '-q', help='no stdout output, except output set by -', action='store_true') + output_group.add_argument('-v', '--verbose', help='verbose output', action='count') + output_group.add_argument('-q', '--quiet', help='no stdout output, except output set by -', action='store_true') args = arg_parser.parse_args(sys_args) if args.csv == '-' or args.json == '-' or args.keepass == '-': args.quiet = args.q = True @@ -421,7 +422,6 @@ def extract_otp_from_otp_url(otpauth_migration_url: str, otps: Otps, urls_count: new_otps_count = 0 # pylint: disable=no-member for raw_otp in payload.otp_parameters: - new_otps_count += 1 if verbose: print(f"\n{len(otps) + 1}. Secret") secret = convert_secret_from_bytes_to_base32_str(raw_otp.secret) if verbose >= LogLevel.DEBUG: log_debug('OTP enum type:', get_enum_name_by_number(raw_otp, 'type')) @@ -435,15 +435,19 @@ def extract_otp_from_otp_url(otpauth_migration_url: str, otps: Otps, urls_count: "counter": raw_otp.counter if raw_otp.type == 1 else None, "url": otp_url } - otps.append(otp) - if not quiet: - print_otp(otp) - if args.printqr: - print_qr(args, otp_url) - if args.saveqr: - save_qr(otp, args, len(otps)) - if not quiet: - print() + if otp not in otps or not args.ignore: + otps.append(otp) + new_otps_count += 1 + if not quiet: + print_otp(otp) + if args.printqr: + print_qr(args, otp_url) + if args.saveqr: + save_qr(otp, args, len(otps)) + if not quiet: + print() + elif args.ignore and not quiet: + eprint(f"Ignored duplicate otp: {otp['name']}", f" / {otp['issuer']}\n" if otp['issuer'] else '\n', sep='') return new_otps_count @@ -613,8 +617,11 @@ def write_keepass_csv(args: Args, otps: Otps) -> None: if args.keepass and len(otps) > 0: has_totp = has_otp_type(otps, 'totp') has_hotp = has_otp_type(otps, 'hotp') - otp_filename_totp = args.keepass if has_totp != has_hotp else add_pre_suffix(args.keepass, "totp") - otp_filename_hotp = args.keepass if has_totp != has_hotp else add_pre_suffix(args.keepass, "hotp") + if args.keepass != '-': + otp_filename_totp = args.keepass if has_totp != has_hotp else add_pre_suffix(args.keepass, "totp") + otp_filename_hotp = args.keepass if has_totp != has_hotp else add_pre_suffix(args.keepass, "hotp") + else: + otp_filename_totp = otp_filename_hotp = '-' if has_totp: count_totp_entries = write_keepass_totp_csv(otp_filename_totp, otps) if has_hotp: diff --git a/tests/extract_otp_secrets_test.py b/tests/extract_otp_secrets_test.py index 2684a37..c20109f 100644 --- a/tests/extract_otp_secrets_test.py +++ b/tests/extract_otp_secrets_test.py @@ -26,6 +26,7 @@ import pathlib import re import sys import time +from typing import Optional import colorama import pytest @@ -354,6 +355,43 @@ def test_extract_saveqr(capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Pa assert count_files_in_dir(tmp_path) == 6 +def test_extract_ignored_duplicates(capsys: pytest.CaptureFixture[str]) -> None: + # Act + extract_otp_secrets.main(['-i', 'example_export.txt']) + + # Assert + captured = capsys.readouterr() + + expected_stdout = '''Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp + +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp + +Name: hotp demo +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: hotp +Counter: 4 + +Name: encoding: ¿äÄéÉ? (demo) +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp + +''' + + expected_stderr = '''Ignored duplicate otp: pi@raspberrypi + +Ignored duplicate otp: pi@raspberrypi / raspberrypi + +''' + + assert captured.out == expected_stdout + assert captured.err == expected_stderr + + def test_normalize_bytes() -> None: assert replace_escaped_octal_utf8_bytes_with_str( 'Before\\\\302\\\\277\\\\303\nname: enc: \\302\\277\\303\\244\\303\\204\\303\\251\\303\\211?\nAfter') == 'Before\\\\302\\\\277\\\\303\nname: enc: ¿äÄéÉ?\nAfter' @@ -421,7 +459,7 @@ def test_extract_help(capsys: pytest.CaptureFixture[str]) -> None: captured = capsys.readouterr() assert len(captured.out) > 0 - assert "-h, --help" in captured.out and "--verbose, -v" in captured.out + assert "-h, --help" in captured.out and "-v, --verbose" in captured.out assert captured.err == '' assert e.type == SystemExit assert e.value.code == 0 @@ -469,12 +507,41 @@ def test_verbose_and_quiet(capsys: pytest.CaptureFixture[str]) -> None: captured = capsys.readouterr() assert len(captured.err) > 0 - assert 'error: argument --quiet/-q: not allowed with argument --verbose/-v' in captured.err + assert 'error: argument -q/--quiet: not allowed with argument -v/--verbose' in captured.err assert captured.out == '' assert e.value.code == 2 assert e.type == SystemExit +@pytest.mark.parametrize("parameter,parameter_value,stdout_expected,stderr_expected", [ + ('-c', 'outfile', False, False), + ('-c', '-', True, False), + ('-k', 'outfile', False, False), + ('-k', '-', True, False), + ('-j', 'outfile', False, False), + ('-s', 'outfile', False, False), + ('-j', '-', True, False), + ('-i', None, False, False), + ('-p', None, True, False), + ('-Q', 'CV2', False, False), + ('-n', None, False, False), +]) +def test_quiet(parameter: str, parameter_value: Optional[str], stdout_expected: bool, stderr_expected: bool, capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None: + args = ['-q', 'example_export.txt', 'example_export.png', parameter] + if parameter_value == 'outfile': + args.append(str(tmp_path / parameter_value)) + elif parameter_value: + args.append(parameter_value) + # Act + extract_otp_secrets.main(args) + + # Assert + captured = capsys.readouterr() + + assert (captured.out == '' and not stdout_expected) or (len(captured.out) > 0 and stdout_expected) + assert (captured.err == '' and not stderr_expected) or (len(captured.err) > 0 and stderr_expected) + + def test_wrong_data(capsys: pytest.CaptureFixture[str]) -> None: with pytest.raises(SystemExit) as e: # Act diff --git a/tests/extract_otp_secrets_txt_unit_test.py b/tests/extract_otp_secrets_txt_unit_test.py index f62310e..27d5512 100644 --- a/tests/extract_otp_secrets_txt_unit_test.py +++ b/tests/extract_otp_secrets_txt_unit_test.py @@ -198,7 +198,7 @@ Type: totp actual_output = out.getvalue() self.assertGreater(len(actual_output), 0) - self.assertTrue("-h, --help" in actual_output and "--verbose, -v" in actual_output) + self.assertTrue("-h, --help" in actual_output and "-v, --verbose" in actual_output) def test_extract_help_2(self) -> None: out = io.StringIO() @@ -209,7 +209,7 @@ Type: totp actual_output = out.getvalue() self.assertGreater(len(actual_output), 0) - self.assertTrue("-h, --help" in actual_output and "--verbose, -v" in actual_output) + self.assertTrue("-h, --help" in actual_output and "-v, --verbose" in actual_output) self.assertEqual(context.exception.code, 0) def test_extract_help_3(self) -> None: @@ -218,7 +218,7 @@ Type: totp extract_otp_secrets.main(['-h']) self.assertGreater(len(actual_output), 0) - self.assertTrue("-h, --help" in "\n".join(actual_output) and "--verbose, -v" in "\n".join(actual_output)) + self.assertTrue("-h, --help" in "\n".join(actual_output) and "-v, --verbose" in "\n".join(actual_output)) self.assertEqual(context.exception.code, 0) def setUp(self) -> None: