Path: blob/develop/tests/unit/customizations/cloudtrail/test_validation.py
1569 views
# Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved.1#2# Licensed under the Apache License, Version 2.0 (the 'License'). You3# may not use this file except in compliance with the License. A copy of4# the License is located at5#6# http://aws.amazon.com/apache2.0/7#8# or in the 'license' file accompanying this file. This file is9# distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF10# ANY KIND, either express or implied. See the License for the specific11# language governing permissions and limitations under the License.12import binascii13import base6414import hashlib15import json16import gzip17from datetime import datetime, timedelta18from dateutil import parser, tz1920import rsa21from argparse import Namespace2223from awscli.testutils import BaseAWSCommandParamsTest24from awscli.customizations.cloudtrail.validation import DigestError, \25extract_digest_key_date, normalize_date, format_date, DigestProvider, \26DigestTraverser, create_digest_traverser, PublicKeyProvider, \27Sha256RSADigestValidator, DATE_FORMAT, CloudTrailValidateLogs, \28parse_date, assert_cloudtrail_arn_is_valid, DigestSignatureError, \29InvalidDigestFormat, S3ClientProvider30from awscli.compat import BytesIO31from botocore.exceptions import ClientError32from awscli.testutils import mock, unittest33from awscli.schema import ParameterRequiredError343536START_DATE = parser.parse('20140810T000000Z')37END_DATE = parser.parse('20150810T000000Z')38TEST_ACCOUNT_ID = '123456789012'39TEST_TRAIL_ARN = 'arn:aws:cloudtrail:us-east-1:%s:trail/foo' % TEST_ACCOUNT_ID40VALID_TEST_KEY = ('MIIBCgKCAQEAn11L2YZ9h7onug2ILi1MWyHiMRsTQjfWE+pHVRLk1QjfW'41'hirG+lpOa8NrwQ/r7Ah5bNL6HepznOU9XTDSfmmnP97mqyc7z/upfZdS/'42'AHhYcGaz7n6Wc/RRBU6VmiPCrAUojuSk6/GjvA8iOPFsYDuBtviXarvuL'43'PlrT9kAd4Lb+rFfR5peEgBEkhlzc5HuWO7S0y+KunqxX6jQBnXGMtxmPB'44'PP0FylgWGNdFtks/4YSKcgqwH0YDcawP9GGGDAeCIqPWIXDLG1jOjRRzW'45'fCmD0iJUkz8vTsn4hq/5ZxRFE7UBAUiVcGbdnDdvVfhF9C3dQiDq3k7ad'46'QIziLT0cShgQIDAQAB')47TEST_ORGANIZATION_ACCOUNT_ID = '987654321098'48TEST_ORGANIZATION_ID = 'o-12345'495051def create_mock_key_provider(key_list):52"""Creates a mock key provider that yields keys for each in key_list"""53public_keys = {}54for k in key_list:55public_keys[k] = {'Fingerprint': k,56'Value': 'ffaa00'}57key_provider = mock.Mock()58key_provider.get_public_keys.return_value = public_keys59return key_provider606162def create_scenario(actions, logs=None):63"""Creates a scenario for a stack of actions6465Each action can be "gap" meaning there is no previous link, "invalid"66meaning we should simulate an invalid digest, "missing" meaning we67should simulate a digest is missing from S3, "bucket_change" meaning68it is a link but the bucket is different than the previous bucket.69Values are popped one by one off of the list until a terminal "gap"70action is found.71"""72keys = [str(i) for i in range(len(actions))]73key_provider = create_mock_key_provider(keys)74digest_provider = MockDigestProvider(actions, logs)75digest_validator = mock.Mock()7677def validate(bucket, key, public_key, digest_data, digest_str):78if '_invalid' in digest_data:79raise DigestError('invalid error')8081digest_validator.validate = validate82return key_provider, digest_provider, digest_validator838485def collecting_callback():86"""Create and return a callback and a list populated with call args"""87calls = []8889def cb(**kwargs):90calls.append(kwargs)9192return cb, calls939495class MockDigestProvider(object):96def __init__(self, actions, logs=None):97self.logs = logs or []98self.actions = actions99self.calls = {'fetch_digest': [], 'load_digest_keys_in_range': []}100self.digests = []101for i in range(len(self.actions)):102self.digests.append(self.get_key_at_position(i))103104def get_key_at_position(self, position):105dt = START_DATE + timedelta(hours=position)106key = ('AWSLogs/{account}/CloudTrail-Digest/us-east-1/{ymd}/{account}_'107'CloudTrail-Digest_us-east-1_foo_us-east-1_{date}.json.gz')108return key.format(109account=TEST_ACCOUNT_ID,110ymd=dt.strftime('%Y/%m/%d'),111date=dt.strftime(DATE_FORMAT))112113@staticmethod114def create_digest(fingerprint, start_date, key, bucket, next_key=None,115next_bucket=None, logs=None):116digest_end_date = start_date + timedelta(hours=1, minutes=30)117return {'digestPublicKeyFingerprint': fingerprint,118'digestEndTime': digest_end_date.strftime(DATE_FORMAT),119'digestStartTime': start_date.strftime(DATE_FORMAT),120'previousDigestS3Bucket': next_bucket,121'previousDigestS3Object': next_key,122'digestS3Bucket': bucket,123'digestS3Object': key,124'awsAccountId': TEST_ACCOUNT_ID,125'previousDigestSignature': 'abcd',126'logFiles': logs or []}127128@staticmethod129def create_link(key, next_key, next_bucket, position, action, logs,130bucket):131"""Creates a link in a digest chain for testing."""132digest_logs = []133if len(logs) > position:134digest_logs = logs[position]135end_date = parse_date(extract_digest_key_date(key))136# gap actions have no previous link.137if action == 'gap':138digest = MockDigestProvider.create_digest(139key=key, bucket=bucket, fingerprint=str(position),140start_date=end_date, logs=digest_logs)141else:142digest = MockDigestProvider.create_digest(143key=key, bucket=bucket, fingerprint=str(position),144start_date=end_date, next_bucket=next_bucket, next_key=next_key,145logs=digest_logs)146# Mark the digest as invalid if specified in the action.147if action == 'invalid':148digest['_invalid'] = True149return digest, json.dumps(digest)150151def load_digest_keys_in_range(self, bucket, prefix, start_date, end_date):152self.calls['load_digest_keys_in_range'].append(locals())153return list(self.digests)154155def fetch_digest(self, bucket, key):156self.calls['fetch_digest'].append(key)157position = self.digests.index(key)158action = self.actions[position]159# Simulate a digest missing from S3160if action == 'missing':161raise ClientError(162{'Error': {'Code': 'NoSuchKey', 'Message': 'foo'}},163'GetObject')164next_key = self.get_key_at_position(position - 1)165next_bucket = int(bucket)166if action == 'bucket_change':167next_bucket += 1168return self.create_link(key, next_key, str(next_bucket), position,169action, self.logs, bucket)170171172class TestValidation(unittest.TestCase):173def test_formats_dates(self):174date = datetime(2015, 8, 21, tzinfo=tz.tzutc())175self.assertEqual('20150821T000000Z', format_date(date))176177def test_parses_dates_with_better_error_message(self):178try:179parse_date('foo')180self.fail('Should have failed to parse')181except ValueError as e:182self.assertIn('Unable to parse date value: foo', str(e))183184def test_parses_dates(self):185date = parse_date('August 25, 2015 00:00:00 UTC')186self.assertEqual(date, datetime(2015, 8, 25, tzinfo=tz.tzutc()))187188def test_ensures_cloudtrail_arns_are_valid(self):189try:190assert_cloudtrail_arn_is_valid('foo:bar:baz')191self.fail('Should have failed')192except ValueError as e:193self.assertIn('Invalid trail ARN provided: foo:bar:baz', str(e))194195def test_ensures_cloudtrail_arns_are_valid_when_missing_resource(self):196try:197assert_cloudtrail_arn_is_valid(198'arn:aws:cloudtrail:us-east-1:%s:foo' % TEST_ACCOUNT_ID)199self.fail('Should have failed')200except ValueError as e:201self.assertIn('Invalid trail ARN provided', str(e))202203def test_allows_valid_arns(self):204assert_cloudtrail_arn_is_valid(205'arn:aws:cloudtrail:us-east-1:%s:trail/foo' % TEST_ACCOUNT_ID)206207def test_normalizes_date_timezones(self):208date = datetime(2015, 8, 21, tzinfo=tz.tzlocal())209normalized = normalize_date(date)210self.assertEqual(tz.tzutc(), normalized.tzinfo)211212def test_extracts_dates_from_digest_keys(self):213arn = ('AWSLogs/{account}/CloudTrail-Digest/us-east-1/2015/08/'214'16/{account}_CloudTrail-Digest_us-east-1_foo_us-east-1_'215'20150816T230550Z.json.gz').format(account=TEST_ACCOUNT_ID)216self.assertEqual('20150816T230550Z', extract_digest_key_date(arn))217218def test_creates_traverser(self):219mock_s3_provider = mock.Mock()220traverser = create_digest_traverser(221trail_arn=TEST_TRAIL_ARN, cloudtrail_client=mock.Mock(),222organization_client=mock.Mock(),223trail_source_region='us-east-1',224s3_client_provider=mock_s3_provider,225bucket='bucket', prefix='prefix')226self.assertEqual('bucket', traverser.starting_bucket)227self.assertEqual('prefix', traverser.starting_prefix)228digest_provider = traverser.digest_provider229self.assertEqual('us-east-1', digest_provider.trail_home_region)230self.assertEqual('foo', digest_provider.trail_name)231232def test_creates_traverser_account_id(self):233mock_s3_provider = mock.Mock()234traverser = create_digest_traverser(235trail_arn=TEST_TRAIL_ARN, cloudtrail_client=mock.Mock(),236organization_client=mock.Mock(),237trail_source_region='us-east-1',238s3_client_provider=mock_s3_provider,239bucket='bucket', prefix='prefix',240account_id=TEST_ORGANIZATION_ACCOUNT_ID)241self.assertEqual('bucket', traverser.starting_bucket)242self.assertEqual('prefix', traverser.starting_prefix)243digest_provider = traverser.digest_provider244self.assertEqual('us-east-1', digest_provider.trail_home_region)245self.assertEqual('foo', digest_provider.trail_name)246self.assertEqual(247TEST_ORGANIZATION_ACCOUNT_ID, digest_provider.account_id)248249def test_creates_traverser_and_gets_trail_by_arn(self):250cloudtrail_client = mock.Mock()251cloudtrail_client.describe_trails.return_value = {'trailList': [252{'TrailARN': TEST_TRAIL_ARN,253'S3BucketName': 'bucket', 'S3KeyPrefix': 'prefix',254'IsOrganizationTrail': False}255]}256traverser = create_digest_traverser(257trail_arn=TEST_TRAIL_ARN, trail_source_region='us-east-1',258cloudtrail_client=cloudtrail_client,259organization_client=mock.Mock(),260s3_client_provider=mock.Mock())261self.assertEqual('bucket', traverser.starting_bucket)262self.assertEqual('prefix', traverser.starting_prefix)263digest_provider = traverser.digest_provider264self.assertEqual('us-east-1', digest_provider.trail_home_region)265self.assertEqual('foo', digest_provider.trail_name)266self.assertEqual(TEST_ACCOUNT_ID, digest_provider.account_id)267268def test_create_traverser_organizational_trail_not_launched(self):269cloudtrail_client = mock.Mock()270cloudtrail_client.describe_trails.return_value = {'trailList': [271{'TrailARN': TEST_TRAIL_ARN,272'S3BucketName': 'bucket', 'S3KeyPrefix': 'prefix'}273]}274traverser = create_digest_traverser(275trail_arn=TEST_TRAIL_ARN, trail_source_region='us-east-1',276cloudtrail_client=cloudtrail_client,277organization_client=mock.Mock(),278s3_client_provider=mock.Mock())279self.assertEqual('bucket', traverser.starting_bucket)280self.assertEqual('prefix', traverser.starting_prefix)281digest_provider = traverser.digest_provider282self.assertEqual('us-east-1', digest_provider.trail_home_region)283self.assertEqual('foo', digest_provider.trail_name)284self.assertEqual(TEST_ACCOUNT_ID, digest_provider.account_id)285286def test_creates_traverser_and_gets_trail_by_arn_s3_bucket_specified(self):287cloudtrail_client = mock.Mock()288traverser = create_digest_traverser(289trail_arn=TEST_TRAIL_ARN, trail_source_region='us-east-1',290cloudtrail_client=cloudtrail_client,291organization_client=mock.Mock(),292s3_client_provider=mock.Mock(),293bucket="bucket")294self.assertEqual('bucket', traverser.starting_bucket)295digest_provider = traverser.digest_provider296self.assertEqual('us-east-1', digest_provider.trail_home_region)297self.assertEqual('foo', digest_provider.trail_name)298self.assertEqual(TEST_ACCOUNT_ID, digest_provider.account_id)299300def test_creates_traverser_and_gets_organization_id(self):301cloudtrail_client = mock.Mock()302cloudtrail_client.describe_trails.return_value = {'trailList': [303{'TrailARN': TEST_TRAIL_ARN,304'S3BucketName': 'bucket', 'S3KeyPrefix': 'prefix',305'IsOrganizationTrail': True}306]}307organization_client = mock.Mock()308organization_client.describe_organization.return_value = {309"Organization": {310"MasterAccountId": TEST_ACCOUNT_ID,311"Id": TEST_ORGANIZATION_ID,312}313}314traverser = create_digest_traverser(315trail_arn=TEST_TRAIL_ARN, trail_source_region='us-east-1',316cloudtrail_client=cloudtrail_client,317organization_client=organization_client,318s3_client_provider=mock.Mock(), account_id=TEST_ACCOUNT_ID)319self.assertEqual('bucket', traverser.starting_bucket)320self.assertEqual('prefix', traverser.starting_prefix)321digest_provider = traverser.digest_provider322self.assertEqual('us-east-1', digest_provider.trail_home_region)323self.assertEqual('foo', digest_provider.trail_name)324self.assertEqual(TEST_ORGANIZATION_ID, digest_provider.organization_id)325326def test_creates_traverser_organization_trail_missing_account_id(self):327cloudtrail_client = mock.Mock()328cloudtrail_client.describe_trails.return_value = {'trailList': [329{'TrailARN': TEST_TRAIL_ARN,330'S3BucketName': 'bucket', 'S3KeyPrefix': 'prefix',331'IsOrganizationTrail': True}332]}333organization_client = mock.Mock()334organization_client.describe_organization.return_value = {335"Organization": {336"MasterAccountId": TEST_ACCOUNT_ID,337"Id": TEST_ORGANIZATION_ID,338}339}340with self.assertRaises(ParameterRequiredError):341create_digest_traverser(342trail_arn=TEST_TRAIL_ARN, trail_source_region='us-east-1',343cloudtrail_client=cloudtrail_client,344organization_client=organization_client,345s3_client_provider=mock.Mock())346347348class TestPublicKeyProvider(unittest.TestCase):349def test_returns_public_key_in_range(self):350cloudtrail_client = mock.Mock()351cloudtrail_client.list_public_keys.return_value = {'PublicKeyList': [352{'Fingerprint': 'a', 'OtherData': 'a', 'Value': 'a'},353{'Fingerprint': 'b', 'OtherData': 'b', 'Value': 'b'},354{'Fingerprint': 'c', 'OtherData': 'c', 'Value': 'c'},355]}356provider = PublicKeyProvider(cloudtrail_client)357start_date = START_DATE358end_date = start_date + timedelta(days=2)359keys = provider.get_public_keys(start_date, end_date)360self.assertEqual({361'a': {'Fingerprint': 'a', 'OtherData': 'a', 'Value': 'a'},362'b': {'Fingerprint': 'b', 'OtherData': 'b', 'Value': 'b'},363'c': {'Fingerprint': 'c', 'OtherData': 'c', 'Value': 'c'},364}, keys)365cloudtrail_client.list_public_keys.assert_has_calls(366[mock.call(EndTime=end_date, StartTime=start_date)])367368369class TestSha256RSADigestValidator(unittest.TestCase):370def setUp(self):371self._digest_data = {'digestStartTime': 'baz',372'digestEndTime': 'foo',373'awsAccountId': 'account',374'digestPublicKeyFingerprint': 'abc',375'digestS3Bucket': 'bucket',376'digestS3Object': 'object',377'previousDigestSignature': 'xyz'}378self._inflated_digest = json.dumps(self._digest_data).encode()379self._digest_data['_signature'] = 'aeff'380381def test_validates_digests(self):382(public_key, private_key) = rsa.newkeys(512)383sha256_hash = hashlib.sha256(self._inflated_digest)384string_to_sign = "%s\n%s/%s\n%s\n%s" % (385self._digest_data['digestEndTime'],386self._digest_data['digestS3Bucket'],387self._digest_data['digestS3Object'],388sha256_hash.hexdigest(),389self._digest_data['previousDigestSignature'])390signature = rsa.sign(string_to_sign.encode(), private_key, 'SHA-256')391self._digest_data['_signature'] = binascii.hexlify(signature)392validator = Sha256RSADigestValidator()393public_key_b64 = base64.b64encode(public_key.save_pkcs1(format='DER'))394validator.validate('b', 'k', public_key_b64, self._digest_data,395self._inflated_digest)396397def test_does_not_expose_underlying_key_decoding_error(self):398validator = Sha256RSADigestValidator()399try:400validator.validate(401'b', 'k', 'YQo=', self._digest_data, 'invalid'.encode())402self.fail('Should have failed')403except DigestError as e:404self.assertEqual(('Digest file\ts3://b/k\tINVALID: Unable to load '405'PKCS #1 key with fingerprint abc'), str(e))406407def test_does_not_expose_underlying_validation_error(self):408validator = Sha256RSADigestValidator()409try:410validator.validate(411'b', 'k', VALID_TEST_KEY, self._digest_data,412'invalid'.encode())413self.fail('Should have failed')414except DigestSignatureError as e:415self.assertEqual(('Digest file\ts3://b/k\tINVALID: signature '416'verification failed'), str(e))417418def test_properly_signs_when_no_previous_signature(self):419validator = Sha256RSADigestValidator()420digest_data = {421'digestEndTime': 'a',422'digestS3Bucket': 'b',423'digestS3Object': 'c',424'previousDigestSignature': None}425signed = validator._create_string_to_sign(digest_data, 'abc'.encode())426self.assertEqual(427('a\nb/c\nba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff6'428'1f20015ad\nnull').encode(), signed)429430431class TestDigestProvider(BaseAWSCommandParamsTest):432def _fake_key(self, date):433parsed = parser.parse(date)434return ('prefix/AWSLogs/{account}/CloudTrail-Digest/us-east-1/{year}/'435'{month}/{day}/{account}_CloudTrail-Digest_us-east-1_foo_'436'us-east-1_{date}.json.gz').format(date=date, year=parsed.year,437month=parsed.month,438account=TEST_ACCOUNT_ID,439day=parsed.day)440441def _get_mock_provider(self, s3_client):442mock_s3_client_provider = mock.Mock()443mock_s3_client_provider.get_client.return_value = s3_client444return DigestProvider(445mock_s3_client_provider, TEST_ACCOUNT_ID, 'foo', 'us-east-1')446447def test_initializes_public_properties(self):448client = mock.Mock()449provider = DigestProvider(client, TEST_ACCOUNT_ID, 'foo', 'us-east-1')450self.assertEqual(TEST_ACCOUNT_ID, provider.account_id)451self.assertEqual('foo', provider.trail_name)452self.assertEqual('us-east-1', provider.trail_home_region)453454def test_returns_digests_in_range(self):455s3_client = self.driver.session.create_client('s3')456keys = [self._fake_key(format_date(START_DATE - timedelta(days=1))),457self._fake_key(format_date(START_DATE + timedelta(days=1))),458self._fake_key(format_date(START_DATE + timedelta(days=2))),459self._fake_key(format_date(START_DATE + timedelta(days=3))),460self._fake_key(format_date(END_DATE + timedelta(hours=1))),461self._fake_key(format_date(END_DATE + timedelta(days=1)))]462# Create a key that looks similar but for a different trail.463bad_name = keys[3].replace('foo', 'baz')464# Create a key that looks similar but is from a different trail source465# region (e.g., CloudTrail-Digest/us-west-2).466bad_region = keys[3].replace(467'CloudTrail-Digest/us-east-1', 'CloudTrail-Digest/us-west-2')468bad_region = bad_region.replace(469'CloudTrail-Digest_us-east-1', 'CloudTrail-Digest_us-west-2')470self.parsed_responses = [471{"Contents": [{"Key": keys[0]}, # skip (date <)472{"Key": keys[1]},473{"Key": keys[2]},474{"Key": 'foo/baz/bar'}, # skip (regex (bogus))475{"Key": bad_name}, # skip (regex (trail name))476{"Key": bad_region}, # skip (regex (source))477{"Key": keys[3]},478{"Key": keys[4]}, # hour is +1, but keep479{"Key": keys[5]}]}] # skip (date >)480self.patch_make_request()481provider = self._get_mock_provider(s3_client)482digests = provider.load_digest_keys_in_range(483'foo', 'prefix', START_DATE, END_DATE)484self.assertNotIn(bad_name, digests)485self.assertNotIn(bad_region, digests)486self.assertEqual(keys[1], digests[0])487self.assertEqual(keys[2], digests[1])488self.assertEqual(keys[3], digests[2])489self.assertEqual(keys[4], digests[3])490491def test_calls_list_objects_correctly(self):492s3_client = mock.Mock()493mock_paginate = s3_client.get_paginator.return_value.paginate494mock_search = mock_paginate.return_value.search495mock_search.return_value = []496provider = self._get_mock_provider(s3_client)497provider.load_digest_keys_in_range(498'1', 'prefix', START_DATE, END_DATE)499marker = ('prefix/AWSLogs/{account}/CloudTrail-Digest/us-east-1/'500'2014/08/09/{account}_CloudTrail-Digest_us-east-1_foo_'501'us-east-1_20140809T235900Z.json.gz')502mock_paginate.assert_called_once_with(503Bucket='1',504Marker=marker.format(account=TEST_ACCOUNT_ID))505506def test_calls_list_objects_correctly_org_trails(self):507s3_client = mock.Mock()508mock_s3_client_provider = mock.Mock()509mock_paginate = s3_client.get_paginator.return_value.paginate510mock_search = mock_paginate.return_value.search511mock_search.return_value = []512mock_s3_client_provider.get_client.return_value = s3_client513provider = DigestProvider(514mock_s3_client_provider, TEST_ORGANIZATION_ACCOUNT_ID,515'foo', 'us-east-1', 'us-east-1',516TEST_ORGANIZATION_ID)517provider.load_digest_keys_in_range(518'1', 'prefix', START_DATE, END_DATE)519marker = (520'prefix/AWSLogs/{organization_id}/{member_account}/'521'CloudTrail-Digest/us-east-1/'522'2014/08/09/{member_account}_CloudTrail-Digest_us-east-1_foo_'523'us-east-1_20140809T235900Z.json.gz'524)525mock_paginate.assert_called_once_with(526Bucket='1',527Marker=marker.format(528member_account=TEST_ORGANIZATION_ACCOUNT_ID,529organization_id=TEST_ORGANIZATION_ID530)531)532533def test_ensures_digest_has_proper_metadata(self):534out = BytesIO()535f = gzip.GzipFile(fileobj=out, mode="wb")536f.write('{"foo":"bar"}'.encode())537f.close()538gzipped_data = out.getvalue()539s3_client = mock.Mock()540s3_client.get_object.return_value = {541'Body': BytesIO(gzipped_data),542'Metadata': {}}543provider = self._get_mock_provider(s3_client)544with self.assertRaises(DigestSignatureError):545provider.fetch_digest('bucket', 'key')546547def test_ensures_digest_can_be_gzip_inflated(self):548s3_client = mock.Mock()549s3_client.get_object.return_value = {550'Body': BytesIO('foo'.encode()),551'Metadata': {}}552provider = self._get_mock_provider(s3_client)553with self.assertRaises(InvalidDigestFormat):554provider.fetch_digest('bucket', 'key')555556def test_ensures_digests_can_be_json_parsed(self):557json_str = '{{{'558out = BytesIO()559f = gzip.GzipFile(fileobj=out, mode="wb")560f.write(json_str.encode())561f.close()562gzipped_data = out.getvalue()563s3_client = mock.Mock()564s3_client.get_object.return_value = {565'Body': BytesIO(gzipped_data),566'Metadata': {'signature': 'abc', 'signature-algorithm': 'SHA256'}}567provider = self._get_mock_provider(s3_client)568with self.assertRaises(InvalidDigestFormat):569provider.fetch_digest('bucket', 'key')570571def test_fetches_digests(self):572json_str = '{"foo":"bar"}'573out = BytesIO()574f = gzip.GzipFile(fileobj=out, mode="wb")575f.write(json_str.encode())576f.close()577gzipped_data = out.getvalue()578s3_client = mock.Mock()579s3_client.get_object.return_value = {580'Body': BytesIO(gzipped_data),581'Metadata': {'signature': 'abc', 'signature-algorithm': 'SHA256'}}582provider = self._get_mock_provider(s3_client)583result = provider.fetch_digest('bucket', 'key')584self.assertEqual({'foo': 'bar', '_signature': 'abc',585'_signature_algorithm': 'SHA256'}, result[0])586self.assertEqual(json_str.encode(), result[1])587588589class TestDigestTraverser(unittest.TestCase):590def test_initializes_with_default_validator(self):591provider = mock.Mock()592traverser = DigestTraverser(593digest_provider=provider, starting_bucket='1',594starting_prefix='baz', public_key_provider=mock.Mock())595self.assertEqual('1', traverser.starting_bucket)596self.assertEqual('baz', traverser.starting_prefix)597self.assertEqual(provider, traverser.digest_provider)598599def test_ensures_public_keys_are_loaded(self):600start_date = START_DATE601end_date = END_DATE602digest_provider = mock.Mock()603key_provider = mock.Mock()604key_provider.get_public_keys.return_value = []605traverser = DigestTraverser(606digest_provider=digest_provider, starting_bucket='1',607starting_prefix='baz', public_key_provider=key_provider)608digest_iter = traverser.traverse(start_date, end_date)609with self.assertRaises(RuntimeError):610next(digest_iter)611key_provider.get_public_keys.assert_called_with(612start_date, end_date)613614def test_ensures_public_key_is_found(self):615start_date = START_DATE616end_date = END_DATE617key_name = end_date.strftime(DATE_FORMAT) + '.json.gz'618region = 'us-west-2'619digest_provider = mock.Mock()620digest_provider.trail_home_region = region621digest_provider.load_digest_keys_in_range.return_value = [key_name]622digest_provider.fetch_digest.return_value = (623{'digestEndTime': 'foo',624'digestStartTime': 'foo',625'awsAccountId': 'account',626'digestPublicKeyFingerprint': 'abc',627'digestS3Bucket': '1',628'digestS3Object': key_name,629'previousDigestSignature': 'xyz'},630'abc'631)632key_provider = mock.Mock()633key_provider.get_public_keys.return_value = [{'Fingerprint': 'a'}]634on_invalid, calls = collecting_callback()635traverser = DigestTraverser(636digest_provider=digest_provider, starting_bucket='1',637starting_prefix='baz', public_key_provider=key_provider,638on_invalid=on_invalid)639digest_iter = traverser.traverse(start_date, end_date)640with self.assertRaises(StopIteration):641next(digest_iter)642self.assertEqual(1, len(calls))643self.assertEqual(644('Digest file\ts3://1/%s\tINVALID: public key not '645'found in region %s for fingerprint abc' % (key_name, region)),646calls[0]['message'])647648def test_invokes_digest_validator(self):649start_date = START_DATE650end_date = END_DATE651key_name = end_date.strftime(DATE_FORMAT) + '.json.gz'652digest = {'digestPublicKeyFingerprint': 'a',653'digestS3Bucket': '1',654'digestS3Object': key_name,655'previousDigestSignature': '...',656'digestStartTime': (end_date - timedelta(hours=1)).strftime(657DATE_FORMAT),658'digestEndTime': end_date.strftime(DATE_FORMAT)}659digest_provider = mock.Mock()660digest_provider.load_digest_keys_in_range.return_value = [661key_name]662digest_provider.fetch_digest.return_value = (digest, key_name)663key_provider = mock.Mock()664public_keys = {'a': {'Fingerprint': 'a', 'Value': 'a'}}665key_provider.get_public_keys.return_value = public_keys666digest_validator = mock.Mock()667traverser = DigestTraverser(668digest_provider=digest_provider, starting_bucket='1',669starting_prefix='baz', public_key_provider=key_provider,670digest_validator=digest_validator)671digest_iter = traverser.traverse(start_date, end_date)672self.assertEqual(digest, next(digest_iter))673digest_validator.validate.assert_called_with(674'1', key_name, public_keys['a']['Value'], digest, key_name)675676def test_ensures_digest_from_same_location_as_json_contents(self):677start_date = START_DATE678end_date = END_DATE679callback, collected = collecting_callback()680key_name = end_date.strftime(DATE_FORMAT) + '.json.gz'681digest = {'digestPublicKeyFingerprint': 'a',682'digestS3Bucket': 'not_same',683'digestS3Object': key_name,684'digestEndTime': end_date.strftime(DATE_FORMAT)}685digest_provider = mock.Mock()686digest_provider.load_digest_keys_in_range.return_value = [key_name]687digest_provider.fetch_digest.return_value = (digest, key_name)688key_provider = mock.Mock()689digest_validator = mock.Mock()690traverser = DigestTraverser(691digest_provider=digest_provider, starting_bucket='1',692starting_prefix='baz', public_key_provider=key_provider,693digest_validator=digest_validator, on_invalid=callback)694digest_iter = traverser.traverse(start_date, end_date)695self.assertIsNone(next(digest_iter, None))696self.assertEqual(1, len(collected))697self.assertEqual(698'Digest file\ts3://1/%s\tINVALID: invalid format' % key_name,699collected[0]['message'])700701def test_loads_digests_in_range(self):702start_date = START_DATE703end_date = START_DATE + timedelta(hours=5)704key_provider, digest_provider, validator = create_scenario(705['gap', 'link', 'link', 'link'])706traverser = DigestTraverser(707digest_provider=digest_provider, starting_bucket='1',708starting_prefix='baz', public_key_provider=key_provider,709digest_validator=validator)710collected = list(traverser.traverse(start_date, end_date))711self.assertEqual(1, key_provider.get_public_keys.call_count)712self.assertEqual(7131, len(digest_provider.calls['load_digest_keys_in_range']))714self.assertEqual(4, len(digest_provider.calls['fetch_digest']))715self.assertEqual(4, len(collected))716717def test_invokes_cb_and_continues_when_missing(self):718start_date = START_DATE719end_date = END_DATE720key_provider, digest_provider, validator = create_scenario(721['gap', 'link', 'missing', 'link'])722on_missing, missing_calls = collecting_callback()723traverser = DigestTraverser(724digest_provider=digest_provider, starting_bucket='1',725starting_prefix='baz', public_key_provider=key_provider,726digest_validator=validator, on_missing=on_missing)727collected = list(traverser.traverse(start_date, end_date))728self.assertEqual(3, len(collected))729self.assertEqual(1, key_provider.get_public_keys.call_count)730self.assertEqual(1, len(missing_calls))731# Ensure the keys were provided in the correct order.732self.assertIn('bucket', missing_calls[0])733self.assertIn('next_end_date', missing_calls[0])734# Ensure the keys were provided in the correct order.735self.assertEqual(digest_provider.digests[1],736missing_calls[0]['next_key'])737self.assertEqual(digest_provider.digests[2],738missing_calls[0]['last_key'])739# Ensure the provider was called correctly740self.assertEqual(1, key_provider.get_public_keys.call_count)741self.assertEqual(7421, len(digest_provider.calls['load_digest_keys_in_range']))743self.assertEqual(4, len(digest_provider.calls['fetch_digest']))744745def test_invokes_cb_and_continues_when_invalid(self):746start_date = START_DATE747end_date = END_DATE748key_provider, digest_provider, validator = create_scenario(749['gap', 'link', 'invalid', 'link', 'invalid'])750on_invalid, invalid_calls = collecting_callback()751traverser = DigestTraverser(752digest_provider=digest_provider, starting_bucket='1',753starting_prefix='baz', public_key_provider=key_provider,754digest_validator=validator, on_invalid=on_invalid)755collected = list(traverser.traverse(start_date, end_date))756self.assertEqual(3, len(collected))757self.assertEqual(1, key_provider.get_public_keys.call_count)758self.assertEqual(2, len(invalid_calls))759# Ensure it was invoked with all the kwargs we expected.760self.assertIn('bucket', invalid_calls[0])761self.assertIn('next_end_date', invalid_calls[0])762# Ensure the keys were provided in the correct order.763self.assertEqual(digest_provider.digests[4],764invalid_calls[0]['last_key'])765self.assertEqual(digest_provider.digests[3],766invalid_calls[0]['next_key'])767self.assertEqual(digest_provider.digests[2],768invalid_calls[1]['last_key'])769self.assertEqual(digest_provider.digests[1],770invalid_calls[1]['next_key'])771# Ensure the provider was called correctly772self.assertEqual(1, key_provider.get_public_keys.call_count)773self.assertEqual(7741, len(digest_provider.calls['load_digest_keys_in_range']))775self.assertEqual(5, len(digest_provider.calls['fetch_digest']))776777def test_invokes_cb_and_continues_when_gap(self):778start_date = START_DATE779end_date = END_DATE780key_provider, digest_provider, validator = create_scenario(781['gap', 'link', 'gap', 'gap'])782on_gap, gap_calls = collecting_callback()783traverser = DigestTraverser(784digest_provider=digest_provider, starting_bucket='1',785starting_prefix='baz', public_key_provider=key_provider,786digest_validator=validator, on_gap=on_gap)787collected = list(traverser.traverse(start_date, end_date))788self.assertEqual(4, len(collected))789self.assertEqual(1, key_provider.get_public_keys.call_count)790self.assertEqual(2, len(gap_calls))791# Ensure it was invoked with all the kwargs we expected.792self.assertIn('bucket', gap_calls[0])793self.assertIn('next_key', gap_calls[0])794self.assertIn('next_end_date', gap_calls[0])795self.assertIn('last_key', gap_calls[0])796self.assertIn('last_start_date', gap_calls[0])797# Ensure the keys were provided in the correct order.798self.assertEqual(digest_provider.digests[3], gap_calls[0]['last_key'])799self.assertEqual(digest_provider.digests[2], gap_calls[0]['next_key'])800self.assertEqual(digest_provider.digests[2], gap_calls[1]['last_key'])801self.assertEqual(digest_provider.digests[1], gap_calls[1]['next_key'])802# Ensure the provider was called correctly803self.assertEqual(1, key_provider.get_public_keys.call_count)804self.assertEqual(8051, len(digest_provider.calls['load_digest_keys_in_range']))806self.assertEqual(4, len(digest_provider.calls['fetch_digest']))807808def test_reloads_objects_on_bucket_change(self):809start_date = START_DATE810end_date = END_DATE811key_provider, digest_provider, validator = create_scenario(812['gap', 'link', 'bucket_change', 'link'])813traverser = DigestTraverser(814digest_provider=digest_provider, starting_bucket='1',815starting_prefix='baz', public_key_provider=key_provider,816digest_validator=validator)817collected = list(traverser.traverse(start_date, end_date))818self.assertEqual(4, len(collected))819self.assertEqual(1, key_provider.get_public_keys.call_count)820# Ensure the provider was called correctly821self.assertEqual(1, key_provider.get_public_keys.call_count)822self.assertEqual(8232, len(digest_provider.calls['load_digest_keys_in_range']))824self.assertEqual(['1', '1', '2', '2'],825[c['digestS3Bucket'] for c in collected])826827def test_does_not_hard_fail_on_invalid_signature(self):828start_date = START_DATE829end_date = END_DATE830end_timestamp = end_date.strftime(DATE_FORMAT) + '.json.gz'831digest = {'digestPublicKeyFingerprint': 'a',832'digestS3Bucket': '1',833'digestS3Object': end_timestamp,834'previousDigestSignature': '...',835'digestStartTime': (end_date - timedelta(hours=1)).strftime(836DATE_FORMAT),837'digestEndTime': end_timestamp,838'_signature': '123'}839digest_provider = mock.Mock()840digest_provider.load_digest_keys_in_range.return_value = [841end_timestamp]842digest_provider.fetch_digest.return_value = (digest, end_timestamp)843key_provider = mock.Mock()844public_keys = {'a': {'Fingerprint': 'a', 'Value': 'a'}}845key_provider.get_public_keys.return_value = public_keys846digest_validator = Sha256RSADigestValidator()847on_invalid, calls = collecting_callback()848traverser = DigestTraverser(849digest_provider=digest_provider, starting_bucket='1',850starting_prefix='baz', public_key_provider=key_provider,851digest_validator=digest_validator, on_invalid=on_invalid)852digest_iter = traverser.traverse(start_date, end_date)853next(digest_iter, None)854self.assertIn(855'Digest file\ts3://1/%s\tINVALID: ' % end_timestamp,856calls[0]['message'])857858859class TestCloudTrailCommand(BaseAWSCommandParamsTest):860def test_s3_client_created_lazily(self):861session = mock.Mock()862command = CloudTrailValidateLogs(session)863parsed_globals = mock.Mock(region=None, verify_ssl=None, endpoint_url=None)864command.setup_services(parsed_globals)865create_client_calls = session.create_client.call_args_list866self.assertEqual(867create_client_calls,868[869mock.call('organizations', verify=None, region_name=None),870mock.call('cloudtrail', verify=None, region_name=None)871]872)873874def test_endpoint_url_is_used_for_cloudtrail(self):875endpoint_url = 'https://mycloudtrail.aws.amazon.com/'876session = mock.Mock()877command = CloudTrailValidateLogs(session)878parsed_globals = mock.Mock(region='foo', verify_ssl=None,879endpoint_url=endpoint_url)880command.setup_services(parsed_globals)881create_client_calls = session.create_client.call_args_list882self.assertEqual(883create_client_calls,884[885mock.call('organizations', verify=None, region_name='foo'),886# Here we should inject the endpoint_url only for cloudtrail.887mock.call('cloudtrail', verify=None, region_name='foo',888endpoint_url=endpoint_url)889]890)891892def test_initializes_args(self):893session = mock.Mock()894command = CloudTrailValidateLogs(session)895start_date = START_DATE.strftime(DATE_FORMAT)896args = Namespace(trail_arn='abc', verbose=True,897start_time=start_date, s3_bucket='bucket',898s3_prefix='prefix', end_time=None, account_id=None)899command.handle_args(args)900self.assertEqual('abc', command.trail_arn)901self.assertEqual(True, command.is_verbose)902self.assertEqual('bucket', command.s3_bucket)903self.assertEqual('prefix', command.s3_prefix)904self.assertEqual(start_date, command.start_time.strftime(DATE_FORMAT))905self.assertIsNotNone(command.end_time)906self.assertGreater(command.end_time, command.start_time)907908909class TestS3ClientProvider(BaseAWSCommandParamsTest):910def test_creates_clients_for_buckets_in_us_east_1(self):911session = mock.Mock()912s3_client = mock.Mock()913session.create_client.return_value = s3_client914s3_client.get_bucket_location.return_value = {'LocationConstraint': ''}915provider = S3ClientProvider(session)916created_client = provider.get_client('foo')917self.assertEqual(s3_client, created_client)918create_client_calls = session.create_client.call_args_list919self.assertEqual(create_client_calls, [mock.call('s3', region_name='us-east-1')])920self.assertEqual(1, s3_client.get_bucket_location.call_count)921922def test_creates_clients_for_buckets_outside_us_east_1(self):923session = mock.Mock()924s3_client = mock.Mock()925session.create_client.return_value = s3_client926s3_client.get_bucket_location.return_value = {927'LocationConstraint': 'us-west-2'}928provider = S3ClientProvider(session, 'us-west-1')929created_client = provider.get_client('foo')930self.assertEqual(s3_client, created_client)931create_client_calls = session.create_client.call_args_list932self.assertEqual(create_client_calls, [933mock.call('s3', region_name='us-west-1'),934mock.call('s3', region_name='us-west-2')935])936self.assertEqual(1, s3_client.get_bucket_location.call_count)937938def test_caches_previously_loaded_bucket_regions(self):939session = mock.Mock()940s3_client = mock.Mock()941session.create_client.return_value = s3_client942s3_client.get_bucket_location.return_value = {'LocationConstraint': ''}943provider = S3ClientProvider(session)944provider.get_client('foo')945self.assertEqual(1, s3_client.get_bucket_location.call_count)946provider.get_client('foo')947self.assertEqual(1, s3_client.get_bucket_location.call_count)948provider.get_client('bar')949self.assertEqual(2, s3_client.get_bucket_location.call_count)950provider.get_client('bar')951self.assertEqual(2, s3_client.get_bucket_location.call_count)952953def test_caches_previously_loaded_clients(self):954session = mock.Mock()955s3_client = mock.Mock()956session.create_client.return_value = s3_client957s3_client.get_bucket_location.return_value = {'LocationConstraint': ''}958provider = S3ClientProvider(session)959client = provider.get_client('foo')960self.assertEqual(1, session.create_client.call_count)961self.assertEqual(client, provider.get_client('foo'))962self.assertEqual(1, session.create_client.call_count)963964def test_removes_cli_error_events(self):965# We should also remove the error handler for S3.966# This can be removed once the client switchover is done.967session = mock.Mock()968s3_client = mock.Mock()969session.create_client.return_value = s3_client970s3_client.get_bucket_location.return_value = {'LocationConstraint': ''}971provider = S3ClientProvider(session)972client = provider.get_client('foo')973974975