347 lines
13 KiB
Python
347 lines
13 KiB
Python
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import uuid
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from time import time
|
|
|
|
import click
|
|
from click_option_group import optgroup
|
|
|
|
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.')
|
|
if not is_ip_address(value) and not is_mac_address(value):
|
|
raise click.BadParameter('Address must be a valid IP address or MAC address.')
|
|
return value
|
|
|
|
|
|
def run_pre(pre):
|
|
subprocess.run(pre, shell=True)
|
|
logger.debug(f'finnished {pre}')
|
|
|
|
|
|
def run_post(post):
|
|
subprocess.run(post, shell=True)
|
|
logger.debug(f'finnished {post}')
|
|
|
|
|
|
@click.command('sniff', help='Sniff packets with tcpdump')
|
|
@optgroup.group('Testbed sources')
|
|
@optgroup.option('--db', '--database', type=str, envvar='IOTTB_DB', show_envvar=True,
|
|
help='Database of device. Only needed if not current default.')
|
|
@optgroup.option('--app', type=str, help='Companion app being used during capture', required=False)
|
|
@optgroup.group('Runtime behaviour')
|
|
@optgroup.option('--unsafe', is_flag=True, default=False, envvar='IOTTB_UNSAFE', is_eager=True,
|
|
help='Disable checks for otherwise required options.\n', show_envvar=True)
|
|
@optgroup.option('--guided', is_flag=True, default=False, envvar='IOTTB_GUIDED', show_envvar=True)
|
|
@optgroup.option('--pre', help='Script to be executed before main command is started.')
|
|
@optgroup.option('--post', help='Script to be executed upon completion of main command.')
|
|
@optgroup.group('Tcpdump options')
|
|
@optgroup.option('-i', '--interface',
|
|
help='Network interface to capture on.' +
|
|
'If not specified tcpdump tries to find and appropriate one.\n', show_envvar=True,
|
|
envvar='IOTTB_CAPTURE_INTERFACE')
|
|
@optgroup.option('-a', '--address', callback=validate_sniff,
|
|
help='IP or MAC address to filter packets by.\n', show_envvar=True,
|
|
envvar='IOTTB_CAPTURE_ADDRESS')
|
|
@optgroup.option('-I', '--monitor-mode', help='Put interface into monitor mode.\n', is_flag=True)
|
|
@optgroup.option('--ff', type=str, envvar='IOTTB_CAPTURE_FILTER', show_envvar=True,
|
|
help='tcpdump filter as string or file path.')
|
|
@optgroup.option('-#', '--print-pacno', is_flag=True, default=True,
|
|
help='Print packet number at beginning of line. True by default.\n')
|
|
@optgroup.option('-e', '--print-ll', is_flag=True, default=False,
|
|
help='Print link layer headers. True by default.')
|
|
@optgroup.option('-c', '--count', type=int, help='Number of packets to capture.', default=1000)
|
|
# @optgroup.option('--mins', type=int, help='Time in minutes to capture.', default=1)
|
|
@click.argument('tcpdump-args', nargs=-1, required=False, metavar='[TCPDUMP-ARGS]')
|
|
@click.argument('device', required=False)
|
|
@click.pass_context
|
|
def sniff(ctx, device, interface, print_pacno, ff, count, monitor_mode, print_ll, address, db, unsafe, guided,
|
|
app, tcpdump_args, pre, post, **params):
|
|
""" Sniff packets from a device """
|
|
logger.info('sniff command invoked')
|
|
# Step 0: run pre script:
|
|
if pre:
|
|
click.echo(f'Running pre command {pre}')
|
|
run_pre(pre)
|
|
# Step1: Load Config
|
|
config = ctx.obj['CONFIG']
|
|
logger.debug(f'Config loaded: {config}')
|
|
|
|
# Step2: determine relevant database
|
|
database = db if db else config.default_database
|
|
path = config.db_path_dict[database]
|
|
full_db_path = Path(path) / database
|
|
logger.debug(f'Full db path is {str(full_db_path)}')
|
|
|
|
# 2.2: Check if it exists
|
|
if not full_db_path.is_dir():
|
|
logger.error('DB unexpectedly missing')
|
|
click.echo('DB unexpectedly missing')
|
|
return
|
|
|
|
canonical_name, aliases = make_canonical_name(device)
|
|
click.echo(f'Using canonical device name {canonical_name}')
|
|
device_path = full_db_path / canonical_name
|
|
|
|
# Step 3: now the device
|
|
if not device_path.exists():
|
|
if not unsafe:
|
|
logger.error(f'Device path {device_path} does not exist')
|
|
click.echo(f'Device path {device_path} does not exist')
|
|
return
|
|
else:
|
|
device_path.mkdir(parents=True, exist_ok=True)
|
|
logger.info(f'Device path {device_path} created')
|
|
|
|
click.echo(f'Found device at path {device_path}')
|
|
# Step 4: Generate filter
|
|
generic_filter = None
|
|
cap_filter = None
|
|
if ff:
|
|
logger.debug(f'ff: {ff}')
|
|
if Path(ff).is_file():
|
|
logger.info('Given filter option is a file')
|
|
with open(ff, 'r') as f:
|
|
cap_filter = f.read().strip()
|
|
else:
|
|
logger.info('Given filter option is an expression')
|
|
cap_filter = ff
|
|
else:
|
|
if address is not None:
|
|
if is_ip_address(address):
|
|
generic_filter = 'net'
|
|
cap_filter = f'{generic_filter} {address}'
|
|
elif is_mac_address(address):
|
|
generic_filter = 'ether net'
|
|
cap_filter = f'{generic_filter} {address}'
|
|
elif not unsafe:
|
|
logger.error('Invalid address format')
|
|
click.echo('Invalid address format')
|
|
return
|
|
|
|
logger.info(f'Generic filter {generic_filter}')
|
|
click.echo(f'Using filter {cap_filter}')
|
|
|
|
# Step 5: prep capture directory
|
|
capture_date = datetime.now().strftime('%Y-%m-%d')
|
|
capture_base_dir = device_path / f'sniffs/{capture_date}'
|
|
capture_base_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
logger.debug(f'Previous captures {capture_base_dir.glob('cap*')}')
|
|
capture_count = sum(1 for _ in capture_base_dir.glob('cap*'))
|
|
logger.debug(f'Capture count is {capture_count}')
|
|
|
|
capture_dir = f'cap{capture_count:04d}-{datetime.now().strftime('%H%M')}'
|
|
logger.debug(f'capture_dir: {capture_dir}')
|
|
|
|
# Full path
|
|
capture_dir_full_path = capture_base_dir / capture_dir
|
|
capture_dir_full_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
click.echo(f'Files will be placed in {str(capture_dir_full_path)}')
|
|
logger.debug(f'successfully created capture directory')
|
|
|
|
# Step 6: Prepare capture file names
|
|
# Generate UUID for filenames
|
|
capture_uuid = str(uuid.uuid4())
|
|
click.echo(f'Capture has id {capture_uuid}')
|
|
|
|
pcap_file = f"{canonical_name}_{capture_uuid}.pcap"
|
|
pcap_file_full_path = capture_dir_full_path / pcap_file
|
|
stdout_log_file = f'stdout_{capture_uuid}.log'
|
|
stderr_log_file = f'stderr_{capture_uuid}.log'
|
|
|
|
logger.debug(f'Full pcap file path is {pcap_file_full_path}')
|
|
logger.info(f'pcap file name is {pcap_file}')
|
|
logger.info(f'stdout log file is {stdout_log_file}')
|
|
logger.info(f'stderr log file is {stderr_log_file}')
|
|
|
|
# Step 7: Build tcpdump command
|
|
logger.debug(f'pgid {os.getpgrp()}')
|
|
logger.debug(f'ppid {os.getppid()}')
|
|
logger.debug(f'(real, effective, saved) user id: {os.getresuid()}')
|
|
logger.debug(f'(real, effective, saved) group id: {os.getresgid()}')
|
|
|
|
cmd = ['sudo', 'tcpdump']
|
|
|
|
# 7.1 process flags
|
|
flags = []
|
|
if print_pacno:
|
|
flags.append('-#')
|
|
if print_ll:
|
|
flags.append('-e')
|
|
if monitor_mode:
|
|
flags.append('-I')
|
|
flags.append('-n') # TODO: Integrate, in case name resolution is wanted!
|
|
cmd.extend(flags)
|
|
flags_string = " ".join(flags)
|
|
logger.debug(f'Flags: {flags_string}')
|
|
|
|
# debug interlude
|
|
verbosity = ctx.obj['VERBOSITY']
|
|
if verbosity > 0:
|
|
verbosity_flag = '-'
|
|
for i in range(0, verbosity):
|
|
verbosity_flag = verbosity_flag + 'v'
|
|
logger.debug(f'verbosity string to pass to tcpdump: {verbosity_flag}')
|
|
cmd.append(verbosity_flag)
|
|
|
|
# 7.2 generic (i.e. reusable) kw args
|
|
generic_kw_args = []
|
|
if count:
|
|
generic_kw_args.extend(['-c', str(count)])
|
|
# if mins:
|
|
# generic_kw_args.extend(['-G', str(mins * 60)]) TODO: this currently loads to errors with sudo
|
|
cmd.extend(generic_kw_args)
|
|
generic_kw_args_string = " ".join(generic_kw_args)
|
|
logger.debug(f'KW args: {generic_kw_args_string}')
|
|
|
|
# 7.3 special kw args (not a priori reusable)
|
|
non_generic_kw_args = []
|
|
if interface:
|
|
non_generic_kw_args.extend(['-i', interface])
|
|
non_generic_kw_args.extend(['-w', str(pcap_file_full_path)])
|
|
cmd.extend(non_generic_kw_args)
|
|
non_generic_kw_args_string = " ".join(non_generic_kw_args)
|
|
logger.debug(f'Non transferable (special) kw args: {non_generic_kw_args_string}')
|
|
|
|
# 7.4 add filter expression
|
|
if cap_filter:
|
|
logger.debug(f'cap_filter (not generic): {cap_filter}')
|
|
cmd.append(cap_filter)
|
|
|
|
full_cmd_string = " ".join(cmd)
|
|
|
|
logger.info(f'tcpdump command: {"".join(full_cmd_string)}')
|
|
click.echo('Capture setup complete!')
|
|
# Step 8: Execute tcpdump command
|
|
start_time = datetime.now().strftime("%H:%M:%S")
|
|
start = time()
|
|
try:
|
|
if guided:
|
|
click.confirm(f'Execute following command: {full_cmd_string}')
|
|
stdout_log_file_abs_path = capture_dir_full_path / stdout_log_file
|
|
stderr_log_file_abs_path = capture_dir_full_path / stderr_log_file
|
|
stdout_log_file_abs_path.touch(mode=0o777)
|
|
stderr_log_file_abs_path.touch(mode=0o777)
|
|
with open(stdout_log_file_abs_path, 'w') as out, open(stderr_log_file_abs_path, 'w') as err:
|
|
logger.debug(f'\nstdout: {out}.\nstderr: {err}.\n')
|
|
|
|
tcp_complete = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|
|
|
out.write(tcp_complete.stdout)
|
|
err.write(tcp_complete.stderr)
|
|
|
|
# click.echo(f'Mock sniff execution')
|
|
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.echo(f'Check {stderr_log_file} for more info.')
|
|
if ctx.obj['DEBUG']:
|
|
msg = [f'STDERR log {stderr_log_file} contents:\n']
|
|
with open(capture_dir_full_path / stderr_log_file) as log:
|
|
for line in log:
|
|
msg.append(line)
|
|
|
|
click.echo("\t".join(msg), lvl='e')
|
|
# print('DEBUG ACTIVE')
|
|
if guided:
|
|
click.prompt('Create metadata anyway?')
|
|
else:
|
|
click.echo('Aborting capture...')
|
|
exit()
|
|
end_time = datetime.now().strftime("%H:%M:%S")
|
|
end = time()
|
|
delta = end - start
|
|
|
|
|
|
click.echo(f'tcpdump took {delta:.2f} seconds.')
|
|
# Step 9: Register metadata
|
|
metadata = {
|
|
'device': canonical_name,
|
|
'device_id': device,
|
|
'capture_id': capture_uuid,
|
|
'capture_date_iso': datetime.now().isoformat(),
|
|
'invoked_command': " ".join(map(str, cmd)),
|
|
'capture_duration': delta,
|
|
'generic_parameters': {
|
|
'flags': flags_string,
|
|
'kwargs': generic_kw_args_string,
|
|
'filter': generic_filter
|
|
},
|
|
'non_generic_parameters': {
|
|
'kwargs': non_generic_kw_args_string,
|
|
'filter': cap_filter
|
|
},
|
|
'features': {
|
|
'interface': interface,
|
|
'address': address
|
|
},
|
|
'resources': {
|
|
'pcap_file': str(pcap_file),
|
|
'stdout_log': str(stdout_log_file),
|
|
'stderr_log': str(stderr_log_file),
|
|
'pre': str(pre),
|
|
'post': str(post)
|
|
},
|
|
'environment': {
|
|
'capture_dir': capture_dir,
|
|
'database': database,
|
|
'capture_base_dir': str(capture_base_dir),
|
|
'capture_dir_abs_path': str(capture_dir_full_path)
|
|
}
|
|
}
|
|
|
|
click.echo('Ensuring correct ownership of created files.')
|
|
username = os.getlogin()
|
|
gid = os.getgid()
|
|
|
|
# Else there are issues when running with sudo:
|
|
try:
|
|
subprocess.run(f'sudo chown -R {username}:{username} {device_path}', shell=True)
|
|
except OSError as e:
|
|
click.echo(f'Some error {e}')
|
|
|
|
click.echo(f'Saving metadata.')
|
|
metadata_abs_path = capture_dir_full_path / 'capture_metadata.json'
|
|
with open(metadata_abs_path, 'w') as f:
|
|
json.dump(metadata, f, indent=4)
|
|
|
|
click.echo(f'END SNIFF SUBCOMMAND')
|
|
if post:
|
|
click.echo(f'Running post script {post}')
|
|
run_post(post)
|
|
|
|
|