Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[apps][onelogin] Adding new integration app for OneLogin Events #355

Merged
merged 15 commits into from
Oct 17, 2017
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions app_integrations/apps/onelogin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"""
Copyright 2017-present, Airbnb Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from datetime import datetime
import re
import requests

from app_integrations import LOGGER
from app_integrations.apps.app_base import AppIntegration


class OneLoginApp(AppIntegration):
"""OneLogin StreamAlert App"""
_ONELOGIN_EVENTS_URL = 'https://api.us.onelogin.com/api/1/events'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we spoke about offline, switch to using a url that can be formatted with a region/localization (us/eu) for this link and the token link.

_ONELOGIN_TOKEN_URL = 'https://api.us.onelogin.com/auth/oauth2/v2/token'
# OneLogin API returns 50 events per page
_MAX_EVENTS_LIMIT = 50

# Define our authorization headers variable
def __init__(self, config):
super(OneLoginApp, self).__init__(config)
self._auth_headers = None
self._next_page_url = None

@classmethod
def _type(cls):
return 'events'

@classmethod
def _endpoint(cls):
"""Class method to return the OneLogin events endpoint

Returns:
str: Path of the events endpoint to query
"""
return cls._ONELOGIN_EVENTS_URL

@classmethod
def service(cls):
return 'onelogin'

def _generate_headers(self, token_url, client_secret, client_id):
"""Each request will request a new token to call the resources APIs.

More details to be found here:
https://developers.onelogin.com/api-docs/1/oauth20-tokens/generate-tokens-2

Returns:
str: Bearer token to be used to call the OneLogin resource APIs
"""
authorization = 'client_id: %s, client_secret: %s' % (client_id, client_secret)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We tend to use str.format(...) instead of the older percent formatting. I'd suggest changing this to:

'client_id: {}, client_secret: {}'.format(client_id, client_secret)

Same for the bearer... formatting below.

headers_token = {'Authorization': authorization,
'Content-Type': 'application/json'}

response = requests.post(token_url,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I anticipate potentially needing post abilities in other future apps as well. Could we move this functionality to the AppIntegration base class in app_base.py with a method name of something like _make_post_request and change the _make_request method to _make_get_request? By doing this, the new method could just return False or the JSON from the response (similar to how _make_request works now).

If this is too much right now, this can happen at a later date - just a thought!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I also refactor the duo application to use the new _make_get_requests in this PR?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes please! :)

json={'grant_type':'client_credentials'},
headers=headers_token)

if not self._check_http_response(response):
return False

bearer = 'bearer:%s' % (response.json()['access_token'])
self._auth_headers = {'Authorization': bearer}

def _gather_logs(self):
"""Gather the authentication log events."""

if not self._auth_headers:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

++ nice!

self._auth_headers = self._generate_headers(self._ONELOGIN_TOKEN_URL,
self._config['auth']['client_secret'],
self._config['auth']['client_id'])

self._next_page_url = self._ONELOGIN_EVENTS_URL
return self._get_onelogin_events()

def _get_onelogin_events(self):
"""Get all events from the endpoint for this timeframe

Returns:
[
{
'id': <int:id>,
'created_at': <str:created_at>,
'account_id': <int:account_id>,
'user_id': <int:user_id>,
'event_type_id': <int:event_type_id>,
'notes': <str:notes>,
'ipaddr': <str:ipaddr>,
'actor_user_id': <int:actor_user_id>,
'assuming_acting_user_id': null,
'role_id': <int:role_id>,
'app_id': <int:app_id>,
'group_id': <int:group_id>,
'otp_device_id': <int:otp_device_id>,
'policy_id': <int:policy_id>,
'actor_system': <str:actor_system>,
'custom_message': <str:custom_message>,
'role_name': <str:role_name>,
'app_name': <str:app_name>,
'group_name': <str:group_name>,
'actor_user_name': <str:actor_user_name>,
'user_name': <str:user_name>,
'policy_name': <str:policy_name>,
'otp_device_name': <str:otp_device_name>,
'operation_name': <str:operation_name>,
'directory_sync_run_id': <int:directory_sync_run_id>,
'directory_id': <int:directory_id>,
'resolution': <str:resolution>,
'client_id': <int:client_id>,
'resource_type_id': <int:resource_type_id>,
'error_description': <str:error_description>
}
]
"""
# OneLogin API expects the ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ
formatted_date = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
params = {'since': formatted_date}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is going to actually only get events since the current time. This will likely result in events for only a few seconds or less (the time between setting this date and actually querying their API for the events).

You'll want to use the self._last_timestamp for this param, but we'll have to reason about this more since you need a formatted time and not a unix/integer timestamp


# Make sure we have authentication headers
if not self._auth_headers:
return False

events = self._get_onelogin_paginated_events(params)

while self._more_to_poll and self._next_page_url:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually think we should avoid doing the pagination directly within the subclass and just let the while within the base classes gather handle this. We can talk more about the requirements for this.

The main thought process here is that this while loop could loop for a good amount of time, and the subclassed apps don't have a concept of when the lambda function is getting close to timing out. By letting the looping happen on the base class, where there is some checks in place to look for nearing the time out, we can operate with a little more safety.

LOGGER.debug('More events to retrieve for \'%s\': %s', self.type(), self._more_to_poll)
pagination = self._get_onelogin_paginated_events(None)

# Add the events to our results, this is equivalent to using events.extend()
events += pagination

# Return the list of logs to the caller so they can be send to the batcher
return events

def _get_onelogin_paginated_events(self, params):
"""Get events from the API pagination url

Returns:
The same as the method _get_onelogin_events()
"""
response = requests.get(self._next_page_url, headers=self._auth_headers, params=params)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the base class' _make_request be used in this instance, since it will do the response check and automatically return the json response value.

if not self._check_http_response(response):
return False

# Extract events from response
events = response.json()['data']

# Do we have a pagination link to follow?
self._more_to_poll = len(events) >= self._MAX_EVENTS_LIMIT

# If we are already retrieved all the events, then set the value to prevent more requests
self._next_page_url = response.json()['pagination']['next_link']

return events

def required_auth_info(self):
return {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we spoke about offline, please add the localization (us/eu) to these so they can be user-provided.

'client_secret':
{
'description': ('the client secret for the OneLogin API. This '
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work with these!

'should a string of 57 alphanumeric characters'),
'format': re.compile(r'^[a-zA-Z0-9]{57}$')
},
'client_id':
{
'description': ('the client id for the OneLogin API. This '
'should a string of 57 alphanumeric characters'),
'format': re.compile(r'^[a-zA-Z0-9]{57}$')
}
}

def _sleep_seconds(self):
"""Return the number of seconds this polling function should sleep for
between requests to avoid failed requests. OneLogin tokens allows for 5000 requests
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to confirm: 5000/hour = ~83/min = ~1.3/sec - there's no way we'll accidentally exceed this?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spoke offline, Javier will intentionally hit the limit locally so he can appropriately catch the exception and throw a useful error if it occurs

Copy link
Contributor Author

@javuto javuto Oct 6, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to do some testing in the actual API to see if we can just generate a new token once we exceed the maximum number of requests per hour (5000) per token. Although I doubt they would allow such a simple workaround to their rate limit. In any case I can add the code to have a safe sleep of 2 seconds per request.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that additional testing should be done to best determine how this should be handled.

every hour, so returning 0 for now.

Returns:
int: Number of seconds that this function should sleep for between requests
"""
return 0
35 changes: 35 additions & 0 deletions conf/logs.json
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,41 @@
]
}
},
"onelogin:events": {
"schema": {
"id": "integer",
"created_at": "string",
"account_id": "integer",
"user_id": "integer",
"event_type_id": "integer",
"notes": "string",
"ipaddr": "string",
"actor_user_id": "integer",
"assuming_acting_user_id": "integer",
"role_id": "integer",
"app_id": "integer",
"group_id": "integer",
"otp_device_id": "integer",
"policy_id": "integer",
"actor_system": "string",
"custom_message": "string",
"role_name": "string",
"app_name": "string",
"group_name": "string",
"actor_user_name": "string",
"user_name": "string",
"policy_name": "string",
"otp_device_name": "string",
"operation_name": "string",
"directory_sync_run_id": "integer",
"directory_id": "integer",
"resolution": "string",
"client_id": "integer",
"resource_type_id": "integer",
"error_description": "string"
},
"parser": "json"
},
"osquery:differential": {
"schema": {
"action": "string",
Expand Down
5 changes: 5 additions & 0 deletions conf/sources.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
"logs": [
"duo"
]
},
"prefix_cluster_onelogin-events-app-name_app": {
"logs": [
"onelogin"
]
}
}
}
Empty file.
16 changes: 16 additions & 0 deletions rules/community/onelogin/onelogin_events_assumed_role.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Alert on the OneLogin event that a user has assumed the role of someone else."""
from stream_alert.rule_processor.rules_engine import StreamRules

rule = StreamRules.rule

@rule(logs=['onelogin:events'],
outputs=['aws-s3:sample-bucket',
'pagerduty:sample-integration'])
def onelogin_events_assumed_role(rec):
"""
author: airbnb_csirt
description: Alert on OneLogin users assuming a different role.
reference: https://developers.onelogin.com/api-docs/1/events/event-types
playbook: N/A
"""
return rec['event_type_id'] == 3
80 changes: 80 additions & 0 deletions tests/integration/rules/onelogin_events_assumed_role.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
{
"records": [
{
"data": {
"id": 123,
"created_at": "2017-10-05T18:11:32Z",
"account_id": 1234,
"user_id": 123456789,
"event_type_id": 3,
"notes": "Notes",
"ipaddr": "0.0.0.0",
"actor_user_id": 987,
"assuming_acting_user_id": 654,
"role_id": 456,
"app_id": 123456,
"group_id": 98765,
"otp_device_id": 11111,
"policy_id": 22222,
"actor_system": "System",
"custom_message": "Message",
"role_name": "Role",
"app_name": "App Name",
"group_name": "Group Name",
"actor_user_name": "",
"user_name": "username",
"policy_name": "Policy Name",
"otp_device_name": "OTP Device Name",
"operation_name": "Operation Name",
"directory_sync_run_id": 7777,
"directory_id": 6666,
"resolution": "Resolved",
"client_id": 11223344,
"resource_type_id": 44332211,
"error_description": "ERROR ERROR"
},
"description": "OneLogin generated event when a user assumed a different role, it should alert",
"service": "stream_alert_app",
"source": "prefix_cluster_onelogin-events-app-name_app",
"trigger": true
},
{
"data": {
"id": 123,
"created_at": "2017-10-05T18:11:32Z",
"account_id": 1234,
"user_id": 123456789,
"event_type_id": 10,
"notes": "Notes",
"ipaddr": "0.0.0.0",
"actor_user_id": 987,
"assuming_acting_user_id": 654,
"role_id": 456,
"app_id": 123456,
"group_id": 98765,
"otp_device_id": 11111,
"policy_id": 22222,
"actor_system": "System",
"custom_message": "Message",
"role_name": "Role",
"app_name": "App Name",
"group_name": "Group Name",
"actor_user_name": "",
"user_name": "username",
"policy_name": "Policy Name",
"otp_device_name": "OTP Device Name",
"operation_name": "Operation Name",
"directory_sync_run_id": 7777,
"directory_id": 6666,
"resolution": "Resolved",
"client_id": 11223344,
"resource_type_id": 44332211,
"error_description": "ERROR ERROR"
},
"description": "OneLogin generated event when a user do any other action, it should not alert",
"service": "stream_alert_app",
"source": "prefix_cluster_onelogin-events-app-name_app",
"trigger": false
}
]
}
Loading