1import click 2import csv 3import shodan 4 5from collections import defaultdict 6from operator import itemgetter 7from shodan import APIError 8from shodan.cli.helpers import get_api_key 9from shodan.helpers import open_file, write_banner 10from time import sleep 11 12 13MAX_QUERY_LENGTH = 1000 14 15 16def aggregate_facet(api, networks, facets): 17 """Merge the results from multiple facet API queries into a single result object. 18 This is necessary because a user might be monitoring a lot of IPs/ networks so it doesn't fit 19 into a single API call. 20 """ 21 def _merge_custom_facets(lfacets, results): 22 for key in results['facets']: 23 if key not in lfacets: 24 lfacets[key] = defaultdict(int) 25 26 for item in results['facets'][key]: 27 lfacets[key][item['value']] += item['count'] 28 29 # We're going to create a custom facets dict where 30 # the key is the value of a facet. Normally the facets 31 # object is a list where each item has a "value" and "count" property. 32 tmp_facets = {} 33 count = 0 34 35 query = 'net:' 36 37 for net in networks: 38 query += '{},'.format(net) 39 40 # Start running API queries if the query length is getting long 41 if len(query) > MAX_QUERY_LENGTH: 42 results = api.count(query[:-1], facets=facets) 43 44 _merge_custom_facets(tmp_facets, results) 45 count += results['total'] 46 query = 'net:' 47 48 # Run any remaining search query 49 if query[-1] != ':': 50 results = api.count(query[:-1], facets=facets) 51 52 _merge_custom_facets(tmp_facets, results) 53 count += results['total'] 54 55 # Convert the internal facets structure back to the one that 56 # the API returns. 57 new_facets = {} 58 for facet in tmp_facets: 59 sorted_items = sorted(tmp_facets[facet].items(), key=itemgetter(1), reverse=True) 60 new_facets[facet] = [{'value': key, 'count': value} for key, value in sorted_items] 61 62 # Make sure the facet keys exist even if there weren't any results 63 for facet, _ in facets: 64 if facet not in new_facets: 65 new_facets[facet] = [] 66 67 return { 68 'matches': [], 69 'facets': new_facets, 70 'total': count, 71 } 72 73 74@click.group() 75def alert(): 76 """Manage the network alerts for your account""" 77 pass 78 79 80@alert.command(name='clear') 81def alert_clear(): 82 """Remove all alerts""" 83 key = get_api_key() 84 85 # Get the list 86 api = shodan.Shodan(key) 87 try: 88 alerts = api.alerts() 89 for alert in alerts: 90 click.echo(u'Removing {} ({})'.format(alert['name'], alert['id'])) 91 api.delete_alert(alert['id']) 92 except shodan.APIError as e: 93 raise click.ClickException(e.value) 94 click.echo("Alerts deleted") 95 96 97@alert.command(name='create') 98@click.argument('name', metavar='<name>') 99@click.argument('netblocks', metavar='<netblocks>', nargs=-1) 100def alert_create(name, netblocks): 101 """Create a network alert to monitor an external network""" 102 key = get_api_key() 103 104 # Get the list 105 api = shodan.Shodan(key) 106 try: 107 alert = api.create_alert(name, netblocks) 108 except shodan.APIError as e: 109 raise click.ClickException(e.value) 110 111 click.secho('Successfully created network alert!', fg='green') 112 click.secho('Alert ID: {}'.format(alert['id']), fg='cyan') 113 114 115@alert.command(name='domain') 116@click.argument('domain', metavar='<domain>', type=str) 117@click.option('--triggers', help='List of triggers to enable', default='malware,industrial_control_system,internet_scanner,iot,open_database,new_service,ssl_expired,vulnerable') 118def alert_domain(domain, triggers): 119 """Create a network alert based on a domain name""" 120 key = get_api_key() 121 122 api = shodan.Shodan(key) 123 try: 124 # Grab a list of IPs for the domain 125 domain = domain.lower() 126 click.secho('Looking up domain information...', dim=True) 127 info = api.dns.domain_info(domain, type='A') 128 domain_ips = set([record['value'] for record in info['data']]) 129 130 # Create the actual alert 131 click.secho('Creating alert...', dim=True) 132 alert = api.create_alert('__domain: {}'.format(domain), list(domain_ips)) 133 134 # Enable the triggers so it starts getting managed by Shodan Monitor 135 click.secho('Enabling triggers...', dim=True) 136 api.enable_alert_trigger(alert['id'], triggers) 137 except shodan.APIError as e: 138 raise click.ClickException(e.value) 139 140 click.secho('Successfully created domain alert!', fg='green') 141 click.secho('Alert ID: {}'.format(alert['id']), fg='cyan') 142 143 144@alert.command(name='download') 145@click.argument('filename', metavar='<filename>', type=str) 146@click.option('--alert-id', help='Specific alert ID to download the data of', default=None) 147def alert_download(filename, alert_id): 148 """Download all information for monitored networks/ IPs.""" 149 key = get_api_key() 150 151 api = shodan.Shodan(key) 152 ips = set() 153 networks = set() 154 155 # Helper method to process batches of IPs 156 def batch(iterable, size=1): 157 iter_length = len(iterable) 158 for ndx in range(0, iter_length, size): 159 yield iterable[ndx:min(ndx + size, iter_length)] 160 161 try: 162 # Get the list of alerts for the user 163 click.echo('Looking up alert information...') 164 if alert_id: 165 alerts = [api.alerts(aid=alert_id.strip())] 166 else: 167 alerts = api.alerts() 168 169 click.echo('Compiling list of networks/ IPs to download...') 170 for alert in alerts: 171 for net in alert['filters']['ip']: 172 if '/' in net: 173 networks.add(net) 174 else: 175 ips.add(net) 176 177 click.echo('Downloading...') 178 with open_file(filename) as fout: 179 # Check if the user is able to use batch IP lookups 180 batch_size = 1 181 if len(ips) > 0: 182 api_info = api.info() 183 if api_info['plan'] in ['corp', 'stream-100']: 184 batch_size = 100 185 186 # Convert it to a list so we can index into it 187 ips = list(ips) 188 189 # Grab all the IP information 190 for ip in batch(ips, size=batch_size): 191 try: 192 click.echo(ip) 193 results = api.host(ip) 194 if not isinstance(results, list): 195 results = [results] 196 197 for host in results: 198 for banner in host['data']: 199 write_banner(fout, banner) 200 except APIError: 201 pass 202 sleep(1) # Slow down a bit to make sure we don't hit the rate limit 203 204 # Grab all the network ranges 205 for net in networks: 206 try: 207 counter = 0 208 click.echo(net) 209 for banner in api.search_cursor('net:{}'.format(net)): 210 write_banner(fout, banner) 211 212 # Slow down a bit to make sure we don't hit the rate limit 213 if counter % 100 == 0: 214 sleep(1) 215 counter += 1 216 except APIError: 217 pass 218 except shodan.APIError as e: 219 raise click.ClickException(e.value) 220 221 click.secho('Successfully downloaded results into: {}'.format(filename), fg='green') 222 223 224@alert.command(name='info') 225@click.argument('alert', metavar='<alert id>') 226def alert_info(alert): 227 """Show information about a specific alert""" 228 key = get_api_key() 229 api = shodan.Shodan(key) 230 231 try: 232 info = api.alerts(aid=alert) 233 except shodan.APIError as e: 234 raise click.ClickException(e.value) 235 236 click.secho(info['name'], fg='cyan') 237 click.secho('Created: ', nl=False, dim=True) 238 click.secho(info['created'], fg='magenta') 239 240 click.secho('Notifications: ', nl=False, dim=True) 241 if 'triggers' in info and info['triggers']: 242 click.secho('enabled', fg='green') 243 else: 244 click.echo('disabled') 245 246 click.echo('') 247 click.secho('Network Range(s):', dim=True) 248 249 for network in info['filters']['ip']: 250 click.echo(u' > {}'.format(click.style(network, fg='yellow'))) 251 252 click.echo('') 253 if 'triggers' in info and info['triggers']: 254 click.secho('Triggers:', dim=True) 255 for trigger in info['triggers']: 256 click.echo(u' > {}'.format(click.style(trigger, fg='yellow'))) 257 click.echo('') 258 259 260@alert.command(name='list') 261@click.option('--expired', help='Whether or not to show expired alerts.', default=True, type=bool) 262def alert_list(expired): 263 """List all the active alerts""" 264 key = get_api_key() 265 266 # Get the list 267 api = shodan.Shodan(key) 268 try: 269 results = api.alerts(include_expired=expired) 270 except shodan.APIError as e: 271 raise click.ClickException(e.value) 272 273 if len(results) > 0: 274 click.echo(u'# {:14} {:<21} {:<15s}'.format('Alert ID', 'Name', 'IP/ Network')) 275 276 for alert in results: 277 click.echo( 278 u'{:16} {:<30} {:<35} '.format( 279 click.style(alert['id'], fg='yellow'), 280 click.style(alert['name'], fg='cyan'), 281 click.style(', '.join(alert['filters']['ip']), fg='white') 282 ), 283 nl=False 284 ) 285 286 if 'triggers' in alert and alert['triggers']: 287 click.secho('Triggers: ', fg='magenta', nl=False) 288 click.echo(', '.join(alert['triggers'].keys()), nl=False) 289 290 if 'expired' in alert and alert['expired']: 291 click.secho('expired', fg='red') 292 else: 293 click.echo('') 294 else: 295 click.echo("You haven't created any alerts yet.") 296 297 298@alert.command(name='stats') 299@click.option('--limit', help='The number of results to return.', default=10, type=int) 300@click.option('--filename', '-O', help='Save the results in a CSV file of the provided name.', default=None) 301@click.argument('facets', metavar='<facets ...>', nargs=-1) 302def alert_stats(limit, filename, facets): 303 """Show summary information about your monitored networks""" 304 # Setup Shodan 305 key = get_api_key() 306 api = shodan.Shodan(key) 307 308 # Make sure the user didn't supply an empty string 309 if not facets: 310 raise click.ClickException('No facets provided') 311 312 facets = [(facet, limit) for facet in facets] 313 314 # Get the list of IPs/ networks that the user is monitoring 315 networks = set() 316 try: 317 alerts = api.alerts() 318 for alert in alerts: 319 for tmp in alert['filters']['ip']: 320 networks.add(tmp) 321 except shodan.APIError as e: 322 raise click.ClickException(e.value) 323 324 # Grab the facets the user requested 325 try: 326 results = aggregate_facet(api, networks, facets) 327 except shodan.APIError as e: 328 raise click.ClickException(e.value) 329 330 # TODO: The below code was taken from __main__.py:stats() - we should refactor it so the code can be shared 331 # Print the stats tables 332 for facet in results['facets']: 333 click.echo('Top {} Results for Facet: {}'.format(len(results['facets'][facet]), facet)) 334 335 for item in results['facets'][facet]: 336 # Force the value to be a string - necessary because some facet values are numbers 337 value = u'{}'.format(item['value']) 338 339 click.echo(click.style(u'{:28s}'.format(value), fg='cyan'), nl=False) 340 click.echo(click.style(u'{:12,d}'.format(item['count']), fg='green')) 341 342 click.echo('') 343 344 # Create the output file if requested 345 fout = None 346 if filename: 347 if not filename.endswith('.csv'): 348 filename += '.csv' 349 fout = open(filename, 'w') 350 writer = csv.writer(fout, dialect=csv.excel) 351 352 # Write the header that contains the facets 353 row = [] 354 for facet in results['facets']: 355 row.append(facet) 356 row.append('') 357 writer.writerow(row) 358 359 # Every facet has 2 columns (key, value) 360 counter = 0 361 has_items = True 362 while has_items: 363 # pylint: disable=W0612 364 row = ['' for i in range(len(results['facets']) * 2)] 365 366 pos = 0 367 has_items = False 368 for facet in results['facets']: 369 values = results['facets'][facet] 370 371 # Add the values for the facet into the current row 372 if len(values) > counter: 373 has_items = True 374 row[pos] = values[counter]['value'] 375 row[pos + 1] = values[counter]['count'] 376 377 pos += 2 378 379 # Write out the row 380 if has_items: 381 writer.writerow(row) 382 383 # Move to the next row of values 384 counter += 1 385 386 387@alert.command(name='remove') 388@click.argument('alert_id', metavar='<alert ID>') 389def alert_remove(alert_id): 390 """Remove the specified alert""" 391 key = get_api_key() 392 393 # Get the list 394 api = shodan.Shodan(key) 395 try: 396 api.delete_alert(alert_id) 397 except shodan.APIError as e: 398 raise click.ClickException(e.value) 399 click.echo("Alert deleted") 400 401 402@alert.command(name='triggers') 403def alert_list_triggers(): 404 """List the available notification triggers""" 405 key = get_api_key() 406 407 # Get the list 408 api = shodan.Shodan(key) 409 try: 410 results = api.alert_triggers() 411 except shodan.APIError as e: 412 raise click.ClickException(e.value) 413 414 if len(results) > 0: 415 click.secho('The following triggers can be enabled on alerts:', dim=True) 416 click.echo('') 417 418 for trigger in sorted(results, key=itemgetter('name')): 419 click.secho('{:<12} '.format('Name'), dim=True, nl=False) 420 click.secho(trigger['name'], fg='yellow') 421 422 click.secho('{:<12} '.format('Description'), dim=True, nl=False) 423 click.secho(trigger['description'], fg='cyan') 424 425 click.secho('{:<12} '.format('Rule'), dim=True, nl=False) 426 click.echo(trigger['rule']) 427 428 click.echo('') 429 else: 430 click.echo("No triggers currently available.") 431 432 433@alert.command(name='enable') 434@click.argument('alert_id', metavar='<alert ID>') 435@click.argument('trigger', metavar='<trigger name>') 436def alert_enable_trigger(alert_id, trigger): 437 """Enable a trigger for the alert""" 438 key = get_api_key() 439 440 # Get the list 441 api = shodan.Shodan(key) 442 try: 443 api.enable_alert_trigger(alert_id, trigger) 444 except shodan.APIError as e: 445 raise click.ClickException(e.value) 446 447 click.secho('Successfully enabled the trigger: {}'.format(trigger), fg='green') 448 449 450@alert.command(name='disable') 451@click.argument('alert_id', metavar='<alert ID>') 452@click.argument('trigger', metavar='<trigger name>') 453def alert_disable_trigger(alert_id, trigger): 454 """Disable a trigger for the alert""" 455 key = get_api_key() 456 457 # Get the list 458 api = shodan.Shodan(key) 459 try: 460 api.disable_alert_trigger(alert_id, trigger) 461 except shodan.APIError as e: 462 raise click.ClickException(e.value) 463 464 click.secho('Successfully disabled the trigger: {}'.format(trigger), fg='green') 465