Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-passivetotal for openSUSE:Factory checked in at 2021-05-23 00:06:08 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-passivetotal (Old) and /work/SRC/openSUSE:Factory/.python-passivetotal.new.2988 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-passivetotal" Sun May 23 00:06:08 2021 rev:7 rq:894928 version:2.4.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-passivetotal/python-passivetotal.changes 2021-04-24 23:10:16.987490681 +0200 +++ /work/SRC/openSUSE:Factory/.python-passivetotal.new.2988/python-passivetotal.changes 2021-05-23 00:06:11.882603217 +0200 @@ -1,0 +2,20 @@ +Fri May 14 14:04:15 UTC 2021 - Sebastian Wagner <sebix+novell....@sebix.at> + +- update to version 2.4.0: + - Enhancements: + - Early implementation of exception handling for SSL properties; analyzer. AnalyzerError now available as a base exception type. + - SSL certs will now populate their own ip property, accessing the SSL history API when needed to fill in the details. + - New iphistory property of SSL certs to support the ip property and give direct access to the historial results. + - Used the tldextract Python library to expose useful properties on Hostname objects such as tld, registered_domain, and subdomain + - Change default days back for date-aware searches to 90 days (was 30) + - Reject IPs as strings for Hostname objects + - Ensure IPs are used when instantiating IPAddress objects + - Defang hostnames (i.e. analyzer.Hostname('api[.]riskiq[.]net') ) + - Support for Articles as a property of Hostnames and IPs, with autoloading for detailed fields including indicators, plus easy access to a list of all articles directly from analyzer.AllArticles() + - Support for Malware as a property of Hostnames and IPs + - Better coverage of pretty printing and dictionary representation across analyzer objects. + - Bug Fixes: + - Exception handling when no details found for an SSL certificate. + - Proper handling of None types that may have prevented result caching + +------------------------------------------------------------------- Old: ---- passivetotal-2.3.0.tar.gz New: ---- passivetotal-2.4.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-passivetotal.spec ++++++ --- /var/tmp/diff_new_pack.le90P8/_old 2021-05-23 00:06:12.334600812 +0200 +++ /var/tmp/diff_new_pack.le90P8/_new 2021-05-23 00:06:12.338600790 +0200 @@ -19,7 +19,7 @@ %{?!python_module:%define python_module() python-%{**} python3-%{**}} %bcond_without test Name: python-passivetotal -Version: 2.3.0 +Version: 2.4.0 Release: 0 Summary: Client for the PassiveTotal REST API License: GPL-2.0-only @@ -34,6 +34,7 @@ Requires: python-future Requires: python-python-dateutil Requires: python-requests +Requires: python-tldextract Requires(post): update-alternatives Requires(postun):update-alternatives BuildArch: noarch ++++++ passivetotal-2.3.0.tar.gz -> passivetotal-2.4.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/PKG-INFO new/passivetotal-2.4.0/PKG-INFO --- old/passivetotal-2.3.0/PKG-INFO 2021-04-14 23:18:17.000000000 +0200 +++ new/passivetotal-2.4.0/PKG-INFO 2021-05-10 18:45:03.000000000 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: passivetotal -Version: 2.3.0 +Version: 2.4.0 Summary: Library for the RiskIQ PassiveTotal and Illuminate API Home-page: https://github.com/passivetotal/python_api Author: RiskIQ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/analyzer/__init__.py new/passivetotal-2.4.0/passivetotal/analyzer/__init__.py --- old/passivetotal-2.3.0/passivetotal/analyzer/__init__.py 2021-04-14 23:16:56.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal/analyzer/__init__.py 2021-05-10 18:44:25.000000000 +0200 @@ -3,8 +3,9 @@ from collections import namedtuple from datetime import datetime, timezone, timedelta from passivetotal import * +from passivetotal.analyzer._common import AnalyzerError, is_ip -DEFAULT_DAYS_BACK = 30 +DEFAULT_DAYS_BACK = 90 api_clients = {} config = { @@ -67,6 +68,23 @@ return config[key] return config +def get_object(input, type=None): + """Get an Analyzer object for a given input and type. If no type is specified, + type will be autodetected based on the input. + + Returns :class:`analyzer.Hostname` or :class:`analyzer.IPAddress`. + """ + objs = { + 'IPAddress': IPAddress, + 'Hostname': Hostname + } + if type is None: + type = 'IPAddress' if is_ip(input) else 'Hostname' + elif type not in objs.keys(): + raise AnalyzerError('type must be IPAddress or Hostname') + return objs[type](input) + + def set_date_range(days_back=DEFAULT_DAYS_BACK, start=None, end=None): """Set a range of dates for all date-bounded API queries. @@ -135,4 +153,5 @@ from passivetotal.analyzer.hostname import Hostname from passivetotal.analyzer.ip import IPAddress -from passivetotal.analyzer.ssl import CertificateField \ No newline at end of file +from passivetotal.analyzer.ssl import CertificateField +from passivetotal.analyzer.articles import AllArticles \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/analyzer/_common.py new/passivetotal-2.4.0/passivetotal/analyzer/_common.py --- old/passivetotal-2.3.0/passivetotal/analyzer/_common.py 2021-04-14 23:16:56.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal/analyzer/_common.py 2021-05-10 18:44:25.000000000 +0200 @@ -1,10 +1,19 @@ """Base classes and common methods for the analyzer package.""" - +import pprint from datetime import datetime +import re + +def is_ip(test): + """Test to see if a string contains an IPv4 address.""" + pattern = re.compile(r"(\d{1,3}(?:\.|\]\.\[|\[\.\]|\(\.\)|{\.})\d{1,3}(?:\.|\]\.\[|\[\.\]|\(\.\)|{\.})\d{1,3}(?:\.|\]\.\[|\[\.\]|\(\.\)|{\.})\d{1,3})") + return len(pattern.findall(test)) > 0 +def refang(hostname): + """Remove square braces around dots in a hostname.""" + return re.sub(r'[\[\]]','', hostname) class RecordList: @@ -221,3 +230,40 @@ """ return len(self) < self._totalrecords + +class PrettyRecord: + """A record that can pretty-print itself. + + For best results, wrap this property in a print() statement. + + Depends on a as_dict property on the base object. + """ + + @property + def pretty(self): + """Pretty printed version of this record.""" + from passivetotal.analyzer import get_config + config = get_config('pprint') + return pprint.pformat(self.as_dict, **config) + + + +class PrettyList: + """A record list that can pretty-print itself. + + Depends on an as_dict property each object in the list. + """ + + @property + def pretty(self): + """Pretty printed version of this record list.""" + from passivetotal.analyzer import get_config + config = get_config('pprint') + return pprint.pformat([r.as_dict for r in self], **config) + + + + +class AnalyzerError(Exception): + """Base error class for Analyzer objects.""" + pass diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/analyzer/articles.py new/passivetotal-2.4.0/passivetotal/analyzer/articles.py --- old/passivetotal-2.3.0/passivetotal/analyzer/articles.py 1970-01-01 01:00:00.000000000 +0100 +++ new/passivetotal-2.4.0/passivetotal/analyzer/articles.py 2021-05-10 18:44:25.000000000 +0200 @@ -0,0 +1,277 @@ +from datetime import datetime, timezone +from passivetotal.analyzer._common import ( + RecordList, Record, FirstLastSeen +) +from passivetotal.analyzer import get_api + + + +class ArticlesList(RecordList): + """List of threat intelligence articles. + + Contains a list of :class:`passivetotal.analyzer.articles.Article` objects. + """ + + def _get_shallow_copy_fields(self): + return ['_totalrecords'] + + def _get_sortable_fields(self): + return ['age','title','type'] + + def parse(self, api_response): + """Parse an API response.""" + self._totalrecords = api_response.get('totalRecords') + self._records = [] + for article in api_response.get('articles', []): + self._records.append(Article(article)) + + def filter_tags(self, tags): + """Filtered article list that includes articles with an exact match to one + or more tags. + + Tests the `match_tags` method on each article. + + :param tags: String with one or multiple comma-separated tags, or a list + :rtype: :class:`passivetotal.analyzer.articles.ArticlesList` + """ + filtered_results = self._make_shallow_copy() + filtered_results._records = filter(lambda r: r.match_tags(tags), self._records) + return filtered_results + + def filter_text(self, text, fields=['tags','title','summary']): + """Filtered article list that contain the text in one or more fields. + + Searches tags, title and summary by default - set `fields` param to a + smaller list to narrow the search. + + :param text: text to search for + :param fields: list of fields to search (optional) + :rtype: :class:`passivetotal.analyzer.articles.ArticlesList` + """ + filtered_results = self._make_shallow_copy() + filtered_results._records = filter(lambda r: r.match_text(text, fields), self._records) + return filtered_results + + + +class AllArticles(ArticlesList): + """All threat intelligence articles currently published by RiskIQ. + + Contains a list of :class:`passivetotal.analyzer.articles.Article` objects. + + By default, instantiating the class will automatically load the entire list + of threat intelligence articles. Pass autoload=False to the constructor to disable + this functionality. + """ + + def __init__(self, autoload = True): + """Initialize a list of articles; will autoload by default. + + :param autoload: whether to automatically load articles upon instantiation (defaults to true) + """ + super().__init__() + if autoload: + self.load() + + def load(self): + """Query the API for articles and load them into an articles list.""" + response = get_api('Articles').get_articles() + self.parse(response) + + + +class Article(Record): + """A threat intelligence article.""" + + def __init__(self, api_response): + self._guid = api_response.get('guid') + self._title = api_response.get('title') + self._summary = api_response.get('summary') + self._type = api_response.get('type') + self._publishdate = api_response.get('publishDate') + self._link = api_response.get('link') + self._categories = api_response.get('categories') + self._tags = api_response.get('tags') + self._indicators = api_response.get('indicators') + + def __str__(self): + return self.title + + def __repr__(self): + return '<Article {}>'.format(self.guid) + + def _api_get_details(self): + """Query the articles detail endpoint to fill in missing fields.""" + response = get_api('Articles').get_details(self._guid) + self._summary = response.get('summary') + self._publishdate = response.get('publishedDate') + self._tags = response.get('tags') + self._categories = response.get('categories') + self._indicators = response.get('indicators') + + def _ensure_details(self): + """Ensure we have details for this article. + + Some API responses do not include full article details. This internal method + will determine if they are missing and trigger an API call to fetch them.""" + if self._summary is None and self._publishdate is None: + self._api_get_details() + + def _indicators_by_type(self, type): + """Get indicators of a specific type. + + Indicators are grouped by type in the API response. This method finds + the group of a specified type and returns the dict of results directly + from the API response. It assumes there is only one instance of a group + type in the indicator list and therefore only returns the first one. + """ + try: + return [ group for group in self.indicators if group['type']==type][0] + except IndexError: + return {'type': None, 'count': 0, 'values': [] } + + def match_tags(self, tags): + """Exact match search for one or more tags in this article's list of tags. + + :param tags: String with one or multiple comma-seperated tags, or a list + :rtype bool: Whether any of the tags are included in this article's list of tags. + """ + if type(tags) is str: + tags = tags.split(',') + return len(set(tags) & set(self.tags)) > 0 + + def match_text(self, text, fields=['tags','title','summary']): + """Case insensitive substring search across article text fields. + + Searches tags, title and summary by default - set `fields` param to a + smaller list to narrow the search. + :param text: text to search for + :param fields: list of fields to search (optional) + :rtype bool: whether the text was found in any of the fields + """ + for field in ['title','summary']: + if field in fields: + if text.casefold() in getattr(self, field).casefold(): + return True + if 'tags' in fields: + for tag in self.tags: + if text.casefold() in tag.casefold(): + return True + return False + + @property + def guid(self): + """Article unique ID within the RiskIQ system.""" + return self._guid + + @property + def title(self): + """Article short title.""" + return self._title + + @property + def type(self): + """Article visibility type (i.e. public, private).""" + return self._type + + @property + def summary(self): + """Article summary.""" + self._ensure_details() + return self._summary + + @property + def date_published(self): + """Date the article was published, as a datetime object.""" + self._ensure_details() + date = datetime.fromisoformat(self._publishdate) + return date + + @property + def age(self): + """Age of the article in days.""" + now = datetime.now(timezone.utc) + interval = now - self.date_published + return interval.days + + @property + def link(self): + """URL to a page with article details.""" + return self._link + + @property + def categories(self): + """List of categories this article is listed in.""" + self._ensure_details() + return self._categories + + @property + def tags(self): + """List of tags attached to this article.""" + self._ensure_details() + return self._tags + + def has_tag(self, tag): + """Whether this article has a given tag.""" + return (tag in self.tags) + + @property + def indicators(self): + """List of indicators associated with this article. + + This is the raw result retuned by the API. Expect an array of objects each + representing a grouping of a particular type of indicator.""" + self._ensure_details() + return self._indicators + + @property + def indicator_count(self): + """Sum of all types of indicators in this article.""" + return sum([i['count'] for i in self.indicators]) + + @property + def indicator_types(self): + """List of the types of indicators associated with this article.""" + return [ group['type'] for group in self.indicators ] + + @property + def ips(self): + """List of IP addresses in this article. + + :rtype: :class:`passivetotal.analyzer.ip.IPAddress` + """ + from passivetotal.analyzer import IPAddress + return [ IPAddress(ip) for ip in self._indicators_by_type('ip')['values'] ] + + @property + def hostnames(self): + """List of hostnames in this article. + + :rtype: :class:`passivetotal.analyzer.ip.Hostname` + """ + from passivetotal.analyzer import Hostname + return [ Hostname(domain) for domain in self._indicators_by_type('domain')['values'] ] + + + +class HasArticles: + + """An object which may be an indicator of compromise (IOC) published in an Article.""" + + def _api_get_articles(self): + """Query the articles API for articles with this entity listed as an indicator.""" + response = get_api('Articles').get_articles_for_indicator( + self.get_host_identifier() + ) + self._articles = ArticlesList(response) + return self._articles + + @property + def articles(self): + """Threat intelligence articles that reference this host. + + :rtype: :class:`passivetotal.analyzer.articles.ArticlesList` + """ + if getattr(self, '_articles', None) is not None: + return self._articles + return self._api_get_articles() \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/analyzer/components.py new/passivetotal-2.4.0/passivetotal/analyzer/components.py --- old/passivetotal-2.3.0/passivetotal/analyzer/components.py 2021-04-13 02:19:09.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal/analyzer/components.py 2021-05-10 18:44:25.000000000 +0200 @@ -137,7 +137,7 @@ :rtype: :class:`passivetotal.analyzer.components.ComponentHistory` """ - if getattr(self, '_components'): + if getattr(self, '_components', None) is not None: return self._components config = get_config() return self._api_get_components( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/analyzer/cookies.py new/passivetotal-2.4.0/passivetotal/analyzer/cookies.py --- old/passivetotal-2.3.0/passivetotal/analyzer/cookies.py 2021-04-13 02:19:09.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal/analyzer/cookies.py 2021-05-10 18:44:25.000000000 +0200 @@ -109,7 +109,7 @@ :rtype: :class:`passivetotal.analyzer.components.CookieHistory` """ - if getattr(self, '_cookies'): + if getattr(self, '_cookies', None) is not None: return self._cookies config = get_config() return self._api_get_cookies( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/analyzer/enrich.py new/passivetotal-2.4.0/passivetotal/analyzer/enrich.py --- old/passivetotal-2.3.0/passivetotal/analyzer/enrich.py 1970-01-01 01:00:00.000000000 +0100 +++ new/passivetotal-2.4.0/passivetotal/analyzer/enrich.py 2021-05-10 18:44:25.000000000 +0200 @@ -0,0 +1,104 @@ +from datetime import date +from passivetotal.analyzer import get_api +from passivetotal.analyzer._common import Record, RecordList, PrettyList, PrettyRecord, AnalyzerError + + + +class MalwareList(RecordList, PrettyList): + + """List of malware hashes associated with a host or domain.""" + + def __str__(self): + pass + + def _get_shallow_copy_fields(self): + return [] + + def _get_sortable_fields(self): + return ['date_collected','source'] + + def parse(self, api_response): + """Parse an API response into a list of records.""" + self._api_success = api_response.get('success',None) + self._records = [] + for result in api_response.get('results',[]): + self._records.append(MalwareRecord(result)) + + + +class MalwareRecord(Record, PrettyRecord): + + """Record of malware associated with a host.""" + + def __init__(self, api_response): + self._date_collected = api_response.get('collectionDate') + self._sample = api_response.get('sample') + self._source = api_response.get('source') + self._source_url = api_response.get('sourceUrl') + + def __str__(self): + return self.hash + + def __repr__(self): + return "<MalwareRecord {0.hash}>".format(self) + + @property + def as_dict(self): + """Malware record as a Python dictionary.""" + return { + field: getattr(self, field) for field in [ + 'hash','source','source_url','date_collected' + ] + } + + @property + def hash(self): + """Hash of the malware sample.""" + return self._sample + + @property + def source(self): + """Source where the malware sample was obtained.""" + return self._source + + @property + def source_url(self): + """URL to malware sample source.""" + return self._source_url + + @property + def date_collected(self): + """Date the malware was collected, as a Python date object.""" + try: + parsed = date.fromisoformat(self._date_collected) + except Exception: + raise AnalyzerError + return parsed + + + +class HasMalware: + + """An object (ip or domain) with malware samples.""" + + def _api_get_malware(self): + """Query the enrichment API for malware samples.""" + try: + response = get_api('Enrichment').get_malware( + query=self.get_host_identifier() + ) + except Exception: + raise AnalyzerError('Error querying enrichment API for malware samples') + self._malware = MalwareList(response) + return self._malware + + @property + def malware(self): + """List of malware hashes associated with this host. + + :rtype: :class:`passivetotal.analyzer.enrich.MalwareList` + """ + if getattr(self, '_malware', None) is not None: + return self._malware + return self._api_get_malware() + \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/analyzer/hostname.py new/passivetotal-2.4.0/passivetotal/analyzer/hostname.py --- old/passivetotal-2.3.0/passivetotal/analyzer/hostname.py 2021-04-14 23:16:56.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal/analyzer/hostname.py 2021-05-10 18:44:25.000000000 +0200 @@ -1,21 +1,26 @@ """Hostname analyzer for the RiskIQ PassiveTotal API.""" import socket -from passivetotal.analyzer import get_api, get_config -from passivetotal.analyzer.pdns import PdnsResolutions -from passivetotal.analyzer.summary import HostnameSummary +import tldextract +from passivetotal.analyzer import get_api, get_config, get_object +from passivetotal.analyzer._common import is_ip, refang, AnalyzerError +from passivetotal.analyzer.pdns import HasResolutions +from passivetotal.analyzer.summary import HostnameSummary, HasSummary from passivetotal.analyzer.whois import DomainWhois from passivetotal.analyzer.ssl import CertificateField -from passivetotal.analyzer.ip import IPAddress from passivetotal.analyzer.hostpairs import HasHostpairs from passivetotal.analyzer.cookies import HasCookies from passivetotal.analyzer.trackers import HasTrackers from passivetotal.analyzer.components import HasComponents from passivetotal.analyzer.illuminate import HasReputation +from passivetotal.analyzer.articles import HasArticles +from passivetotal.analyzer.enrich import HasMalware -class Hostname(HasComponents, HasCookies, HasTrackers, HasHostpairs, HasReputation): +class Hostname(HasComponents, HasCookies, HasTrackers, HasHostpairs, + HasReputation, HasArticles, HasResolutions, HasSummary, + HasMalware): """Represents a hostname such as api.passivetotal.org. @@ -31,21 +36,16 @@ def __new__(cls, hostname): """Create or find an instance for the given hostname.""" + hostname = refang(hostname) + if is_ip(hostname): + raise AnalyzerError('Use analyzer.IPAddress for IPv4 addresses.') self = cls._instances.get(hostname) if self is None: self = cls._instances[hostname] = object.__new__(Hostname) self._hostname = hostname - self._current_ip = None - self._whois = None - self._resolutions = None - self._summary = None - self._components = None - self._cookies = None - self._trackers = None self._pairs = {} self._pairs['parents'] = None self._pairs['children'] = None - self._reputation = None return self def __str__(self): @@ -82,27 +82,13 @@ address as the query value. """ return self._hostname - - def _api_get_resolutions(self, unique=False, start_date=None, end_date=None, timeout=None, sources=None): - """Query the pDNS API for resolution history.""" - meth = get_api('DNS').get_unique_resolutions if unique else get_api('DNS').get_passive_dns - response = meth( - query=self._hostname, - start=start_date, - end=end_date, - timeout=timeout, - sources=sources - ) - self._resolutions = PdnsResolutions(response) - return self._resolutions - + def _api_get_summary(self): """Query the Cards API for summary data.""" - response = get_api('Cards').get_summary(query=self._hostname) + response = get_api('Cards').get_summary(query=self.get_host_identifier()) self._summary = HostnameSummary(response) return self._summary - def _api_get_whois(self, compact=False): """Query the Whois API for complete whois details.""" response = get_api('Whois').get_whois_details(query=self._hostname, compact_record=compact) @@ -112,9 +98,58 @@ def _query_dns(self): """Perform a DNS lookup.""" ip = socket.gethostbyname(self._hostname) - self._current_ip = IPAddress(ip) + self._current_ip = get_object(ip,'IPAddress') return self._current_ip + def _extract(self): + """Use the tldextract library to extract parts out of the hostname.""" + self._tldextract = tldextract.extract(self._hostname) + return self._tldextract + + @property + def domain(self): + """Returns only the domain portion of the registered domain name for this hostname. + + Uses the `tldextract` library and returns the domain property of the + `ExtractResults` named tuple. + """ + if getattr(self, '_tldextract', None) is not None: + return self._tldextract.domain + return self._extract().domain + + @property + def tld(self): + """Returns the top-level domain name (TLD) for this hostname. + + Uses the `tldextract` library and returns the suffix property of the + `ExtractResults` named tuple. + """ + if getattr(self, '_tldextract', None) is not None: + return self._tldextract.suffix + return self._extract().suffix + + @property + def registered_domain(self): + """Returns the registered domain name (with TLD) for this hostname. + + Uses the `tldextract` library and returns the registered_domain property of the + `ExtractResults` named tuple. + """ + if getattr(self, '_tldextract', None) is not None: + return self._tldextract.registered_domain + return self._extract().registered_domain + + @property + def subdomain(self): + """Entire set of subdomains for this hostname (third level and higher). + + Uses the `tldextract` library and returns the subdomain property of the + `ExtractResults` named tuple. + """ + if getattr(self, '_tldextract', None) is not None: + return self._tldextract.subdomain + return self._extract().subdomain + @property def hostname(self): """Hostname as a string.""" @@ -128,32 +163,11 @@ :rtype: :class:`passivetotal.analyzer.IPAddress` """ - if getattr(self, '_current_ip'): + if getattr(self, '_current_ip', None) is not None: return self._current_ip return self._query_dns() @property - def resolutions(self): - """ List of pDNS resolutions where hostname was the DNS query value. - - Bounded by dates set in :meth:`passivetotal.analyzer.set_date_range`. - - Provides list of :class:`passivetotal.analyzer.pdns.PdnsRecord` objects. - - :rtype: :class:`passivetotal.analyzer.pdns.PdnsResolutions` - """ - if getattr(self, '_resolutions'): - return self._resolutions - config = get_config() - return self._api_get_resolutions( - unique=False, - start_date=config['start_date'], - end_date=config['end_date'], - timeout=config['pdns_timeout'], - sources=config['pdns_sources'] - ) - - @property def certificates(self): """List of certificates where this hostname is contained in the subjectAlternativeName field. @@ -165,22 +179,12 @@ return CertificateField('subjectAlternativeName', self._hostname).certificates @property - def summary(self): - """Summary of PassiveTotal data available for this hostname. - - :rtype: :class:`passivetotal.analyzer.summary.HostnameSummary` - """ - if getattr(self, '_summary'): - return self._summary - return self._api_get_summary() - - @property def whois(self): """Most recently available Whois record for the hostname's domain name. :rtype: :class:`passivetotal.analyzer.whois.DomainWhois` """ - if getattr(self, '_whois'): + if getattr(self, '_whois', None) is not None: return self._whois return self._api_get_whois( compact=False diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/analyzer/hostpairs.py new/passivetotal-2.4.0/passivetotal/analyzer/hostpairs.py --- old/passivetotal-2.3.0/passivetotal/analyzer/hostpairs.py 2021-04-14 23:16:56.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal/analyzer/hostpairs.py 2021-05-10 18:44:25.000000000 +0200 @@ -3,7 +3,7 @@ from passivetotal.analyzer._common import ( RecordList, Record, FirstLastSeen, PagedRecordList ) -from passivetotal.analyzer import get_api, get_config +from passivetotal.analyzer import get_api, get_config, get_object @@ -101,14 +101,12 @@ @property def child(self): """Descendant hostname for this pairing.""" - from passivetotal.analyzer import Hostname - return Hostname(self._child) + return get_object(self._child) @property def parent(self): """Parent hostname for this pairing.""" - from passivetotal.analyzer import Hostname - return Hostname(self._parent) + return get_object(self._parent) @@ -144,7 +142,7 @@ :rtype: :class:`passivetotal.analyzer.hostpairs.HostpairHistory` """ - if self._pairs['parents']: + if self._pairs['parents'] is not None: return self._pairs['parents'] config = get_config() return self._api_get_hostpairs( @@ -159,7 +157,7 @@ :rtype: :class:`passivetotal.analyzer.hostpairs.HostpairHistory` """ - if self._pairs['children']: + if self._pairs['children'] is not None: return self._pairs['children'] config = get_config() return self._api_get_hostpairs( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/analyzer/illuminate.py new/passivetotal-2.4.0/passivetotal/analyzer/illuminate.py --- old/passivetotal-2.3.0/passivetotal/analyzer/illuminate.py 2021-04-14 23:16:56.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal/analyzer/illuminate.py 2021-05-10 18:44:25.000000000 +0200 @@ -72,6 +72,6 @@ :rtype: :class:`passivetotal.analyzer.illuminate.ReputationScore` """ - if getattr(self, '_reputation'): + if getattr(self, '_reputation', None) is not None: return self._reputation return self._api_get_reputation() \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/analyzer/ip.py new/passivetotal-2.4.0/passivetotal/analyzer/ip.py --- old/passivetotal-2.3.0/passivetotal/analyzer/ip.py 2021-04-14 23:16:56.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal/analyzer/ip.py 2021-05-10 18:44:25.000000000 +0200 @@ -2,19 +2,24 @@ from passivetotal.analyzer import get_api, get_config -from passivetotal.analyzer.pdns import PdnsResolutions +from passivetotal.analyzer._common import is_ip, AnalyzerError +from passivetotal.analyzer.pdns import HasResolutions from passivetotal.analyzer.services import Services from passivetotal.analyzer.ssl import Certificates -from passivetotal.analyzer.summary import IPSummary +from passivetotal.analyzer.summary import IPSummary, HasSummary from passivetotal.analyzer.hostpairs import HasHostpairs from passivetotal.analyzer.cookies import HasCookies from passivetotal.analyzer.trackers import HasTrackers from passivetotal.analyzer.components import HasComponents from passivetotal.analyzer.illuminate import HasReputation +from passivetotal.analyzer.articles import HasArticles +from passivetotal.analyzer.enrich import HasMalware -class IPAddress(HasComponents, HasCookies, HasHostpairs, HasTrackers, HasReputation): +class IPAddress(HasComponents, HasCookies, HasHostpairs, HasTrackers, + HasReputation, HasArticles, HasResolutions, HasSummary, + HasMalware): """Represents an IPv4 address such as 8.8.8.8 @@ -29,22 +34,15 @@ def __new__(cls, ip): """Create or find an instance for the given IP.""" + if not is_ip(ip): + raise AnalyzerError('Invalid IP address') self = cls._instances.get(ip) if self is None: self = cls._instances[ip] = object.__new__(IPAddress) self._ip = ip - self._resolutions = None - self._services = None - self._ssl_history = None - self._summary = None - self._whois = None - self._components = None - self._cookies = None - self._trackers = None self._pairs = {} self._pairs['parents'] = None self._pairs['children'] = None - self._reputation = None return self def __str__(self): @@ -83,24 +81,17 @@ """ return self._ip - def _api_get_resolutions(self, unique=False, start_date=None, end_date=None, timeout=None, sources=None): - """Query the pDNS API for resolution history.""" - meth = get_api('DNS').get_unique_resolutions if unique else get_api('DNS').get_passive_dns - response = meth( - query=self._ip, - start=start_date, - end=end_date, - timeout=timeout, - sources=sources - ) - self._resolutions = PdnsResolutions(api_response=response) - return self._resolutions - def _api_get_services(self): """Query the services API for service and port history.""" response = get_api('Services').get_services(query=self._ip) self._services = Services(response) return self._services + + def _api_get_summary(self): + """Query the Cards API for summary data.""" + response = get_api('Cards').get_summary(query=self.get_host_identifier()) + self._summary = IPSummary(response) + return self._summary def _api_get_ssl_history(self): """Query the SSL API for certificate history.""" @@ -108,12 +99,6 @@ self._ssl_history = Certificates(response) return self._ssl_history - def _api_get_summary(self): - """Query the Cards API for summary data.""" - response = get_api('Cards').get_summary(query=self._ip) - self._summary = IPSummary(response) - return self._summary - def _api_get_whois(self): """Query the pDNS API for resolution history.""" self._whois = get_api('Whois').get_whois_details(query=self._ip) @@ -129,50 +114,19 @@ """History of :class:`passivetotal.analyzer.ssl.Certificates` presented by services hosted on this IP address. """ - if getattr(self, '_ssl_history'): + if getattr(self, '_ssl_history', None) is not None: return self._ssl_history return self._api_get_ssl_history() @property - def resolutions(self): - """:class:`passivetotal.analyzer.pdns.PdnsResolutions` where this - IP was the DNS response value. - - Bounded by dates set in :meth:`passivetotal.analyzer.set_date_range`. - `timeout` and `sources` params are also set by the analyzer configuration. - - Provides list of :class:`passivetotal.analyzer.pdns.PdnsRecord` objects. - """ - if getattr(self, '_resolutions'): - return self._resolutions - config = get_config() - return self._api_get_resolutions( - unique=False, - start_date=config['start_date'], - end_date=config['end_date'], - timeout=config['pdns_timeout'], - sources=config['pdns_sources'] - ) - - @property def services(self): - if getattr(self, '_services'): + if getattr(self, '_services', None) is not None: return self._services return self._api_get_services() - - @property - def summary(self): - """Summary of PassiveTotal data available for this IP. - - :rtype: :class:`passivetotal.analyzer.summary.IPSummary` - """ - if getattr(self, '_summary'): - return self._summary - return self._api_get_summary() @property def whois(self): """Whois record details for this IP.""" - if getattr(self, '_whois'): + if getattr(self, '_whois', None) is not None: return self._whois return self._api_get_whois() \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/analyzer/pdns.py new/passivetotal-2.4.0/passivetotal/analyzer/pdns.py --- old/passivetotal-2.3.0/passivetotal/analyzer/pdns.py 2021-04-13 02:19:09.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal/analyzer/pdns.py 2021-05-10 18:44:25.000000000 +0200 @@ -1,5 +1,5 @@ from datetime import datetime -from passivetotal.analyzer import get_config +from passivetotal.analyzer import get_config, get_api from passivetotal.analyzer._common import RecordList, Record, FirstLastSeen @@ -162,4 +162,42 @@ @property def rawrecord(self): return self._rawrecord - \ No newline at end of file + + + +class HasResolutions: + """An object with pDNS resolutions.""" + + def _api_get_resolutions(self, unique=False, start_date=None, end_date=None, timeout=None, sources=None): + """Query the pDNS API for resolution history.""" + meth = get_api('DNS').get_unique_resolutions if unique else get_api('DNS').get_passive_dns + response = meth( + query=self.get_host_identifier(), + start=start_date, + end=end_date, + timeout=timeout, + sources=sources + ) + self._resolutions = PdnsResolutions(api_response=response) + return self._resolutions + + @property + def resolutions(self): + """:class:`passivetotal.analyzer.pdns.PdnsResolutions` where this + object was the DNS response value. + + Bounded by dates set in :meth:`passivetotal.analyzer.set_date_range`. + `timeout` and `sources` params are also set by the analyzer configuration. + + Provides list of :class:`passivetotal.analyzer.pdns.PdnsRecord` objects. + """ + if getattr(self, '_resolutions', None) is not None: + return self._resolutions + config = get_config() + return self._api_get_resolutions( + unique=False, + start_date=config['start_date'], + end_date=config['end_date'], + timeout=config['pdns_timeout'], + sources=config['pdns_sources'] + ) \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/analyzer/services.py new/passivetotal-2.4.0/passivetotal/analyzer/services.py --- old/passivetotal-2.3.0/passivetotal/analyzer/services.py 2021-04-13 02:19:09.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal/analyzer/services.py 2021-05-10 18:44:25.000000000 +0200 @@ -1,11 +1,10 @@ from datetime import datetime -import pprint -from passivetotal.analyzer._common import RecordList, Record, FirstLastSeen +from passivetotal.analyzer._common import RecordList, Record, PrettyRecord, PrettyList, FirstLastSeen from passivetotal.analyzer.ssl import CertHistoryRecord -from passivetotal.analyzer import get_api, get_config +from passivetotal.analyzer import get_api -class Services(RecordList): +class Services(RecordList, PrettyList): """Historical port, service and banner data.""" @@ -53,7 +52,7 @@ -class ServiceRecord(Record, FirstLastSeen): +class ServiceRecord(Record, FirstLastSeen, PrettyRecord): """Record of an observed port with current and recent services.""" @@ -85,12 +84,6 @@ 'lastseen' ] } - - @property - def pretty(self): - """Pretty printed version of services data.""" - config = get_config('pprint') - return pprint.pformat(self.as_dict, **config) @property def port(self): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/analyzer/ssl.py new/passivetotal-2.4.0/passivetotal/analyzer/ssl.py --- old/passivetotal-2.3.0/passivetotal/analyzer/ssl.py 2021-04-13 02:19:09.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal/analyzer/ssl.py 2021-05-10 18:44:25.000000000 +0200 @@ -1,7 +1,7 @@ from datetime import datetime import pprint -from passivetotal.analyzer._common import RecordList, Record, FirstLastSeen -from passivetotal.analyzer import get_api, get_config +from passivetotal.analyzer._common import RecordList, Record, FirstLastSeen, AnalyzerError +from passivetotal.analyzer import get_api, get_config, get_object @@ -86,7 +86,10 @@ """Use the 'SSL' request wrapper to perform an SSL certificate search by field.""" if type(self._value) == list: raise ValueError('Cannot search a list') - response = get_api('SSL').search_ssl_certificate_by_field(query=self._value, field=self._name) + try: + response = get_api('SSL').search_ssl_certificate_by_field(query=self._value, field=self._name) + except Exception: + raise AnalyzerError self._certificates = Certificates(response) return self._certificates @@ -155,6 +158,40 @@ self._values[fieldname] = CertificateField(fieldname, self._cert_details.get(fieldname)) return self._values[fieldname] + def _api_get_ip_history(self): + try: + response = get_api('SSL').get_ssl_certificate_history(query=self.hash) + except Exception as e: + raise AnalyzerError + self._ip_history = response['results'][0] + return self._ip_history + + @property + def iphistory(self): + """Get the direct API response for a history query on this certificates hash. + + For most use cases, the `ips` property is a more direct route to get the list + of IPs previously associated with this SSL certificate. + """ + if getattr(self, '_ip_history', None) is not None: + return self._ip_history + return self._api_get_ip_history() + + @property + def ips(self): + """Provides list of :class:`passivetotal.analyzer.IPAddress` instances + representing IP addresses associated with this SSL certificate.""" + history = self.iphistory + ips = [] + if history['ipAddresses'] == 'N/A': + return ips + for ip in history['ipAddresses']: + try: + ips.append(get_object(ip,'IPAddress')) + except AnalyzerError: + continue + return ips + @property def as_dict(self): """All SSL fields as a mapping with string values.""" @@ -480,16 +517,21 @@ self._ips = record.get('ipAddresses',[]) def __str__(self): - ips = 'ip' if len(self._ips)==1 else 'ips' - return '{0.hash} on {ipcount} {ips} from {0.firstseen_date} to {0.lastseen_date}'.format(self, ipcount=len(self._ips), ips=ips) + return '{0.hash} from {0.firstseen_date} to {0.lastseen_date}'.format(self) def __repr__(self): return "<CertHistoryRecord '{0.hash}'>".format(self) def _api_get_details(self): """Query the SSL API for certificate details.""" - response = get_api('SSL').get_ssl_certificate_details(query=self._sha1) - self._cert_details = response['results'][0] # API oddly returns an array + try: + response = get_api('SSL').get_ssl_certificate_details(query=self._sha1) + except Exception: + raise AnalyzerError + try: + self._cert_details = response['results'][0] # API oddly returns an array + except IndexError: + raise SSLAnalyzerError('No details available for this certificate') self._has_details = True return self._cert_details @@ -502,13 +544,10 @@ return self._api_get_details() - @property - def ips(self): - """Provides list of :class:`passivetotal.analyzer.IPAddress` instances - representing IP addresses associated with this SSL certificate.""" - from passivetotal.analyzer import IPAddress - for ip in self._ips: - yield IPAddress(ip) +class SSLAnalyzerError(AnalyzerError): + """An exception raised when accessing SSL properties in the Analyzer module.""" + pass + \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/analyzer/summary.py new/passivetotal-2.4.0/passivetotal/analyzer/summary.py --- old/passivetotal-2.3.0/passivetotal/analyzer/summary.py 2021-04-13 02:19:09.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal/analyzer/summary.py 2021-05-10 18:44:25.000000000 +0200 @@ -1,4 +1,4 @@ -from passivetotal.analyzer import get_config + @@ -170,4 +170,18 @@ def services(self): """Number of service (port) history records for this IP.""" return self._count_or_none('services') - \ No newline at end of file + + + +class HasSummary: + """An object with summary card data.""" + + @property + def summary(self): + """Summary of PassiveTotal data available for this hostname. + + :rtype: :class:`passivetotal.analyzer.summary.HostnameSummary` + """ + if getattr(self, '_summary', None) is not None: + return self._summary + return self._api_get_summary() \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/analyzer/trackers.py new/passivetotal-2.4.0/passivetotal/analyzer/trackers.py --- old/passivetotal-2.3.0/passivetotal/analyzer/trackers.py 2021-04-13 02:19:09.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal/analyzer/trackers.py 2021-05-10 18:44:25.000000000 +0200 @@ -3,7 +3,7 @@ from passivetotal.analyzer._common import ( RecordList, Record, FirstLastSeen, PagedRecordList ) -from passivetotal.analyzer import get_api, get_config +from passivetotal.analyzer import get_api, get_config, get_object @@ -27,9 +27,8 @@ @property def hostnames(self): """List of unique hostnames in the tracker record list.""" - from passivetotal.analyzer import Hostname return set( - Hostname(host) for host in set([record.hostname for record in self]) + get_object(host) for host in set([record.hostname for record in self]) ) @property @@ -122,7 +121,7 @@ :rtype: :class:`passivetotal.analyzer.trackers.TrackersHistory` """ - if getattr(self, '_trackers'): + if getattr(self, '_trackers', None) is not None: return self._trackers config = get_config() return self._api_get_trackers( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal/libs/articles.py new/passivetotal-2.4.0/passivetotal/libs/articles.py --- old/passivetotal-2.3.0/passivetotal/libs/articles.py 2021-03-15 15:30:16.000000000 +0100 +++ new/passivetotal-2.4.0/passivetotal/libs/articles.py 2021-05-10 18:44:25.000000000 +0200 @@ -45,6 +45,17 @@ :return: Dict of results """ return self._get('articles', 'indicators', **kwargs) + + def get_articles_for_indicator(self, indicator, indicator_type=None): + """Get articles that reference an indicator (typically a domain or IP). + + Reference: https://api.riskiq.net/api/articles/#!/default/get_pt_v2_articles_indicator + + :param indicator: Indicator to search, typically domain or IP + :param indicator_type: Type of indicator to search for (optional) + :return: Dict of results + """ + return self._get('articles', 'indicator', query=indicator, type=indicator_type) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal.egg-info/PKG-INFO new/passivetotal-2.4.0/passivetotal.egg-info/PKG-INFO --- old/passivetotal-2.3.0/passivetotal.egg-info/PKG-INFO 2021-04-14 23:18:17.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal.egg-info/PKG-INFO 2021-05-10 18:45:03.000000000 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: passivetotal -Version: 2.3.0 +Version: 2.4.0 Summary: Library for the RiskIQ PassiveTotal and Illuminate API Home-page: https://github.com/passivetotal/python_api Author: RiskIQ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal.egg-info/SOURCES.txt new/passivetotal-2.4.0/passivetotal.egg-info/SOURCES.txt --- old/passivetotal-2.3.0/passivetotal.egg-info/SOURCES.txt 2021-04-14 23:18:17.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal.egg-info/SOURCES.txt 2021-05-10 18:45:03.000000000 +0200 @@ -16,8 +16,10 @@ passivetotal.egg-info/top_level.txt passivetotal/analyzer/__init__.py passivetotal/analyzer/_common.py +passivetotal/analyzer/articles.py passivetotal/analyzer/components.py passivetotal/analyzer/cookies.py +passivetotal/analyzer/enrich.py passivetotal/analyzer/hostname.py passivetotal/analyzer/hostpairs.py passivetotal/analyzer/illuminate.py diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/passivetotal.egg-info/requires.txt new/passivetotal-2.4.0/passivetotal.egg-info/requires.txt --- old/passivetotal-2.3.0/passivetotal.egg-info/requires.txt 2021-04-14 23:18:17.000000000 +0200 +++ new/passivetotal-2.4.0/passivetotal.egg-info/requires.txt 2021-05-10 18:45:03.000000000 +0200 @@ -2,3 +2,4 @@ ez_setup python-dateutil future +tldextract diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/passivetotal-2.3.0/setup.py new/passivetotal-2.4.0/setup.py --- old/passivetotal-2.3.0/setup.py 2021-04-14 23:16:56.000000000 +0200 +++ new/passivetotal-2.4.0/setup.py 2021-05-10 18:44:25.000000000 +0200 @@ -8,14 +8,14 @@ setup( name='passivetotal', - version='2.3.0', + version='2.4.0', description='Library for the RiskIQ PassiveTotal and Illuminate API', url="https://github.com/passivetotal/python_api", author="RiskIQ", author_email="ad...@passivetotal.org", license="GPLv2", packages=find_packages(), - install_requires=['requests', 'ez_setup', 'python-dateutil', 'future'], + install_requires=['requests', 'ez_setup', 'python-dateutil', 'future', 'tldextract'], long_description=read('README.md'), long_description_content_type="text/markdown", classifiers=[],