From 1fa7ad14992053679492b750b6c836930ded8853 Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Sat, 20 Jul 2024 14:14:11 -0400 Subject: [PATCH 1/3] Creating decorator for API key verification --- shodan/cli/alert.py | 85 ++++++++++++++++++++------------------ shodan/cli/data.py | 18 ++++---- shodan/cli/helpers.py | 32 ++++++++------ shodan/cli/organization.py | 18 ++++---- 4 files changed, 83 insertions(+), 70 deletions(-) diff --git a/shodan/cli/alert.py b/shodan/cli/alert.py index 1df11ea..3559042 100644 --- a/shodan/cli/alert.py +++ b/shodan/cli/alert.py @@ -82,9 +82,9 @@ def alert(): @alert.command(name='clear') -def alert_clear(): +@get_api_key +def alert_clear(key): """Remove all alerts""" - key = get_api_key() # Get the list api = shodan.Shodan(key) @@ -101,9 +101,9 @@ def alert_clear(): @alert.command(name='create') @click.argument('name', metavar='') @click.argument('netblocks', metavar='', nargs=-1) -def alert_create(name, netblocks): +@get_api_key +def alert_create(name, netblocks, key): """Create a network alert to monitor an external network""" - key = get_api_key() # Get the list api = shodan.Shodan(key) @@ -118,12 +118,15 @@ def alert_create(name, netblocks): @alert.command(name='domain') @click.argument('domain', metavar='', type=str) -@click.option('--triggers', help='List of triggers to enable', default='malware,industrial_control_system,internet_scanner,iot,open_database,new_service,ssl_expired,vulnerable') -def alert_domain(domain, triggers): +@click.option('--triggers', help='List of triggers to enable', + default='malware,industrial_control_system,internet_scanner,iot,open_database,new_service,' + 'ssl_expired,vulnerable' + ) +@get_api_key +def alert_domain(domain, triggers, key): """Create a network alert based on a domain name""" - key = get_api_key() - api = shodan.Shodan(key) + try: # Grab a list of IPs for the domain domain = domain.lower() @@ -160,9 +163,9 @@ def alert_domain(domain, triggers): @alert.command(name='download') @click.argument('filename', metavar='', type=str) @click.option('--alert-id', help='Specific alert ID to download the data of', default=None) -def alert_download(filename, alert_id): +@get_api_key +def alert_download(filename, alert_id, key): """Download all information for monitored networks/ IPs.""" - key = get_api_key() api = shodan.Shodan(key) ips = set() @@ -181,7 +184,7 @@ def batch(iterable, size=1): alerts = [api.alerts(aid=alert_id.strip())] else: alerts = api.alerts() - + click.echo('Compiling list of networks/ IPs to download...') for alert in alerts: for net in alert['filters']['ip']: @@ -189,7 +192,7 @@ def batch(iterable, size=1): networks.add(net) else: ips.add(net) - + click.echo('Downloading...') with open_file(filename) as fout: # Check if the user is able to use batch IP lookups @@ -198,7 +201,7 @@ def batch(iterable, size=1): api_info = api.info() if api_info['plan'] in ['corp', 'stream-100']: batch_size = 100 - + # Convert it to a list so we can index into it ips = list(ips) @@ -209,14 +212,14 @@ def batch(iterable, size=1): results = api.host(ip) if not isinstance(results, list): results = [results] - + for host in results: for banner in host['data']: write_banner(fout, banner) except APIError: pass sleep(1) # Slow down a bit to make sure we don't hit the rate limit - + # Grab all the network ranges for net in networks: try: @@ -224,7 +227,7 @@ def batch(iterable, size=1): click.echo(net) for banner in api.search_cursor('net:{}'.format(net)): write_banner(fout, banner) - + # Slow down a bit to make sure we don't hit the rate limit if counter % 100 == 0: sleep(1) @@ -233,16 +236,16 @@ def batch(iterable, size=1): pass except shodan.APIError as e: raise click.ClickException(e.value) - + click.secho('Successfully downloaded results into: {}'.format(filename), fg='green') @alert.command(name='export') @click.option('--filename', help='Name of the output file', default='shodan-alerts.json.gz', type=str) -def alert_export(filename): +@get_api_key +def alert_export(filename, key): """Export the configuration of monitored networks/ IPs to be used by ``shodan alert import``.""" - # Setup the API wrapper - key = get_api_key() + # Set up the API wrapper api = shodan.Shodan(key) try: @@ -256,16 +259,16 @@ def alert_export(filename): json.dump(alerts, fout) except Exception as e: raise click.ClickException(e.value) - + click.secho('Successfully exported monitored networks', fg='green') @alert.command(name='import') @click.argument('filename', metavar='') -def alert_import(filename): +@get_api_key +def alert_import(filename, key): """Export the configuration of monitored networks/ IPs to be used by ``shodan alert import``.""" - # Setup the API wrapper - key = get_api_key() + # Set up the API wrapper api = shodan.Shodan(key) # A mapping of the old notifier IDs to the new ones @@ -293,7 +296,7 @@ def alert_import(filename): if info.get('ignore', []): for whitelist in info['ignore']: api.ignore_alert_trigger_notification(alert['id'], trigger, whitelist['ip'], whitelist['port']) - + # Enable the notifiers for prev_notifier in item.get('notifiers', []): # We don't need to do anything for the default notifier as that @@ -303,7 +306,7 @@ def alert_import(filename): # Get the new notifier based on the ID of the old one notifier = notifier_map.get(prev_notifier['id']) - + # Create the notifier if it doesn't yet exist if notifier is None: notifier = api.notifier.create(prev_notifier['provider'], prev_notifier['args'], description=prev_notifier['description']) @@ -314,15 +317,15 @@ def alert_import(filename): api.add_alert_notifier(alert['id'], notifier['id']) except Exception as e: raise click.ClickException(e.value) - + click.secho('Successfully imported monitored networks', fg='green') @alert.command(name='info') @click.argument('alert', metavar='') -def alert_info(alert): +@get_api_key +def alert_info(alert, key): """Show information about a specific alert""" - key = get_api_key() api = shodan.Shodan(key) try: @@ -356,9 +359,9 @@ def alert_info(alert): @alert.command(name='list') @click.option('--expired', help='Whether or not to show expired alerts.', default=True, type=bool) -def alert_list(expired): +@get_api_key +def alert_list(key, expired): """List all the active alerts""" - key = get_api_key() # Get the list api = shodan.Shodan(key) @@ -396,10 +399,10 @@ def alert_list(expired): @click.option('--limit', help='The number of results to return.', default=10, type=int) @click.option('--filename', '-O', help='Save the results in a CSV file of the provided name.', default=None) @click.argument('facets', metavar='', nargs=-1) -def alert_stats(limit, filename, facets): +@get_api_key +def alert_stats(limit, filename, facets, key): """Show summary information about your monitored networks""" # Setup Shodan - key = get_api_key() api = shodan.Shodan(key) # Make sure the user didn't supply an empty string @@ -483,9 +486,9 @@ def alert_stats(limit, filename, facets): @alert.command(name='remove') @click.argument('alert_id', metavar='') -def alert_remove(alert_id): +@get_api_key +def alert_remove(alert_id, key): """Remove the specified alert""" - key = get_api_key() # Get the list api = shodan.Shodan(key) @@ -496,10 +499,10 @@ def alert_remove(alert_id): click.echo("Alert deleted") +@get_api_key @alert.command(name='triggers') -def alert_list_triggers(): +def alert_list_triggers(key): """List the available notification triggers""" - key = get_api_key() # Get the list api = shodan.Shodan(key) @@ -530,9 +533,9 @@ def alert_list_triggers(): @alert.command(name='enable') @click.argument('alert_id', metavar='') @click.argument('trigger', metavar='') -def alert_enable_trigger(alert_id, trigger): +@get_api_key +def alert_enable_trigger(alert_id, trigger, key): """Enable a trigger for the alert""" - key = get_api_key() # Get the list api = shodan.Shodan(key) @@ -547,9 +550,9 @@ def alert_enable_trigger(alert_id, trigger): @alert.command(name='disable') @click.argument('alert_id', metavar='') @click.argument('trigger', metavar='') -def alert_disable_trigger(alert_id, trigger): +@get_api_key +def alert_disable_trigger(alert_id, trigger, key): """Disable a trigger for the alert""" - key = get_api_key() # Get the list api = shodan.Shodan(key) diff --git a/shodan/cli/data.py b/shodan/cli/data.py index 98d7852..2a3e45f 100644 --- a/shodan/cli/data.py +++ b/shodan/cli/data.py @@ -14,10 +14,10 @@ def data(): @data.command(name='list') @click.option('--dataset', help='See the available files in the given dataset', default=None, type=str) -def data_list(dataset): +@get_api_key +def data_list(dataset, key): """List available datasets or the files within those datasets.""" - # Setup the API connection - key = get_api_key() + # Set up the API connection api = shodan.Shodan(key) if dataset: @@ -43,13 +43,17 @@ def data_list(dataset): @data.command(name='download') -@click.option('--chunksize', help='The size of the chunks that are downloaded into memory before writing them to disk.', default=1024, type=int) +@click.option('--chunksize', help='The size of the chunks that are downloaded into memory before ' + 'writing them to disk.', + default=1024, type=int + ) @click.option('--filename', '-O', help='Save the file as the provided filename instead of the default.') @click.argument('dataset', metavar='') @click.argument('name', metavar='') -def data_download(chunksize, filename, dataset, name): - # Setup the API connection - key = get_api_key() +@get_api_key +def data_download(chunksize, filename, dataset, name, key): + """Download a dataset, or a file within a dataset.""" + # Set up the API connection api = shodan.Shodan(key) # Get the file object that the user requested which will contain the URL and total file size diff --git a/shodan/cli/helpers.py b/shodan/cli/helpers.py index bde2f07..afe3f0d 100644 --- a/shodan/cli/helpers.py +++ b/shodan/cli/helpers.py @@ -8,6 +8,7 @@ import os import sys from ipaddress import ip_network, ip_address +from functools import wraps from .settings import SHODAN_CONFIG_DIR @@ -17,22 +18,27 @@ basestring = (str, ) # Python 3 -def get_api_key(): - '''Returns the API key of the current logged-in user.''' - shodan_dir = os.path.expanduser(SHODAN_CONFIG_DIR) - keyfile = shodan_dir + '/api_key' +def get_api_key(func): - # If the file doesn't yet exist let the user know that they need to - # initialize the shodan cli - if not os.path.exists(keyfile): - raise click.ClickException('Please run "shodan init " before using this command') + @wraps(func) + def decorator(*args, **kwargs): + shodan_dir = os.path.expanduser(SHODAN_CONFIG_DIR) + keyfile = shodan_dir + '/api_key' - # Make sure it is a read-only file - if not oct(os.stat(keyfile).st_mode).endswith("600"): - os.chmod(keyfile, 0o600) + # If the file doesn't yet exist let the user know that they need to + # initialize the shodan cli + if not os.path.exists(keyfile): + raise click.ClickException('Please run "shodan init " before using this command') - with open(keyfile, 'r') as fin: - return fin.read().strip() + # Make sure it is a read-only file + if not oct(os.stat(keyfile).st_mode).endswith("600"): + os.chmod(keyfile, 0o600) + + with open(keyfile, 'r') as fin: + passwd = fin.read().strip() + + return func(key=passwd, **kwargs) + return decorator def escape_data(args): diff --git a/shodan/cli/organization.py b/shodan/cli/organization.py index 5fbb764..7044a21 100644 --- a/shodan/cli/organization.py +++ b/shodan/cli/organization.py @@ -10,12 +10,12 @@ def org(): pass -@org.command() +@org.command(name='add') @click.option('--silent', help="Don't send a notification to the user", default=False, is_flag=True) @click.argument('user', metavar='') -def add(silent, user): +@get_api_key +def add(silent, user, key): """Add a new member""" - key = get_api_key() api = shodan.Shodan(key) try: @@ -26,10 +26,10 @@ def add(silent, user): click.secho('Successfully added the new member', fg='green') -@org.command() -def info(): +@org.command(name='info') +@get_api_key +def info(key): """Show an overview of the organization""" - key = get_api_key() api = shodan.Shodan(key) try: organization = api.org.info() @@ -65,11 +65,11 @@ def info(): click.secho('No members yet', dim=True) -@org.command() +@org.command(name='remove') @click.argument('user', metavar='') -def remove(user): +@get_api_key +def remove(user, key): """Remove and downgrade a member""" - key = get_api_key() api = shodan.Shodan(key) try: From 66b3c076e8e34e15f3041a667ee19ac3fff1e2ce Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Sat, 20 Jul 2024 14:46:20 -0400 Subject: [PATCH 2/3] Adding API decorator to commands --- shodan/__main__.py | 58 ++++++++++++++++++++++------------------------ shodan/cli/scan.py | 34 +++++++++++++++------------ 2 files changed, 47 insertions(+), 45 deletions(-) diff --git a/shodan/__main__.py b/shodan/__main__.py index 4093b94..43584e0 100644 --- a/shodan/__main__.py +++ b/shodan/__main__.py @@ -84,7 +84,7 @@ def main(): pass -# Setup the large subcommands +# Set up the large subcommands main.add_command(alert) main.add_command(data) main.add_command(org) @@ -147,9 +147,9 @@ def convert(fields, input, format): @click.option('--save', '-S', help='Save the information in the a file named after the domain (append if file exists).', default=False, is_flag=True) @click.option('--history', '-H', help='Include historical DNS data in the results', default=False, is_flag=True) @click.option('--type', '-T', help='Only returns DNS records of the provided type', default=None) -def domain_info(domain, details, save, history, type): +@get_api_key +def domain_info(domain, details, save, history, type, key): """View all available information for a domain""" - key = get_api_key() api = shodan.Shodan(key) try: @@ -239,10 +239,9 @@ def init(key): @main.command() @click.argument('query', metavar='', nargs=-1) -def count(query): +@get_api_key +def count(query, key): """Returns the number of results for a search""" - key = get_api_key() - # Create the query string out of the provided tuple query = ' '.join(query).strip() @@ -265,10 +264,9 @@ def count(query): @click.option('--limit', help='The number of results you want to download. -1 to download all the data possible.', default=1000, type=int) @click.argument('filename', metavar='') @click.argument('query', metavar='', nargs=-1) -def download(fields, limit, filename, query): +@get_api_key +def download(fields, limit, filename, query, key): """Download search results and save them in a compressed JSON file.""" - key = get_api_key() - # Create the query string out of the provided tuple query = ' '.join(query).strip() @@ -336,9 +334,9 @@ def download(fields, limit, filename, query): @click.option('--filename', '-O', help='Save the host information in the given file (append if file exists).', default=None) @click.option('--save', '-S', help='Save the host information in the a file named after the IP (append if file exists).', default=False, is_flag=True) @click.argument('ip', metavar='') -def host(format, history, filename, save, ip): +@get_api_key +def host(format, history, filename, save, ip, key): """View all available information for an IP address""" - key = get_api_key() api = shodan.Shodan(key) try: @@ -367,9 +365,9 @@ def host(format, history, filename, save, ip): @main.command() -def info(): +@get_api_key +def info(key): """Shows general information about your account""" - key = get_api_key() api = shodan.Shodan(key) try: results = api.info() @@ -398,7 +396,7 @@ def parse(color, fields, filters, filename, separator, filenames): has_filters = len(filters) > 0 - # Setup the output file handle + # Set up the output file handle fout = None if filename: # If no filters were provided raise an error since it doesn't make much sense w/out them @@ -450,10 +448,9 @@ def parse(color, fields, filters, filename, separator, filenames): @main.command() @click.option('--ipv6', '-6', is_flag=True, default=False, help='Try to use IPv6 instead of IPv4') -def myip(ipv6): +@get_api_key +def myip(ipv6, key): """Print your external IP address""" - key = get_api_key() - api = shodan.Shodan(key) # Use the IPv6-enabled domain if requested @@ -472,10 +469,9 @@ def myip(ipv6): @click.option('--limit', help='The number of search results that should be returned. Maximum: 1000', default=100, type=int) @click.option('--separator', help='The separator between the properties of the search results.', default='\t') @click.argument('query', metavar='', nargs=-1) -def search(color, fields, limit, separator, query): +@get_api_key +def search(color, fields, limit, separator, query, key): """Search the Shodan database""" - key = get_api_key() - # Create the query string out of the provided tuple query = ' '.join(query).strip() @@ -543,10 +539,10 @@ def search(color, fields, limit, separator, query): @click.option('--facets', help='List of facets to get statistics for.', default='country,org') @click.option('--filename', '-O', help='Save the results in a CSV file of the provided name.', default=None) @click.argument('query', metavar='', nargs=-1) -def stats(limit, facets, filename, query): +@get_api_key +def stats(limit, facets, filename, query, key): """Provide summary information about a search query""" # Setup Shodan - key = get_api_key() api = shodan.Shodan(key) # Create the query string out of the provided tuple @@ -644,10 +640,12 @@ def stats(limit, facets, filename, query): @click.option('--timeout', help='Timeout. Should the shodan stream cease to send data, then timeout after seconds.', default=0, type=int) @click.option('--color/--no-color', default=True) @click.option('--quiet', help='Disable the printing of information to the screen.', is_flag=True) -def stream(streamer, fields, separator, datadir, asn, alert, countries, custom_filters, ports, tags, vulns, limit, compresslevel, timeout, color, quiet): +@get_api_key +def stream(streamer, fields, separator, datadir, asn, alert, countries, custom_filters, ports, tags, vulns, limit, + compresslevel, timeout, color, quiet, key + ): """Stream data in real-time.""" # Setup the Shodan API - key = get_api_key() api = shodan.Shodan(key) # Temporarily change the baseurl @@ -810,9 +808,9 @@ def _create_stream(name, args, timeout): @click.option('--filename', '-O', help='Save the full results in the given file (append if file exists).', default=None) @click.option('--save', '-S', help='Save the full results in the a file named after the query (append if file exists).', default=False, is_flag=True) @click.argument('query', metavar='', nargs=-1) -def trends(filename, save, facets, query): +@get_api_key +def trends(filename, save, facets, query, key): """Search Shodan historical database""" - key = get_api_key() api = shodan.Shodan(key) # Create the query string out of the provided tuple @@ -903,9 +901,9 @@ def trends(filename, save, facets, query): @main.command() @click.argument('ip', metavar='') -def honeyscore(ip): +@get_api_key +def honeyscore(ip, key): """Check whether the IP is a honeypot or not.""" - key = get_api_key() api = shodan.Shodan(key) try: @@ -924,9 +922,9 @@ def honeyscore(ip): @main.command() -def radar(): +@get_api_key +def radar(key): """Real-Time Map of some results as Shodan finds them.""" - key = get_api_key() api = shodan.Shodan(key) from shodan.cli.worldmap import launch_map diff --git a/shodan/cli/scan.py b/shodan/cli/scan.py index cfc7aab..b00b676 100644 --- a/shodan/cli/scan.py +++ b/shodan/cli/scan.py @@ -18,9 +18,9 @@ def scan(): @scan.command(name='list') -def scan_list(): +@get_api_key +def scan_list(key): """Show recently launched scans""" - key = get_api_key() # Get the list api = shodan.Shodan(key) @@ -50,9 +50,9 @@ def scan_list(): @click.option('--quiet', help='Disable the printing of information to the screen.', default=False, is_flag=True) @click.argument('port', type=int) @click.argument('protocol', type=str) -def scan_internet(quiet, port, protocol): +@get_api_key +def scan_internet(quiet, port, protocol, key): """Scan the Internet for a specific port and protocol using the Shodan infrastructure.""" - key = get_api_key() api = shodan.Shodan(key) try: @@ -117,9 +117,9 @@ def scan_internet(quiet, port, protocol): @scan.command(name='protocols') -def scan_protocols(): +@get_api_key +def scan_protocols(key): """List the protocols that you can scan with using Shodan.""" - key = get_api_key() api = shodan.Shodan(key) try: protocols = api.protocols() @@ -131,14 +131,16 @@ def scan_protocols(): @scan.command(name='submit') -@click.option('--wait', help='How long to wait for results to come back. If this is set to "0" or below return immediately.', default=20, type=int) +@click.option('--wait', help='How long to wait for results to come back. If this is set to "0" ' + 'or below return immediately.', default=20, type=int + ) @click.option('--filename', help='Save the results in the given file.', default='', type=str) @click.option('--force', default=False, is_flag=True) @click.option('--verbose', default=False, is_flag=True) @click.argument('netblocks', metavar='', nargs=-1) -def scan_submit(wait, filename, force, verbose, netblocks): +@get_api_key +def scan_submit(wait, filename, force, verbose, netblocks, key): """Scan an IP/ netblock using Shodan.""" - key = get_api_key() api = shodan.Shodan(key) alert = None @@ -146,7 +148,6 @@ def scan_submit(wait, filename, force, verbose, netblocks): try: # Submit the scan scan = api.scan(netblocks, force=force) - now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M') click.echo('') @@ -158,9 +159,11 @@ def scan_submit(wait, filename, force, verbose, netblocks): # Return immediately if wait <= 0: click.echo('Scan ID: {}'.format(scan['id'])) - click.echo('Exiting now, not waiting for results. Use the API or website to retrieve the results of the scan.') + click.echo('Exiting now, not waiting for results. Use the API or website to retrieve the ' + 'results of the scan.' + ) else: - # Setup an alert to wait for responses + # Set up an alert to wait for responses alert = api.create_alert('Scan: {}'.format(', '.join(netblocks)), netblocks) # Create the output file if necessary @@ -195,7 +198,8 @@ def scan_submit(wait, filename, force, verbose, netblocks): hosts[helpers.get_ip(banner)][banner['port']] = banner cache[cache_key] = True - # If we've grabbed data for more than 60 seconds it might just be a busy network and we should move on + # If we've grabbed data for more than 60 seconds it might just be a busy network + # and we should move on if time.time() - scan_start >= 60: scan = api.scan_status(scan['id']) @@ -332,9 +336,9 @@ def print_banner(banner): @scan.command(name='status') @click.argument('scan_id', type=str) -def scan_status(scan_id): +@get_api_key +def scan_status(scan_id, key): """Check the status of an on-demand scan.""" - key = get_api_key() api = shodan.Shodan(key) try: scan = api.scan_status(scan_id) From ff748662d15815532e58061177ea73d15e7401d9 Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Sat, 20 Jul 2024 15:22:16 -0400 Subject: [PATCH 3/3] Further simplifying decorator --- shodan/__main__.py | 75 +++++++++++-------------------- shodan/cli/alert.py | 90 ++++++++++++-------------------------- shodan/cli/data.py | 16 +++---- shodan/cli/helpers.py | 8 ++-- shodan/cli/organization.py | 19 +++----- shodan/cli/scan.py | 30 +++++-------- 6 files changed, 81 insertions(+), 157 deletions(-) diff --git a/shodan/__main__.py b/shodan/__main__.py index 43584e0..4c1e357 100644 --- a/shodan/__main__.py +++ b/shodan/__main__.py @@ -45,7 +45,7 @@ from shodan.cli.settings import SHODAN_CONFIG_DIR, COLORIZE_FIELDS # Helper methods -from shodan.cli.helpers import async_spinner, get_api_key, escape_data, timestr, open_streaming_file, get_banner_field, match_filters +from shodan.cli.helpers import async_spinner, get_shodan_inst, escape_data, timestr, open_streaming_file, get_banner_field, match_filters from shodan.cli.host import HOST_PRINT # Allow 3rd-parties to develop custom commands @@ -147,11 +147,9 @@ def convert(fields, input, format): @click.option('--save', '-S', help='Save the information in the a file named after the domain (append if file exists).', default=False, is_flag=True) @click.option('--history', '-H', help='Include historical DNS data in the results', default=False, is_flag=True) @click.option('--type', '-T', help='Only returns DNS records of the provided type', default=None) -@get_api_key -def domain_info(domain, details, save, history, type, key): +@get_shodan_inst +def domain_info(domain, details, save, history, type, api): """View all available information for a domain""" - api = shodan.Shodan(key) - try: info = api.dns.domain_info(domain, history=history, type=type) except shodan.APIError as e: @@ -239,8 +237,8 @@ def init(key): @main.command() @click.argument('query', metavar='', nargs=-1) -@get_api_key -def count(query, key): +@get_shodan_inst +def count(query, api): """Returns the number of results for a search""" # Create the query string out of the provided tuple query = ' '.join(query).strip() @@ -250,7 +248,6 @@ def count(query, key): raise click.ClickException('Empty search query') # Perform the search - api = shodan.Shodan(key) try: results = api.count(query) except shodan.APIError as e: @@ -264,8 +261,8 @@ def count(query, key): @click.option('--limit', help='The number of results you want to download. -1 to download all the data possible.', default=1000, type=int) @click.argument('filename', metavar='') @click.argument('query', metavar='', nargs=-1) -@get_api_key -def download(fields, limit, filename, query, key): +@get_shodan_inst +def download(fields, limit, filename, query, api): """Download search results and save them in a compressed JSON file.""" # Create the query string out of the provided tuple query = ' '.join(query).strip() @@ -287,8 +284,6 @@ def download(fields, limit, filename, query, key): fields = [item.strip() for item in fields.split(',')] # Perform the search - api = shodan.Shodan(key) - try: total = api.count(query)['total'] info = api.info() @@ -334,11 +329,9 @@ def download(fields, limit, filename, query, key): @click.option('--filename', '-O', help='Save the host information in the given file (append if file exists).', default=None) @click.option('--save', '-S', help='Save the host information in the a file named after the IP (append if file exists).', default=False, is_flag=True) @click.argument('ip', metavar='') -@get_api_key -def host(format, history, filename, save, ip, key): +@get_shodan_inst +def host(format, history, filename, save, ip, api): """View all available information for an IP address""" - api = shodan.Shodan(key) - try: host = api.host(ip, history=history) @@ -365,10 +358,9 @@ def host(format, history, filename, save, ip, key): @main.command() -@get_api_key -def info(key): +@get_shodan_inst +def info(api): """Shows general information about your account""" - api = shodan.Shodan(key) try: results = api.info() except shodan.APIError as e: @@ -448,11 +440,9 @@ def parse(color, fields, filters, filename, separator, filenames): @main.command() @click.option('--ipv6', '-6', is_flag=True, default=False, help='Try to use IPv6 instead of IPv4') -@get_api_key -def myip(ipv6, key): +@get_shodan_inst +def myip(ipv6, api): """Print your external IP address""" - api = shodan.Shodan(key) - # Use the IPv6-enabled domain if requested if ipv6: api.base_url = 'https://apiv6.shodan.io' @@ -469,8 +459,8 @@ def myip(ipv6, key): @click.option('--limit', help='The number of search results that should be returned. Maximum: 1000', default=100, type=int) @click.option('--separator', help='The separator between the properties of the search results.', default='\t') @click.argument('query', metavar='', nargs=-1) -@get_api_key -def search(color, fields, limit, separator, query, key): +@get_shodan_inst +def search(color, fields, limit, separator, query, api): """Search the Shodan database""" # Create the query string out of the provided tuple query = ' '.join(query).strip() @@ -490,7 +480,6 @@ def search(color, fields, limit, separator, query, key): raise click.ClickException('Please define at least one property to show') # Perform the search - api = shodan.Shodan(key) try: results = api.search(query, limit=limit, minify=False, fields=fields) except shodan.APIError as e: @@ -539,12 +528,9 @@ def search(color, fields, limit, separator, query, key): @click.option('--facets', help='List of facets to get statistics for.', default='country,org') @click.option('--filename', '-O', help='Save the results in a CSV file of the provided name.', default=None) @click.argument('query', metavar='', nargs=-1) -@get_api_key -def stats(limit, facets, filename, query, key): +@get_shodan_inst +def stats(limit, facets, filename, query, api): """Provide summary information about a search query""" - # Setup Shodan - api = shodan.Shodan(key) - # Create the query string out of the provided tuple query = ' '.join(query).strip() @@ -640,14 +626,11 @@ def stats(limit, facets, filename, query, key): @click.option('--timeout', help='Timeout. Should the shodan stream cease to send data, then timeout after seconds.', default=0, type=int) @click.option('--color/--no-color', default=True) @click.option('--quiet', help='Disable the printing of information to the screen.', is_flag=True) -@get_api_key +@get_shodan_inst def stream(streamer, fields, separator, datadir, asn, alert, countries, custom_filters, ports, tags, vulns, limit, - compresslevel, timeout, color, quiet, key + compresslevel, timeout, color, quiet, api ): """Stream data in real-time.""" - # Setup the Shodan API - api = shodan.Shodan(key) - # Temporarily change the baseurl api.stream.base_url = streamer @@ -718,7 +701,7 @@ def stream(streamer, fields, separator, datadir, asn, alert, countries, custom_f else: stream_type = 'all' - # Decide which stream to subscribe to based on whether or not ports were selected + # Decide which stream to subscribe to based on whether ports were selected def _create_stream(name, args, timeout): return { 'all': api.stream.banners(timeout=timeout), @@ -808,11 +791,9 @@ def _create_stream(name, args, timeout): @click.option('--filename', '-O', help='Save the full results in the given file (append if file exists).', default=None) @click.option('--save', '-S', help='Save the full results in the a file named after the query (append if file exists).', default=False, is_flag=True) @click.argument('query', metavar='', nargs=-1) -@get_api_key -def trends(filename, save, facets, query, key): +@get_shodan_inst +def trends(filename, save, facets, query, api): """Search Shodan historical database""" - api = shodan.Shodan(key) - # Create the query string out of the provided tuple query = ' '.join(query).strip() facets = facets.strip() @@ -901,11 +882,9 @@ def trends(filename, save, facets, query, key): @main.command() @click.argument('ip', metavar='') -@get_api_key -def honeyscore(ip, key): +@get_shodan_inst +def honeyscore(ip, api): """Check whether the IP is a honeypot or not.""" - api = shodan.Shodan(key) - try: score = api.labs.honeyscore(ip) @@ -922,11 +901,9 @@ def honeyscore(ip, key): @main.command() -@get_api_key -def radar(key): +@get_shodan_inst +def radar(api): """Real-Time Map of some results as Shodan finds them.""" - api = shodan.Shodan(key) - from shodan.cli.worldmap import launch_map try: diff --git a/shodan/cli/alert.py b/shodan/cli/alert.py index 3559042..600560c 100644 --- a/shodan/cli/alert.py +++ b/shodan/cli/alert.py @@ -9,7 +9,7 @@ from collections import defaultdict from operator import itemgetter from shodan import APIError -from shodan.cli.helpers import get_api_key +from shodan.cli.helpers import get_shodan_inst from shodan.helpers import open_file, write_banner from time import sleep @@ -82,12 +82,9 @@ def alert(): @alert.command(name='clear') -@get_api_key -def alert_clear(key): +@get_shodan_inst +def alert_clear(api): """Remove all alerts""" - - # Get the list - api = shodan.Shodan(key) try: alerts = api.alerts() for alert in alerts: @@ -101,12 +98,9 @@ def alert_clear(key): @alert.command(name='create') @click.argument('name', metavar='') @click.argument('netblocks', metavar='', nargs=-1) -@get_api_key -def alert_create(name, netblocks, key): +@get_shodan_inst +def alert_create(name, netblocks, api): """Create a network alert to monitor an external network""" - - # Get the list - api = shodan.Shodan(key) try: alert = api.create_alert(name, netblocks) except shodan.APIError as e: @@ -122,11 +116,9 @@ def alert_create(name, netblocks, key): default='malware,industrial_control_system,internet_scanner,iot,open_database,new_service,' 'ssl_expired,vulnerable' ) -@get_api_key -def alert_domain(domain, triggers, key): +@get_shodan_inst +def alert_domain(domain, triggers, api): """Create a network alert based on a domain name""" - api = shodan.Shodan(key) - try: # Grab a list of IPs for the domain domain = domain.lower() @@ -163,11 +155,9 @@ def alert_domain(domain, triggers, key): @alert.command(name='download') @click.argument('filename', metavar='', type=str) @click.option('--alert-id', help='Specific alert ID to download the data of', default=None) -@get_api_key -def alert_download(filename, alert_id, key): +@get_shodan_inst +def alert_download(filename, alert_id, api): """Download all information for monitored networks/ IPs.""" - - api = shodan.Shodan(key) ips = set() networks = set() @@ -242,12 +232,9 @@ def batch(iterable, size=1): @alert.command(name='export') @click.option('--filename', help='Name of the output file', default='shodan-alerts.json.gz', type=str) -@get_api_key -def alert_export(filename, key): +@get_shodan_inst +def alert_export(filename, api): """Export the configuration of monitored networks/ IPs to be used by ``shodan alert import``.""" - # Set up the API wrapper - api = shodan.Shodan(key) - try: # Get the list of alerts for the user click.echo('Looking up alert information...') @@ -265,12 +252,9 @@ def alert_export(filename, key): @alert.command(name='import') @click.argument('filename', metavar='') -@get_api_key -def alert_import(filename, key): +@get_shodan_inst +def alert_import(filename, api): """Export the configuration of monitored networks/ IPs to be used by ``shodan alert import``.""" - # Set up the API wrapper - api = shodan.Shodan(key) - # A mapping of the old notifier IDs to the new ones notifier_map = {} @@ -323,11 +307,9 @@ def alert_import(filename, key): @alert.command(name='info') @click.argument('alert', metavar='') -@get_api_key -def alert_info(alert, key): +@get_shodan_inst +def alert_info(alert, api): """Show information about a specific alert""" - api = shodan.Shodan(key) - try: info = api.alerts(aid=alert) except shodan.APIError as e: @@ -359,12 +341,9 @@ def alert_info(alert, key): @alert.command(name='list') @click.option('--expired', help='Whether or not to show expired alerts.', default=True, type=bool) -@get_api_key -def alert_list(key, expired): +@get_shodan_inst +def alert_list(expired, api): """List all the active alerts""" - - # Get the list - api = shodan.Shodan(key) try: results = api.alerts(include_expired=expired) except shodan.APIError as e: @@ -399,12 +378,9 @@ def alert_list(key, expired): @click.option('--limit', help='The number of results to return.', default=10, type=int) @click.option('--filename', '-O', help='Save the results in a CSV file of the provided name.', default=None) @click.argument('facets', metavar='', nargs=-1) -@get_api_key -def alert_stats(limit, filename, facets, key): +@get_shodan_inst +def alert_stats(limit, filename, facets, api): """Show summary information about your monitored networks""" - # Setup Shodan - api = shodan.Shodan(key) - # Make sure the user didn't supply an empty string if not facets: raise click.ClickException('No facets provided') @@ -486,12 +462,9 @@ def alert_stats(limit, filename, facets, key): @alert.command(name='remove') @click.argument('alert_id', metavar='') -@get_api_key -def alert_remove(alert_id, key): +@get_shodan_inst +def alert_remove(alert_id, api): """Remove the specified alert""" - - # Get the list - api = shodan.Shodan(key) try: api.delete_alert(alert_id) except shodan.APIError as e: @@ -499,13 +472,10 @@ def alert_remove(alert_id, key): click.echo("Alert deleted") -@get_api_key @alert.command(name='triggers') -def alert_list_triggers(key): +@get_shodan_inst +def alert_list_triggers(api): """List the available notification triggers""" - - # Get the list - api = shodan.Shodan(key) try: results = api.alert_triggers() except shodan.APIError as e: @@ -533,12 +503,9 @@ def alert_list_triggers(key): @alert.command(name='enable') @click.argument('alert_id', metavar='') @click.argument('trigger', metavar='') -@get_api_key -def alert_enable_trigger(alert_id, trigger, key): +@get_shodan_inst +def alert_enable_trigger(alert_id, trigger, api): """Enable a trigger for the alert""" - - # Get the list - api = shodan.Shodan(key) try: api.enable_alert_trigger(alert_id, trigger) except shodan.APIError as e: @@ -550,12 +517,9 @@ def alert_enable_trigger(alert_id, trigger, key): @alert.command(name='disable') @click.argument('alert_id', metavar='') @click.argument('trigger', metavar='') -@get_api_key -def alert_disable_trigger(alert_id, trigger, key): +@get_shodan_inst +def alert_disable_trigger(alert_id, trigger, api): """Disable a trigger for the alert""" - - # Get the list - api = shodan.Shodan(key) try: api.disable_alert_trigger(alert_id, trigger) except shodan.APIError as e: diff --git a/shodan/cli/data.py b/shodan/cli/data.py index 2a3e45f..6f2ca3c 100644 --- a/shodan/cli/data.py +++ b/shodan/cli/data.py @@ -3,7 +3,7 @@ import shodan import shodan.helpers as helpers -from shodan.cli.helpers import get_api_key +from shodan.cli.helpers import get_shodan_inst @click.group() @@ -14,12 +14,9 @@ def data(): @data.command(name='list') @click.option('--dataset', help='See the available files in the given dataset', default=None, type=str) -@get_api_key -def data_list(dataset, key): +@get_shodan_inst +def data_list(dataset, api): """List available datasets or the files within those datasets.""" - # Set up the API connection - api = shodan.Shodan(key) - if dataset: # Show the files within this dataset files = api.data.list_files(dataset) @@ -50,12 +47,9 @@ def data_list(dataset, key): @click.option('--filename', '-O', help='Save the file as the provided filename instead of the default.') @click.argument('dataset', metavar='') @click.argument('name', metavar='') -@get_api_key -def data_download(chunksize, filename, dataset, name, key): +@get_shodan_inst +def data_download(chunksize, filename, dataset, name, api): """Download a dataset, or a file within a dataset.""" - # Set up the API connection - api = shodan.Shodan(key) - # Get the file object that the user requested which will contain the URL and total file size file = None try: diff --git a/shodan/cli/helpers.py b/shodan/cli/helpers.py index afe3f0d..5710281 100644 --- a/shodan/cli/helpers.py +++ b/shodan/cli/helpers.py @@ -7,6 +7,7 @@ import itertools import os import sys +from shodan import Shodan from ipaddress import ip_network, ip_address from functools import wraps @@ -18,7 +19,7 @@ basestring = (str, ) # Python 3 -def get_api_key(func): +def get_shodan_inst(func): @wraps(func) def decorator(*args, **kwargs): @@ -35,9 +36,10 @@ def decorator(*args, **kwargs): os.chmod(keyfile, 0o600) with open(keyfile, 'r') as fin: - passwd = fin.read().strip() + key = fin.read().strip() - return func(key=passwd, **kwargs) + shodan_inst = Shodan(key=key) + return func(api=shodan_inst, **kwargs) return decorator diff --git a/shodan/cli/organization.py b/shodan/cli/organization.py index 7044a21..f8d5a21 100644 --- a/shodan/cli/organization.py +++ b/shodan/cli/organization.py @@ -1,7 +1,7 @@ import click import shodan -from shodan.cli.helpers import get_api_key, humanize_api_plan +from shodan.cli.helpers import get_shodan_inst, humanize_api_plan @click.group() @@ -13,11 +13,9 @@ def org(): @org.command(name='add') @click.option('--silent', help="Don't send a notification to the user", default=False, is_flag=True) @click.argument('user', metavar='') -@get_api_key -def add(silent, user, key): +@get_shodan_inst +def add(silent, user, api): """Add a new member""" - api = shodan.Shodan(key) - try: api.org.add_member(user, notify=not silent) except shodan.APIError as e: @@ -27,10 +25,9 @@ def add(silent, user, key): @org.command(name='info') -@get_api_key -def info(key): +@get_shodan_inst +def info(api): """Show an overview of the organization""" - api = shodan.Shodan(key) try: organization = api.org.info() except shodan.APIError as e: @@ -67,11 +64,9 @@ def info(key): @org.command(name='remove') @click.argument('user', metavar='') -@get_api_key -def remove(user, key): +@get_shodan_inst +def remove(user, api): """Remove and downgrade a member""" - api = shodan.Shodan(key) - try: api.org.remove_member(user) except shodan.APIError as e: diff --git a/shodan/cli/scan.py b/shodan/cli/scan.py index b00b676..72b59d9 100644 --- a/shodan/cli/scan.py +++ b/shodan/cli/scan.py @@ -7,7 +7,7 @@ import threading import time -from shodan.cli.helpers import get_api_key, async_spinner +from shodan.cli.helpers import get_shodan_inst, async_spinner from shodan.cli.settings import COLORIZE_FIELDS @@ -18,12 +18,9 @@ def scan(): @scan.command(name='list') -@get_api_key -def scan_list(key): +@get_shodan_inst +def scan_list(api): """Show recently launched scans""" - - # Get the list - api = shodan.Shodan(key) try: scans = api.scans() except shodan.APIError as e: @@ -50,11 +47,9 @@ def scan_list(key): @click.option('--quiet', help='Disable the printing of information to the screen.', default=False, is_flag=True) @click.argument('port', type=int) @click.argument('protocol', type=str) -@get_api_key -def scan_internet(quiet, port, protocol, key): +@get_shodan_inst +def scan_internet(quiet, port, protocol, api): """Scan the Internet for a specific port and protocol using the Shodan infrastructure.""" - api = shodan.Shodan(key) - try: # Submit the request to Shodan click.echo('Submitting Internet scan to Shodan...', nl=False) @@ -117,10 +112,9 @@ def scan_internet(quiet, port, protocol, key): @scan.command(name='protocols') -@get_api_key -def scan_protocols(key): +@get_shodan_inst +def scan_protocols(api): """List the protocols that you can scan with using Shodan.""" - api = shodan.Shodan(key) try: protocols = api.protocols() @@ -138,10 +132,9 @@ def scan_protocols(key): @click.option('--force', default=False, is_flag=True) @click.option('--verbose', default=False, is_flag=True) @click.argument('netblocks', metavar='', nargs=-1) -@get_api_key -def scan_submit(wait, filename, force, verbose, netblocks, key): +@get_shodan_inst +def scan_submit(wait, filename, force, verbose, netblocks, api): """Scan an IP/ netblock using Shodan.""" - api = shodan.Shodan(key) alert = None # Submit the IPs for scanning @@ -336,10 +329,9 @@ def print_banner(banner): @scan.command(name='status') @click.argument('scan_id', type=str) -@get_api_key -def scan_status(scan_id, key): +@get_shodan_inst +def scan_status(scan_id, api): """Check the status of an on-demand scan.""" - api = shodan.Shodan(key) try: scan = api.scan_status(scan_id) click.echo(scan['status'])