-
Notifications
You must be signed in to change notification settings - Fork 555
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Initial version of shodan command-line utility.
- Loading branch information
Showing
4 changed files
with
240 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,4 @@ | ||
click | ||
colorama | ||
requests | ||
simplejson |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters