Skip to content

Commit 2eabba5

Browse files
authored
Merge pull request #26 from passivetotal/illuminate
Support RiskIQ Illuminate Reputation API
2 parents 414a93d + 9c1b4f9 commit 2eabba5

File tree

15 files changed

+399
-29
lines changed

15 files changed

+399
-29
lines changed

README.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
## Introduction
1111

1212
This Python library provides an interface to the RiskIQ PassiveTotal Internet
13-
intelligence database. Security researchers and network defenders use
14-
PassiveTotal to map threat actor infrastructure, profile hostnames & IP
15-
addresses, discover web technologies on Internet hosts.
13+
intelligence database and the RiskIQ Illuminate Reputation Score.
14+
15+
Security researchers and network defenders use RiskIQ PassiveTotal to map threat
16+
actor infrastructure, profile hostnames & IP addresses, discover web technologies
17+
on Internet hosts.
1618

1719
Capabilites of this library include:
1820
* Credential management - protect API keys from accidental disclosure
@@ -25,7 +27,8 @@ To learn more about RiskIQ and start a free trial, visit [https://community.risk
2527
## Getting Started
2628

2729
### Install the PassiveTotal Library
28-
The PassiveTotal Python library is availabe in pip under the package name `passivetotal`. Consider setting up a [virtual environment](https://docs.python.org/3/library/venv.html), then run:
30+
The PassiveTotal Python library is available in pip under the package name `passivetotal`.
31+
Consider setting up a [virtual environment](https://docs.python.org/3/library/venv.html), then run:
2932
```
3033
pip install passivetotal
3134
```
@@ -36,15 +39,17 @@ Queries to the API must be authenticated with a PassiveTotal API key.
3639
1. Log in (or sign up) at [community.riskiq.com](https://community.riskiq.com)
3740
2. Access your profile by clicking the person icon in the upper-right corner of the page.
3841
3. Click on "Account Settings"
39-
4. Under "API Access", click "Show" to reveal your user or organization credentials.
42+
4. Under "API Access", click "Show" to reveal your API credentials.
4043

4144
The identifier for your API account is alternatively called a "username", a "user", or
4245
an "API key". Look for an email address and use that value when prompted for your
4346
"API username".
4447

4548
The "API Secret" is a long string of characters that should be kept secure. It is
46-
the primary authentication method for your API account. Note your PassiveTotal
47-
account may have a seperate "API Secret" for your organization.
49+
the primary authentication method for your API account.
50+
51+
Your PassiveTotal account may have a separate "API Secret" for your organization - when
52+
available, **always use your organization key** unless you have a specific reason not to.
4853

4954

5055
### Build a Config File

passivetotal/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
from .libs.ssl import SslRequest
1414
from .libs.whois import WhoisRequest
1515
from .libs.generic import GenericRequest
16+
from .libs.illuminate import IlluminateRequest

passivetotal/analyzer/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ def init(**kwargs):
4040
(ProjectsRequest, 'Projects'),
4141
(ServicesRequest, 'Services'),
4242
(SslRequest, 'SSL'),
43-
(WhoisRequest, 'Whois')
43+
(WhoisRequest, 'Whois'),
44+
(IlluminateRequest, 'Illuminate'),
4445
]
4546
for c, name in api_classes:
4647
if 'username' in kwargs and 'api_key' in kwargs:

passivetotal/analyzer/_common.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,32 @@ def sorted_by(self, field, reverse=False):
7878
sorted_results._records = sorted(self.all, key=lambda record: getattr(record, field), reverse=reverse)
7979
return sorted_results
8080

81+
def _ensure_firstlastseen(self):
82+
"""Ensure this record list has records of type FirstLastSeen."""
83+
if not isinstance(self._records[0], FirstLastSeen):
84+
raise TypeError('Cannot filter on a record type without firstseen / lastseen fields')
85+
86+
def filter_dateseen_after(self, date_string):
87+
self._ensure_firstlastseen()
88+
dateobj = datetime.fromisoformat(date_string)
89+
filtered_results = self._make_shallow_copy()
90+
filtered_results._records = filter(lambda r: r.firstseen > dateobj, self._records)
91+
return filtered_results
92+
93+
def filter_dateseen_before(self, date_string):
94+
self._ensure_firstlastseen()
95+
dateobj = datetime.fromisoformat(date_string)
96+
filtered_results = self._make_shallow_copy()
97+
filtered_results._records = filter(lambda r: r.lastseen < dateobj, self._records)
98+
return filtered_results
99+
100+
def filter_dateseen_between(self, start_date_string, end_date_string):
101+
self._ensure_firstlastseen()
102+
dateobj_start = datetime.fromisoformat(start_date_string)
103+
dateobj_end = datetime.fromisoformat(end_date_string)
104+
filtered_results = self._make_shallow_copy()
105+
filtered_results._records = filter(lambda r: r.firstseen >= dateobj_start and r.lastseen <= dateobj_end, self._records)
106+
return filtered_results
81107

82108

83109
class Record:

passivetotal/analyzer/hostname.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
from passivetotal.analyzer.cookies import HasCookies
1212
from passivetotal.analyzer.trackers import HasTrackers
1313
from passivetotal.analyzer.components import HasComponents
14+
from passivetotal.analyzer.illuminate import HasReputation
1415

1516

1617

17-
class Hostname(HasComponents, HasCookies, HasTrackers, HasHostpairs):
18+
class Hostname(HasComponents, HasCookies, HasTrackers, HasHostpairs, HasReputation):
1819

1920
"""Represents a hostname such as api.passivetotal.org.
2021
@@ -44,6 +45,7 @@ def __new__(cls, hostname):
4445
self._pairs = {}
4546
self._pairs['parents'] = None
4647
self._pairs['children'] = None
48+
self._reputation = None
4749
return self
4850

4951
def __str__(self):
@@ -52,6 +54,27 @@ def __str__(self):
5254
def __repr__(self):
5355
return "Hostname('{}')".format(self.hostname)
5456

57+
def reset(self, prop=None):
58+
"""Reset this instance to clear all (default) or one cached properties.
59+
60+
Useful when changing module-level settings such as analyzer.set_date_range().
61+
62+
:param str prop: Property to reset (optional, if none provided all values will be cleared)
63+
"""
64+
resettable_fields = ['whois','resolutions','summary','components',
65+
'cookies','trackers','pairs','reputation']
66+
if not prop:
67+
for field in resettable_fields:
68+
setattr(self, '_'+field, None)
69+
self._reset_hostpairs()
70+
else:
71+
if prop not in resettable_fields:
72+
raise ValueError('Invalid property to reset')
73+
if prop == 'pairs':
74+
self._reset_hostpairs()
75+
else:
76+
setattr(self, '_'+prop, None)
77+
5578
def get_host_identifier(self):
5679
"""Alias for the hostname as a string.
5780

passivetotal/analyzer/hostpairs.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ class HostpairHistory(RecordList, PagedRecordList):
1111

1212
"""Historical connections between hosts."""
1313

14-
def __init__(self, api_response, direction=None):
14+
def __init__(self, api_response=None, direction=None):
1515
self._direction = direction
16-
self.parse(api_response)
16+
if api_response:
17+
self.parse(api_response)
1718

1819
def _get_shallow_copy_fields(self):
1920
return ['_totalrecords','_direction']
@@ -115,6 +116,12 @@ class HasHostpairs:
115116

116117
"""An object with hostpair history."""
117118

119+
def _reset_hostpairs(self):
120+
"""Reset the instance hostpairs private attributes."""
121+
self._pairs = {}
122+
self._pairs['parents'] = None
123+
self._pairs['children'] = None
124+
118125
def _api_get_hostpairs(self, direction, start_date=None, end_date=None):
119126
"""Query the hostpairs API for the parent or child relationships.
120127
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from datetime import datetime
2+
import pprint
3+
from functools import total_ordering
4+
from passivetotal.analyzer import get_api, get_config
5+
6+
7+
@total_ordering
8+
class ReputationScore:
9+
10+
"""RiskIQ Illuminate Reputation profile for a hostname or an IP."""
11+
12+
def __init__(self, api_response):
13+
self._response = api_response
14+
15+
def __str__(self):
16+
return '{0.score} ({0.classification})'.format(self)
17+
18+
def __repr__(self):
19+
return '<ReputationScore {0.score} "{0.classification}">'.format(self)
20+
21+
def __int__(self):
22+
return self.score
23+
24+
def __gt__(self, other):
25+
return self.score > other
26+
27+
def __eq__(self, other):
28+
return self.score == other
29+
30+
@property
31+
def score(self):
32+
"""Reputation score as an integer ranging from 0-100.
33+
34+
Higher values indicate a greater likelihood of maliciousness.
35+
"""
36+
return self._response.get('score')
37+
38+
@property
39+
def classification(self):
40+
"""Reputation classification as a string.
41+
42+
Typical values include GOOD, SUSPICIOUS, MALICIOUS, or UNKNOWN.
43+
"""
44+
return self._response.get('classification')
45+
46+
@property
47+
def rules(self):
48+
"""List of rules that informed the reputation score.
49+
50+
Returns a list of dictionaries.
51+
"""
52+
return self._response.get('rules')
53+
54+
55+
56+
class HasReputation:
57+
58+
"""An object with a RiskIQ Illuminate Reputation score."""
59+
60+
def _api_get_reputation(self):
61+
"""Query the reputation endpoint."""
62+
63+
response = get_api('Illuminate').get_reputation(
64+
query=self.get_host_identifier()
65+
)
66+
self._reputation = ReputationScore(response)
67+
return self._reputation
68+
69+
@property
70+
def reputation(self):
71+
"""RiskIQ Illuminate Reputation profile for a hostname or IP.
72+
73+
:rtype: :class:`passivetotal.analyzer.illuminate.ReputationScore`
74+
"""
75+
if getattr(self, '_reputation'):
76+
return self._reputation
77+
return self._api_get_reputation()

passivetotal/analyzer/ip.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
from passivetotal.analyzer.cookies import HasCookies
1111
from passivetotal.analyzer.trackers import HasTrackers
1212
from passivetotal.analyzer.components import HasComponents
13+
from passivetotal.analyzer.illuminate import HasReputation
1314

1415

1516

16-
class IPAddress(HasComponents, HasCookies, HasHostpairs, HasTrackers):
17+
class IPAddress(HasComponents, HasCookies, HasHostpairs, HasTrackers, HasReputation):
1718

1819
"""Represents an IPv4 address such as 8.8.8.8
1920
@@ -43,6 +44,7 @@ def __new__(cls, ip):
4344
self._pairs = {}
4445
self._pairs['parents'] = None
4546
self._pairs['children'] = None
47+
self._reputation = None
4648
return self
4749

4850
def __str__(self):
@@ -51,6 +53,28 @@ def __str__(self):
5153
def __repr__(self):
5254
return "IPAddress('{}')".format(self.ip)
5355

56+
def reset(self, prop=None):
57+
"""Reset this instance to clear all (default) or one cached properties.
58+
59+
Useful when changing module-level settings such as analyzer.set_date_range().
60+
61+
:param str prop: Property to reset (optional, if none provided all values will be cleared)
62+
"""
63+
resettable_fields = ['whois','resolutions','summary','components',
64+
'services','ssl_history',
65+
'cookies','trackers','pairs','reputation']
66+
if not prop:
67+
for field in resettable_fields:
68+
setattr(self, '_'+field, None)
69+
self._reset_hostpairs()
70+
else:
71+
if prop not in resettable_fields:
72+
raise ValueError('Invalid property to reset')
73+
if prop == 'pairs':
74+
self._reset_hostpairs()
75+
else:
76+
setattr(self, '_'+prop, None)
77+
5478
def get_host_identifier(self):
5579
"""Alias for the IP address as a string.
5680

passivetotal/cli/client.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from passivetotal.libs.cookies import CookiesRequest, CookiesResponse
1818
from passivetotal.libs.services import ServicesRequest, ServicesResponse
1919
from passivetotal.libs.projects import ProjectsRequest, ProjectsResponse
20+
from passivetotal.libs.illuminate import IlluminateRequest, IlluminateReputationResponse
2021
from passivetotal.response import Response
2122

2223
__author__ = 'Brandon Dixon (PassiveTotal)'
@@ -280,6 +281,21 @@ def call_projects(args):
280281
data = ProjectsResponse.process(response)
281282
return data
282283

284+
def call_illuminate(args):
285+
client = IlluminateRequest.from_config()
286+
if args.illuminate_cmd == 'reputation':
287+
results = []
288+
for host in args.hosts:
289+
try:
290+
response = client.get_reputation(query=host)
291+
except Exception as e:
292+
response = {}
293+
response.update({'host': host})
294+
if args.brief:
295+
del(response['rules'])
296+
results.append(response)
297+
data = IlluminateReputationResponse.process(results)
298+
return data
283299

284300
def write_output(results, arguments):
285301
"""Format data based on the type.
@@ -506,7 +522,15 @@ def main():
506522
projects.add_argument('--format', choices=['json'], default='json',
507523
help="Format of the output from the query")
508524

509-
525+
illuminate = subs.add_parser('illuminate', help="Query RiskIQ Illuminate API")
526+
illuminate.add_argument('--reputation', dest='illuminate_cmd', action='store_const', const='reputation',
527+
help="Get hostname or IP reputation from RiskIQ Illuminate.")
528+
illuminate.add_argument('--format', choices=['json','csv','text'], default='json',
529+
help="Format of the output from the query")
530+
illuminate.add_argument('--brief', action='store_true',
531+
help="Create a brief output; for reputation, prints score and classification only")
532+
illuminate.add_argument('hosts', metavar='query', nargs='+',
533+
help="One or more hostnames or IPs")
510534
args, unknown = parser.parse_known_args()
511535
data = None
512536

@@ -535,6 +559,8 @@ def main():
535559
data = call_services(args)
536560
elif args.cmd == 'projects':
537561
data = call_projects(args)
562+
elif args.cmd == 'illuminate':
563+
data = call_illuminate(args)
538564
else:
539565
parser.print_usage()
540566
sys.exit(1)

0 commit comments

Comments
 (0)