#!/usr/bin/env python ''' F-Secure Virus World Map console edition See README.md for more details Copyright 2012-2013 Jyrki Muukkonen Released under the MIT license. See LICENSE.txt or http://www.opensource.org/licenses/mit-license.php ASCII map in map-world-01.txt is copyright: "Map 1998 Matthew Thomas. Freely usable as long as this line is included" ''' import curses import locale import random import time from shodan.helpers import get_ip MAPS = { 'world': { # offset (as (y, x) for curses...) 'corners': (1, 4, 23, 73), # lat top, lon left, lat bottom, lon right 'coords': [90.0, -180.0, -90.0, 180.0], 'data': ''' . _..::__: ,-"-"._ |7 , _,.__ _.___ _ _<_>`!(._`.`-. / _._ `_ ,_/ ' '-._.---.-.__ .{ " " `-==,',._\{ \ / {) / _ ">_,-' ` mt-2_ \_.:--. `._ )`^-. "' , [_/( __,/-' '"' \ " _L oD_,--' ) /. (| | ,' _)_.\\._<> 6 _,' / ' `. / [_/_'` `"( <'} ) \\ .-. ) / `-'"..' `:._ _) ' ` \ ( `( / `:\ > \ ,-^. /' ' `._, "" | \`' \| ?_) {\ `=.---. `._._ ,' "` |' ,- '. | `-._ | / `:`<_|h--._ ( > . | , `=.__.`-'\ `. / | |{| ,-.,\ . | ,' \ / `' ," \ | / |_' | __ / | | '-' `-' \. |/ " / \. ' ,/ ______._.--._ _..---.---------._ ,-----"-..?----_/ ) _,-'" " ( Map 1998 Matthew Thomas. Freely usable as long as this line is included ''' } } class AsciiMap(object): """ Helper class for handling map drawing and coordinate calculations """ def __init__(self, map_name='world', map_conf=None, window=None, encoding=None): if map_conf is None: map_conf = MAPS[map_name] self.map = map_conf['data'] self.coords = map_conf['coords'] self.corners = map_conf['corners'] if window is None: window = curses.newwin(0, 0) self.window = window self.data = [] self.data_timestamp = None # JSON contents _should_ be UTF8 (so, python internal unicode here...) if encoding is None: encoding = locale.getpreferredencoding() self.encoding = encoding # check if we can use transparent background or not if curses.can_change_color(): curses.use_default_colors() background = -1 else: background = curses.COLOR_BLACK tmp_colors = [ ('red', curses.COLOR_RED, background), ('blue', curses.COLOR_BLUE, background), ('pink', curses.COLOR_MAGENTA, background) ] self.colors = {} if curses.has_colors(): for i, (name, fgcolor, bgcolor) in enumerate(tmp_colors, 1): curses.init_pair(i, fgcolor, bgcolor) self.colors[name] = i def latlon_to_coords(self, lat, lon): """ Convert lat/lon coordinates to character positions. Very naive version, assumes that we are drawing the whole world TODO: filter out stuff that doesn't fit TODO: make it possible to use "zoomed" maps """ width = (self.corners[3]-self.corners[1]) height = (self.corners[2]-self.corners[0]) # change to 0-180, 0-360 abs_lat = -lat+90 abs_lon = lon+180 x = (abs_lon/360.0)*width + self.corners[1] y = (abs_lat/180.0)*height + self.corners[0] return int(x), int(y) def set_data(self, data): """ Set / convert internal data. For now it just selects a random set to show. """ entries = [] # Grab 5 random banners to display for banner in random.sample(data, min(len(data), 5)): desc = '{} -> {} / {}'.format(get_ip(banner), banner['port'], banner['location']['country_code']) if banner['location']['city']: desc += ' {}'.format(banner['location']['city']) if 'tags' in banner and banner['tags']: desc += ' / {}'.format(','.join(banner['tags'])) entry = ( float(banner['location']['latitude']), float(banner['location']['longitude']), '*', desc, curses.A_BOLD, 'red', ) entries.append(entry) self.data = entries def draw(self, target): """ Draw internal data to curses window """ self.window.clear() self.window.addstr(0, 0, self.map) # FIXME: position to be defined in map config? row = self.corners[2]-6 items_to_show = 5 for lat, lon, char, desc, attrs, color in self.data: # to make this work almost everywhere. see http://docs.python.org/2/library/curses.html if desc: desc = desc.encode(self.encoding, 'ignore') if items_to_show <= 0: break char_x, char_y = self.latlon_to_coords(lat, lon) if self.colors and color: attrs |= curses.color_pair(self.colors[color]) self.window.addstr(char_y, char_x, char, attrs) if desc: det_show = "%s %s" % (char, desc) else: det_show = None if det_show is not None: try: self.window.addstr(row, 1, det_show, attrs) row += 1 items_to_show -= 1 except StandardError: # FIXME: check window size before addstr() break self.window.overwrite(target) self.window.leaveok(1) class MapApp(object): """ Virus World Map ncurses application """ def __init__(self, api): self.api = api self.data = None self.last_fetch = 0 self.sleep = 10 # tenths of seconds, for curses.halfdelay() self.polling_interval = 60 def fetch_data(self, epoch_now, force_refresh=False): """ (Re)fetch data from JSON stream """ refresh = False if force_refresh or self.data is None: refresh = True else: if self.last_fetch + self.polling_interval <= epoch_now: refresh = True if refresh: try: # Grab 20 banners from the main stream banners = [] for banner in self.api.stream.banners(): if 'location' in banner and banner['location']['latitude']: banners.append(banner) if len(banners) >= 20: break self.data = banners self.last_fetch = epoch_now except StandardError: raise return refresh def run(self, scr): """ Initialize and run the application """ m = AsciiMap() curses.halfdelay(self.sleep) while True: now = int(time.time()) refresh = self.fetch_data(now) m.set_data(self.data) m.draw(scr) scr.addstr(0, 1, 'Shodan Radar', curses.A_BOLD) scr.addstr(0, 40, time.strftime("%c UTC", time.gmtime(now)).rjust(37), curses.A_BOLD) # Key Input # q - Quit event = scr.getch() if event == ord('q'): break # redraw window (to fix encoding/rendering bugs and to hide other messages to same tty) # user pressed 'r' or new data was fetched if refresh: m.window.redrawwin() def launch_map(api): app = MapApp(api) return curses.wrapper(app.run) def main(argv=None): """ Main function / entry point """ from shodan import Shodan from shodan.cli.helpers import get_api_key api = Shodan(get_api_key()) return launch_map(api) if __name__ == '__main__': import sys sys.exit(main())