Skip to content

Commit

Permalink
Initial version of shodan command-line utility.
Browse files Browse the repository at this point in the history
  • Loading branch information
achillean committed Oct 5, 2014
1 parent c1e7a15 commit 716e630
Show file tree
Hide file tree
Showing 4 changed files with 240 additions and 4 deletions.
220 changes: 220 additions & 0 deletions bin/shodan
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
#!/usr/bin/env python
"""
Shodan CLI
Note: Always run "shodan init <api key>" before trying to execute any other command!
A simple interface to search Shodan, download data and parse compressed JSON files.
The following commands are currently supported:
count
init
myip
parse
search
"""

import click
import gzip
import os
import os.path
import shodan
import simplejson


# Constants
SHODAN_CONFIG_DIR = '~/.shodan/'
ARRAY_SEPARATOR = ';'
COLORIZE_FIELDS = {
'ip_str': 'green',
'port': 'yellow',
'data': 'white',
'hostnames': 'magenta',
'org': 'cyan',
}


# Utility methods
def get_api_key():
shodan_dir = os.path.expanduser(SHODAN_CONFIG_DIR)
with open(shodan_dir + '/api_key', 'r') as fin:
return fin.read().strip()
raise click.ClickException('Please run "shodan init <api key>" before using this command')

def escape_data(args):
return args.encode('ascii', 'replace').replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t')


@click.group()
def main():
pass

@main.command()
@click.argument('key', metavar='<api key>')
def init(key):
"""Initialize the Shodan command-line"""
# Create the directory if necessary
shodan_dir = os.path.expanduser(SHODAN_CONFIG_DIR)
if not os.path.isdir(shodan_dir):
try:
os.mkdir(shodan_dir)
except OSError:
raise click.ClickException('Unable to create directory to store the Shodan API key (%s)' % shodan_dir)

# Store the API key in the user's directory
with open(shodan_dir + '/api_key', 'w') as fout:
fout.write(key.strip())
click.echo(click.style('Successfully initialized', fg='green'))


@main.command()
@click.argument('query', metavar='<search query>', nargs=-1)
def count(query):
"""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()

# Make sure the user didn't supply an empty string
if query == '':
raise click.ClickException('Empty search query')

# Perform the search
api = shodan.Shodan(key)
try:
results = api.count(query)
except shodan.APIError, e:
raise click.ClickException(e.value)

click.echo(results['total'])

@main.command()
@click.option('--color/--no-color', default=True)
@click.option('--fields', help='List of properties to output.', default='ip_str,port,hostnames,data')
@click.option('--separator', help='The separator between the properties of the search results.', default='\t')
@click.argument('filename', metavar='<filename>', type=click.Path(exists=True))
def parse(color, fields, separator, filename):
# Make sure it's some sort of json file
if not filename.endswith('.json.gz') and not filename.endswith('.json'):
raise click.ClickException('Invalid file, please make sure it is a valid Shodan JSON file')

# Strip out any whitespace in the fields and turn them into an array
fields = [item.strip() for item in fields.split(',')]

if len(fields) == 0:
raise click.ClickException('Please define at least one property to show')

# Create a file handle depending on the filetype
if filename.endswith('.gz'):
fin = gzip.open(filename, 'r')
else:
fin = open(filename, 'r')

for line in fin:
# Convert the JSON into a native Python object
banner = simplejson.loads(line)
row = ''

# Loop over all the fields and print the banner as a row
for field in fields:
tmp = ''
if field in banner and banner[field]:
field_type = type(banner[field])

# If the field is an array then merge it together
if field_type == list:
tmp = ';'.join(banner[field])
elif field_type in [int, float]:
tmp = str(banner[field])
else:
tmp = escape_data(banner[field])

# Colorize certain fields if the user wants it
if color:
tmp = click.style(tmp, fg=COLORIZE_FIELDS.get(field, 'white'))

# Add the field information to the row
row += tmp + separator

click.echo(row)

@main.command()
def myip():
"""Print your external IP address"""
key = get_api_key()

api = shodan.Shodan(key)
try:
click.echo(api.tools.myip())
except shodan.APIError, e:
raise click.ClickException(e.value)

@main.command()
@click.option('--color/--no-color', default=True)
@click.option('--fields', help='List of properties to show in the search results.', default='ip_str,port,hostnames,data')
@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='<search query>', nargs=-1)
def search(color, fields, limit, separator, query):
"""Search the Shodan database"""
key = get_api_key()

# Create the query string out of the provided tuple
query = ' '.join(query).strip()

# Make sure the user didn't supply an empty string
if query == '':
raise click.ClickException('Empty search query')

# For now we only allow up to 1000 results at a time
if limit > 1000:
raise click.ClickException('Too many results requested, maximum is 1,000')

# Strip out any whitespace in the fields and turn them into an array
fields = [item.strip() for item in fields.split(',')]

if len(fields) == 0:
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)
except shodan.APIError, e:
raise click.ClickException(e.value)

# We buffer the entire output so we can use click's pager functionality
output = ''
for banner in results['matches']:
row = ''

# Loop over all the fields and print the banner as a row
for field in fields:
tmp = ''
if field in banner and banner[field]:
field_type = type(banner[field])

# If the field is an array then merge it together
if field_type == list:
tmp = ';'.join(banner[field])
elif field_type in [int, float]:
tmp = str(banner[field])
else:
tmp = escape_data(banner[field])

# Colorize certain fields if the user wants it
if color:
tmp = click.style(tmp, fg=COLORIZE_FIELDS.get(field, 'white'))

# Add the field information to the row
row += tmp + separator

# click.echo(out + separator, nl=False)
output += row + '\n'
# click.echo('')
click.echo_via_pager(output)

if __name__ == '__main__':
main()
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
click
colorama
requests
simplejson
7 changes: 4 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@

setup(
name = 'shodan',
version = '1.0.7',
description = 'Python library for Shodan (https://developer.shodan.io)',
version = '1.1.0',
description = 'Python library and command-line utility for Shodan (https://developer.shodan.io)',
author = 'John Matherly',
author_email = 'jmath@shodan.io',
url = 'http://github.com/achillean/shodan-python/tree/master',
packages = ['shodan'],
install_requires=["simplejson", "requests"],
scripts = ['bin/shodan'],
install_requires=["simplejson", "requests", "click", "colorama"],
classifiers = [
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
Expand Down
15 changes: 14 additions & 1 deletion shodan/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ class Shodan:
:ivar stream: An instance of `shodan.Shodan.Stream` that provides access to the Streaming API.
"""

class Tools:

def __init__(self, parent):
self.parent = parent

def myip(self):
"""Get your current IP address as seen from the Internet.
:returns: str -- your IP address
"""
return self.parent._request('/tools/myip', {})

class Exploits:

def __init__(self, parent):
Expand Down Expand Up @@ -143,6 +155,7 @@ def __init__(self, key):
self.base_url = 'https://api.shodan.io'
self.base_exploits_url = 'https://exploits.shodan.io'
self.exploits = self.Exploits(self)
self.tools = self.Tools(self)
self.stream = self.Stream(self)

def _request(self, function, params, service='shodan'):
Expand Down Expand Up @@ -186,7 +199,7 @@ def _request(self, function, params, service='shodan'):
raise APIError('Unable to parse JSON response')

# Raise an exception if an error occurred
if data.get('error', None):
if type(data) == dict and data.get('error', None):
raise APIError(data['error'])

# Return the data
Expand Down

0 comments on commit 716e630

Please sign in to comment.