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=[],

Reply via email to