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