From 55d8d59bc3c82e3f35d1afbe85fe86b5c0cc75ca Mon Sep 17 00:00:00 2001 From: David xu Date: Sun, 17 Jul 2022 22:12:35 +0800 Subject: [PATCH 01/18] add missing return --- shodan/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shodan/helpers.py b/shodan/helpers.py index 378b1bb..8900468 100644 --- a/shodan/helpers.py +++ b/shodan/helpers.py @@ -164,7 +164,7 @@ def humanize_bytes(byte_count, precision=1): if byte_count == 1: return '1 byte' if byte_count < 1024: - '{0:0.{1}f} {2}'.format(byte_count, 0, 'bytes') + return '{0:0.{1}f} {2}'.format(byte_count, 0, 'bytes') suffixes = ['KB', 'MB', 'GB', 'TB', 'PB'] multiple = 1024.0 # .0 to force float on python 2 From 4419d7167fad366b8942643037c427a62b0cb5bb Mon Sep 17 00:00:00 2001 From: John Matherly Date: Mon, 15 May 2023 09:50:54 -0700 Subject: [PATCH 02/18] Add support for the new 'fields' parameter of the /shodan/host/search method so we only grab the specific properties/ fields from the banner. --- setup.py | 2 +- shodan/__main__.py | 11 ++++++++--- shodan/client.py | 11 ++++++++--- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/setup.py b/setup.py index 266ce81..e769440 100755 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name='shodan', - version='1.28.0', + version='1.29.0', description='Python library and command-line utility for Shodan (https://developer.shodan.io)', long_description=README, long_description_content_type='text/x-rst', diff --git a/shodan/__main__.py b/shodan/__main__.py index 07af59c..f11a72a 100644 --- a/shodan/__main__.py +++ b/shodan/__main__.py @@ -255,10 +255,11 @@ def count(query): @main.command() +@click.option('--fields', help='Specify the list of properties to download instead of grabbing the full banner', default=None, type=str) @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(limit, filename, query): +def download(fields, limit, filename, query): """Download search results and save them in a compressed JSON file.""" key = get_api_key() @@ -276,6 +277,10 @@ def download(limit, filename, query): # Add the appropriate extension if it's not there atm if not filename.endswith('.json.gz'): filename += '.json.gz' + + # Strip out any whitespace in the fields and turn them into an array + if fields is not None: + fields = [item.strip() for item in fields.split(',')] # Perform the search api = shodan.Shodan(key) @@ -302,7 +307,7 @@ def download(limit, filename, query): with helpers.open_file(filename, 'w') as fout: count = 0 try: - cursor = api.search_cursor(query, minify=False) + cursor = api.search_cursor(query, minify=False, fields=fields) with click.progressbar(cursor, length=limit) as bar: for banner in bar: helpers.write_banner(fout, banner) @@ -485,7 +490,7 @@ def search(color, fields, limit, separator, query): # Perform the search api = shodan.Shodan(key) try: - results = api.search(query, limit=limit) + results = api.search(query, limit=limit, minify=False, fields=fields) except shodan.APIError as e: raise click.ClickException(e.value) diff --git a/shodan/client.py b/shodan/client.py index 70ca8f3..b9cb487 100644 --- a/shodan/client.py +++ b/shodan/client.py @@ -480,7 +480,7 @@ def scan_status(self, scan_id): """ return self._request('/shodan/scan/{}'.format(scan_id), {}) - def search(self, query, page=1, limit=None, offset=None, facets=None, minify=True): + def search(self, query, page=1, limit=None, offset=None, facets=None, minify=True, fields=None): """Search the SHODAN database. :param query: Search query; identical syntax to the website @@ -495,6 +495,8 @@ def search(self, query, page=1, limit=None, offset=None, facets=None, minify=Tru :type facets: str :param minify: (optional) Whether to minify the banner and only return the important data :type minify: bool + :param fields: (optional) List of properties that should get returned. This option is mutually exclusive with the "minify" parameter + :type fields: str :returns: A dictionary with 2 main items: matches and total. If facets have been provided then another property called "facets" will be available at the top-level of the dictionary. Visit the website for more detailed information. """ @@ -511,10 +513,13 @@ def search(self, query, page=1, limit=None, offset=None, facets=None, minify=Tru if facets: args['facets'] = create_facet_string(facets) + + if fields and isinstance(fields, list): + args['fields'] = ','.join(fields) return self._request('/shodan/host/search', args) - def search_cursor(self, query, minify=True, retries=5): + def search_cursor(self, query, minify=True, retries=5, fields=None): """Search the SHODAN database. This method returns an iterator that can directly be in a loop. Use it when you want to loop over @@ -542,7 +547,7 @@ def search_cursor(self, query, minify=True, retries=5): while results['matches']: try: - results = self.search(query, minify=minify, page=page) + results = self.search(query, minify=minify, page=page, fields=fields) for banner in results['matches']: try: yield banner From 9ccc16ada4761d19ee2079a9334cac3ddbc62415 Mon Sep 17 00:00:00 2001 From: John Matherly Date: Wed, 17 May 2023 11:00:46 -0700 Subject: [PATCH 03/18] The screenshot data has been moved to the top-level "screenshot" property. Update the helpers.get_screenshot() method to look in that location before falling back to the old opts.screenshot property. --- setup.py | 2 +- shodan/helpers.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index e769440..3adcd8b 100755 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name='shodan', - version='1.29.0', + version='1.29.1', description='Python library and command-line utility for Shodan (https://developer.shodan.io)', long_description=README, long_description_content_type='text/x-rst', diff --git a/shodan/helpers.py b/shodan/helpers.py index 378b1bb..432cbeb 100644 --- a/shodan/helpers.py +++ b/shodan/helpers.py @@ -122,8 +122,11 @@ def iterate_files(files, fast=False): def get_screenshot(banner): - if 'opts' in banner and 'screenshot' in banner['opts']: + if 'screenshot' in banner and banner['screenshot']: + return banner['screenshot'] + elif 'opts' in banner and 'screenshot' in banner['opts']: return banner['opts']['screenshot'] + return None From c97ebae69d3343da455bc5aa277c54f7cf3e115a Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Sun, 18 Jun 2023 21:08:39 -0400 Subject: [PATCH 04/18] Added a check for file size of input, dynamically set workbook.use_zip64() --- shodan/__main__.py | 7 ++++++- shodan/cli/converter/csvc.py | 2 +- shodan/cli/converter/excel.py | 6 +++++- shodan/cli/converter/geojson.py | 2 +- shodan/cli/converter/images.py | 2 +- shodan/cli/converter/kml.py | 2 +- 6 files changed, 15 insertions(+), 6 deletions(-) diff --git a/shodan/__main__.py b/shodan/__main__.py index f11a72a..aadb2d5 100644 --- a/shodan/__main__.py +++ b/shodan/__main__.py @@ -100,6 +100,7 @@ def convert(fields, input, format): Example: shodan convert data.json.gz kml """ + file_size = 0 # Check that the converter allows a custom list of fields converter_class = CONVERTERS.get(format) if fields: @@ -107,6 +108,10 @@ def convert(fields, input, format): raise click.ClickException('File format doesnt support custom list of fields') converter_class.fields = [item.strip() for item in fields.split(',')] # Use the custom fields the user specified + # Check file size of input + if os.path.exists(input): + file_size = os.path.getsize(input) + # Get the basename for the input file basename = input.replace('.json.gz', '').replace('.json', '') @@ -124,7 +129,7 @@ def convert(fields, input, format): # Initialize the file converter converter = converter_class(fout) - converter.process([input]) + converter.process([input], file_size) finished_event.set() progress_bar_thread.join() diff --git a/shodan/cli/converter/csvc.py b/shodan/cli/converter/csvc.py index 20c3d03..2e4e2f2 100644 --- a/shodan/cli/converter/csvc.py +++ b/shodan/cli/converter/csvc.py @@ -50,7 +50,7 @@ class CsvConverter(Converter): 'title', ] - def process(self, files): + def process(self, files, file_size): writer = csv_writer(self.fout, dialect=excel, lineterminator='\n') # Write the header diff --git a/shodan/cli/converter/excel.py b/shodan/cli/converter/excel.py index 24177eb..4db78a4 100644 --- a/shodan/cli/converter/excel.py +++ b/shodan/cli/converter/excel.py @@ -41,7 +41,7 @@ class ExcelConverter(Converter): 'http.title': 'Website Title', } - def process(self, files): + def process(self, files, file_size): # Get the filename from the already-open file handle filename = self.fout.name @@ -51,6 +51,10 @@ def process(self, files): # Create the new workbook workbook = Workbook(filename) + # Check if Excel file is larger than 5GB + if file_size > 5e9: + workbook.use_zip64() + # Define some common styles/ formats bold = workbook.add_format({ 'bold': 1, diff --git a/shodan/cli/converter/geojson.py b/shodan/cli/converter/geojson.py index 3f6c975..83fb935 100644 --- a/shodan/cli/converter/geojson.py +++ b/shodan/cli/converter/geojson.py @@ -14,7 +14,7 @@ def header(self): def footer(self): self.fout.write("""{ }]}""") - def process(self, files): + def process(self, files, file_size): # Write the header self.header() diff --git a/shodan/cli/converter/images.py b/shodan/cli/converter/images.py index 24c68c3..fba9d11 100644 --- a/shodan/cli/converter/images.py +++ b/shodan/cli/converter/images.py @@ -15,7 +15,7 @@ class ImagesConverter(Converter): # the user know where the images have been stored. dirname = None - def process(self, files): + def process(self, files, file_size): # Get the filename from the already-open file handle and use it as # the directory name to store the images. self.dirname = self.fout.name[:-7] + '-images' diff --git a/shodan/cli/converter/kml.py b/shodan/cli/converter/kml.py index 2cf3d44..9259ddf 100644 --- a/shodan/cli/converter/kml.py +++ b/shodan/cli/converter/kml.py @@ -13,7 +13,7 @@ def header(self): def footer(self): self.fout.write("""""") - def process(self, files): + def process(self, files, file_size): # Write the header self.header() From c3d6d63d4743fd71642b084ff8b2da56f37717eb Mon Sep 17 00:00:00 2001 From: Thong Nguyen Date: Fri, 23 Jun 2023 16:09:18 +0700 Subject: [PATCH 05/18] Add Shodan Trends API/ CLI --- shodan/__main__.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++ shodan/client.py | 57 ++++++++++++++++++++++++++----- 2 files changed, 131 insertions(+), 9 deletions(-) diff --git a/shodan/__main__.py b/shodan/__main__.py index f11a72a..3a2fdf8 100644 --- a/shodan/__main__.py +++ b/shodan/__main__.py @@ -22,6 +22,7 @@ search stats stream + trends """ @@ -35,6 +36,7 @@ import threading import requests import time +import json # The file converters that are used to go from .json.gz to various other formats from shodan.cli.converter import CsvConverter, KmlConverter, GeoJsonConverter, ExcelConverter, ImagesConverter @@ -799,6 +801,87 @@ def _create_stream(name, args, timeout): stream = _create_stream(stream_type, stream_args, timeout=timeout) +@main.command() +@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.option('--separator', help='The separator between the properties of the search results.', default='\t') +@click.option('--facets', help='List of facets to get summary information on.', required=True, type=str) +@click.argument('query', metavar='', nargs=-1) +def trends(filename, save, separator, facets, query): + """Search Shodan historical database""" + key = get_api_key() + api = shodan.Shodan(key) + + # Create the query string out of the provided tuple + query = ' '.join(query).strip() + facets = facets.strip() + + # Make sure the user didn't supply an empty query or facets + if query == '': + raise click.ClickException('Empty search query') + + if facets == '': + raise click.ClickException('Empty search facets') + + # Convert comma-separated facets string to list + parsed_facets = [] + for facet in facets.split(','): + parts = facet.strip().split(":") + if len(parts) > 1: + parsed_facets.append((parts[0], parts[1])) + else: + parsed_facets.append((parts[0])) + + # Perform the search + try: + results = api.trends.search(query, facets=parsed_facets) + except shodan.APIError as e: + raise click.ClickException(e.value) + + # Error out if no results were found + if results['total'] == 0: + raise click.ClickException('No search results found') + + result_facets = list(results['facets'].keys()) + + # Save the results first to file if user request + if filename or save: + if not filename: + filename = '{}-trends.json.gz'.format(query.replace(' ', '-')) + elif not filename.endswith('.json.gz'): + filename += '.json.gz' + + # Create/ append to the file + with helpers.open_file(filename) as fout: + for index, match in enumerate(results['matches']): + # Append facet info to make up a line + match["facets"] = {} + for facet in result_facets: + match["facets"][facet] = results['facets'][facet][index]['values'] + line = json.dumps(match) + '\n' + fout.write(line.encode('utf-8')) + + click.echo(click.style(u'Saved results into file {}'.format(filename), 'green')) + + # We buffer the entire output so we can use click's pager functionality + output = u'' + + # Output example: + # 2017-06 + # os + # Linux 3.x 384148 + # Windows 7 or 8 25531 + for index, match in enumerate(results['matches']): + output += click.style(match['month'] + u'\n', fg='green') + + for facet in result_facets: + output += ' ' + facet + u'\n' + for bucket in results['facets'][facet][index]['values']: + output += ' ' + str(bucket['value']) + separator + str(bucket['count']) + u'\n' + + click.echo_via_pager(output) + + @main.command() @click.argument('ip', metavar='') def honeyscore(ip): diff --git a/shodan/client.py b/shodan/client.py index b9cb487..7b76fdc 100644 --- a/shodan/client.py +++ b/shodan/client.py @@ -85,7 +85,7 @@ class Notifier: def __init__(self, parent): self.parent = parent - + def create(self, provider, args, description=None): """Get the settings for the specified notifier that a user has configured. @@ -101,9 +101,9 @@ def create(self, provider, args, description=None): if description: args['description'] = description - + return self.parent._request('/notifier', args, method='post') - + def edit(self, nid, args): """Get the settings for the specified notifier that a user has configured. @@ -114,7 +114,7 @@ def edit(self, nid, args): :returns: dict -- fields are 'success' and 'id' of the notifier """ return self.parent._request('/notifier/{}'.format(nid), args, method='put') - + def get(self, nid): """Get the settings for the specified notifier that a user has configured. @@ -137,7 +137,7 @@ def list_providers(self): :returns: A list of providers where each object describes a provider """ return self.parent._request('/notifier/provider', {}) - + def remove(self, nid): """Delete the provided notifier. @@ -253,6 +253,42 @@ def remove_member(self, user): """ return self.parent._request('/org/member/{}'.format(user), {}, method='DELETE')['success'] + class Trends: + + def __init__(self, parent): + self.parent = parent + + def search(self, query, facets): + """Search the Shodan historical database. + + :param query: Search query; identical syntax to the website + :type query: str + :param facets: (optional) A list of properties to get summary information on + :type facets: str + + :returns: A dictionary with 3 main items: matches, facets and total. Visit the website for more detailed information. + """ + args = { + 'query': query, + 'facets': create_facet_string(facets), + } + + return self.parent._request('/api/v1/search', args, service='trends') + + def search_facets(self): + """This method returns a list of facets that can be used to get a breakdown of the top values for a property. + + :returns: A list of strings where each is a facet name + """ + return self.parent._request('/api/v1/search/facets', {}, service='trends') + + def search_filters(self): + """This method returns a list of search filters that can be used in the search query. + + :returns: A list of strings where each is a filter name + """ + return self.parent._request('/api/v1/search/filters', {}, service='trends') + def __init__(self, key, proxies=None): """Initializes the API object. @@ -264,9 +300,11 @@ def __init__(self, key, proxies=None): self.api_key = key self.base_url = 'https://api.shodan.io' self.base_exploits_url = 'https://exploits.shodan.io' + self.base_trends_url = 'https://trends.shodan.io' self.data = self.Data(self) self.dns = self.Dns(self) self.exploits = self.Exploits(self) + self.trends = self.Trends(self) self.labs = self.Labs(self) self.notifier = self.Notifier(self) self.org = self.Organization(self) @@ -297,6 +335,7 @@ def _request(self, function, params, service='shodan', method='get'): base_url = { 'shodan': self.base_url, 'exploits': self.base_exploits_url, + 'trends': self.base_trends_url, }.get(service, 'shodan') # Wait for API rate limit @@ -513,7 +552,7 @@ def search(self, query, page=1, limit=None, offset=None, facets=None, minify=Tru if facets: args['facets'] = create_facet_string(facets) - + if fields and isinstance(fields, list): args['fields'] = ','.join(fields) @@ -733,17 +772,17 @@ def ignore_alert_trigger_notification(self, aid, trigger, ip, port, vulns=None): # a different API endpoint. if trigger in ('vulnerable', 'vulnerable_unverified') and vulns and isinstance(vulns, list): return self._request('/shodan/alert/{}/trigger/{}/ignore/{}:{}/{}'.format(aid, trigger, ip, port, ','.join(vulns)), {}, method='put') - + return self._request('/shodan/alert/{}/trigger/{}/ignore/{}:{}'.format(aid, trigger, ip, port), {}, method='put') def unignore_alert_trigger_notification(self, aid, trigger, ip, port): """Re-enable trigger notifications for the provided IP and port""" return self._request('/shodan/alert/{}/trigger/{}/ignore/{}:{}'.format(aid, trigger, ip, port), {}, method='delete') - + def add_alert_notifier(self, aid, nid): """Enable the given notifier for an alert that has triggers enabled.""" return self._request('/shodan/alert/{}/notifier/{}'.format(aid, nid), {}, method='put') - + def remove_alert_notifier(self, aid, nid): """Remove the given notifier for an alert that has triggers enabled.""" return self._request('/shodan/alert/{}/notifier/{}'.format(aid, nid), {}, method='delete') From 9cc33f5700af88067391f3bd64fbb65001a666ee Mon Sep 17 00:00:00 2001 From: Thong Nguyen Date: Fri, 23 Jun 2023 16:10:47 +0700 Subject: [PATCH 06/18] Add API tests --- tests/test_shodan.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/test_shodan.py b/tests/test_shodan.py index f3405ce..ebe7a90 100644 --- a/tests/test_shodan.py +++ b/tests/test_shodan.py @@ -22,7 +22,8 @@ class ShodanTests(unittest.TestCase): } def setUp(self): - self.api = shodan.Shodan(open('SHODAN-API-KEY').read().strip()) + with open('SHODAN-API-KEY') as f: + self.api = shodan.Shodan(f.read().strip()) def test_search_simple(self): results = self.api.search(self.QUERIES['simple']) @@ -115,6 +116,24 @@ def test_exploits_count_facets(self): self.assertTrue(results['facets']['source']) self.assertTrue(len(results['facets']['author']) == 1) + def test_trends_search(self): + results = self.api.trends.search('apache', facets=[('product', 10)]) + self.assertIn('matches', results) + self.assertIn('facets', results) + self.assertIn('total', results) + self.assertTrue(results['matches']) + self.assertIn('2023-06', [bucket['key'] for bucket in results['facets']['product']]) + + def test_trends_search_filters(self): + results = self.api.trends.search_filters() + self.assertIn('has_ipv6', results) + self.assertNotIn('http.html', results) + + def test_trends_search_facets(self): + results = self.api.trends.search_facets() + self.assertIn('product', results) + self.assertNotIn('cpe', results) + # Test error responses def test_invalid_key(self): api = shodan.Shodan('garbage') From 3fe0ad88b434fe480b7621f8fac20216c3578fea Mon Sep 17 00:00:00 2001 From: Thong Nguyen Date: Fri, 23 Jun 2023 16:11:34 +0700 Subject: [PATCH 07/18] Bump version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3adcd8b..2546ea7 100755 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name='shodan', - version='1.29.1', + version='1.30.0', description='Python library and command-line utility for Shodan (https://developer.shodan.io)', long_description=README, long_description_content_type='text/x-rst', From 0ad61d20d4cf676269c798e573ded4439603b218 Mon Sep 17 00:00:00 2001 From: John Matherly Date: Sun, 25 Jun 2023 16:46:00 -0700 Subject: [PATCH 08/18] Change cutoff to 4GB as that's what the xlsxwriter documentation says --- shodan/cli/converter/excel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shodan/cli/converter/excel.py b/shodan/cli/converter/excel.py index 4db78a4..2021a33 100644 --- a/shodan/cli/converter/excel.py +++ b/shodan/cli/converter/excel.py @@ -51,8 +51,8 @@ def process(self, files, file_size): # Create the new workbook workbook = Workbook(filename) - # Check if Excel file is larger than 5GB - if file_size > 5e9: + # Check if Excel file is larger than 4GB + if file_size > 4e9: workbook.use_zip64() # Define some common styles/ formats From 3d1f8922cd42420eba459a5d8bd41d974ad55ec1 Mon Sep 17 00:00:00 2001 From: Thong Nguyen Date: Mon, 3 Jul 2023 10:57:56 +0700 Subject: [PATCH 09/18] Better output format --- shodan/__main__.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/shodan/__main__.py b/shodan/__main__.py index 3a2fdf8..439752d 100644 --- a/shodan/__main__.py +++ b/shodan/__main__.py @@ -279,7 +279,7 @@ def download(fields, limit, filename, query): # Add the appropriate extension if it's not there atm if not filename.endswith('.json.gz'): filename += '.json.gz' - + # Strip out any whitespace in the fields and turn them into an array if fields is not None: fields = [item.strip() for item in fields.split(',')] @@ -804,10 +804,9 @@ def _create_stream(name, args, timeout): @main.command() @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.option('--separator', help='The separator between the properties of the search results.', default='\t') @click.option('--facets', help='List of facets to get summary information on.', required=True, type=str) @click.argument('query', metavar='', nargs=-1) -def trends(filename, save, separator, facets, query): +def trends(filename, save, facets, query): """Search Shodan historical database""" key = get_api_key() api = shodan.Shodan(key) @@ -869,15 +868,17 @@ def trends(filename, save, separator, facets, query): # Output example: # 2017-06 # os - # Linux 3.x 384148 - # Windows 7 or 8 25531 + # Linux 3.x 146,502 + # Windows 7 or 8 2,189 for index, match in enumerate(results['matches']): output += click.style(match['month'] + u'\n', fg='green') - - for facet in result_facets: - output += ' ' + facet + u'\n' - for bucket in results['facets'][facet][index]['values']: - output += ' ' + str(bucket['value']) + separator + str(bucket['count']) + u'\n' + if match['count'] > 0: + for facet in result_facets: + output += click.style(u' {}\n'.format(facet), fg='cyan') + for bucket in results['facets'][facet][index]['values']: + output += u' {:60}{}\n'.format(click.style(bucket['value'], bold=True), click.style(u'{:20,d}'.format(bucket['count']), fg='green')) + else: + output += u'{}\n'.format(click.style('N/A', bold=True)) click.echo_via_pager(output) From 9e0f4dddbc5c8737270b70c119674abe618f44b5 Mon Sep 17 00:00:00 2001 From: Thong Nguyen Date: Fri, 7 Jul 2023 08:34:16 +0700 Subject: [PATCH 10/18] Make facets as required arguments --- shodan/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shodan/__main__.py b/shodan/__main__.py index 3a81b0d..1c4a74d 100644 --- a/shodan/__main__.py +++ b/shodan/__main__.py @@ -809,7 +809,7 @@ def _create_stream(name, args, timeout): @main.command() @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.option('--facets', help='List of facets to get summary information on.', required=True, type=str) +@click.argument('facets', metavar='') @click.argument('query', metavar='', nargs=-1) def trends(filename, save, facets, query): """Search Shodan historical database""" From a9d692b05aeea978632d17601cfec997bc8995cd Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Tue, 11 Jul 2023 23:07:34 -0400 Subject: [PATCH 11/18] Updated implementation to monitor hostnames for domain-based monitoring. --- requirements.txt | 3 ++- shodan/cli/alert.py | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 5095f64..2692414 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ click-plugins colorama requests>=2.2.1 XlsxWriter -ipaddress;python_version<='2.7' \ No newline at end of file +ipaddress;python_version<='2.7' +tldextract \ No newline at end of file diff --git a/shodan/cli/alert.py b/shodan/cli/alert.py index 2dc3e58..0030589 100644 --- a/shodan/cli/alert.py +++ b/shodan/cli/alert.py @@ -3,6 +3,7 @@ import gzip import json import shodan +from tldextract import extract from collections import defaultdict from operator import itemgetter @@ -125,9 +126,15 @@ def alert_domain(domain, triggers): try: # Grab a list of IPs for the domain domain = domain.lower() + domain_parse = extract(domain) click.secho('Looking up domain information...', dim=True) info = api.dns.domain_info(domain, type='A') - domain_ips = set([record['value'] for record in info['data']]) + + if domain_parse.subdomain: + domain_ips = set([record['value'] for record in info['data'] + if record['subdomain'] == domain_parse.subdomain]) + else: + domain_ips = set([record['value'] for record in info['data']]) # Create the actual alert click.secho('Creating alert...', dim=True) From c90b3dd5ade683e16c7681c444790f448150a401 Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Wed, 12 Jul 2023 22:35:36 -0400 Subject: [PATCH 12/18] Added input validation by updating click.argument for input parameter. --- shodan/__main__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/shodan/__main__.py b/shodan/__main__.py index 1c4a74d..8b4eb2b 100644 --- a/shodan/__main__.py +++ b/shodan/__main__.py @@ -93,7 +93,7 @@ def main(): @main.command() @click.option('--fields', help='List of properties to output.', default=None) -@click.argument('input', metavar='') +@click.argument('input', metavar='', type=click.Path(exists=True)) @click.argument('format', metavar='', type=click.Choice(CONVERTERS.keys())) def convert(fields, input, format): """Convert the given input data file into a different format. The following file formats are supported: @@ -110,9 +110,8 @@ def convert(fields, input, format): raise click.ClickException('File format doesnt support custom list of fields') converter_class.fields = [item.strip() for item in fields.split(',')] # Use the custom fields the user specified - # Check file size of input - if os.path.exists(input): - file_size = os.path.getsize(input) + # click.Path ensures that file path exists + file_size = os.path.getsize(input) # Get the basename for the input file basename = input.replace('.json.gz', '').replace('.json', '') From 1b7cb65e7e3d4b0490aa1009d5eaac72257a7e60 Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Thu, 13 Jul 2023 20:07:17 -0400 Subject: [PATCH 13/18] Updated CLI logic to filter out private IPs when creating a domain-based alert. --- shodan/cli/alert.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/shodan/cli/alert.py b/shodan/cli/alert.py index 0030589..6f82c1a 100644 --- a/shodan/cli/alert.py +++ b/shodan/cli/alert.py @@ -4,6 +4,7 @@ import json import shodan from tldextract import extract +from ipaddress import ip_address from collections import defaultdict from operator import itemgetter @@ -120,6 +121,7 @@ def alert_create(name, netblocks): @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): """Create a network alert based on a domain name""" + flag = True key = get_api_key() api = shodan.Shodan(key) @@ -132,22 +134,30 @@ def alert_domain(domain, triggers): if domain_parse.subdomain: domain_ips = set([record['value'] for record in info['data'] - if record['subdomain'] == domain_parse.subdomain]) + if record['subdomain'] == domain_parse.subdomain and + not ip_address(record['value']).is_private]) else: - domain_ips = set([record['value'] for record in info['data']]) + domain_ips = set([record['value'] for record in info['data'] + if not ip_address(record['value']).is_private]) - # Create the actual alert - click.secho('Creating alert...', dim=True) - alert = api.create_alert('__domain: {}'.format(domain), list(domain_ips)) + if not domain_ips: + flag = False + click.secho('No external IPs were found to be associated with this domain. ' + 'No alert was created.', dim=True) + else: + # Create the actual alert + click.secho('Creating alert...', dim=True) + alert = api.create_alert('__domain: {}'.format(domain), list(domain_ips)) - # Enable the triggers so it starts getting managed by Shodan Monitor - click.secho('Enabling triggers...', dim=True) - api.enable_alert_trigger(alert['id'], triggers) + # Enable the triggers so it starts getting managed by Shodan Monitor + click.secho('Enabling triggers...', dim=True) + api.enable_alert_trigger(alert['id'], triggers) except shodan.APIError as e: raise click.ClickException(e.value) - click.secho('Successfully created domain alert!', fg='green') - click.secho('Alert ID: {}'.format(alert['id']), fg='cyan') + if flag: + click.secho('Successfully created domain alert!', fg='green') + click.secho('Alert ID: {}'.format(alert['id']), fg='cyan') @alert.command(name='download') From 3036d83a9de0efb35202d094521f1bfd152d14b2 Mon Sep 17 00:00:00 2001 From: Richard Howe Date: Fri, 14 Jul 2023 16:31:08 -0400 Subject: [PATCH 14/18] Updating implementation based on reviewer recommendations. --- shodan/cli/alert.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/shodan/cli/alert.py b/shodan/cli/alert.py index 6f82c1a..1df11ea 100644 --- a/shodan/cli/alert.py +++ b/shodan/cli/alert.py @@ -121,7 +121,6 @@ def alert_create(name, netblocks): @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): """Create a network alert based on a domain name""" - flag = True key = get_api_key() api = shodan.Shodan(key) @@ -141,23 +140,21 @@ def alert_domain(domain, triggers): if not ip_address(record['value']).is_private]) if not domain_ips: - flag = False - click.secho('No external IPs were found to be associated with this domain. ' - 'No alert was created.', dim=True) - else: - # Create the actual alert - click.secho('Creating alert...', dim=True) - alert = api.create_alert('__domain: {}'.format(domain), list(domain_ips)) + raise click.ClickException('No external IPs were found to be associated with this domain. ' + 'No alert was created.') + + # Create the actual alert + click.secho('Creating alert...', dim=True) + alert = api.create_alert('__domain: {}'.format(domain), list(domain_ips)) - # Enable the triggers so it starts getting managed by Shodan Monitor - click.secho('Enabling triggers...', dim=True) - api.enable_alert_trigger(alert['id'], triggers) + # Enable the triggers so it starts getting managed by Shodan Monitor + click.secho('Enabling triggers...', dim=True) + api.enable_alert_trigger(alert['id'], triggers) except shodan.APIError as e: raise click.ClickException(e.value) - if flag: - click.secho('Successfully created domain alert!', fg='green') - click.secho('Alert ID: {}'.format(alert['id']), fg='cyan') + click.secho('Successfully created domain alert!', fg='green') + click.secho('Alert ID: {}'.format(alert['id']), fg='cyan') @alert.command(name='download') From 759a8561f821bf2e970f0594200221271bcbb7a9 Mon Sep 17 00:00:00 2001 From: John Matherly Date: Wed, 19 Jul 2023 15:55:20 -0700 Subject: [PATCH 15/18] Update "shodan host" output to show certificate issuer/ subject and HTTP title --- shodan/cli/host.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/shodan/cli/host.py b/shodan/cli/host.py index e90e372..8ffdeed 100644 --- a/shodan/cli/host.py +++ b/shodan/cli/host.py @@ -91,8 +91,20 @@ def host_print_pretty(host, history=False): click.echo(click.style('\t\t({})'.format(date), fg='white', dim=True), nl=False) click.echo('') + # Show optional HTTP information + if 'http' in banner: + if 'title' in banner['http'] and banner['http']['title']: + click.echo('\t|-- HTTP title: {}'.format(banner['http']['title'])) + # Show optional ssl info if 'ssl' in banner: + if 'cert' in banner['ssl'] and banner['ssl']['cert']: + if 'issuer' in banner['ssl']['cert'] and banner['ssl']['cert']['issuer']: + issuer = ', '.join(['{}={}'.format(key, value) for key, value in banner['ssl']['cert']['issuer'].items()]) + click.echo('\t|-- Cert Issuer: {}'.format(issuer)) + if 'subject' in banner['ssl']['cert'] and banner['ssl']['cert']['subject']: + subject = ', '.join(['{}={}'.format(key, value) for key, value in banner['ssl']['cert']['subject'].items()]) + click.echo('\t|-- Cert Subject: {}'.format(subject)) if 'versions' in banner['ssl'] and banner['ssl']['versions']: click.echo('\t|-- SSL Versions: {}'.format(', '.join([item for item in sorted(banner['ssl']['versions']) if not version.startswith('-')]))) if 'dhparams' in banner['ssl'] and banner['ssl']['dhparams']: From 769e38448414c97eb7a34ecd3e88f9e0e280b2ee Mon Sep 17 00:00:00 2001 From: Thong Nguyen Date: Thu, 20 Jul 2023 11:50:51 +0700 Subject: [PATCH 16/18] Make Trends API facets param as optional, if not supply then show total query results over time --- shodan/__main__.py | 54 ++++++++++++++++++++++++++++---------------- tests/test_shodan.py | 9 +++++++- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/shodan/__main__.py b/shodan/__main__.py index 8b4eb2b..4093b94 100644 --- a/shodan/__main__.py +++ b/shodan/__main__.py @@ -806,9 +806,9 @@ def _create_stream(name, args, timeout): @main.command() +@click.option('--facets', help='List of facets to get summary information on, if empty then show query total results over time', default='', type=str) @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('facets', metavar='') @click.argument('query', metavar='', nargs=-1) def trends(filename, save, facets, query): """Search Shodan historical database""" @@ -823,12 +823,12 @@ def trends(filename, save, facets, query): if query == '': raise click.ClickException('Empty search query') - if facets == '': - raise click.ClickException('Empty search facets') - # Convert comma-separated facets string to list parsed_facets = [] for facet in facets.split(','): + if not facet: + continue + parts = facet.strip().split(":") if len(parts) > 1: parsed_facets.append((parts[0], parts[1])) @@ -845,7 +845,9 @@ def trends(filename, save, facets, query): if results['total'] == 0: raise click.ClickException('No search results found') - result_facets = list(results['facets'].keys()) + result_facets = [] + if results.get("facets"): + result_facets = list(results["facets"].keys()) # Save the results first to file if user request if filename or save: @@ -858,31 +860,43 @@ def trends(filename, save, facets, query): with helpers.open_file(filename) as fout: for index, match in enumerate(results['matches']): # Append facet info to make up a line - match["facets"] = {} - for facet in result_facets: - match["facets"][facet] = results['facets'][facet][index]['values'] - line = json.dumps(match) + '\n' - fout.write(line.encode('utf-8')) + if result_facets: + match["facets"] = {} + for facet in result_facets: + match["facets"][facet] = results['facets'][facet][index]['values'] + + line = json.dumps(match) + '\n' + fout.write(line.encode('utf-8')) click.echo(click.style(u'Saved results into file {}'.format(filename), 'green')) # We buffer the entire output so we can use click's pager functionality output = u'' - # Output example: + # Output examples: + # - Facet by os # 2017-06 # os # Linux 3.x 146,502 # Windows 7 or 8 2,189 - for index, match in enumerate(results['matches']): - output += click.style(match['month'] + u'\n', fg='green') - if match['count'] > 0: - for facet in result_facets: - output += click.style(u' {}\n'.format(facet), fg='cyan') - for bucket in results['facets'][facet][index]['values']: - output += u' {:60}{}\n'.format(click.style(bucket['value'], bold=True), click.style(u'{:20,d}'.format(bucket['count']), fg='green')) - else: - output += u'{}\n'.format(click.style('N/A', bold=True)) + # + # - Without facets + # 2017-06 19,799,459 + # 2017-07 21,077,099 + if result_facets: + for index, match in enumerate(results['matches']): + output += click.style(match['month'] + u'\n', fg='green') + if match['count'] > 0: + for facet in result_facets: + output += click.style(u' {}\n'.format(facet), fg='cyan') + for bucket in results['facets'][facet][index]['values']: + output += u' {:60}{}\n'.format(click.style(bucket['value'], bold=True), click.style(u'{:20,d}'.format(bucket['count']), fg='green')) + else: + output += u'{}\n'.format(click.style('N/A', bold=True)) + else: + # Without facets, show query total results over time + for index, match in enumerate(results['matches']): + output += u'{:20}{}\n'.format(click.style(match['month'], bold=True), click.style(u'{:20,d}'.format(match['count']), fg='green')) click.echo_via_pager(output) diff --git a/tests/test_shodan.py b/tests/test_shodan.py index ebe7a90..94ffc70 100644 --- a/tests/test_shodan.py +++ b/tests/test_shodan.py @@ -118,12 +118,19 @@ def test_exploits_count_facets(self): def test_trends_search(self): results = self.api.trends.search('apache', facets=[('product', 10)]) + self.assertIn('total', results) self.assertIn('matches', results) self.assertIn('facets', results) - self.assertIn('total', results) self.assertTrue(results['matches']) self.assertIn('2023-06', [bucket['key'] for bucket in results['facets']['product']]) + results = self.api.trends.search('apache', facets=[]) + self.assertIn('total', results) + self.assertIn('matches', results) + self.assertNotIn('facets', results) + self.assertTrue(results['matches']) + self.assertIn('2023-06', [match['month'] for match in results['matches']]) + def test_trends_search_filters(self): results = self.api.trends.search_filters() self.assertIn('has_ipv6', results) From a08353e40b2018ec0dbf04012e9a5bfbe87f55ec Mon Sep 17 00:00:00 2001 From: John Matherly Date: Fri, 13 Oct 2023 18:15:18 -0700 Subject: [PATCH 17/18] Improved handling of downloads to prevent it from exiting prematurely if the API only returns partial or missing results for a search results page. --- setup.py | 2 +- shodan/client.py | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/setup.py b/setup.py index 2546ea7..f421898 100755 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name='shodan', - version='1.30.0', + version='1.30.1', description='Python library and command-line utility for Shodan (https://developer.shodan.io)', long_description=README, long_description_content_type='text/x-rst', diff --git a/shodan/client.py b/shodan/client.py index 7b76fdc..ab81302 100644 --- a/shodan/client.py +++ b/shodan/client.py @@ -7,6 +7,7 @@ :copyright: (c) 2014- by John Matherly """ +import math import time import requests @@ -576,15 +577,23 @@ def search_cursor(self, query, minify=True, retries=5, fields=None): :returns: A search cursor that can be used as an iterator/ generator. """ page = 1 + total_pages = 0 tries = 0 - # Placeholder results object to make the while loop below easier - results = { - 'matches': [True], - 'total': None, - } + # Grab the initial page and use the total to calculate the expected number of pages + results = self.search(query, minify=minify, page=page, fields=fields) + if results['total']: + total_pages = int(math.ceil(results['total'] / 100)) + + for banner in results['matches']: + try: + yield banner + except GeneratorExit: + return # exit out of the function + page += 1 - while results['matches']: + # Keep iterating over the results from page 2 onwards + while page <= total_pages: try: results = self.search(query, minify=minify, page=page, fields=fields) for banner in results['matches']: From 87a0688d1e5b7e4bb13ae4f5fd7cb937a671cba8 Mon Sep 17 00:00:00 2001 From: John Matherly Date: Sat, 16 Dec 2023 17:29:15 -0800 Subject: [PATCH 18/18] Consolidate all Shodan methods to use the internal _request method instead of sometimes using the shodan.helpers.api_request method. New environment variable SHODAN_API_URL that can be used to overwrite the base_url used for the API requests. --- setup.py | 2 +- shodan/client.py | 30 +++++++++++++++++++----------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/setup.py b/setup.py index f421898..53bbd9a 100755 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name='shodan', - version='1.30.1', + version='1.31.0', description='Python library and command-line utility for Shodan (https://developer.shodan.io)', long_description=README, long_description_content_type='text/x-rst', diff --git a/shodan/client.py b/shodan/client.py index ab81302..21c70af 100644 --- a/shodan/client.py +++ b/shodan/client.py @@ -8,13 +8,14 @@ :copyright: (c) 2014- by John Matherly """ import math +import os import time import requests import json from .exception import APIError -from .helpers import api_request, create_facet_string +from .helpers import create_facet_string from .stream import Stream @@ -314,11 +315,15 @@ def __init__(self, key, proxies=None): self._session = requests.Session() self.api_rate_limit = 1 # Requests per second self._api_query_time = None + if proxies: self._session.proxies.update(proxies) self._session.trust_env = False + + if os.environ.get('SHODAN_API_URL'): + self.base_url = os.environ.get('SHODAN_API_URL') - def _request(self, function, params, service='shodan', method='get'): + def _request(self, function, params, service='shodan', method='get', json_data=None): """General-purpose function to create web requests to SHODAN. Arguments: @@ -348,7 +353,13 @@ def _request(self, function, params, service='shodan', method='get'): try: method = method.lower() if method == 'post': - data = self._session.post(base_url + function, params) + if json_data: + data = self._session.post(base_url + function, params=params, + data=json.dumps(json_data), + headers={'content-type': 'application/json'}, + ) + else: + data = self._session.post(base_url + function, params) elif method == 'put': data = self._session.put(base_url + function, params=params) elif method == 'delete': @@ -711,8 +722,7 @@ def create_alert(self, name, ip, expires=0): 'expires': expires, } - response = api_request(self.api_key, '/shodan/alert', data=data, params={}, method='post', - proxies=self._session.proxies) + response = self._request('/shodan/alert', params={}, json_data=data, method='post') return response @@ -732,8 +742,7 @@ def edit_alert(self, aid, ip): }, } - response = api_request(self.api_key, '/shodan/alert/{}'.format(aid), data=data, params={}, method='post', - proxies=self._session.proxies) + response = self._request('/shodan/alert/{}'.format(aid), params={}, json_data=data, method='post') return response @@ -744,9 +753,9 @@ def alerts(self, aid=None, include_expired=True): else: func = '/shodan/alert/info' - response = api_request(self.api_key, func, params={ + response = self._request(func, params={ 'include_expired': include_expired, - }, proxies=self._session.proxies) + }) return response @@ -754,8 +763,7 @@ def delete_alert(self, aid): """Delete the alert with the given ID.""" func = '/shodan/alert/{}'.format(aid) - response = api_request(self.api_key, func, params={}, method='delete', - proxies=self._session.proxies) + response = self._request(func, params={}, method='delete') return response