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

Implements a v2 Lambda Output with AssumeRole #1227

Merged
merged 7 commits into from
Apr 7, 2020
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions conf/outputs.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"aws-lambda": {
"sample-lambda": "function-name:qualifier"
},
"aws-lambda-v2": [
Copy link
Contributor

Choose a reason for hiding this comment

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

IIRC, this is not actually how this info will be written to the outputs.json config file for AWSOutput types .. see here:

def format_output_config(cls, service_config, values):
"""Format the output configuration for this AWS service to be written to disk
AWS services are stored as a dictionary within the config instead of a list so
we have access to the AWS value (arn/bucket name/etc) for Terraform
Args:
service_config (dict): The actual outputs config that has been read in
values (OrderedDict): Contains all the OutputProperty items for this service
Returns:
dict{<string>: <string>}: Updated dictionary of descriptors and
values for this AWS service needed for the output configuration
NOTE: S3 requires the bucket name, not an arn, for this value.
Instead of implementing this differently in subclasses, all AWSOutput
subclasses should use a generic 'aws_value' to store the value for the
descriptor used in configuration
"""
return dict(service_config.get(cls.__service__, {}),
**{values['descriptor'].value: values['aws_value'].value})

did you confirm that this actually works as expected with the manage.py outputs new ... command?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh wow, no this is totally not gonna work. Good catch. No I didn't confirm it as I'm still testing it, but it's good to know that it's definitely wrong.

I think the easy fix is to just not inherit from this base class and just use the OutputDispatcher base class like any other sane output.

Copy link
Contributor

Choose a reason for hiding this comment

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

agreed - we can trend away from the AWSOutput class usage for future aws outputs now that ssm is a thing

"sample-lambda"
],
"aws-s3": {
"bucket": "aws-s3-bucket"
},
Expand Down
8 changes: 4 additions & 4 deletions docs/source/outputs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ Adding a new configuration for a currently supported service is handled using ``
.. note::

``<SERVICE_NAME>`` above should be one of the following supported service identifiers.
``aws-cloudwatch-log``, ``aws-firehose``, ``aws-lambda``, ``aws-s3``, ``aws-sns``, ``aws-sqs``,
``carbonblack``, ``github``, ``jira``, ``komand``, ``pagerduty``, ``pagerduty-incident``,
``pagerduty-v2``, ``phantom``, ``slack``
``aws-cloudwatch-log``, ``aws-firehose``, ``aws-lambda``, ``aws-lambda-v2``, ``aws-s3``,
``aws-sns``, ``aws-sqs``, ``carbonblack``, ``github``, ``jira``, ``komand``, ``pagerduty``,
``pagerduty-incident``, ``pagerduty-v2``, ``phantom``, ``slack``

For example:

Expand Down Expand Up @@ -158,7 +158,7 @@ The ``OutputProperty`` object used in ``get_user_defined_properties`` is a ``nam

:cred_requirement:
A ``boolean`` that indicates whether this value is required for API access with this service. Ultimately, setting this value to ``True`` indicates
that the value should be encrypted and stored in Amazon S3.
that the value should be encrypted and stored in Amazon Systems Manager.
Default is: ``False``


Expand Down
160 changes: 159 additions & 1 deletion streamalert/alert_processor/outputs/aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,10 @@ def _firehose_request_wrapper(json_alert, delivery_stream):

@StreamAlertOutput
class LambdaOutput(AWSOutput):
"""LambdaOutput handles all alert dispatching to AWS Lambda"""
"""LambdaOutput handles all alert dispatching to AWS Lambda

This output is deprecated by the aws-lambda-v2 output
"""
__service__ = 'aws-lambda'

@classmethod
Expand Down Expand Up @@ -256,6 +259,160 @@ def _dispatch(self, alert, descriptor):
return True


@StreamAlertOutput
class LambdaOutputV2(OutputDispatcher):
"""LambdaOutput handles all alert dispatching to AWS Lambda"""
__service__ = 'aws-lambda-v2'

@classmethod
def get_user_defined_properties(cls):
"""Get properties that must be assigned by the user when configuring a new Lambda
output. This should be sensitive or unique information for this use-case that needs
to come from the user.

Every output should return a dict that contains a 'descriptor' with a description of the
integration being configured.

Sending to Lambda also requires a user provided Lambda function name and optional qualifier
(if applicable for the user's use case). A fully-qualified AWS ARN is also acceptable for
this value. This value should not be masked during input and is not a credential requirement
that needs encrypted.

When invoking a Lambda function in a different AWS account, the Alert Processor will have
to first assume a role in the target account. Both the Alert Processor and the destination
role will need AssumeRole IAM policies to allow this:

@see https://aws.amazon.com/premiumsupport/knowledge-center/lambda-function-assume-iam-role/

Returns:
OrderedDict: Contains various OutputProperty items
"""
return OrderedDict(
[
(
'descriptor',
OutputProperty(
description='a short and unique descriptor for this Lambda function '
'configuration (ie: abbreviated name)'
)
),
(
'lambda_function_arn',
OutputProperty(
description='The ARN of the AWS Lambda function to Invoke',
input_restrictions={' '},
cred_requirement=True
)
),
(
'function_qualifier',
OutputProperty(
description='The function qualifier/alias to invoke.',
input_restrictions={' '},
cred_requirement=True
)
),
(
'assume_role_arn',
OutputProperty(
description='When provided, will use AssumeRole with this ARN',
input_restrictions={' '},
cred_requirement=True
)
),
]
)

def _dispatch(self, alert, descriptor):
"""Send alert to a Lambda function

The alert gets dumped to a JSON string to be sent to the Lambda function

Publishing:
By default this output sends the JSON-serialized alert record as the payload to the
lambda function. You can override this:

- @aws-lambda.alert_data (dict):
Overrides the alert record. Will instead send this dict, JSON-serialized, to
Lambda as the payload.

Args:
alert (Alert): Alert instance which triggered a rule
descriptor (str): Output descriptor

Returns:
bool: True if alert was sent successfully, False otherwise
"""
creds = self._load_creds(descriptor)
if not creds:
LOGGER.error("No credentials found for descriptor: %s", descriptor)
return False

# Create the publication
publication = compose_alert(alert, self, descriptor)

# Defaults
default_alert_data = alert.record

# Override with publisher
alert_data = publication.get('@aws-lambda.alert_data', default_alert_data)
alert_string = json.dumps(alert_data, separators=(',', ':'))

client = self._build_client(creds)

function_name = creds['lambda_function_arn']
qualifier = creds.get('function_qualifier', False)

LOGGER.debug('Sending alert to Lambda function %s', function_name)
invocation_opts = {
'FunctionName': function_name,
'InvocationType': 'Event',
'Payload': alert_string,
}

# Use the qualifier if it's available. Passing an empty qualifier in
# with `Qualifier=''` or `Qualifier=None` does not work
if qualifier:
invocation_opts['Qualifier'] = qualifier

client.invoke(**invocation_opts)

return True

def _build_client(self, creds):
"""
Generates a boto3 client for the current AWS Lambda invocation. Will perform AssumeRole
if an assume role is provided.

Params:
creds (dict): Result of _load_creds()

Returns:
boto3.session.Session.client
"""
client_opts = {
'region_name': self.region
}

assume_role_arn = creds.get('assume_role_arn', False)
if assume_role_arn:
LOGGER.debug('Assuming role: %s', assume_role_arn)
sts_connection = boto3.client('sts')
acct_b = sts_connection.assume_role(
RoleArn=assume_role_arn,
RoleSessionName="streamalert_alert_processor"
)

client_opts['aws_access_key_id'] = acct_b['Credentials']['AccessKeyId']
client_opts['aws_secret_access_key'] = acct_b['Credentials']['SecretAccessKey']
client_opts['aws_session_token'] = acct_b['Credentials']['SessionToken']

return boto3.client(
'lambda',
**client_opts
)


@StreamAlertOutput
class S3Output(AWSOutput):
"""S3Output handles all alert dispatching for AWS S3"""
Expand Down Expand Up @@ -481,6 +638,7 @@ def _dispatch(self, alert, descriptor):

return True


@StreamAlertOutput
class SESOutput(OutputDispatcher):
"""Handle all alert dispatching for AWS SES"""
Expand Down
41 changes: 40 additions & 1 deletion tests/unit/streamalert/alert_processor/outputs/test_aws.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
SESOutput,
SNSOutput,
SQSOutput,
CloudwatchLogOutput
CloudwatchLogOutput,
LambdaOutputV2,
)
from tests.unit.streamalert.alert_processor import (
CONFIG,
Expand Down Expand Up @@ -151,6 +152,44 @@ def test_dispatch_with_qualifier(self, log_mock):
self.SERVICE, alt_descriptor)


@patch.object(aws_outputs, 'boto3', MagicMock())
class TestLambdaV2Output:
"""Test class for LambdaOutput"""
DESCRIPTOR = 'unit_test_lambda'
SERVICE = 'aws-lambda-v2'
OUTPUT = ':'.join([SERVICE, DESCRIPTOR])
CREDS = {
'lambda_function_arn': 'arn:aws:lambda:us-east-1:11111111:function:my_func',
'function_qualifier': 'production',
'assume_role_arn': 'arn:aws:iam::11111111:role/my_path/my_role',
}

@patch('streamalert.alert_processor.outputs.output_base.OutputCredentialsProvider')
def setup(self, provider_constructor):
"""Setup before each method"""
provider = MagicMock()
provider_constructor.return_value = provider
provider.load_credentials = Mock(
side_effect=lambda x: self.CREDS if x == self.DESCRIPTOR else None
)

self._provider = provider
self._dispatcher = LambdaOutputV2(None)

def test_locals(self):
"""LambdaOutput local variables"""
assert_equal(self._dispatcher.__class__.__name__, 'LambdaOutputV2')
assert_equal(self._dispatcher.__service__, self.SERVICE)

@patch('logging.Logger.info')
def test_dispatch(self, log_mock):
"""LambdaOutput dispatch"""
assert_true(self._dispatcher.dispatch(get_alert(), self.OUTPUT))

log_mock.assert_called_with('Successfully sent alert to %s:%s',
self.SERVICE, self.DESCRIPTOR)


@mock_s3
class TestS3Output:
"""Test class for S3Output"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def test_output_loading():
expected_outputs = {
'aws-firehose',
'aws-lambda',
'aws-lambda-v2',
'aws-s3',
'aws-ses',
'aws-sns',
Expand Down