From 5ccb9daca05f0100075fac671bc2d46415f1b7c5 Mon Sep 17 00:00:00 2001 From: Sebastian Lenzlinger Date: Fri, 28 Jun 2024 20:43:23 +0200 Subject: [PATCH] SYNC --- .gitignore | 2 + iottb/commands/developer.py | 6 +- iottb/commands/sniff.py | 127 +++++++++++++++++++++++++++++++- iottb/main.py | 4 +- iottb/models/iottb_config.py | 2 +- iottb/models/sniff_metadata.py | 4 + iottb/utils/logger_config.py | 7 +- iottb/utils/user_interaction.py | 7 ++ poetry.lock | 100 ++++++++++++++++++++++++- pyproject.toml | 6 ++ 10 files changed, 253 insertions(+), 12 deletions(-) create mode 100644 iottb/models/sniff_metadata.py create mode 100644 iottb/utils/user_interaction.py diff --git a/.gitignore b/.gitignore index 5a8cfd7..40296c5 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ logs/ .idea/**/dynamic.xml .idea/**/uiDesigner.xml .idea/**/dbnavigator.xml + +.private/ diff --git a/iottb/commands/developer.py b/iottb/commands/developer.py index a8b9163..461d96b 100644 --- a/iottb/commands/developer.py +++ b/iottb/commands/developer.py @@ -1,10 +1,12 @@ from pathlib import Path - +import logging import click -from iottb.main import DB_NAME, logger, CFG_FILE_PATH +from iottb.definitions import DB_NAME, CFG_FILE_PATH from iottb.models.iottb_config import IottbConfig +logger = logging.getLogger(__name__) + @click.command() @click.option('--file', default=DB_NAME) diff --git a/iottb/commands/sniff.py b/iottb/commands/sniff.py index 27edbef..ba841ea 100644 --- a/iottb/commands/sniff.py +++ b/iottb/commands/sniff.py @@ -1,8 +1,133 @@ import click +import subprocess +import json +from pathlib import Path import logging -from iottb import definitions +import re +from datetime import datetime +from iottb.definitions import APP_NAME, CFG_FILE_PATH +from iottb.models.iottb_config import IottbConfig +from iottb.utils.string_processing import make_canonical_name +# Setup logger +logger = logging.getLogger('iottb.sniff') +def is_ip_address(address): + ip_pattern = re.compile(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$") + return ip_pattern.match(address) is not None +def is_mac_address(address): + mac_pattern = re.compile(r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$") + return mac_pattern.match(address) is not None + +def load_config(cfg_file): + """Loads configuration from the given file path.""" + with open(cfg_file, 'r') as config_file: + return json.load(config_file) + + +def validate_sniff(ctx, param, value): + logger.info('Validating sniff...') + if ctx.params.get('unsafe') and not value: + return None + if not ctx.params.get('unsafe') and not value: + raise click.BadParameter('Address is required unless --unsafe is set.') + return value + + +@click.command('sniff', help='Sniff packets with tcpdump') +@click.argument('device') +@click.option('-i', '--interface', callback=validate_sniff, help='Network interface to capture on', + envvar='IOTTB_CAPTURE_INTERFACE') +@click.option('-a', '--address', callback=validate_sniff, help='IP or MAC address to filter packets by', + envvar='IOTTB_CAPTURE_ADDRESS') +@click.option('--db', '--database', type=click.Path(exists=True, file_okay=False), envvar='IOTTB_DB', + help='Database of device. Only needed if not current default.') +@click.option('--unsafe', is_flag=True, default=False, envvar='IOTTB_UNSAFE', is_eager=True, + help='Disable checks for otherwise required options') +@click.option('--guided', is_flag=True, default=False) +def sniff(device, interface, address, db, unsafe, guided): + """ Sniff packets from a device """ + logger.info('sniff command invoked') + + # Step1: Load Config + config = IottbConfig(Path(CFG_FILE_PATH)) + logger.debug(f'Config loaded: {config}') + + # Step2: determine relevant database + database = db if db else config.default_database + path = config.default_db_location[database] + full_db_path = Path(path) / database + logger.debug(f'Full db path is {str(path)}') + + # Check if it exists + assert full_db_path.is_dir(), "DB unexpectedly missing" + + canonical_name = make_canonical_name(device) + click.echo(f'Using canonical device name {canonical_name}') + + if not database_path: + logger.error('No default database path found in configuration') + click.echo('No default database path found in configuration') + return + + # Verify device directory + device_path = Path(database_path) / device + if not device_path.exists(): + logger.error(f'Device path {device_path} does not exist') + click.echo(f'Device path {device_path} does not exist') + return + + # Generate filter + if not unsafe: + if is_ip_address(address): + packet_filter = f"host {address}" + elif is_mac_address(address): + packet_filter = f"ether host {address}" + else: + logger.error('Invalid address format') + click.echo('Invalid address format') + return + else: + packet_filter = None + + # Prepare capture directory + capture_dir = device_path / 'captures' / datetime.now().strftime('%Y%m%d_%H%M%S') + capture_dir.mkdir(parents=True, exist_ok=True) + + # Prepare capture file + pcap_file = capture_dir / f"{device}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pcap" + + # Build tcpdump command + cmd = ['sudo', 'tcpdump', '-i', interface, '-w', str(pcap_file)] + if packet_filter: + cmd.append(packet_filter) + logger.info(f'Executing: {" ".join(cmd)}') + + # Execute tcpdump + try: + subprocess.run(cmd, check=True) + click.echo(f"Capture complete. Saved to {pcap_file}") + except subprocess.CalledProcessError as e: + logger.error(f'Failed to capture packets: {e}') + click.echo(f'Failed to capture packets: {e}') + + +@click.command('sniff', help='Sniff packets with tcpdump') +@click.argument('device') +@click.option('-i', '--interface', required=False, help='Network interface to capture on', envvar='IOTTB_CAPTURE_INTERFACE') +@click.option('-a', '--address', required=True, help='IP or MAC address to filter packets by', envvar='IOTTB_CAPTURE_ADDRESS') +@click.option('--db', '--database', type=click.Path(exists=True, file_okay=False), envvar='IOTTB_DB', + help='Database of device. Only needed if not current default.') +@click.option('--unsafe', is_flag=True, default=False, envvar='IOTTB_UNSAFE', + help='Disable checks for otherwise required options') +@click.option('--guided', is_flag=True) +def sniff2(device, interface, address, cfg_file): + """ Sniff packets from a device """ + logger.info('sniff command invoked') + # Step 1: Load Config + # Dependency: Config file must exist + config = IottbConfig(Path(CFG_FILE_PATH)) + logger.debug(f'Config loaded: {config}') diff --git a/iottb/main.py b/iottb/main.py index 9b28924..6000d5b 100644 --- a/iottb/main.py +++ b/iottb/main.py @@ -2,6 +2,7 @@ import click from pathlib import Path import logging +from iottb.commands.sniff import sniff from iottb.commands.developer import set_key_in_table_to, rm_cfg, rm_dbs, show_cfg ################################################## # Import package modules @@ -41,7 +42,7 @@ def cli(ctx, verbosity, debug, cfg_file): ctx.ensure_object(dict) # Make sure context is ready for use logger.info("Starting execution.") ctx.obj['CONFIG'] = IottbConfig(cfg_file) # Load configuration directly - # ctx.meta['FULL_PATH_CONFIG_FILE'] = + ctx.meta['FULL_PATH_CONFIG_FILE'] = str(cfg_file) ################################################################################## @@ -55,6 +56,7 @@ cli.add_command(rm_dbs) # noinspection PyTypeChecker cli.add_command(add_device) cli.add_command(show_cfg) +cli.add_command(sniff) if __name__ == '__main__': cli(auto_envvar_prefix='IOTTB', show_default=True, show_envvars=True) diff --git a/iottb/models/iottb_config.py b/iottb/models/iottb_config.py index 1d4418d..25736dc 100644 --- a/iottb/models/iottb_config.py +++ b/iottb/models/iottb_config.py @@ -10,7 +10,7 @@ DB_NAME = definitions.DB_NAME class IottbConfig: - """ Class to handle operations on the testbed configuration. + """ Class to handle testbed configuration. TODO: Add instead of overwrite Database locations when initializing if a location with valid db exists. diff --git a/iottb/models/sniff_metadata.py b/iottb/models/sniff_metadata.py new file mode 100644 index 0000000..9e66e0d --- /dev/null +++ b/iottb/models/sniff_metadata.py @@ -0,0 +1,4 @@ +import logging + +logger = logging.getLogger('iottb.sniff') # Log with sniff subcommand + diff --git a/iottb/utils/logger_config.py b/iottb/utils/logger_config.py index 1ececee..5cf76ad 100644 --- a/iottb/utils/logger_config.py +++ b/iottb/utils/logger_config.py @@ -1,10 +1,7 @@ -import json -import click -from pathlib import Path import logging -from logging.handlers import RotatingFileHandler import sys -from iottb.models.iottb_config import IottbConfig +from logging.handlers import RotatingFileHandler + from iottb import definitions from iottb.definitions import MAX_VERBOSITY, CONSOLE_LOG_FORMATS, APP_NAME, LOGFILE_LOG_FORMAT diff --git a/iottb/utils/user_interaction.py b/iottb/utils/user_interaction.py new file mode 100644 index 0000000..a69eeb0 --- /dev/null +++ b/iottb/utils/user_interaction.py @@ -0,0 +1,7 @@ +import click +import logging +from iottb import definitions + + +class IottbPrompt: + pass diff --git a/poetry.lock b/poetry.lock index 1034779..b23fb0e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,7 +1,103 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. -package = [] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "8.2.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, + {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2.0" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "scapy" +version = "2.5.0" +description = "Scapy: interactive packet manipulation tool" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" +files = [ + {file = "scapy-2.5.0.tar.gz", hash = "sha256:5b260c2b754fd8d409ba83ee7aee294ecdbb2c235f9f78fe90bc11cb6e5debc2"}, +] + +[package.extras] +basic = ["ipython"] +complete = ["cryptography (>=2.0)", "ipython", "matplotlib", "pyx"] +docs = ["sphinx (>=3.0.0)", "sphinx_rtd_theme (>=0.4.3)", "tox (>=3.0.0)"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "34e39677d8527182346093002688d17a5d2fc204b9eb3e094b2e6ac519028228" +content-hash = "10b2c268b0f10db15eab2cca3d2dc9dc25bc60f4b218ebf786fb780fa85557e0" diff --git a/pyproject.toml b/pyproject.toml index b05d1f3..eda5a1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,19 @@ version = "0.1.0" description = "IoT Testbed" authors = ["Sebastian Lenzlinger "] readme = "README.md" +package-mode = false [tool.poetry.dependencies] python = "^3.12" +click = "^8.1" +scapy = "^2.5" [tool.poetry.scripts] iottb = "iottb.main:cli" +[tool.poetry.group.test.dependencies] +pytest = "^8.2.2" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api"