diff --git a/README.md b/README.md index 5e763329..4cde6729 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ Features * Client, proxy and server classes implemented for some of the protocols. +* Command-line tools for offline work with SAP archive, PSE, Credv2, and SSFS + files. + * Example scripts to illustrate the use of the different modules and protocols. diff --git a/bin/pysapcar b/bin/pysapcar index 8f6f5ad7..f4122dff 100755 --- a/bin/pysapcar +++ b/bin/pysapcar @@ -125,13 +125,14 @@ class PySAPCAR(object): # Opens the input/output file self.archive_fd = None if options.filename: + file_mode = {"r": "rb", "w": "wb", "r+": "r+b"}[self.mode] try: - self.archive_fd = open(options.filename, self.mode) + self.archive_fd = open(options.filename, file_mode) except IOError as e: self.logger.error("pysapcar: error opening '%s' (%s)" % (options.filename, e.strerror)) return else: - self.archive_fd = stdin + self.archive_fd = stdin.buffer if hasattr(stdin, "buffer") else stdin # Execute the action try: @@ -152,10 +153,18 @@ class PySAPCAR(object): sapcar = SAPCARArchive(self.archive_fd, mode=self.mode) self.logger.info("pysapcar: Processing archive '%s' (version %s)", self.archive_fd.name, sapcar.version) except Exception as e: - self.logger.error("pysapcar: Error processing archive '%s' (%s)", self.archive_fd.name, e.message) + self.logger.error("pysapcar: Error processing archive '%s' (%s)", self.archive_fd.name, str(e)) return None return sapcar + @staticmethod + def _to_archive_filename(filename): + return filename.encode() if isinstance(filename, str) else filename + + @staticmethod + def _to_path(filename): + return filename.decode() if isinstance(filename, bytes) else filename + @staticmethod def target_files(filenames, target_filenames=None): """Generates the list of files to work on. It calculates @@ -169,7 +178,8 @@ class PySAPCAR(object): """ files = set(filenames) if target_filenames: - files = files.intersection(set(target_filenames)) + target_filenames = set(PySAPCAR._to_archive_filename(filename) for filename in target_filenames) + files = files.intersection(target_filenames) for filename in files: yield filename @@ -237,7 +247,7 @@ class PySAPCAR(object): for filename in self.target_files(sapcar.files_names, args): flag = CONTINUE fil = sapcar.files[filename] - filename = path.normpath(filename.replace("\x00", "")) # Take out null bytes if found + filename = path.normpath(self._to_path(filename).replace("\x00", "")) # Take out null bytes if found if options.outdir: # Have to strip directory separator from the beginning of the file name, because path.join disregards # all previous components if any of the following components is an absolute path @@ -266,7 +276,7 @@ class PySAPCAR(object): try: data = fil.open(enforce_checksum=options.enforce_checksum).read() except (SAPCARInvalidFileException, DecompressError) as e: - self.logger.error("pysapcar: Invalid SAP CAR file '%s' (%s)", self.archive_fd.name, e.message) + self.logger.error("pysapcar: Invalid SAP CAR file '%s' (%s)", self.archive_fd.name, str(e)) if options.break_on_error: flag = STOP else: diff --git a/bin/pysapgenpse b/bin/pysapgenpse index b06359a9..179cdedf 100755 --- a/bin/pysapgenpse +++ b/bin/pysapgenpse @@ -140,11 +140,11 @@ class PySAPGenPSE(object): """ try: - with open(filename) as f: + with open(filename, "rb") as f: obj = cls(f.read()) self.logger.info("pysapgenpse: Reading {} file '{}'".format(type, filename)) - except IOError as e: - self.logger.error("pysapgenpse: Error reading {} file '{}' ({})".format(type, filename, e.message)) + except Exception as e: + self.logger.error("pysapgenpse: Error reading {} file '{}' ({})".format(type, filename, str(e))) return None return obj @@ -170,8 +170,11 @@ class PySAPGenPSE(object): self.logger.error("pysapgenpse: Unable to read certificates in PSE file {}\n".format(options.filename)) return - if pse is None or not pse.enc_cont: + if pse is None: + return + if not pse.enc_cont: self.logger.error("pysapgenpse: No encrypted content found in file {}".format(options.filename)) + return plain = pse.decrypt(options.pin) self.logger.info("Decrypted PSE, {} bytes".format(len(plain))) @@ -205,7 +208,9 @@ class PySAPGenPSE(object): return # Validate that there are credentials there - if not (cred_v2 and cred_v2.creds): + if cred_v2 is None: + return + if not cred_v2.creds: self.logger.error("pysapgenpse: No credentials found in file {}\n".format(options.filename)) return @@ -259,7 +264,7 @@ class PySAPGenPSE(object): pin = plain.decrypt_provider(cred) except Exception as e: self.logger.error("pysapgenpse: Unable to decrypt using the provider {} ({}), writing plain blob".format( - plain.option1.val, e.message)) + plain.option1.val, str(e))) pin = plain.pin.val else: pin = plain.pin.val @@ -283,6 +288,8 @@ class PySAPGenPSE(object): :type output: string """ if output_filename: + if isinstance(output, str): + output = output.encode() with open(output_filename, "wb") as output_file: output_file.write(output) self.logger.info("pysapgenpse: Output written to file '{}'".format(output_filename)) diff --git a/bin/pysaphdbuserstore b/bin/pysaphdbuserstore index 1020eb5b..f9268368 100755 --- a/bin/pysaphdbuserstore +++ b/bin/pysaphdbuserstore @@ -79,7 +79,7 @@ class PySAPHDBUserStore(object): parser.add_argument_group("List command options") get = parser.add_argument_group("Get command options") - get.add_argument("--decrypt", dest="decrypt", action="store_false", help="Whether to try to decrypt the value") + get.add_argument("--decrypt", dest="decrypt", action="store_true", help="Whether to try to decrypt the value") misc = parser.add_argument_group("Misc options") misc.add_argument("--deleted", dest="deleted", action="store_true", help="Show deleted records") @@ -139,29 +139,36 @@ class PySAPHDBUserStore(object): """ try: - with open(filename) as f: + with open(filename, "rb") as f: obj = cls(f.read()) self.logger.info("pysaphdbuserstore: Reading {} file '{}'".format(type, filename)) - except IOError as e: - self.logger.error("pysaphdbuserstore: Error reading {} file '{}' ({})".format(type, filename, e.message)) + except Exception as e: + self.logger.error("pysaphdbuserstore: Error reading {} file '{}' ({})".format(type, filename, str(e))) return None return obj + @staticmethod + def _to_text(value): + """Convert bytes values to text for command-line display.""" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value + def list(self, options, args): """List records in a SSFS Data file """ # Parse the data file - try: - ssfs_data = self.open_file(options.data_filename, SAPSSFSData, "SSFS Data") - except Exception: + ssfs_data = self.open_file(options.data_filename, SAPSSFSData, "SSFS Data") + if ssfs_data is None: self.logger.error("pysaphdbuserstore: Unable to read data in file {}\n".format(options.data_filename)) + return for ssfs_record in ssfs_data.records: if options.deleted or not ssfs_record.deleted: self.logger.info("%s\t%s\t%s", - ssfs_record.key_name.rstrip(" "), + self._to_text(ssfs_record.key_name.rstrip(b" ")), "Plaintext" if ssfs_record.is_stored_as_plaintext else "Encrypted", ssfs_record.timestamp) @@ -169,17 +176,22 @@ class PySAPHDBUserStore(object): """Get a record value in a SSFS Data file """ - # Parse the key file - try: + ssfs_key = None + if options.decrypt: ssfs_key = self.open_file(options.key_filename, SAPSSFSKey, "SSFS Key") - except Exception: - self.logger.error("pysaphdbuserstore: Unable to read key in file {}\n".format(options.key_filename)) + if ssfs_key is None: + self.logger.error("pysaphdbuserstore: Unable to read key in file {}\n".format(options.key_filename)) + return # Parse the data file - try: - ssfs_data = self.open_file(options.data_filename, SAPSSFSData, "SSFS Data") - except Exception: + ssfs_data = self.open_file(options.data_filename, SAPSSFSData, "SSFS Data") + if ssfs_data is None: self.logger.error("pysaphdbuserstore: Unable to read data in file {}\n".format(options.data_filename)) + return + + if not args: + self.logger.error("pysaphdbuserstore: No record key specified") + return for arg in args: if ssfs_data.has_record(arg): @@ -187,11 +199,12 @@ class PySAPHDBUserStore(object): if options.deleted or not ssfs_record.deleted: self.logger.info("Is Deleted : %s", ssfs_record.deleted) self.logger.info("Is Valid : %s", ssfs_record.valid) - self.logger.info("Record Key : %s", ssfs_record.key_name) + self.logger.info("Record Key : %s", self._to_text(ssfs_record.key_name.rstrip(b" "))) self.logger.info("Time Stamp : %s", ssfs_record.timestamp) - self.logger.info("Host Name : %s", ssfs_record.host) - self.logger.info("OS-User : %s", ssfs_record.user) - self.logger.info("Record Value : %s", ssfs_record.get_plain_data(ssfs_key)) + self.logger.info("Host Name : %s", self._to_text(ssfs_record.host.rstrip(b" "))) + self.logger.info("OS-User : %s", self._to_text(ssfs_record.user.rstrip(b" "))) + value = ssfs_record.get_plain_data(ssfs_key) if options.decrypt else ssfs_record.data + self.logger.info("Record Value : %s", value) else: self.logger.info("Record with key %s not found in data file.", arg) diff --git a/docs/dev/testing.rst b/docs/dev/testing.rst index 76747db8..79320e50 100644 --- a/docs/dev/testing.rst +++ b/docs/dev/testing.rst @@ -19,6 +19,12 @@ Integration tests They are marked with ``integration`` and are skipped automatically when the environment cannot bind sockets. +Bin-script tests + Subprocess tests for installed command-line tools under ``bin/``. They are + marked with ``bin_script`` and run separately from core library tests so CLI + process handling, packaging assumptions, and stdout/stderr behavior do not + affect the module-level unit suite. + Core test suites ---------------- @@ -36,6 +42,9 @@ The current test coverage is centered on these modules: * ``tests/saprfc_test.py`` covers RFC packet variants and field handling. * ``tests/sapcar_test.py``, ``tests/sapcredv2_test.py``, ``tests/sappse_test.py``, and ``tests/sapssfs_test.py`` cover the file-format and crypto-oriented paths. +* ``tests/pysapcar_script_test.py``, ``tests/pysapgenpse_script_test.py``, and + ``tests/pysaphdbuserstore_script_test.py`` cover the command-line tools with + ``bin_script``-marked subprocess tests. * ``tests/sapdiag_test.py``, ``tests/sapni_test.py``, ``tests/saprouter_test.py``, and ``tests/saphdb_test.py`` cover protocol packet handling, with the socket heavy cases marked as integration. @@ -66,6 +75,10 @@ Run the full unit suite with tox:: $ python3 -m tox -e unit +Run the bin-script suite separately:: + + $ python3 -m tox -e bin-scripts + Run the integration suite separately:: $ python3 -m tox -e integration @@ -87,6 +100,7 @@ Good additions usually follow these rules: * deterministic inputs and outputs; * negative-path coverage for invalid versions, malformed fields, and error handling; +* ``bin_script`` markers for subprocess tests of installed command-line tools; * integration markers only for code that must bind sockets or talk to a live service. diff --git a/docs/examples/index.rst b/docs/examples/index.rst index d4729bd9..787a9b8a 100644 --- a/docs/examples/index.rst +++ b/docs/examples/index.rst @@ -10,6 +10,10 @@ installation, and ``router_password_check`` requires ``fau_timer`` from ``mona-timing-lib`` because those packages are not covered by ``requirements-examples.txt``. +Installed utilities such as ``pysapcar``, ``pysapgenpse``, and +``pysaphdbuserstore`` are documented separately in +:doc:`../tools/index`. + For practical offline workflows, see the file format notebooks for :doc:`SAPCAR archive inspection and extraction <../fileformats/SAPCAR>`, :doc:`SAP Credv2 parsing <../fileformats/SAPCredv2>`, diff --git a/docs/index.rst b/docs/index.rst index a2798e8e..6bb67ad8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -50,6 +50,9 @@ Features * Client, proxy and server classes implemented for some of the protocols. +* Command-line tools for offline work with SAP archive, PSE, Credv2, and SSFS + files. + * Example scripts to illustrate the use of the different modules and protocols. @@ -62,6 +65,7 @@ User guide user/index protocols/index fileformats/index + tools/index examples/index Development guide diff --git a/docs/tools/index.rst b/docs/tools/index.rst new file mode 100644 index 00000000..8785de57 --- /dev/null +++ b/docs/tools/index.rst @@ -0,0 +1,90 @@ +.. Command-line tools frontend + +Command-line tools +================== + +pysap installs a small set of command-line tools for offline work with SAP file +formats. These are persistent utilities, not example scripts. They are installed +with the package and operate on local files supplied by the user. + +The tools are experimental and focused on pysap-supported formats. Use the +module APIs directly when an application needs stricter error handling or a +stable integration contract. + + +``pysapcar`` +------------ + +``pysapcar`` works with SAP ``SAR`` archive files through the +:mod:`pysap.SAPCAR` module. It can create, append, list, and extract archives. + +List archive contents:: + + $ pysapcar -t -f archive.sar + +Extract an archive into a directory:: + + $ pysapcar -x -f archive.sar -o output-dir + +Create an archive from local files:: + + $ pysapcar -c -f archive.sar file1.txt file2.txt + +Append a file to an existing archive:: + + $ pysapcar -a -f archive.sar file3.txt + +Relevant options include ``-v`` for verbose output, +``--enforce-checksum`` to stop extraction of files with invalid checksums, and +``--break-on-error`` to stop processing after an extraction error. + + +``pysapgenpse`` +--------------- + +``pysapgenpse`` provides offline helpers for SAP Personal Security Environment +(``PSE``) and SSO Credential (``Credv2``) files through :mod:`pysap.SAPPSE` and +:mod:`pysap.SAPCredv2`. + +List credentials stored in a Credv2 file:: + + $ pysapgenpse -c seclogin -l -f cred_v2 + +Decrypt a credential PIN with a known user name:: + + $ pysapgenpse -c seclogin -d -f cred_v2 -u username + +Decrypt PSE encrypted content with a known PIN:: + + $ pysapgenpse -c get_pse_certs -f local.pse -x pin + +Write decrypted output to a file:: + + $ pysapgenpse -c get_pse_certs -f local.pse -x pin -o output.der + +If ``-f`` is omitted for ``seclogin`` and ``SECUDIR`` is set, the tool looks for +``cred_v2`` in that directory. The ``-u`` option controls the user name used for +credential decryption; otherwise ``USER`` or ``USERNAME`` is used when present. + + +``pysaphdbuserstore`` +--------------------- + +``pysaphdbuserstore`` inspects SAP HANA client secure user store files backed by +SSFS key/data files through :mod:`pysap.SAPSSFS`. + +List records in an SSFS data file:: + + $ pysaphdbuserstore -c list -d SSFS_HDB.DAT + +Show a record without decrypting encrypted content:: + + $ pysaphdbuserstore -c get -d SSFS_HDB.DAT HDB/KEYNAME/DB_USER + +Decrypt an encrypted record when the matching key file is available:: + + $ pysaphdbuserstore -c get -d SSFS_HDB.DAT -k SSFS_HDB.KEY --decrypt HDB/KEYNAME/DB_PASSWORD + +If ``-d`` or ``-k`` is omitted, the tool uses the default HANA client secure +store paths under ``$HOME/.hdb//``. Use ``--deleted`` to include +records marked as deleted. diff --git a/docs/user/index.rst b/docs/user/index.rst index b60959a7..b5f8ff07 100644 --- a/docs/user/index.rst +++ b/docs/user/index.rst @@ -55,6 +55,9 @@ following command on a terminal:: $ python3 -m pip install pysap +Package installation also installs the :doc:`command-line tools <../tools/index>` +``pysapcar``, ``pysapgenpse``, and ``pysaphdbuserstore``. + Some example scripts has additional required libraries: - ``tabulate`` diff --git a/pyproject.toml b/pyproject.toml index cb9aee0c..7afabbe7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,8 @@ addopts = [ ] markers = [ "unit: fast unit tests that only exercise in-process logic", - "integration: tests that require sockets, subprocesses, or platform-specific runtime behavior", + "bin_script: subprocess tests for installed command-line tools in bin/", + "integration: tests that require sockets or live transport behavior", "slow: tests that are valid but intentionally expensive", "packet_visual: slow packet canvas rendering tests", "network: tests that require external network access", diff --git a/setup.py b/setup.py index 623b601f..a6c2771c 100755 --- a/setup.py +++ b/setup.py @@ -190,7 +190,7 @@ def clean_notebook(nb): py_modules=['pysapcompress'], # Script files - scripts=['bin/pysapcar', 'bin/pysapgenpse'], + scripts=['bin/pysapcar', 'bin/pysapgenpse', 'bin/pysaphdbuserstore'], # Documentation commands cmdclass={'doc': DocumentationCommand, diff --git a/tests/pysapcar_script_test.py b/tests/pysapcar_script_test.py new file mode 100644 index 00000000..8a3ca56c --- /dev/null +++ b/tests/pysapcar_script_test.py @@ -0,0 +1,95 @@ +# encoding: utf-8 +# pysap - Python library for crafting SAP's network protocols packets +# +# 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 2 +# 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. +# +# Author: +# Martin Gallo (@martingalloar) +# + +# Standard imports +import subprocess +import sys +import tempfile +import unittest +from os.path import dirname, join +# External imports +import pytest +# Custom imports +from tests.utils import data_filename, script_env + + +pytestmark = pytest.mark.bin_script + + +class PySAPCARScriptTest(unittest.TestCase): + + SCRIPT = join(dirname(dirname(__file__)), "bin", "pysapcar") + TEST_STRING = b"The quick brown fox jumps over the lazy dog" + + def run_script(self, *args): + return subprocess.run( + [sys.executable, self.SCRIPT] + list(args), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=script_env(), + check=False, + ) + + def test_list_archive(self): + result = self.run_script("-t", "-f", data_filename("car200_test_string.sar")) + + self.assertEqual(0, result.returncode) + self.assertIn("Processing archive", result.stdout) + self.assertIn("test_string.txt", result.stdout) + + def test_list_archive_with_filename_filter(self): + result = self.run_script("-t", "-f", data_filename("car201_test_string.sar"), "test_string.txt") + + self.assertEqual(0, result.returncode) + self.assertIn("test_string.txt", result.stdout) + + def test_extract_archive(self): + with tempfile.TemporaryDirectory() as output_dir: + result = self.run_script("-x", "-f", data_filename("car200_test_string.sar"), "-o", output_dir) + + self.assertEqual(0, result.returncode) + self.assertIn("1 file(s) processed", result.stdout) + with open(join(output_dir, "test_string.txt"), "rb") as extracted_file: + self.assertEqual(self.TEST_STRING, extracted_file.read()) + + def test_create_and_append_archive(self): + with tempfile.TemporaryDirectory() as output_dir: + archive_file = join(output_dir, "created.sar") + first_file = join(output_dir, "first.txt") + second_file = join(output_dir, "second.txt") + with open(first_file, "wb") as first: + first.write(b"first") + with open(second_file, "wb") as second: + second.write(b"second") + + create_result = self.run_script("-c", "-f", archive_file, first_file) + append_result = self.run_script("-a", "-f", archive_file, second_file) + list_result = self.run_script("-t", "-f", archive_file) + + self.assertEqual(0, create_result.returncode) + self.assertEqual(0, append_result.returncode) + self.assertEqual(0, list_result.returncode) + self.assertIn("first.txt", list_result.stdout) + self.assertIn("second.txt", list_result.stdout) + + def test_missing_archive_returns_cleanly(self): + result = self.run_script("-t", "-f", data_filename("does-not-exist.sar")) + + self.assertEqual(0, result.returncode) + self.assertIn("error opening", result.stdout) + self.assertNotIn("Traceback", result.stdout) diff --git a/tests/pysapgenpse_script_test.py b/tests/pysapgenpse_script_test.py new file mode 100644 index 00000000..68f68ec6 --- /dev/null +++ b/tests/pysapgenpse_script_test.py @@ -0,0 +1,127 @@ +# encoding: utf-8 +# pysap - Python library for crafting SAP's network protocols packets +# +# 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 2 +# 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. +# +# Author: +# Martin Gallo (@martingalloar) +# + +# Standard imports +import subprocess +import sys +import tempfile +import unittest +from os.path import dirname, exists, join +# External imports +import pytest +# Custom imports +from tests.utils import data_filename, script_env + + +pytestmark = pytest.mark.bin_script + + +class PySAPGenPSESecLoginScriptTest(unittest.TestCase): + + SCRIPT = join(dirname(dirname(__file__)), "bin", "pysapgenpse") + + def run_script(self, *args): + return subprocess.run( + [sys.executable, self.SCRIPT] + list(args), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=script_env(), + check=False, + ) + + def test_seclogin_lists_credentials(self): + result = self.run_script("-c", "seclogin", "-l", "-f", data_filename("credv2_lps_off_v0_3des")) + + self.assertEqual(0, result.returncode) + self.assertIn("Reading credentials file", result.stdout) + self.assertIn("CN=PSEOwner", result.stdout) + self.assertIn("1 readable SSO-Credentials available", result.stdout) + + def test_seclogin_decrypts_credentials(self): + result = self.run_script( + "-c", "seclogin", + "-d", + "-f", data_filename("credv2_lps_off_v1_aes256"), + "-u", "username", + "--no-decrypt-provider", + ) + + self.assertEqual(0, result.returncode) + self.assertIn("PIN:", result.stdout) + self.assertIn("1234567890", result.stdout) + + def test_missing_credential_file_returns_cleanly(self): + result = self.run_script("-c", "seclogin", "-l", "-f", data_filename("does-not-exist")) + + self.assertEqual(0, result.returncode) + self.assertIn("Error reading credentials file", result.stdout) + self.assertNotIn("Traceback", result.stdout) + + +class PySAPGenPSEPSEScriptTest(unittest.TestCase): + + SCRIPT = join(dirname(dirname(__file__)), "bin", "pysapgenpse") + DECRYPT_PIN = "1234567980" + + def run_script(self, *args): + return subprocess.run( + [sys.executable, self.SCRIPT] + list(args), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=script_env(), + check=False, + ) + + def test_get_pse_certs_decrypts_pse(self): + result = self.run_script( + "-c", "get_pse_certs", + "-f", data_filename("pse_v2_lps_off_pbes1_3des_sha1.pse"), + "-x", self.DECRYPT_PIN, + ) + + self.assertEqual(0, result.returncode) + self.assertIn("Reading PSE file", result.stdout) + self.assertIn("Decrypted PSE", result.stdout) + + def test_get_pse_certs_writes_output_file(self): + with tempfile.TemporaryDirectory() as output_dir: + output_file = join(output_dir, "certs.der") + result = self.run_script( + "-c", "get_pse_certs", + "-f", data_filename("pse_v2_lps_off_pbes1_3des_sha1.pse"), + "-x", self.DECRYPT_PIN, + "-o", output_file, + ) + + self.assertEqual(0, result.returncode) + self.assertIn("Output written to file", result.stdout) + self.assertTrue(exists(output_file)) + with open(output_file, "rb") as output: + self.assertGreater(len(output.read()), 0) + + def test_missing_pse_file_returns_cleanly(self): + result = self.run_script( + "-c", "get_pse_certs", + "-f", data_filename("does-not-exist.pse"), + "-x", self.DECRYPT_PIN, + ) + + self.assertEqual(0, result.returncode) + self.assertIn("Error reading PSE file", result.stdout) + self.assertNotIn("Traceback", result.stdout) diff --git a/tests/pysaphdbuserstore_script_test.py b/tests/pysaphdbuserstore_script_test.py new file mode 100644 index 00000000..b2c6e18a --- /dev/null +++ b/tests/pysaphdbuserstore_script_test.py @@ -0,0 +1,117 @@ +# encoding: utf-8 +# pysap - Python library for crafting SAP's network protocols packets +# +# 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 2 +# 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. +# +# Author: +# Martin Gallo (@martingalloar) +# + +# Standard imports +import subprocess +import sys +import unittest +from os.path import dirname, join +# External imports +import pytest +# Custom imports +from tests.utils import data_filename, script_env + + +pytestmark = pytest.mark.bin_script + + +class PySAPHDBUserStoreScriptTest(unittest.TestCase): + + SCRIPT = join(dirname(dirname(__file__)), "bin", "pysaphdbuserstore") + + @staticmethod + def run_script(*args): + command = [sys.executable, PySAPHDBUserStoreScriptTest.SCRIPT] + command.extend(args) + result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=script_env(), check=False) + output = result.stdout + result.stderr + return result.returncode, output.decode("utf-8", errors="replace") + + def test_list_records(self): + """Test listing records from a SSFS Data file.""" + + returncode, output = self.run_script("-c", "list", + "-d", data_filename("ssfs_hdb_dat")) + + self.assertEqual(returncode, 0) + self.assertIn("HDB/KEYNAME/DB_USER\tPlaintext", output) + self.assertIn("HDB/KEYNAME/DB_PASSWORD\tEncrypted", output) + + def test_get_plaintext_record(self): + """Test getting a plaintext record from a SSFS Data file.""" + + returncode, output = self.run_script("-c", "get", + "-d", data_filename("ssfs_hdb_dat"), + "HDB/KEYNAME/DB_USER") + + self.assertEqual(returncode, 0) + self.assertIn("Record Key : HDB/KEYNAME/DB_USER", output) + self.assertIn("Record Value : b'SomeUser'", output) + + def test_get_encrypted_record_without_decrypt(self): + """Test getting an encrypted record without decrypting it.""" + + returncode, output = self.run_script("-c", "get", + "-d", data_filename("ssfs_hdb_dat"), + "HDB/KEYNAME/DB_PASSWORD") + + self.assertEqual(returncode, 0) + self.assertIn("Record Key : HDB/KEYNAME/DB_PASSWORD", output) + self.assertNotIn("SomePassword", output) + + def test_get_encrypted_record_with_decrypt(self): + """Test decrypting an encrypted record with a SSFS Key file.""" + + returncode, output = self.run_script("-c", "get", + "-d", data_filename("ssfs_hdb_dat"), + "-k", data_filename("ssfs_hdb_key"), + "--decrypt", + "HDB/KEYNAME/DB_PASSWORD") + + self.assertEqual(returncode, 0) + self.assertIn("Record Key : HDB/KEYNAME/DB_PASSWORD", output) + self.assertIn("Record Value : b'SomePassword'", output) + + def test_get_missing_record(self): + """Test getting a missing record from a SSFS Data file.""" + + returncode, output = self.run_script("-c", "get", + "-d", data_filename("ssfs_hdb_dat"), + "HDB/KEYNAME/MISSING") + + self.assertEqual(returncode, 0) + self.assertIn("Record with key HDB/KEYNAME/MISSING not found", output) + + def test_get_without_record_key_returns_cleanly(self): + """Test the get command reports a missing record key.""" + + returncode, output = self.run_script("-c", "get", + "-d", data_filename("ssfs_hdb_dat")) + + self.assertEqual(returncode, 0) + self.assertIn("No record key specified", output) + self.assertNotIn("Traceback", output) + + def test_missing_file_path_returns_cleanly(self): + """Test missing files are reported without a traceback.""" + + returncode, output = self.run_script("-c", "list", + "-d", data_filename("ssfs_missing_dat")) + + self.assertEqual(returncode, 0) + self.assertIn("Unable to read data", output) + self.assertNotIn("Traceback", output) diff --git a/tests/utils.py b/tests/utils.py index 86a58d52..2097b074 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -18,13 +18,25 @@ # Standard imports from binascii import unhexlify +from os import environ from os.path import join as join, dirname +REPO_ROOT = dirname(dirname(__file__)) + + def data_filename(filename): return join(dirname(__file__), 'data', filename) +def script_env(): + """Build an environment for subprocess tests that import the local checkout.""" + env = environ.copy() + pythonpath = env.get("PYTHONPATH") + env["PYTHONPATH"] = REPO_ROOT if not pythonpath else REPO_ROOT + ":" + pythonpath + return env + + def read_data_file(filename, unhex=True): filename = data_filename(filename) if unhex: diff --git a/tox.ini b/tox.ini index 368fb2f0..4c7e1459 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -env_list = lint-core, lint-examples, unit, packet-visuals, integration +env_list = lint-core, lint-examples, unit, bin-scripts, packet-visuals, integration skip_missing_interpreters = true [testenv] @@ -11,7 +11,10 @@ deps = commands = python -m pytest {posargs} [testenv:unit] -commands = python -m pytest -m "not integration and not packet_visual" {posargs} +commands = python -m pytest -m "not integration and not packet_visual and not bin_script" {posargs} + +[testenv:bin-scripts] +commands = python -m pytest -m bin_script {posargs} [testenv:packet-visuals] commands = python -m pytest -m packet_visual {posargs}