From 25f5733858871b4fa2b0382b832ee13314d05cf4 Mon Sep 17 00:00:00 2001 From: scito Date: Sat, 3 Sep 2022 15:38:47 +0200 Subject: [PATCH 1/4] add github test action and add pytest --- .github/workflows/extract_otp_secret_keys.yml | 35 +++++++++ README.md | 19 ++++- test_extract_otp_secret_keys_pytest.py | 77 +++++++++++++++++++ ...> test_extract_otp_secret_keys_unittest.py | 0 4 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/extract_otp_secret_keys.yml create mode 100644 test_extract_otp_secret_keys_pytest.py rename unittest_extract_otp_secret_keys.py => test_extract_otp_secret_keys_unittest.py (100%) diff --git a/.github/workflows/extract_otp_secret_keys.yml b/.github/workflows/extract_otp_secret_keys.yml new file mode 100644 index 00000000..d0e5789d --- /dev/null +++ b/.github/workflows/extract_otp_secret_keys.yml @@ -0,0 +1,35 @@ +name: extract_otp_secret_keys + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest + - name: Test with unittest + run: | + python -m unittest diff --git a/README.md b/README.md index 01864285..a50030d1 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,9 @@ Install [devbox](https://github.com/jetpack-io/devbox), which is a wrapper for n devbox shell ``` -## Unit Tests +## Tests + +### unittest There are basic unit tests, see `unittest_extract_otp_secret_keys.py`. @@ -72,3 +74,18 @@ Run unit tests: ``` python -m unittest ``` + +### PyTest + +There are basic pytests, see `test_extract_otp_secret_keys.py`. + +Run pytests: + +``` +pytest unittest +``` +or + +``` +python -m pytest +``` diff --git a/test_extract_otp_secret_keys_pytest.py b/test_extract_otp_secret_keys_pytest.py new file mode 100644 index 00000000..8aa6de48 --- /dev/null +++ b/test_extract_otp_secret_keys_pytest.py @@ -0,0 +1,77 @@ +# pytest for extract_otp_secret_keys.py + +# Run tests: +# pytest + +# Author: Scito (https://scito.ch) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import csv +import json +import os + +import extract_otp_secret_keys + +def test_extract_csv(): + # Arrange + cleanup() + + # Act + extract_otp_secret_keys.main(['-q', '-c', 'test_example_output.csv', 'example_export.txt']) + + # Assert + expected_csv = read_csv('example_output.csv') + actual_csv = read_csv('test_example_output.csv') + + assert actual_csv == actual_csv + + # Clean up + cleanup() + +def test_extract_json(): + # Arrange + cleanup() + + # Act + extract_otp_secret_keys.main(['-q', '-j', 'test_example_output.json', 'example_export.txt']) + + expected_json = read_json('example_output.json') + actual_json = read_json('test_example_output.json') + + assert actual_json == expected_json + + # Clean up + cleanup() + +def cleanup(): + remove_file('test_example_output.csv') + remove_file('test_example_output.json') + +def remove_file(filename): + if os.path.exists(filename): os.remove(filename) + +def read_csv(filename): + """Returns a list of lines.""" + with open(filename, "r") as infile: + lines = [] + reader = csv.reader(infile) + for line in reader: + lines.append(line) + return lines + +def read_json(filename): + """Returns a list or a dictionary.""" + with open(filename, "r") as infile: + return json.load(infile) diff --git a/unittest_extract_otp_secret_keys.py b/test_extract_otp_secret_keys_unittest.py similarity index 100% rename from unittest_extract_otp_secret_keys.py rename to test_extract_otp_secret_keys_unittest.py From 44c74728c9fe504a48229f41da9dc85e60c464bb Mon Sep 17 00:00:00 2001 From: scito Date: Sat, 3 Sep 2022 16:12:28 +0200 Subject: [PATCH 2/4] refactor to satisfy flak8 --- .flake8 | 8 +++++++ .github/workflows/extract_otp_secret_keys.yml | 2 +- extract_otp_secret_keys.py | 23 ++++++++++++++----- test_extract_otp_secret_keys_pytest.py | 8 ++++++- test_extract_otp_secret_keys_unittest.py | 4 ++++ 5 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 .flake8 diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..f57bb5db --- /dev/null +++ b/.flake8 @@ -0,0 +1,8 @@ +[flake8] +ignore = + E701 +exclude = + protobuf_generated_python + __pycache__ +per-file-ignores = + extract_otp_secret_keys.py: F821, F401 diff --git a/.github/workflows/extract_otp_secret_keys.yml b/.github/workflows/extract_otp_secret_keys.yml index d0e5789d..a1c25ea1 100644 --- a/.github/workflows/extract_otp_secret_keys.yml +++ b/.github/workflows/extract_otp_secret_keys.yml @@ -26,7 +26,7 @@ jobs: # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=200 --statistics - name: Test with pytest run: | pytest diff --git a/extract_otp_secret_keys.py b/extract_otp_secret_keys.py index 788e91fb..543ca983 100644 --- a/extract_otp_secret_keys.py +++ b/extract_otp_secret_keys.py @@ -49,17 +49,20 @@ import json from urllib.parse import parse_qs, urlencode, urlparse, quote from os import path, mkdir -from re import sub, compile as rcompile +from re import compile as rcompile import protobuf_generated_python.google_auth_pb2 + # https://stackoverflow.com/questions/40226049/find-enums-listed-in-python-descriptor-for-protobuf def get_enum_name_by_number(parent, field_name): field_value = getattr(parent, field_name) return parent.DESCRIPTOR.fields_by_name[field_name].enum_type.values_by_number.get(field_value).name + def convert_secret_from_bytes_to_base32_str(bytes): return str(base64.b32encode(bytes), 'utf-8').replace('=', '') + def save_qr(data, name): global verbose qr = QRCode() @@ -68,11 +71,13 @@ def save_qr(data, name): if verbose: print('Saving to {}'.format(name)) img.save(name) + def print_qr(data): qr = QRCode() qr.add_data(data) qr.print_ascii() + def parse_args(sys_args): arg_parser = argparse.ArgumentParser() arg_parser.add_argument('--verbose', '-v', help='verbose output', action='store_true') @@ -88,9 +93,11 @@ def parse_args(sys_args): sys.exit(1) return args + def sys_main(): main(sys.argv[1:]) + def main(sys_args): global verbose, quiet args = parse_args(sys_args) @@ -102,6 +109,7 @@ def main(sys_args): write_csv(args, otps) write_json(args, otps) + def extract_otps(args): global verbose, quiet quiet = args.quiet @@ -115,7 +123,7 @@ def extract_otps(args): if not line.startswith('otpauth-migration://'): print('\nWARN: line is not a otpauth-migration:// URL\ninput file: {}\nline "{}"\nProbably a wrong file was given'.format(args.infile, line)) parsed_url = urlparse(line) params = parse_qs(parsed_url.query) - if not 'data' in params: + if 'data' not in params: print('\nERROR: no data query parameter in input URL\ninput file: {}\nline "{}"\nProbably a wrong file was given'.format(args.infile, line)) sys.exit(1) data_encoded = params['data'][0] @@ -135,7 +143,7 @@ def extract_otps(args): if otp.issuer and not quiet: print('Issuer: {}'.format(otp.issuer)) otp_type = get_enum_name_by_number(otp, 'type') if not quiet: print('Type: {}'.format(otp_type)) - url_params = { 'secret': secret } + url_params = {'secret': secret} if otp.type == 1: url_params['counter'] = otp.counter if otp.issuer: url_params['issuer'] = otp.issuer otp_url = 'otpauth://{}/{}?'.format('totp' if otp.type == 2 else 'hotp', quote(otp.name)) + urlencode(url_params) @@ -143,7 +151,7 @@ def extract_otps(args): if args.printqr: print_qr(otp_url) if args.saveqr: - if not(path.exists('qr')): mkdir('qr') + if not (path.exists('qr')): mkdir('qr') pattern = rcompile(r'[\W_]+') file_otp_name = pattern.sub('', otp.name) file_otp_issuer = pattern.sub('', otp.issuer) @@ -156,9 +164,10 @@ def extract_otps(args): "issuer": otp.issuer, "type": otp_type, "url": otp_url - }) + }) return otps + def write_csv(args, otps): global verbose, quiet if args.csv and len(otps) > 0: @@ -168,12 +177,14 @@ def write_csv(args, otps): writer.writerows(otps) if not quiet: print("Exported {} otps to csv".format(len(otps))) + def write_json(args, otps): global verbose, quiet if args.json: with open(args.json, "w") as outfile: - json.dump(otps, outfile, indent = 4) + json.dump(otps, outfile, indent=4) if not quiet: print("Exported {} otp entries to json".format(len(otps))) + if __name__ == '__main__': sys_main() diff --git a/test_extract_otp_secret_keys_pytest.py b/test_extract_otp_secret_keys_pytest.py index 8aa6de48..0fd86a87 100644 --- a/test_extract_otp_secret_keys_pytest.py +++ b/test_extract_otp_secret_keys_pytest.py @@ -24,6 +24,7 @@ import extract_otp_secret_keys + def test_extract_csv(): # Arrange cleanup() @@ -35,11 +36,12 @@ def test_extract_csv(): expected_csv = read_csv('example_output.csv') actual_csv = read_csv('test_example_output.csv') - assert actual_csv == actual_csv + assert actual_csv == expected_csv # Clean up cleanup() + def test_extract_json(): # Arrange cleanup() @@ -55,13 +57,16 @@ def test_extract_json(): # Clean up cleanup() + def cleanup(): remove_file('test_example_output.csv') remove_file('test_example_output.json') + def remove_file(filename): if os.path.exists(filename): os.remove(filename) + def read_csv(filename): """Returns a list of lines.""" with open(filename, "r") as infile: @@ -71,6 +76,7 @@ def read_csv(filename): lines.append(line) return lines + def read_json(filename): """Returns a list or a dictionary.""" with open(filename, "r") as infile: diff --git a/test_extract_otp_secret_keys_unittest.py b/test_extract_otp_secret_keys_unittest.py index e1011186..e07a1fde 100644 --- a/test_extract_otp_secret_keys_unittest.py +++ b/test_extract_otp_secret_keys_unittest.py @@ -25,6 +25,7 @@ import extract_otp_secret_keys + class TestExtract(unittest.TestCase): def test_extract_csv(self): @@ -53,9 +54,11 @@ def cleanup(self): remove_file('test_example_output.csv') remove_file('test_example_output.json') + def remove_file(filename): if os.path.exists(filename): os.remove(filename) + def read_csv(filename): """Returns a list of lines.""" with open(filename, "r") as infile: @@ -65,6 +68,7 @@ def read_csv(filename): lines.append(line) return lines + def read_json(filename): """Returns a list or a dictionary.""" with open(filename, "r") as infile: From 17202bed4a5b1f74e7f7ea01d983677672255597 Mon Sep 17 00:00:00 2001 From: scito Date: Sat, 3 Sep 2022 16:17:26 +0200 Subject: [PATCH 3/4] add latest Python version to workflow --- .github/workflows/extract_otp_secret_keys.yml | 2 +- .gitignore | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/extract_otp_secret_keys.yml b/.github/workflows/extract_otp_secret_keys.yml index a1c25ea1..3e837b54 100644 --- a/.github/workflows/extract_otp_secret_keys.yml +++ b/.github/workflows/extract_otp_secret_keys.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.x"] steps: - uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index 1c93df5e..b7c98f96 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ venv/ !devbox.json !example_output.json !example_output.csv +!.github/ +!.flake8 From a6a791b553a02c7863e8a859818d7aade3be73f6 Mon Sep 17 00:00:00 2001 From: scito Date: Sat, 3 Sep 2022 16:20:51 +0200 Subject: [PATCH 4/4] add badge to REAME --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index a50030d1..dd7e9e44 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Extract two-factor authentication (2FA, TFA) secret keys from export QR codes of "Google Authenticator" app +## Testing Status + +[![extract_otp_secret_keys](https://github.com/scito/extract_otp_secret_keys/actions/workflows/extract_otp_secret_keys.yml/badge.svg)](https://github.com/scito/extract_otp_secret_keys/actions/workflows/extract_otp_secret_keys.yml) + ## Usage 1. Export the QR codes from "Google Authenticator" app