Quantcast
Channel: Scanner – Security List Network™
Viewing all articles
Browse latest Browse all 271

Instarecon – Basic automated digital reconnaissance.

$
0
0

Automated basic digital reconnaissance. Great for getting an initial footprint of your targets and discovering additional subdomains. InstaRecon will do:
– DNS (direct, PTR, MX, NS) lookups
– Whois (domains and IP) lookups
– Google dorks in search of subdomains
– Shodan lookups
– Reverse DNS lookups on entire CIDRs
…all printed nicely on your console or csv file.
InstaRecon will never scan a target directly. Information is retrieved from DNS/Whois servers, Google, and Shodan.

Example Querying Shodan for open ports

Example Querying Shodan for open ports

Requirement :
+ argparse==1.2.1
+ click==3.3
+ colorama==0.3.3
+ dnspython==1.12.0
+ ipaddr==2.1.11
+ ipaddress==1.0.7
+ ipwhois==0.10.1
+ pythonwhois==2.4.3
+ requests==2.5.3
+ shodan==1.2.6
+ simplejson==3.6.5
+ wsgiref==0.1.2

instarecon.py Code:

#!/usr/bin/env python
import sys
import socket
import argparse
import requests
import re
import time
from random import randint
import csv
import os
import datetime

import pythonwhois as whois #http://cryto.net/pythonwhois/usage.html https://github.com/joepie91/python-whois
from ipwhois import IPWhois as ipw #https://pypi.python.org/pypi/ipwhois
import ipaddress as ipa #https://docs.python.org/3/library/ipaddress.html
import dns.resolver,dns.reversename,dns.name #http://www.dnspython.org/docs/1.12.0/
import shodan #https://shodan.readthedocs.org/en/latest/index.html


class Host(object):
    """
    Host represents an entity on the internet. Can originate from a domain or from an IP.
    Keyword arguments:
    domain -- DNS/Domain name e.g. google.com
    type -- either 'domain' or 'ip', depending on original information passed to __init__
    ips -- List of instances of IP. Each instance contains:
        ip -- Str representation of IP e.g. 8.8.8.8
        rev_domains -- list of str representing reverse domains related to ip 
        whois_ip -- Dict containing results from a Whois lookup on an IP
        shodan -- Dict containing results from Shodan lookups
        cidr -- Str of CIDR that this ip is part of (taken from whois_ip results)
    mx -- Set of Hosts for each DNS MX entry for original self.domain
    ns -- Set of Hosts for each DNS NS entry for original self.domain
    whois_domain -- str representation of the Whois query
    subdomains -- Set of Hosts for each related Host found that is a subdomain of self.domain
    linkedin_page -- Str of LinkedIn url that contains domain in html
    related_hosts -- Set of Hosts that may be related to host, as they're part of the same cidrs
    cidrs -- set of ipa.Ipv4Networks for each ip.cidr 
    """

    def __init__(self,domain=None,ips=(),reverse_domains=()):
        #Type check - depends on what parameters have been passed
        if domain:
            self.type = 'domain'
        elif ips:
            self.type = 'ip'
        else:
            raise ValueError

        self.domain = domain
        self.ips = [ IP(str(ip),reverse_domains) for ip in ips ]
        self.mx = set()
        self.ns = set()
        self.whois_domain = None
        self.subdomains = set()
        self.linkedin_page = None
        self.related_hosts = set()
        self.cidrs = set()

    dns_resolver = dns.resolver.Resolver()
    dns_resolver.timeout = 5
    dns_resolver.lifetime = 5

    def __str__(self):
        if self.type == 'domain':
            return str(self.domain)
        elif self.type == 'ip':
            return str(self.ips[0])
    
    def __hash__(self):
        if self.type == 'domain':
            return hash(('domain',self.domain))
        elif self.type == 'ip':
            return hash(('ip',','.join([str(ip) for ip in self.ips])))
    
    def __eq__(self,other):
        if self.type == 'domain':
            return self.domain == other.domain
        elif self.type == 'ip':
            return self.ips == other.ips

    def dns_lookups(self):
        """
        Basic DNS lookups on self. Returns self.
        1) Direct DNS lookup on self.domain
        2) Reverse DNS lookup on each self.ips
        """
        if self.type == 'domain':
            self._get_ips()
        for ip in self.ips: ip.get_rev_domains() 
        return self

    def mx_dns_lookup(self):
        """
        DNS lookup to find MX entries i.e. mail servers responsible for self.domain
        """
        if self.domain:
            mx_list = self._ret_mx_by_name(self.domain)
            if mx_list:
                self.mx.update([ Host(domain=mx).dns_lookups() for mx in mx_list ])
                self._add_to_subdomains_if_valid(subdomains_as_hosts=self.mx)

    def ns_dns_lookup(self):
        """
        DNS lookup to find NS entries i.e. name/DNS servers responsible for self.domain
        """        
        if self.domain:
            ns_list = self._ret_ns_by_name(self.domain)
            if ns_list:
                self.ns.update([ Host(domain=ns).dns_lookups() for ns in ns_list ])
                self._add_to_subdomains_if_valid(subdomains_as_hosts=self.ns)
    
    def google_lookups(self):
        """
        Google queries to find related subdomains and linkedin pages.
        """
        if self.domain:
            self.linkedin_page = self._ret_linkedin_page_from_google(self.domain)

            self._add_to_subdomains_if_valid(subdomains_as_str=self._ret_subdomains_from_google())

    def get_rev_domains_for_ips(self):
        """
        Reverse DNS lookup on each IP in self.ips
        """
        if self.ips:
            for ip in self.ips:
                ip.get_rev_domains()
        return self

    def get_whois_domain(self,num=0):
        """
        Whois lookup on self.domain. Saved in self.whois_domain as string, 
        since each top-level domain has a way of representing data.
        This makes it hard to index it, almost a project on its on.
        """
        try:
            if self.domain:
                query = whois.get_whois(self.domain)

                if 'raw' in query:
                    self.whois_domain = query['raw'][0].split('<')[0].lstrip().rstrip()

        except Exception as e:
            InstaRecon.error(e,sys._getframe().f_code.co_name)
            pass
    
    def get_all_whois_ip(self):
        """
        IP Whois lookups on each ip within self.ips
        Saved in each ip.whois_ip as dict, since this is how it is returned by ipwhois library.
        """
        #Keeps track of lookups already made - cidr as key, whois_ip dict as val
        cidrs_found = {}
        for ip in self.ips:
            cidr_found = False
            for cidr,whois_ip in cidrs_found.iteritems():
                if ipa.ip_address(ip.ip.decode('unicode-escape')) in cidr: #cidr already IPv4.Network obj
                    #If cidr is already in Host, we won't get_whois_ip again.
                    #Instead we will save cidr in ip just to make it easier to relate them later
                    ip.cidr,ip.whois_ip = cidr,whois_ip
                    cidr_found = True
                    break
            if not cidr_found:
                ip.get_whois_ip()
                cidrs_found[ip.cidr] = ip.whois_ip

        self.cidrs = set([ip.cidr for ip in self.ips if ip.cidr])

    def get_all_shodan(self,key):
        """
        Shodan lookups for each ip within self.ips.
        Saved in ip.shodan as dict.
        """
        if key:
            for ip in self.ips:
                ip.get_shodan(key)
    
    def _get_ips(self):
        """
        Does direct DNS lookup to get IPs from self.domains.
        Used internally by self.dns_lookups()
        """
        if self.domain and not self.ips:
            ips = self._ret_host_by_name(self.domain)
            if ips:
                self.ips = [ IP(str(ip)) for ip in ips ]
        return self

    @staticmethod
    def _ret_host_by_name(name):
        try:
            return Host.dns_resolver.query(name)
        except (dns.resolver.NXDOMAIN,dns.resolver.NoAnswer,dns.exception.Timeout) as e:
            InstaRecon.error('[-] Host lookup failed for '+name,sys._getframe().f_code.co_name)
            pass

    @staticmethod
    def _ret_mx_by_name(name):
        try:
            #rdata.exchange for domains and rdata.preference for integer
            return [str(mx.exchange).rstrip('.') for mx in Host.dns_resolver.query(name,'MX')]
        except (dns.resolver.NXDOMAIN,dns.resolver.NoAnswer,dns.exception.Timeout) as e:
            InstaRecon.error('[-] MX lookup failed for '+name,sys._getframe().f_code.co_name)
            pass

    @staticmethod
    def _ret_ns_by_name(name):
        try:
            #rdata.exchange for domains and rdata.preference for integer
            return [str(ns).rstrip('.') for ns in Host.dns_resolver.query(name,'NS')]
        except (dns.resolver.NXDOMAIN,dns.resolver.NoAnswer,dns.exception.Timeout) as e:
            InstaRecon.error('[-] NS lookup failed for '+name,sys._getframe().f_code.co_name)
            pass

    @staticmethod
    def _ret_linkedin_page_from_google(name):
        """
        Uses a google query to find a possible LinkedIn page related to name (usually self.domain)
        Google query is "site:linkedin.com/company name", and first result is used
        """
        try:
            request='http://google.com/search?hl=en&meta=&num=10&q=site:linkedin.com/company%20"'+name+'"'
            google_search = requests.get(request)
            google_results = re.findall('<cite>(.+?)<\/cite>', google_search.text)
            for url in google_results:
                if 'linkedin.com/company/' in url:
                    return re.sub('<.*?>', '', url)
        except Exception as e:
            InstaRecon.error(e,sys._getframe().f_code.co_name)


    def _ret_subdomains_from_google(self):
        """ 
        This method uses google dorks to get as many subdomains from google as possible
        It returns a set of Hosts for each subdomain found in google
        Each Host will have dns_lookups() already callled, with possibly ips and rev_domains filled
        """

        def _google_subdomains_lookup(domain,subdomains_to_avoid,num,counter):
            """
            Sub method that reaches out to google using the following query:
            site:*.domain -site:subdomain_to_avoid1 -site:subdomain_to_avoid2 -site:subdomain_to_avoid3...
            Returns list of unique subdomain strings
            """

            #Sleep some time between 0 - 4.999 seconds
            time.sleep(randint(0,4)+randint(0,1000)*0.001)


            request = 'http://google.com/search?hl=en&meta=&num='+str(num)+'&start='+str(counter)+'&q='+\
                        'site%3A%2A'+domain

            for subdomain in subdomains_to_avoid:
                #Don't want to remove original name from google query
                if subdomain != domain:
                    request = ''.join([request,'%20%2Dsite%3A',str(subdomain)])

            google_search = None
            try:
                google_search = requests.get(request)
            except Exception as e:
                InstaRecon.error(e,sys._getframe().f_code.co_name)

            new_subdomains = set()
            if google_search:
                google_results = re.findall('<cite>(.+?)<\/cite>', google_search.text)


                for url in set(google_results):
                    #Removing html tags from inside url (sometimes they ise <b> or <i> for ads)
                    url = re.sub('<.*?>', '', url)

                    #Follows Javascript pattern of accessing URLs
                    g_host = url
                    g_protocol = ''
                    g_pathname = ''

                    temp = url.split('://')

                    #If there is g_protocol e.g. http://, ftp://, etc
                    if len(temp)>1:
                        g_protocol = temp[0]
                        #remove g_protocol from url
                        url = ''.join(temp[1:])

                    temp = url.split('/')
                    #if there is a pathname after host
                    if len(temp)>1:

                        g_pathname = '/'.join(temp[1:])
                        g_host = temp[0]

                    new_subdomains.add(g_host)

                #TODO do something with g_pathname and g_protocol
                #Currently not using protocol or pathname for anything
            return list(new_subdomains)

        #Keeps subdomains found by _google_subdomains_lookup
        subdomains_discovered = []
        #Variable to check if there is any new result in the last iteration
        subdomains_in_last_iteration = -1

        while len(subdomains_discovered) > subdomains_in_last_iteration:
                                
            subdomains_in_last_iteration = len(subdomains_discovered)
            
            subdomains_discovered += _google_subdomains_lookup(self.domain,subdomains_discovered,100,0)
            subdomains_discovered = list(set(subdomains_discovered))

        subdomains_discovered += _google_subdomains_lookup(self.domain,subdomains_discovered,100,100)
        subdomains_discovered = list(set(subdomains_discovered))
        return subdomains_discovered


    def _add_to_subdomains_if_valid(self,subdomains_as_str=None,subdomains_as_hosts=None):
        """
        Add Hosts from subdomains_as_str or subdomains_as_hosts to self.subdomains if indeed these hosts are subdomains
        subdomains_as_hosts and subdomains_as_str should be iterable list or set
        """
        if subdomains_as_str:
            self.subdomains.update(
                [ Host(domain=subdomain).dns_lookups() for subdomain in subdomains_as_str if self._is_parent_domain_of(subdomain) ]
            )

        elif subdomains_as_hosts:
            self.subdomains.update(
                [ subdomain for subdomain in subdomains_as_hosts if self._is_parent_domain_of(subdomain) ]
            )

    def _is_parent_domain_of(self,subdomain):
        """
        Checks if subdomain is indeed a subdomain of self.domain
        In addition it filters out invalid dns names
        """
        if isinstance(subdomain, Host):
            #If subdomain has .domain
            if subdomain.domain:
                try:
                    return dns.name.from_text(subdomain.domain).is_subdomain(dns.name.from_text(self.domain))
                except Exception as e:
                    pass

            #If subdomain doesn't have .domain, if was found through reverse dns scan on cidr
            #So I must add the rev parameter to subdomain as .domain, so it looks better on the csv
            elif subdomain.ips[0].rev_domains:
                for rev in subdomain.ips[0].rev_domains:
                    try:
                        if dns.name.from_text(rev).is_subdomain(dns.name.from_text(self.domain)):
                            #Adding a .rev_domains str to .domain
                            subdomain.domain = rev
                            return True
                    except Exception as e:
                        pass     
        else:
            try:
                return dns.name.from_text(subdomain).is_subdomain(dns.name.from_text(self.domain))
            except dns.name.EmptyLabel:
                #EmptyLabel is an exception raised for bad dns strings
                pass
            
        return False

    def reverse_lookup_on_related_cidrs(self,feedback=False):
        """
        Does reverse dns lookups in all cidrs that are related to this host
        Will be used to check for subdomains found through reverse lookup
        """
        for cidr in self.cidrs:
            for ip in cidr:
                
                #Holds lookup results
                lookup_result = None
                #Used to repeat same scan if user issues KeyboardInterrupt
                this_scan_completed = False

                while not this_scan_completed:
                    try:
                        lookup_result = Host.dns_resolver.query(dns.reversename.from_address(str(ip)),'PTR')
                        this_scan_completed = True
                    except (dns.resolver.NXDOMAIN,
                        dns.resolver.NoAnswer,
                        dns.resolver.NoNameservers,
                        dns.exception.Timeout) as e:
                        this_scan_completed = True
                    except KeyboardInterrupt:
                        if isinstance(self, Network):
                            raise KeyboardInterrupt
                        else:
                            if raw_input('[-] Sure you want to stop scanning '+str(cidr)+\
                                '? Program flow will continue normally. (y/N):') in ['Y','y']:
                                return

                    if lookup_result:
                        #Organizing reverse lookup results
                        reverse_domains = [ str(domain).rstrip('.') for domain in lookup_result ]
                        #Creating new host
                        new_host = Host(ips=[ip],reverse_domains=reverse_domains)

                        #Append host to current host self.related_hosts
                        self.related_hosts.add(new_host)

                        #Don't want to do this in case self is Network
                        if type(self) is Host:
                            #Adds new_host to self.subdomains if new_host indeed is subdomain
                            self._add_to_subdomains_if_valid(subdomains_as_hosts=[new_host])

                        if feedback: print new_host.print_all_ips()

        if not self.related_hosts: print '# No results for this range'

    def print_all_ips(self):
        if self.ips:
            return '\n'.join([ ip.print_ip() for ip in self.ips ]).rstrip()
        
    def print_subdomains(self):
        return self._print_domains(sorted(self.subdomains, key=lambda x: x.domain))

    @staticmethod
    def _print_domains(hosts):
        #Static method that prints a list of domains with its respective ips and rev_domains
        #domains should be a list of Hosts
        if hosts:
            ret = ''
            for host in hosts:

                ret = ''.join([ret,host.domain])
                
                p = host.print_all_ips()
                if p: ret = ''.join([ret,'\n\t',p.replace('\n','\n\t')])
                
                ret = ''.join([ret,'\n'])

            return ret.rstrip().lstrip()

    def print_all_ns(self):
        #Print all NS records
        return self._print_domains(self.ns)

    def print_all_mx(self):
        #Print all MS records
        return self._print_domains(self.mx)

    def print_dns_only(self):
        return self._print_domains([self])

    def print_all_whois_ip(self):
        #Prints whois_ip records related to all self.ips
        ret = set([ip.print_whois_ip() for ip in self.ips if ip.whois_ip])
        return '\n'.join(ret).lstrip().rstrip()

    def print_all_shodan(self):
        #Print all Shodan entries (one for each IP in self.ips)

        ret = [ ip.print_shodan() for ip in self.ips if ip.shodan ]
        return '\n'.join(ret).lstrip().rstrip()

    def print_as_csv_lines(self):
        """Generator that yields each IP within self.ips as a csv line."""

        yield ['Target:', str(self)]
        
        if self.ips:
            yield [
                    'Domain',
                    'IP',
                    'Reverse domains',
                    'NS',
                    'MX',
                    # 'Subdomains',
                    'Domain whois',
                    'IP whois',
                    'Shodan',
                    'LinkedIn page',
                    'CIDRs'
                ]

            for ip in self.ips:
                yield [
                    self.domain,
                    str(ip),
                    '\n'.join(ip.rev_domains),
                    self.print_all_ns(),
                    self.print_all_mx(),
                    # self.print_subdomains(),
                    self.whois_domain,
                    ip.print_whois_ip(),
                    ip.print_shodan(),
                    self.linkedin_page,
                    ', '.join([str(cidr) for cidr in self.cidrs])
                ]

        if self.subdomains:
            yield ['\n']
            yield ['Subdomains for '+str(self.domain)]
            yield ['Domain','IP','Reverse domains']
            
            for sub in sorted(self.subdomains, key=lambda x: x.domain):
                for ip in sub.ips:
                    if sub.domain: 
                        yield [ sub.domain,ip.ip,','.join( ip.rev_domains ) ]
                    else:
                        yield [ ip.rev_domains[0],ip.ip,','.join( ip.rev_domains ) ]

        if self.related_hosts:
            yield ['\n']
            yield ['Hosts in same CIDR as',str(self),'(all results found, including subdomains)']
            yield ['IP','Reverse domains']

            for sub in sorted(self.related_hosts, key=lambda x: x.ips[0]):
                yield [
                    ','.join([ str(ip) for ip in sub.ips ]),
                    ','.join([ ','.join(ip.rev_domains) for ip in sub.ips ]),
                ]

    def do_all_lookups(self, shodan_key=None):
        """
        This method does all possible direct lookups for a Host.
        Not called by any Host or Scan function, only here for testing purposes.
        """
        self.dns_lookups()
        self.ns_dns_lookup()
        self.mx_dns_lookup()
        self.get_whois_domain()
        self.get_all_whois_ip()
        if shodan_key: self.get_all_shodan(shodan_key)
        self.google_lookups()

class Network(Host):
    """
    Subclass of Host that represents an IP network to be scanned.
    Keywork arguments:
    cidrs -- set of IPv4Networks to be scanned
    related_hosts -- set of valid Hosts found by scanning each cidr in cidrs
    """
    def __init__(self,cidrs=()):
        """
        cidrs parameter should be an iterable containing a single ipaddress.IPv4Network
        It is treated as a set of CIDRs to allow reuse of methods from Host
        """
        self.cidrs = set([ cidr for cidr in cidrs if isinstance(cidr,ipa.IPv4Network) ])
        if not self.cidrs: raise ValueError
        self.related_hosts = set()

    def __str__(self):
        return ','.join([str(cidr) for cidr in self.cidrs])
    
    def __hash__(self):
        return hash(('cidrs',','.join([str(cidr) for cidr in self.cidrs])))
    
    def __eq__(self,other):
        return self.cidrs == other.cidrs

    def print_as_csv_lines(self):
        """Overrides method from Host. Yields each Host in related_hosts as csv line"""
        yield ['Target: '+', '.join([ str(cidr) for cidr in self.cidrs ])]
        
        if self.related_hosts:
            yield ['IP','Reverse domains',]
            for host in self.related_hosts:
                yield [
                    ', '.join([ str(ip) for ip in host.ips ]),
                    ', '.join([ ', '.join(ip.rev_domains) for ip in host.ips ]),
                ]
        else:
            yield ['No results']

class IP(object):
    """
    IP and information specific to it. Hosts contain multiple IPs,
    as a domain can resolve to multiple IPs.
    Keyword arguments:
    ip -- Str representation of this ip e.g. '8.8.8.8'
    whois_ip -- Dict containing results for Whois lookups against self.ip
    cidr -- ipa.IPv4Network that contains self.ip (taken from whois_ip), e.g. 8.8.8.0/24
    rev_domains -- List of str for each reverse domain for self.ip, found through reverse DNS lookups
    shodan -- Dict containing Shodan results
    """

    def __init__(self,ip,rev_domains=None):
        if rev_domains is None: rev_domains = []
        self.ip = str(ip)
        self.rev_domains = rev_domains
        self.whois_ip = {}
        self.cidr = None
        self.shodan = None

    def __str__(self):
        return str(self.ip)

    def __hash__(self):
        return hash(('ip',self.ip))
    
    def __eq__(self,other):
        return self.ip == other.ip

    @staticmethod
    def _ret_host_by_ip(ip):
        try:
            return Host.dns_resolver.query(dns.reversename.from_address(ip),'PTR')
        except (dns.resolver.NXDOMAIN,dns.resolver.NoAnswer,dns.exception.Timeout) as e:
            InstaRecon.error('[-] Host lookup failed for '+ip,sys._getframe().f_code.co_name)

    def get_rev_domains(self):
        rev_domains = None
        rev_domains = self._ret_host_by_ip(self.ip)
        if rev_domains:
            self.rev_domains = [ str(domain).rstrip('.') for domain in rev_domains ]
        return self

    def get_shodan(self, key):
        try:
            shodan_api_key = key
            api = shodan.Shodan(shodan_api_key)
            self.shodan = api.host(str(self))
        except Exception as e:
            InstaRecon.error(e,sys._getframe().f_code.co_name)

    def get_whois_ip(self):
        try:
            self.whois_ip = ipw(str(self)).lookup() or None
        except Exception as e:
            InstaRecon.error(e,sys._getframe().f_code.co_name)
        
        if self.whois_ip:
            if 'nets' in self.whois_ip:
                if self.whois_ip['nets']:
                    cidrs = [ ipa.ip_network(net['cidr'].decode('unicode-escape'),strict=False) for net in self.whois_ip['nets'] ]
                    for cidr in cidrs:
                        self.cidr = self.cidr or cidr
                        if cidr.num_addresses > self.cidr.num_addresses:
                            self.cidr = cidr
        return self
    
    def print_ip(self):
        ret = str(self.ip)

        if self.rev_domains:
            if len(self.rev_domains) < 2:
                ret =  ''.join([ret,' - ',self.rev_domains[0]])
            else:
                for rev in self.rev_domains:
                    ret =  '\t'.join([ret,'\n',rev])
        return ret

    def print_whois_ip(self):
        if self.whois_ip:
            result = ''
            #Printing all lines except 'nets' annd 'query'
            for key,val in sorted(self.whois_ip.iteritems()):
                if val and key not in ['nets','query']:
                    result = '\n'.join([result,key+': '+str(val)])
            #Printing each dict within 'nets'
            for key,net in enumerate(self.whois_ip['nets']):
                result = '\n'.join([result,'net '+str(key)+':'])
                if net['cidr']: result = '\n\t'.join([ result,'cidr' + ': ' + net['cidr'].replace('\n',', ') ])
                if net['range']: result = '\n\t'.join([ result,'range' + ': ' + net['range'].replace('\n',', ') ])
                if net['name']: result = '\n\t'.join([ result,'name' + ': ' + net['name'].replace('\n',', ') ])
                if net['description']: result = '\n\t'.join([ result,'description' + ': ' + net['description'].replace('\n',', ') ])
                if net['handle']:  result = '\n\t'.join([ result,'handle' + ': ' + net['handle'].replace('\n',', ') ])
                result = '\n\t'.join([ result,'' ])
                if net['address']:  result = '\n\t'.join([ result,'address' + ': ' + net['address'].replace('\n',', ') ])
                if net['city']:  result = '\n\t'.join([ result,'city' + ': ' + net['city'].replace('\n',', ') ])
                if net['state']:  result = '\n\t'.join([ result,'state' + ': ' + net['state'].replace('\n',', ') ])
                if net['postal_code']:  result = '\n\t'.join([ result,'postal_code' + ': ' + net['postal_code'].replace('\n',', ') ])
                if net['country']:  result = '\n\t'.join([ result,'country' + ': ' + net['country'].replace('\n',', ') ])
                result = '\n\t'.join([ result,'' ])
                if net['abuse_emails']:  result = '\n\t'.join([ result,'abuse_emails' + ': ' + net['abuse_emails'].replace('\n',', ') ])
                if net['tech_emails']:  result = '\n\t'.join([ result,'tech_emails' + ': ' + net['tech_emails'].replace('\n',', ') ])
                if net['misc_emails']:  result = '\n\t'.join([ result,'misc_emails' + ': ' + net['misc_emails'].replace('\n',', ') ])
                result = '\n\t'.join([ result,'' ])
                if net['created']:  result = '\n\t'.join([ result,'created' + ': ' + time.strftime("%Y-%m-%d %H:%M:%S", time.strptime(net['created'].replace('\n',', '),'%Y-%m-%dT%H:%M:%S')) ])
                if net['updated']:  result = '\n\t'.join([ result,'updated' + ': ' + time.strftime("%Y-%m-%d %H:%M:%S", time.strptime(net['updated'].replace('\n',', '),'%Y-%m-%dT%H:%M:%S')) ])

                # for key2,val2 in sorted(net.iteritems()):
                #         result = '\n\t'.join([result,key2+': '+str(val2).replace('\n',', ')])     
            return result.lstrip().rstrip()

    def print_shodan(self):
        if self.shodan:

            result = ''.join(['IP: ',self.shodan.get('ip_str'),'\n'])
            result = ''.join([result,'Organization: ',self.shodan.get('org','n/a'),'\n'])

            if self.shodan.get('os','n/a'):
                result = ''.join([result,'OS: ',self.shodan.get('os','n/a'),'\n'])

            if self.shodan.get('isp','n/a'):
                result = ''.join([result,'ISP: ',self.shodan.get('isp','n/a'),'\n'])

            if len(self.shodan['data']) > 0:
                for item in self.shodan['data']:
                    result = '\n'.join([
                        result,
                        'Port: {}'.format(item['port']),
                        'Banner: {}'.format(item['data'].replace('\n','\n\t').rstrip()),
                        ])
            return result.rstrip().lstrip()

class InstaRecon(object):
    """
    Holds all Host entries and manages scans, interpret user input, threads and outputs.
    Keyword arguments:
    feedback -- Bool flag for output printing. Static variable.
    versobe -- Bool flag for verbose output printing. Static variable.
    nameserver -- Str DNS server to be used for lookups (consumed by dns.resolver module)
    shodan_key -- Str key used for Shodan lookups
    targets -- Set of Hosts or Networks that will be scanned1
    bad_targets -- Set of user inputs that could not be understood or resolved
    """
    version = '0.1'
    feedback = False
    verbose = False
    shodan_key = None
    entry_banner = '# InstaRecon v'+version+' - by Luis Teixeira (teix.co)'
    exit_banner = '# Done'

    def __init__(self,nameserver=None,timeout=None,shodan_key=None,feedback=False,verbose=False,dns_only=False):

        InstaRecon.feedback = feedback
        InstaRecon.verbose=verbose
        InstaRecon.shodan_key = shodan_key
        if nameserver: 
            Host.dns_resolver.nameservers = [nameserver]
        if timeout: 
            Host.dns_resolver.timeout = timeout
            Host.dns_resolver.lifetime = timeout
        self.dns_only = dns_only
        self.targets = set()
        self.bad_targets = set()

    @staticmethod
    def error(e, method_name=None):
        if InstaRecon.feedback and InstaRecon.verbose: 
            print '# Error:', str(e),'| method:',method_name

    def populate(self, user_supplied_list):
        for user_supplied in user_supplied_list:
            self.add_host(user_supplied)

        if self.feedback:
            if not self.targets:
                print '# No hosts to scan'
            else:
                print '# Scanning',str(len(self.targets))+'/'+str(len(user_supplied_list)),'hosts'

                if not self.dns_only:
                    if not self.shodan_key:
                        print '# No Shodan key provided'
                    else:
                        print'# Shodan key provided -',self.shodan_key


    def add_host(self, user_supplied):
        """
        Add string passed by user to self.targets as proper Host objects
        For this, it parses user_supplied strings to separate IPs, Domains, and Networks.
        """
        #Test if user_supplied is an IP?
        try:
            ip = ipa.ip_address(user_supplied.decode('unicode-escape'))
            #if not (ip.is_multicast or ip.is_unspecified or ip.is_reserved or ip.is_loopback):
            self.targets.add(Host(ips=[str(ip)]))
            return
        except ValueError as e:
            pass

        #Test if user_supplied is a valid network range?
        try:
            net = ipa.ip_network(user_supplied.decode('unicode-escape'),strict=False)
            self.targets.add(Network([net]))
            return
        except ValueError as e:
            pass

        #Test if user_supplied is a valid DNS?
        try:
            ips = Host.dns_resolver.query(user_supplied)
            self.targets.add(Host(domain=user_supplied,ips=[str(ip) for ip in ips]))
            return
        except (dns.resolver.NXDOMAIN, dns.exception.SyntaxError) as e:
            #If here so results from network won't be so verbose
            if InstaRecon.feedback: print '[-] Couldn\'t resolve or understand -', user_supplied
            pass

        self.bad_targets.add(user_supplied)

    def scan_targets(self):
        for target in self.targets:
            if type(target) is Host:
                if self.dns_only:
                    self.dns_scan_on_host(target)
                else:
                    self.full_scan_on_host(target)
            elif type(target) is Network:
                self.reverse_dns_on_network(target)

    def reverse_dns_on_network(self,network):
        """Does reverse dns lookups on a network object"""
        fb = self.feedback
        if fb:
            print ''
            print '# _____________ Reverse DNS lookups on {} _____________ #'.format(str(network))

        network.reverse_lookup_on_related_cidrs(fb)

    def full_scan_on_host(self,host):
        """Does all possible scans for host"""
        fb = self.feedback
            
        if fb: 
            print ''
            print '# ____________________ Scanning {} ____________________ #'.format(str(host))

        ###DNS and Whois lookups
        if fb: 
            print ''
            print '# DNS lookups'

        host.dns_lookups()
        if fb:
            if host.domain:
                print '[*] Domain: '+host.domain
            
            #IPs and reverse domains
            if host.ips: 
                print ''
                print '[*] IPs & reverse DNS: '
                print host.print_all_ips()
        
        host.ns_dns_lookup()
        #NS records
        if host.ns and fb:
            print ''
            print '[*] NS records:'
            print host.print_all_ns()

        host.mx_dns_lookup()
        #MX records
        if host.mx and fb:
            print ''
            print '[*] MX records:'
            print host.print_all_mx()

        if fb: 
            print ''
            print '# Whois lookups'

        host.get_whois_domain()
        if host.whois_domain and fb:
            print ''
            print '[*] Whois domain:'
            print host.whois_domain

        host.get_all_whois_ip()
        if fb:
            m = host.print_all_whois_ip()
            if m:
                print ''
                print '[*] Whois IP:'
                print m

        #Shodan lookup
        if self.shodan_key:
            
            if fb: 
                print ''
                print '# Querying Shodan for open ports'

            host.get_all_shodan(self.shodan_key)

            if fb:
                m = host.print_all_shodan()
                if m:
                    print '[*] Shodan:'
                    print m

        #Google subdomains lookup
        if host.domain:
            if fb:
                print ''
                print '# Querying Google for subdomains and Linkedin pages, this might take a while'
            
            host.google_lookups()
            
            if fb:
                if host.linkedin_page:
                    print '[*] Possible LinkedIn page: '+host.linkedin_page

                if host.subdomains:
                    print '[*] Subdomains:'+'\n'+host.print_subdomains()
                else:
                    print '[-] Error: No subdomains found in Google. If you are scanning a lot, Google might be blocking your requests.'
        
        #DNS lookups on entire CIDRs taken from host.get_whois_ip()
        if host.cidrs:
            if fb:
                print ''
                print '# Reverse DNS lookup on range {}'.format(', '.join([str(cidr) for cidr in host.cidrs]))
            host.reverse_lookup_on_related_cidrs(feedback=True)
    
    def dns_scan_on_host(self,host):
        """Does only direct and reverse DNS lookups for host"""
        fb = self.feedback
            
        if fb: 
            print ''
            print '# _________________ DNS lookups on {} _________________ #'.format(str(host))

        host.dns_lookups()
        if fb:
            if host.domain:
                print ''
                print host.print_dns_only()

    def write_output_csv(self, filename=None):
        """Writes output for each target as csv in filename"""
        if filename:
            filename = os.path.expanduser(filename)
            fb = self.feedback

            if fb: 
                print '# Saving output csv file'

            output_as_lines = []

            for host in self.targets:
                try:
                    #Using generator to get one csv line at a time (one Host can yield multiple lines)
                    generator = host.print_as_csv_lines()
                    while True:
                        output_as_lines.append(generator.next())

                except StopIteration:
                    #Space between targets
                    output_as_lines.append(['\n'])
                
            output_written = False
            while not output_written:
                
                try:
                    with open(filename, 'wb') as f:
                        writer = csv.writer(f)

                        for line in output_as_lines:
                            writer.writerow(line)

                        output_written = True
                
                except Exception as e:
                    error = '[-] Something went wrong, can\'t open output file. Press anything to try again.'
                    if self.verbose: error = ''.join([error,'\nError: ',str(e)])
                    raw_input(error)
                
                except KeyboardInterrupt:
                    if raw_input('[-] Sure you want to exit without saving your file (Y/n)?') in ['y','Y','']:
                        sys.exit('# Scan interrupted')

if __name__ == '__main__':
    parser = argparse.ArgumentParser(
        description=InstaRecon.entry_banner,
        usage='%(prog)s [options] target1 [target2 ... targetN]',
        epilog=argparse.SUPPRESS,
        )
    parser.add_argument('targets', nargs='+', help='targets to be scanned - can be in the format of a domain (google.com), an IP (8.8.8.8) or a network range (8.8.8.0/24)')
    parser.add_argument('-o', '--output', required=False,nargs='?',help='output filename as csv')
    parser.add_argument('-n', '--nameserver', required=False,nargs='?',help='alternative DNS server to query')
    parser.add_argument('-s', '--shodan_key', required=False,nargs='?',help='shodan key for automated port/service information')
    parser.add_argument('-v','--verbose', action='store_true',help='verbose errors')
    parser.add_argument('-d','--dns_only', action='store_true',help='direct and reverse DNS lookups only')
    args = parser.parse_args()

    targets = sorted(set(args.targets))

    scan = InstaRecon(
        nameserver=args.nameserver,
        shodan_key=args.shodan_key,
        feedback=True,
        verbose=args.verbose,
        dns_only=args.dns_only,
        )
    
    try:
        print scan.entry_banner
        scan.populate(targets)
        scan.scan_targets()
        scan.write_output_csv(args.output)
        print scan.exit_banner
    except KeyboardInterrupt:
        sys.exit('# Scan interrupted')
    except (dns.resolver.NoNameservers):
        sys.exit('# Something went wrong. Sure you got internet connection?')

Example Output :

$ ./instarecon.py -s <shodan_key> -o ~/Desktop/github.com.csv github.com
# InstaRecon v0.1 - by Luis Teixeira (teix.co)
# Scanning 1/1 hosts
# Shodan key provided - <shodan_key>

# ____________________ Scanning github.com ____________________ #

# DNS lookups
[*] Domain: github.com

[*] IPs & reverse DNS: 
192.30.252.130 - github.com

[*] NS records:
ns4.p16.dynect.net
    204.13.251.16 - ns4.p16.dynect.net
ns3.p16.dynect.net
    208.78.71.16 - ns3.p16.dynect.net
ns2.p16.dynect.net
    204.13.250.16 - ns2.p16.dynect.net
ns1.p16.dynect.net
    208.78.70.16 - ns1.p16.dynect.net

[*] MX records:
ALT2.ASPMX.L.GOOGLE.com
    173.194.64.27 - oa-in-f27.1e100.net
ASPMX.L.GOOGLE.com
    74.125.203.26
ALT3.ASPMX.L.GOOGLE.com
    64.233.177.26
ALT4.ASPMX.L.GOOGLE.com
    173.194.219.27
ALT1.ASPMX.L.GOOGLE.com
    74.125.25.26 - pa-in-f26.1e100.net

# Whois lookups

[*] Whois domain:
Domain Name: github.com
Registry Domain ID: 1264983250_DOMAIN_COM-VRSN
Registrar WHOIS Server: whois.markmonitor.com
Registrar URL: http://www.markmonitor.com
Updated Date: 2015-01-08T04:00:18-0800
Creation Date: 2007-10-09T11:20:50-0700
Registrar Registration Expiration Date: 2020-10-09T11:20:50-0700
Registrar: MarkMonitor, Inc.
Registrar IANA ID: 292
Registrar Abuse Contact Email: abusecomplaints@markmonitor.com
Registrar Abuse Contact Phone: +1.2083895740
Domain Status: clientUpdateProhibited (https://www.icann.org/epp#clientUpdateProhibited)
Domain Status: clientTransferProhibited (https://www.icann.org/epp#clientTransferProhibited)
Domain Status: clientDeleteProhibited (https://www.icann.org/epp#clientDeleteProhibited)
Registry Registrant ID: 
Registrant Name: GitHub Hostmaster
Registrant Organization: GitHub, Inc.
Registrant Street: 88 Colin P Kelly Jr St, 
Registrant City: San Francisco
Registrant State/Province: CA
Registrant Postal Code: 94107
Registrant Country: US
Registrant Phone: +1.4157354488
Registrant Phone Ext: 
Registrant Fax: 
Registrant Fax Ext: 
Registrant Email: hostmaster@github.com
Registry Admin ID: 
Admin Name: GitHub Hostmaster
Admin Organization: GitHub, Inc.
Admin Street: 88 Colin P Kelly Jr St, 
Admin City: San Francisco
Admin State/Province: CA
Admin Postal Code: 94107
Admin Country: US
Admin Phone: +1.4157354488
Admin Phone Ext: 
Admin Fax: 
Admin Fax Ext: 
Admin Email: hostmaster@github.com
Registry Tech ID: 
Tech Name: GitHub Hostmaster
Tech Organization: GitHub, Inc.
Tech Street: 88 Colin P Kelly Jr St, 
Tech City: San Francisco
Tech State/Province: CA
Tech Postal Code: 94107
Tech Country: US
Tech Phone: +1.4157354488
Tech Phone Ext: 
Tech Fax: 
Tech Fax Ext: 
Tech Email: hostmaster@github.com
Name Server: ns1.p16.dynect.net
Name Server: ns2.p16.dynect.net
Name Server: ns4.p16.dynect.net
Name Server: ns3.p16.dynect.net
DNSSEC: unsigned
URL of the ICANN WHOIS Data Problem Reporting System: http://wdprs.internic.net/
>>> Last update of WHOIS database: 2015-05-04T06:48:47-0700

[*] Whois IP:
asn: 36459
asn_cidr: 192.30.252.0/24
asn_country_code: US
asn_date: 2012-11-15
asn_registry: arin
net 0:
    cidr: 192.30.252.0/22
    range: 192.30.252.0 - 192.30.255.255
    name: GITHUB-NET4-1
    description: GitHub, Inc.
    handle: NET-192-30-252-0-1

    address: 88 Colin P Kelly Jr Street
    city: San Francisco
    state: CA
    postal_code: 94107
    country: US

    abuse_emails: abuse@github.com
    tech_emails: hostmaster@github.com

    created: 2012-11-15 00:00:00
    updated: 2013-01-05 00:00:00

# Querying Shodan for open ports
[*] Shodan:
IP: 192.30.252.130
Organization: GitHub
ISP: GitHub

Port: 22
Banner: SSH-2.0-libssh-0.6.0
    Key type: ssh-rsa
    Key: AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PH
    kccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETY
    P81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoW
    f9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lG
    HSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ==
    Fingerprint: 16:27:ac:a5:76:28:2d:36:63:1b:56:4d:eb:df:a6:48
Port: 80
Banner: HTTP/1.1 301 Moved Permanently
    Content-length: 0
    Location: https://192.30.252.130/
    Connection: close

# Querying Google for subdomains and Linkedin pages, this might take a while
[*] Possible LinkedIn page: https://au.linkedin.com/company/github
[*] Subdomains:
blueimp.github.com
    199.27.75.133
bounty.github.com
    199.27.75.133
designmodo.github.com
    199.27.75.133
developer.github.com
    199.27.75.133
digitaloxford.github.com
    199.27.75.133
documentcloud.github.com
    199.27.75.133
education.github.com
    50.19.229.116 - ec2-50-19-229-116.compute-1.amazonaws.com
    50.17.253.231 - ec2-50-17-253-231.compute-1.amazonaws.com
    54.221.249.148 - ec2-54-221-249-148.compute-1.amazonaws.com
enterprise.github.com
    54.243.192.65 - ec2-54-243-192-65.compute-1.amazonaws.com
    54.243.49.169 - ec2-54-243-49-169.compute-1.amazonaws.com
erkie.github.com
    199.27.75.133
eternicode.github.com
    199.27.75.133
facebook.github.com
    199.27.75.133
fortawesome.github.com
    199.27.75.133
gist.github.com
    192.30.252.141 - gist.github.com
guides.github.com
    199.27.75.133
h5bp.github.com
    199.27.75.133
harvesthq.github.com
    199.27.75.133
help.github.com
    199.27.75.133
hexchat.github.com
    199.27.75.133
hubot.github.com
    199.27.75.133
ipython.github.com
    199.27.75.133
janpaepke.github.com
    199.27.75.133
jgilfelt.github.com
    199.27.75.133
jobs.github.com
    54.163.15.207 - ec2-54-163-15-207.compute-1.amazonaws.com
kangax.github.com
    199.27.75.133
karlseguin.github.com
    199.27.75.133
kouphax.github.com
    199.27.75.133
learnboost.github.com
    199.27.75.133
liferay.github.com
    199.27.75.133
lloyd.github.com
    199.27.75.133
mac.github.com
    199.27.75.133
mapbox.github.com
    199.27.75.133
matplotlib.github.com
    199.27.75.133
mbostock.github.com
    199.27.75.133
mdo.github.com
    199.27.75.133
mindmup.github.com
    199.27.75.133
mrdoob.github.com
    199.27.75.133
msysgit.github.com
    199.27.75.133
nativescript.github.com
    199.27.75.133
necolas.github.com
    199.27.75.133
nodeca.github.com
    199.27.75.133
onedrive.github.com
    199.27.75.133
pages.github.com
    199.27.75.133
panrafal.github.com
    199.27.75.133
parquet.github.com
    199.27.75.133
pnts.github.com
    199.27.75.133
raw.github.com
    199.27.75.133
rg3.github.com
    199.27.75.133
rosedu.github.com
    199.27.75.133
schacon.github.com
    199.27.75.133
scottjehl.github.com
    199.27.75.133
shop.github.com
    192.30.252.129 - github.com
shopify.github.com
    199.27.75.133
status.github.com
    184.73.218.119 - ec2-184-73-218-119.compute-1.amazonaws.com
    107.20.225.214 - ec2-107-20-225-214.compute-1.amazonaws.com
thoughtbot.github.com
    199.27.75.133
tomchristie.github.com
    199.27.75.133
training.github.com
    199.27.75.133
try.github.com
    199.27.75.133
twbs.github.com
    199.27.75.133
twitter.github.com
    199.27.75.133
visualstudio.github.com
    54.192.134.13 - server-54-192-134-13.syd1.r.cloudfront.net
    54.230.135.112 - server-54-230-135-112.syd1.r.cloudfront.net
    54.192.134.21 - server-54-192-134-21.syd1.r.cloudfront.net
    54.230.134.194 - server-54-230-134-194.syd1.r.cloudfront.net
    54.192.133.169 - server-54-192-133-169.syd1.r.cloudfront.net
    54.192.133.193 - server-54-192-133-193.syd1.r.cloudfront.net
    54.230.134.145 - server-54-230-134-145.syd1.r.cloudfront.net
    54.240.176.208 - server-54-240-176-208.syd1.r.cloudfront.net
wagerfield.github.com
    199.27.75.133
webcomponents.github.com
    199.27.75.133
webpack.github.com
    199.27.75.133
weheart.github.com
    199.27.75.133

# Reverse DNS lookup on range 192.30.252.0/22
192.30.252.80 - ns1.github.com
192.30.252.81 - ns2.github.com
192.30.252.86 - live.github.com
192.30.252.87 - live.github.com
192.30.252.88 - live.github.com
192.30.252.97 - ops-lb-ip1.iad.github.com
192.30.252.98 - ops-lb-ip2.iad.github.com
192.30.252.128 - github.com
192.30.252.129 - github.com
192.30.252.130 - github.com
192.30.252.131 - github.com
192.30.252.132 - assets.github.com
192.30.252.133 - assets.github.com
192.30.252.134 - assets.github.com
192.30.252.135 - assets.github.com
192.30.252.136 - api.github.com
192.30.252.137 - api.github.com
192.30.252.138 - api.github.com
192.30.252.139 - api.github.com
192.30.252.140 - gist.github.com
192.30.252.141 - gist.github.com
192.30.252.142 - gist.github.com
192.30.252.143 - gist.github.com
192.30.252.144 - codeload.github.com
192.30.252.145 - codeload.github.com
192.30.252.146 - codeload.github.com
192.30.252.147 - codeload.github.com
192.30.252.148 - ssh.github.com
192.30.252.149 - ssh.github.com
192.30.252.150 - ssh.github.com
192.30.252.151 - ssh.github.com
192.30.252.152 - pages.github.com
192.30.252.153 - pages.github.com
192.30.252.154 - pages.github.com
192.30.252.155 - pages.github.com
192.30.252.156 - githubusercontent.github.com
192.30.252.157 - githubusercontent.github.com
192.30.252.158 - githubusercontent.github.com
192.30.252.159 - githubusercontent.github.com
192.30.252.192 - github-smtp2-ext1.iad.github.net
192.30.252.193 - github-smtp2-ext2.iad.github.net
192.30.252.194 - github-smtp2-ext3.iad.github.net
192.30.252.195 - github-smtp2-ext4.iad.github.net
192.30.252.196 - github-smtp2-ext5.iad.github.net
192.30.252.197 - github-smtp2-ext6.iad.github.net
192.30.252.198 - github-smtp2-ext7.iad.github.net
192.30.252.199 - github-smtp2-ext8.iad.github.net
192.30.253.1 - ops-puppetmaster1-cp1-prd.iad.github.com
192.30.253.2 - janky-nix101-cp1-prd.iad.github.com
192.30.253.3 - janky-nix102-cp1-prd.iad.github.com
192.30.253.4 - janky-nix103-cp1-prd.iad.github.com
192.30.253.5 - janky-nix104-cp1-prd.iad.github.com
192.30.253.6 - janky-nix105-cp1-prd.iad.github.com
192.30.253.7 - janky-nix106-cp1-prd.iad.github.com
192.30.253.8 - janky-nix107-cp1-prd.iad.github.com
192.30.253.9 - janky-nix108-cp1-prd.iad.github.com
192.30.253.10 - gw.internaltools-esx1-cp1-prd.iad.github.com
192.30.253.11 - janky-chromium101-cp1-prd.iad.github.com
192.30.253.12 - gw.internaltools-esx2-cp1-prd.iad.github.com
192.30.253.13 - github-mon2ext-cp1-prd.iad.github.net
192.30.253.16 - github-smtp2a-ext-cp1-prd.iad.github.net
192.30.253.17 - github-smtp2b-ext-cp1-prd.iad.github.net
192.30.253.23 - ops-bastion1-cp1-prd.iad.github.com
192.30.253.30 - github-slowsmtp1-ext-cp1-prd.iad.github.net
192.30.254.1 - github-lb3a-cp1-prd.iad.github.com
192.30.254.2 - github-lb3b-cp1-prd.iad.github.com
192.30.254.3 - github-lb3c-cp1-prd.iad.github.com
192.30.254.4 - github-lb3d-cp1-prd.iad.github.com
# Saving output csv file
# Done

Download : Master.zip  | Clone Url
Source : https://github.com/vergl4s


Viewing all articles
Browse latest Browse all 271

Trending Articles