Skip to content

Commit b47720f

Browse files
authored
Add EICAR archive to live_test (#90)
1 parent d443913 commit b47720f

File tree

7 files changed

+198
-124
lines changed

7 files changed

+198
-124
lines changed

manage.py

Lines changed: 15 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,21 @@
44
import argparse
55
import base64
66
import getpass
7-
import hashlib
87
import inspect
98
import os
10-
import pprint
119
import re
1210
import subprocess
1311
import sys
14-
import time
1512
from typing import Set
1613
import unittest
17-
import uuid
1814

1915
import boto3
20-
from boto3.dynamodb.conditions import Attr, Key
2116
import hcl
2217

2318
from lambda_functions.analyzer.common import COMPILED_RULES_FILENAME
2419
from lambda_functions.build import build as lambda_build
2520
from rules import compile_rules, clone_rules
26-
from tests.rules.eicar_rule_test import EICAR_STRING
21+
from tests import live_test
2722

2823
# BinaryAlert version.
2924
VERSION = '1.1.0.beta'
@@ -33,6 +28,7 @@
3328
TERRAFORM_DIR = os.path.join(PROJECT_DIR, 'terraform')
3429
CONFIG_FILE = os.path.join(TERRAFORM_DIR, 'terraform.tfvars')
3530
VARIABLES_FILE = os.path.join(TERRAFORM_DIR, 'variables.tf')
31+
TEST_FILES = os.path.join(PROJECT_DIR, 'tests', 'files')
3632

3733
# Terraform identifiers.
3834
CB_KMS_ALIAS_TERRAFORM_ID = 'aws_kms_alias.encrypt_credentials_alias'
@@ -167,10 +163,18 @@ def encrypted_carbon_black_api_token(self, value: str):
167163
def force_destroy(self) -> str:
168164
return self._config['force_destroy']
169165

166+
@property
167+
def binaryalert_analyzer_name(self) -> str:
168+
return '{}_binaryalert_analyzer'.format(self.name_prefix)
169+
170170
@property
171171
def binaryalert_batcher_name(self) -> str:
172172
return '{}_binaryalert_batcher'.format(self.name_prefix)
173173

174+
@property
175+
def binaryalert_dynamo_table_name(self) -> str:
176+
return '{}_binaryalert_matches'.format(self.name_prefix)
177+
174178
@property
175179
def binaryalert_s3_bucket_name(self) -> str:
176180
return '{}.binaryalert-binaries.{}'.format(
@@ -450,60 +454,14 @@ def destroy(self) -> None:
450454
subprocess.call(['terraform', 'destroy'])
451455

452456
def live_test(self) -> None:
453-
"""Upload an EICAR test file to BinaryAlert which should trigger a YARA match alert.
457+
"""Upload test files to BinaryAlert which should trigger YARA matches.
454458
455459
Raises:
456-
TestFailureError: If the live test failed (YARA match not found).
460+
TestFailureError: If the live test failed (YARA matches not found).
457461
"""
458-
bucket_name = self._config.binaryalert_s3_bucket_name
459-
test_filename = 'eicar_test_{}.txt'.format(uuid.uuid4())
460-
s3_identifier = 'S3:{}:{}'.format(bucket_name, test_filename)
461-
462-
print('Uploading EICAR test file {}...'.format(s3_identifier))
463-
bucket = boto3.resource('s3').Bucket(bucket_name)
464-
bucket.put_object(
465-
Body=EICAR_STRING.encode('UTF-8'),
466-
Key=test_filename,
467-
Metadata={'filepath': test_filename}
468-
)
469-
470-
table_name = '{}_binaryalert_matches'.format(self._config.name_prefix)
471-
print('EICAR test file uploaded! Connecting to table DynamoDB:{}...'.format(table_name))
472-
table = boto3.resource('dynamodb').Table(table_name)
473-
eicar_sha256 = hashlib.sha256(EICAR_STRING.encode('UTF-8')).hexdigest()
474-
dynamo_record_found = False
475-
476-
for attempt in range(1, 11):
477-
time.sleep(5)
478-
print('\t[{}/10] Querying DynamoDB table for the expected YARA match entry...'.format(
479-
attempt))
480-
items = table.query(
481-
Select='ALL_ATTRIBUTES',
482-
Limit=1,
483-
ConsistentRead=True,
484-
ScanIndexForward=False, # Sort by AnalyzerVersion descending (e.g. newest first).
485-
KeyConditionExpression=Key('SHA256').eq(eicar_sha256),
486-
FilterExpression=Attr('S3Objects').contains(s3_identifier)
487-
).get('Items')
488-
489-
if items:
490-
print('\nSUCCESS: Expected DynamoDB entry for the EICAR file was found!\n')
491-
dynamo_record_found = True
492-
pprint.pprint(items[0])
493-
494-
print('\nRemoving DynamoDB EICAR entry...')
495-
lambda_version = items[0]['AnalyzerVersion']
496-
table.delete_item(Key={'SHA256': eicar_sha256, 'AnalyzerVersion': lambda_version})
497-
break
498-
elif attempt == 10:
499-
print('\nFAIL: Expected DynamoDB entry for the EICAR file was *not* found!\n')
500-
501-
print('Removing EICAR test file from S3...')
502-
bucket.delete_objects(Delete={'Objects': [{'Key': test_filename}]})
503-
504-
if dynamo_record_found:
505-
print('\nLive test succeeded! Verify the alert was sent to your SNS subscription(s).')
506-
else:
462+
if not live_test.run(self._config.binaryalert_s3_bucket_name,
463+
self._config.binaryalert_analyzer_name,
464+
self._config.binaryalert_dynamo_table_name):
507465
raise TestFailureError(
508466
'\nLive test failed! See https://binaryalert.io/troubleshooting-faq.html')
509467

terraform/s3.tf

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,14 @@ resource "aws_s3_bucket" "binaryalert_binaries" {
6767
prefix = ""
6868
enabled = true
6969

70-
// Old/deleted object versions are permanently removed after 1 day.
70+
// Old object versions are permanently removed after 1 day.
7171
noncurrent_version_expiration {
7272
days = 1
7373
}
74+
75+
expiration {
76+
expired_object_delete_marker = true
77+
}
7478
}
7579

7680
tags {

tests/files/eicar.tar.gz.bz2

343 Bytes
Binary file not shown.

tests/files/eicar.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*

tests/live_test.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Upload test files to S3 and see if the expected matches appear in Dynamo."""
2+
import hashlib
3+
import os
4+
import pprint
5+
import time
6+
from typing import Dict, List
7+
import uuid
8+
9+
import boto3
10+
11+
TEST_DIR = os.path.dirname(os.path.realpath(__file__))
12+
13+
14+
def _upload_test_files_to_s3(bucket_name: str) -> Dict[str, str]:
15+
"""Upload test files to S3 and returns a map from SHA256 to S3 identifier."""
16+
bucket = boto3.resource('s3').Bucket(bucket_name)
17+
random_suffix = str(uuid.uuid4()).split('-')[-1]
18+
19+
result = {}
20+
for filename in ['eicar.txt', 'eicar.tar.gz.bz2']:
21+
filepath = os.path.join(TEST_DIR, 'files', filename)
22+
s3_object_key = '{}_{}'.format(filename, random_suffix)
23+
s3_full_identifier = 'S3:{}:{}'.format(bucket_name, s3_object_key)
24+
25+
with open(filepath, 'rb') as f:
26+
sha256 = hashlib.sha256(f.read()).hexdigest()
27+
result[sha256] = s3_full_identifier
28+
29+
print('Uploading {} to {}...'.format(filename, s3_full_identifier))
30+
bucket.upload_file(filepath, s3_object_key, ExtraArgs={'Metadata': {'filepath': filename}})
31+
32+
return result
33+
34+
35+
def _lambda_production_version(function_name: str) -> int:
36+
"""Find the version associated with the Production alias of a Lambda function."""
37+
print('Looking up version of {}:Production...'.format(function_name))
38+
response = boto3.client('lambda').list_aliases(FunctionName=function_name)
39+
for alias in response['Aliases']:
40+
if alias['Name'] == 'Production':
41+
return int(alias['FunctionVersion'])
42+
return -1
43+
44+
45+
def _query_dynamo_for_test_files(
46+
table_name: str, file_info: Dict[str, str], analyzer_version: int,
47+
max_attempts: int = 15) -> List:
48+
"""Repeatedly query DynamoDB to look for the expected YARA matches.
49+
50+
Args:
51+
table_name: Name of the DynamoDB match table.
52+
file_info: Dictionary from _upload_test_files_to_s3.
53+
analyzer_version: The underlying Lambda version for the Production alias of the analyzer.
54+
max_attempts: Max number of times to query for results (with 5 seconds between each).
55+
56+
Returns:
57+
True if the expected entries were found
58+
"""
59+
client = boto3.client('dynamodb')
60+
61+
for attempt in range(1, max_attempts + 1):
62+
if attempt > 1:
63+
time.sleep(5)
64+
print('\t[{}/{}] Querying DynamoDB table for the expected YARA match entries...'.format(
65+
attempt, max_attempts))
66+
67+
results = client.batch_get_item(
68+
RequestItems={
69+
table_name: {
70+
'Keys': [
71+
{
72+
'SHA256': {'S': sha},
73+
'AnalyzerVersion': {'N': str(analyzer_version)}
74+
}
75+
for sha in file_info
76+
]
77+
}
78+
}
79+
)['Responses'][table_name]
80+
81+
if len(results) < len(file_info):
82+
# If there weren't as many matches as files uploaded, stop and try again.
83+
continue
84+
85+
# Make sure the matches found are from the files we uploaded (and not others).
86+
all_objects_found = True
87+
for entry in results:
88+
file_id = file_info[entry['SHA256']['S']]
89+
if file_id not in entry['S3Objects']['SS']:
90+
all_objects_found = False
91+
break
92+
93+
if not all_objects_found:
94+
continue
95+
96+
# The results check out!
97+
return results
98+
99+
return []
100+
101+
102+
def _cleanup(
103+
bucket_name: str, file_info: Dict[str, str], table_name: str,
104+
analyzer_version: int) -> None:
105+
"""Remove test files and match information."""
106+
print('Removing test files from S3...')
107+
bucket = boto3.resource('s3').Bucket(bucket_name)
108+
bucket.delete_objects(
109+
Delete={
110+
'Objects': [
111+
{'Key': s3_identifier.split(':')[-1]}
112+
for s3_identifier in file_info.values()
113+
]
114+
}
115+
)
116+
117+
print('Removing DynamoDB match entries...')
118+
client = boto3.resource('dynamodb')
119+
120+
client.batch_write_item(
121+
RequestItems={
122+
table_name: [
123+
{
124+
'DeleteRequest': {
125+
'Key': {
126+
'SHA256': sha,
127+
'AnalyzerVersion': analyzer_version
128+
}
129+
}
130+
}
131+
for sha in file_info
132+
]
133+
}
134+
)
135+
136+
137+
def run(bucket_name: str, analyzer_function_name: str, table_name: str) -> bool:
138+
"""Upload an EICAR test file to BinaryAlert which should trigger a YARA match alert.
139+
140+
Args:
141+
bucket_name: Name of the S3 bucket containing binaries.
142+
analyzer_function_name: Name of the YARA analyzer Lambda function.
143+
table_name: Name of the Dynamo table storing YARA match information.
144+
145+
Returns:
146+
True if the test was successful, False otherwise.
147+
"""
148+
test_file_info = _upload_test_files_to_s3(bucket_name)
149+
analyzer_version = _lambda_production_version(analyzer_function_name)
150+
results = _query_dynamo_for_test_files(table_name, test_file_info, analyzer_version)
151+
152+
if results:
153+
print()
154+
pprint.pprint(results)
155+
print('\nSUCCESS: Expected DynamoDB entries for the test files were found!')
156+
else:
157+
print('\nFAIL: Expected DynamoDB entries for the test files were *not* found :(\n')
158+
159+
_cleanup(bucket_name, test_file_info, table_name, analyzer_version)
160+
print('Done!')
161+
return bool(results)

tests/manage_test.py

Lines changed: 6 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,12 @@
66
import os
77
import subprocess
88
import sys
9-
import time
10-
import uuid
119
from unittest import mock, TestCase
1210

1311
import boto3
1412
from pyfakefs import fake_filesystem_unittest
1513

1614
import manage
17-
from tests.rules.eicar_rule_test import EICAR_STRING
1815

1916

2017
def _mock_input(prompt: str) -> str:
@@ -375,61 +372,9 @@ def test_destroy(self, mock_check_call: mock.MagicMock, mock_call: mock.MagicMoc
375372
mock_check_call.assert_called_once()
376373
mock_call.assert_called_once()
377374

378-
@mock.patch.object(time, 'sleep', mock.MagicMock())
379-
@mock.patch.object(boto3, 'resource')
380-
@mock.patch.object(manage, 'print')
381-
@mock.patch.object(manage, 'pprint', mock.MagicMock())
382-
@mock.patch.object(uuid, 'uuid4', return_value='test-uuid')
383-
def test_live_test(self, mock_uuid: mock.MagicMock, mock_print: mock.MagicMock,
384-
mock_resource: mock.MagicMock):
385-
"""Verify execution order for boto3 and print mock calls."""
386-
self.manager.live_test()
387-
388-
mock_uuid.assert_called_once()
389-
390-
mock_resource.assert_has_calls([
391-
mock.call('s3'),
392-
mock.call().Bucket('test.prefix.binaryalert-binaries.us-test-1'),
393-
mock.call().Bucket().put_object(
394-
Body=bytes('{}'.format(EICAR_STRING), 'utf-8'),
395-
Key='eicar_test_test-uuid.txt',
396-
Metadata={'filepath': 'eicar_test_test-uuid.txt'}
397-
),
398-
mock.call('dynamodb'),
399-
mock.call().Table('test_prefix_binaryalert_matches'),
400-
mock.call().Table().query(
401-
Select='ALL_ATTRIBUTES',
402-
Limit=1,
403-
ConsistentRead=True,
404-
ScanIndexForward=False,
405-
KeyConditionExpression=mock.ANY,
406-
FilterExpression=mock.ANY
407-
)
408-
])
409-
410-
mock_resource.assert_has_calls([
411-
mock.call().Table().delete_item(Key=mock.ANY),
412-
mock.call().Bucket().delete_objects(
413-
Delete={'Objects': [{'Key': 'eicar_test_test-uuid.txt'}]}
414-
)
415-
])
416-
417-
mock_print.assert_has_calls([
418-
mock.call(
419-
'Uploading EICAR test file '
420-
'S3:test.prefix.binaryalert-binaries.us-test-1:eicar_test_test-uuid.txt...'
421-
),
422-
mock.call(
423-
'EICAR test file uploaded! '
424-
'Connecting to table DynamoDB:test_prefix_binaryalert_matches...'
425-
),
426-
mock.call(
427-
'\t[1/10] Querying DynamoDB table for the expected YARA match entry...'
428-
),
429-
mock.call('\nSUCCESS: Expected DynamoDB entry for the EICAR file was found!\n'),
430-
mock.call('\nRemoving DynamoDB EICAR entry...'),
431-
mock.call('Removing EICAR test file from S3...'),
432-
mock.call(
433-
'\nLive test succeeded! Verify the alert was sent to your SNS subscription(s).'
434-
)
435-
])
375+
@mock.patch.object(manage.live_test, 'run', return_value=False)
376+
def test_live_test(self, mock_live_test: mock.MagicMock):
377+
"""Live test wrapper raises TestFailureError if appropriate."""
378+
with self.assertRaises(manage.TestFailureError):
379+
self.manager.live_test()
380+
mock_live_test.assert_called_once()

0 commit comments

Comments
 (0)