From cb2746b0c36d4090aef0e2ef1f9b6d3fe5eaf771 Mon Sep 17 00:00:00 2001 From: Sheena Todhunter Date: Wed, 3 Feb 2021 16:13:17 -0800 Subject: [PATCH 01/11] services.py now includes the LastUsedDate of key --- sleuth/sleuth/services.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/sleuth/sleuth/services.py b/sleuth/sleuth/services.py index 9b83353a..fab660be 100644 --- a/sleuth/sleuth/services.py +++ b/sleuth/sleuth/services.py @@ -25,13 +25,15 @@ def get_iam_key_info(user): list (Key): Return list of keys for a single user """ from sleuth.auditor import Key - resp = IAM.list_access_keys(UserName=user.username) - keys = [] - for k in resp['AccessKeyMetadata']: + key_info = IAM.list_access_keys(UserName=user.username) + for k in key_info['AccessKeyMetadata']: + access_date=IAM.get_access_key_last_used(AccessKeyId=k['AccessKeyId']) keys.append(Key(k['UserName'], k['AccessKeyId'], k['Status'], - k['CreateDate'])) + k['CreateDate'], + access_date['AccessKeyLastUsed']['LastUsedDate'] if 'LastUsedDate' in access_date['AccessKeyLastUsed'] else k['CreateDate'])) + return keys From d88b5952efc2f7a5fe7a74cd85c70fbcfa129658 Mon Sep 17 00:00:00 2001 From: Sheena Todhunter Date: Wed, 3 Feb 2021 16:21:25 -0800 Subject: [PATCH 02/11] add new variables for last used age to auditor and apply new logic to handle --- sleuth/sleuth/auditor.py | 40 +++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/sleuth/sleuth/auditor.py b/sleuth/sleuth/auditor.py index 7d8f6c0a..301b4651 100644 --- a/sleuth/sleuth/auditor.py +++ b/sleuth/sleuth/auditor.py @@ -16,48 +16,58 @@ class Key(): key_id = "" created = None status = "" + last_used = None audit_state = None - age = 0 + creation_age = 0 + access_age = 0 valid_for = 0 - def __init__(self, username, key_id, status, created): + def __init__(self, username, key_id, status, created, last_used): self.username = username self.key_id = key_id self.status = status self.created = created + self.last_used = last_used - self.age = (dt.datetime.now(dt.timezone.utc) - self.created).days + self.creation_age = (dt.datetime.now(dt.timezone.utc) - self.created).days + self.access_age = (dt.datetime.now(dt.timezone.utc) - self.last_used).days - def audit(self, rotate_age, expire_age): + def audit(self, rotate_age, expire_age, max_last_used_age): """ - Audits the key and sets the status state based on key age + Audits the key and sets the status state based on key creation age and last used age - Note if the key is below rotate the audit_state=good. If the key is disabled will be marked as disabled. + Note if the key is below rotate or last used age, the audit_state=good. + If the key is disabled will be marked as disabled. Parameters: rotate (int): Age key must be before audit_state=old expire (int): Age key must be before audit_state=expire + last_used_age (int): Age of last key usage must be before audit_state=expire Returns: None """ assert(rotate_age < expire_age) + assert(max_last_used_age <= expire_age) # set the valid_for in the object - self.valid_for = expire_age - self.age + self.valid_for = min(expire_age - self.creation_age, max_last_used_age) # lets audit the age - if self.age < rotate_age: - self.audit_state = 'good' - if self.age >= rotate_age and self.age < expire_age: - self.audit_state = 'old' - if self.age >= expire_age: + if self.creation_age >= expire_age: self.audit_state = 'expire' + elif self.access_age >= max_last_used_age: + self.audit_state = 'expire' + elif self.creation_age >= rotate_age and self.creation_age < expire_age: + self.audit_state = 'old' + elif self.creation_age < rotate_age: + self.audit_state = 'good' + + # lets audit the status if self.status == 'Inactive' and os.environ['ENABLE_AUTO_EXPIRE'] == 'true': self.audit_state = 'disabled' - class User(): username = "" user_id = "" @@ -92,7 +102,7 @@ def print_key_report(users): u.slack_id, k.key_id, k.audit_state, - k.age + k.creation_age ]) print(tabulate(tbl_data, headers=['UserName', 'Slack ID', 'Key ID', 'Status', 'Age in Days'])) @@ -103,7 +113,7 @@ def audit(): # lets audit keys so the ages and state are set for u in iam_users: - u.audit(int(os.environ['WARNING_AGE']), int(os.environ['EXPIRATION_AGE'])) + u.audit(int(os.environ['WARNING_AGE']), int(os.environ['EXPIRATION_AGE']), int(os.environ['LAST_USED_AGE'])) if os.environ.get('DEBUG', False): print_key_report(iam_users) From 1b2f975461d38c6770301110f007cd82b4be49a3 Mon Sep 17 00:00:00 2001 From: Sheena Todhunter Date: Wed, 3 Feb 2021 16:36:04 -0800 Subject: [PATCH 03/11] add new last used variable to test_services --- sleuth/tests/test_services.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sleuth/tests/test_services.py b/sleuth/tests/test_services.py index 7ad8a4a9..3a4518ee 100644 --- a/sleuth/tests/test_services.py +++ b/sleuth/tests/test_services.py @@ -7,11 +7,12 @@ from sleuth.auditor import Key, User created = datetime.datetime(2019, 1, 1, tzinfo=datetime.timezone.utc) +lastused = created = datetime.datetime(2019, 1, 3, tzinfo=datetime.timezone.utc) user1 = User('user1', 'slackuser1', 'U12345') user2 = User('user1', 'slackuser1', 'U67890') -key1 = Key('user1', 'asdfksakfa', 'Active', created) +key1 = Key('user1', 'asdfksakfa', 'Active', created, lastused) key1.audit_state = 'old' -key2 = Key('user2', 'ldasfkk', 'Active', created) +key2 = Key('user2', 'ldasfkk', 'Active', created, lastused) key2.audit_state = 'expire' user1.keys = [key1] user2.keys = [key2] From bdede2a42e66771f41ae83ef1f2080d837ba0824 Mon Sep 17 00:00:00 2001 From: Sheena Todhunter Date: Wed, 3 Feb 2021 16:36:39 -0800 Subject: [PATCH 04/11] add new last used variable and new test for auditing variable to test_auditor --- sleuth/tests/test_auditor.py | 66 +++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/sleuth/tests/test_auditor.py b/sleuth/tests/test_auditor.py index 457cd1be..46815008 100644 --- a/sleuth/tests/test_auditor.py +++ b/sleuth/tests/test_auditor.py @@ -5,59 +5,69 @@ from sleuth.auditor import Key - @freeze_time("2019-01-16") class TestKey(): def test_normal(self): """Normal happy path, key is good""" created = datetime.datetime(2019, 1, 1, tzinfo=datetime.timezone.utc) - k = Key('username', 'keyid', 'Active', created) - k.audit(60, 80) - - assert k.age == 15 + last_used = datetime.datetime(2019, 1, 2, tzinfo=datetime.timezone.utc) + k = Key('username', 'keyid', 'Active', created, last_used) + k.audit(60, 80, 20) + assert k.creation_age == 15 assert k.audit_state == 'good' def test_rotate(self): """Key is past rotate age, key is marked as old""" created = datetime.datetime(2019, 1, 1, tzinfo=datetime.timezone.utc) - k = Key('username', 'keyid', 'Active', created) - k.audit(10, 80) - - assert k.audit_state == 'old' + last_used = datetime.datetime(2019, 1, 2, tzinfo=datetime.timezone.utc) + key = Key('username', 'keyid', 'Active', created, last_used) + key.audit(10, 80, 20) + assert key.audit_state == 'old' def test_old(self): """Key is past max threshold, key is marked as expired""" created = datetime.datetime(2019, 1, 1, tzinfo=datetime.timezone.utc) - k = Key('username', 'keyid', 'Active', created) - k.audit(10, 11) - - assert k.audit_state == 'expire' + last_used = datetime.datetime(2019, 1, 2, tzinfo=datetime.timezone.utc) + key = Key('username', 'keyid', 'Active', created, last_used) + key.audit(10, 11, 10) + assert key.audit_state == 'expire' def test_no_disable(self, monkeypatch): """Key is disabled AWS status of Inactive, but disabling is turned off so key remains audit state expire""" monkeypatch.setenv('ENABLE_AUTO_EXPIRE', 'false') created = datetime.datetime(2019, 1, 1, tzinfo=datetime.timezone.utc) - k = Key('username', 'keyid', 'Inactive', created) - - k.audit(10, 11) - assert k.audit_state == 'expire' + last_used = datetime.datetime(2019, 1, 2, tzinfo=datetime.timezone.utc) + key = Key('user2', 'ldasfkk', 'Inactive', created, last_used) + key.audit(10, 11, 10) + assert key.audit_state == 'expire' - def test_inactive(self, monkeypatch): + def test_last_used(self, monkeypatch): + """Key has not been used in X days, key marked is disabled""" + monkeypatch.setenv('ENABLE_AUTO_EXPIRE', 'true') + monkeypatch.setenv('LAST_USED_AGE', '10') + created = datetime.datetime(2019, 1, 1, tzinfo=datetime.timezone.utc) + last_used = datetime.datetime(2019, 1, 2, tzinfo=datetime.timezone.utc) + key = Key('user3', 'kljin', 'Active', created, last_used) + key.audit(10, 11, 1) + assert key.audit_state == 'expire' + key.audit(60, 80, 1) + assert key.audit_state == 'expire' + + def test_disabled(self, monkeypatch): """Key is disabled AWS status of Inactive, key marked is disabled""" monkeypatch.setenv('ENABLE_AUTO_EXPIRE', 'true') created = datetime.datetime(2019, 1, 1, tzinfo=datetime.timezone.utc) - k = Key('username', 'keyid', 'Inactive', created) - - k.audit(10, 11) - assert k.audit_state == 'disabled' - k.audit(60, 80) - assert k.audit_state == 'disabled' - + last_used = datetime.datetime(2019, 1, 2, tzinfo=datetime.timezone.utc) + key = Key('user2', 'ldasfkk', 'Inactive', created, last_used) + key.audit(10, 11, 10) + assert key.audit_state == 'disabled' + key.audit(60, 80, 30) + assert key.audit_state == 'disabled' def test_invalid(self): """Key is disabled AWS status of Inactive, key marked is disabled""" created = datetime.datetime(2019, 1, 1, tzinfo=datetime.timezone.utc) - k = Key('username', 'keyid', 'Inactive', created) - + last_used = datetime.datetime(2019, 1, 2, tzinfo=datetime.timezone.utc) + key = Key('user2', 'ldasfkk', 'Inactive', created, last_used) with pytest.raises(AssertionError): - k.audit(5, 1) \ No newline at end of file + key.audit(5, 1, 1) \ No newline at end of file From 2dfa827946de3a795d7f9e996dc422ebc07f0b23 Mon Sep 17 00:00:00 2001 From: Sheena Todhunter Date: Wed, 3 Feb 2021 16:36:57 -0800 Subject: [PATCH 05/11] add new LAST_USED_AGE to main.tf --- examples/simple/main.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/simple/main.tf b/examples/simple/main.tf index ddf72c8c..f2f9b1ee 100644 --- a/examples/simple/main.tf +++ b/examples/simple/main.tf @@ -29,6 +29,7 @@ module "iam_sleuth" { ENABLE_AUTO_EXPIRE = false EXPIRATION_AGE = 90 WARNING_AGE = 85 + LAST_USED_AGE = 30 MSG_TITLE = "Key Rotation Instructions" MSG_TEXT = "Please run key rotation tool!" } From 05fe8ecae37b7dc0e19be802860371603a4ae839 Mon Sep 17 00:00:00 2001 From: Sheena Todhunter Date: Wed, 3 Feb 2021 16:37:22 -0800 Subject: [PATCH 06/11] update README for new LAST_USED_AGE variable --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2869da2d..bb041490 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,9 @@ An auditing tool for AWS keys that audits, alerts, and disable keys if not withi Sleuth runs periodically, normally once a day in the middle of business hours. Sleuth does the following: -- Inspect each Access Key based on set age threshold (default 90 days) +- Inspect each Access Key based on: + - set creation age threshold (default 90 days) + - set last accessed age threshold (default 30 days) - If Access Key is approaching threshold will ping user with a reminder to cycle key - If key age is at or over threshold will disable Access Key along with a final notice @@ -81,6 +83,7 @@ module "iam_sleuth" { ENABLE_AUTO_EXPIRE = "false" EXPIRATION_AGE = 90 WARNING_AGE = 50 + LAST_USED_AGE = 30 SLACK_URL = data.aws_ssm_parameter.slack_url.value SNS_TOPIC = "" MSG_TITLE = "Key Rotation Instructions" @@ -101,8 +104,8 @@ The behavior can be configured by environment variables. | Name | Description | |------|------------ | | ENABLE_AUTO_EXPIRE | Must be set to `true` for key disable action | -| EXPIRATION_AGE | Age in days to disable a AWS key | -| WARNING_AGE | Age in days of key to send notifications, must be lower than EXPIRATION_AGE | +| EXPIRATION_AGE | Age of key creation (in days) to disable a AWS key | +| WARNING_AGE | Age of key creation (in days) to send notifications, must be lower than EXPIRATION_AGE | | MSG_TITLE | Title of the notification message | | MSG_TEXT | Instructions on key rotation | | SLACK_URL | Incoming webhook to send notifications to | From ebdd99740496405a362d0678f321b3a8992025b3 Mon Sep 17 00:00:00 2001 From: Sheena Todhunter Date: Wed, 3 Feb 2021 18:37:01 -0800 Subject: [PATCH 07/11] Add last used env var to table --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index bb041490..b6db29bd 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ The behavior can be configured by environment variables. | ENABLE_AUTO_EXPIRE | Must be set to `true` for key disable action | | EXPIRATION_AGE | Age of key creation (in days) to disable a AWS key | | WARNING_AGE | Age of key creation (in days) to send notifications, must be lower than EXPIRATION_AGE | +| LAST_USED_AGE | Age of last key usage (in days) to send notifications, must be lower than or equal to EXPIRATION_AGE | | MSG_TITLE | Title of the notification message | | MSG_TEXT | Instructions on key rotation | | SLACK_URL | Incoming webhook to send notifications to | From cdae5e366ef730e10016eb73997218509cb78c95 Mon Sep 17 00:00:00 2001 From: Sheena Todhunter Date: Thu, 4 Feb 2021 08:07:29 -0800 Subject: [PATCH 08/11] set valid_for using expiration and creation ages --- sleuth/sleuth/auditor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sleuth/sleuth/auditor.py b/sleuth/sleuth/auditor.py index 301b4651..e83d55fb 100644 --- a/sleuth/sleuth/auditor.py +++ b/sleuth/sleuth/auditor.py @@ -52,7 +52,7 @@ def audit(self, rotate_age, expire_age, max_last_used_age): assert(max_last_used_age <= expire_age) # set the valid_for in the object - self.valid_for = min(expire_age - self.creation_age, max_last_used_age) + self.valid_for = expire_age - self.creation_age # lets audit the age if self.creation_age >= expire_age: From 87ddffb6ae7e3e75938b1b576c9e5384f4ee7d30 Mon Sep 17 00:00:00 2001 From: Sheena Todhunter Date: Thu, 4 Feb 2021 10:42:19 -0800 Subject: [PATCH 09/11] set last used age as an optional parameter, using expiration age as default --- sleuth/sleuth/auditor.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/sleuth/sleuth/auditor.py b/sleuth/sleuth/auditor.py index e83d55fb..380e1ad1 100644 --- a/sleuth/sleuth/auditor.py +++ b/sleuth/sleuth/auditor.py @@ -65,7 +65,7 @@ def audit(self, rotate_age, expire_age, max_last_used_age): self.audit_state = 'good' # lets audit the status - if self.status == 'Inactive' and os.environ['ENABLE_AUTO_EXPIRE'] == 'true': + if self.status == 'Inactive' and os.environ.get('ENABLE_AUTO_EXPIRE', False) == 'true': self.audit_state = 'disabled' class User(): @@ -79,9 +79,9 @@ def __init__(self, user_id, username, slack_id=None): self.username = username self.slack_id = slack_id - def audit(self, rotate=80, expire=90): + def audit(self, rotate=80, expire=90, last_used=90): for k in self.keys: - k.audit(rotate, expire) + k.audit(rotate, expire, last_used) def print_key_report(users): """Prints table of report @@ -102,10 +102,11 @@ def print_key_report(users): u.slack_id, k.key_id, k.audit_state, - k.creation_age + k.creation_age, + k.access_age ]) - print(tabulate(tbl_data, headers=['UserName', 'Slack ID', 'Key ID', 'Status', 'Age in Days'])) + print(tabulate(tbl_data, headers=['UserName', 'Slack ID', 'Key ID', 'Status', 'Age in Days', 'Last Access Age'])) def audit(): @@ -113,7 +114,8 @@ def audit(): # lets audit keys so the ages and state are set for u in iam_users: - u.audit(int(os.environ['WARNING_AGE']), int(os.environ['EXPIRATION_AGE']), int(os.environ['LAST_USED_AGE'])) + # Do not require last used age, set to expiration age as default + u.audit(int(os.environ['WARNING_AGE']), int(os.environ['EXPIRATION_AGE']), int(os.environ.get('LAST_USED_AGE', os.environ['EXPIRATION_AGE']))) if os.environ.get('DEBUG', False): print_key_report(iam_users) From 6c97bab0f176eca6767be58507cef8e0d708fdca Mon Sep 17 00:00:00 2001 From: Sheena Todhunter Date: Thu, 4 Feb 2021 10:42:31 -0800 Subject: [PATCH 10/11] add missing keys list --- sleuth/sleuth/services.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sleuth/sleuth/services.py b/sleuth/sleuth/services.py index fab660be..72819849 100644 --- a/sleuth/sleuth/services.py +++ b/sleuth/sleuth/services.py @@ -25,6 +25,7 @@ def get_iam_key_info(user): list (Key): Return list of keys for a single user """ from sleuth.auditor import Key + keys = [] key_info = IAM.list_access_keys(UserName=user.username) for k in key_info['AccessKeyMetadata']: access_date=IAM.get_access_key_last_used(AccessKeyId=k['AccessKeyId']) From 5b8c05b84d590aec2a51866b5c150f95161e4f44 Mon Sep 17 00:00:00 2001 From: Sheena Todhunter Date: Thu, 4 Feb 2021 10:44:25 -0800 Subject: [PATCH 11/11] update readme to reflect that LAST_USED_AGE is optional --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b6db29bd..77cc0ab5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Sleuth runs periodically, normally once a day in the middle of business hours. S - Inspect each Access Key based on: - set creation age threshold (default 90 days) - - set last accessed age threshold (default 30 days) + - set last accessed age threshold (optional, set to creation age threshold as default) - If Access Key is approaching threshold will ping user with a reminder to cycle key - If key age is at or over threshold will disable Access Key along with a final notice @@ -106,7 +106,7 @@ The behavior can be configured by environment variables. | ENABLE_AUTO_EXPIRE | Must be set to `true` for key disable action | | EXPIRATION_AGE | Age of key creation (in days) to disable a AWS key | | WARNING_AGE | Age of key creation (in days) to send notifications, must be lower than EXPIRATION_AGE | -| LAST_USED_AGE | Age of last key usage (in days) to send notifications, must be lower than or equal to EXPIRATION_AGE | +| LAST_USED_AGE | OPTIONAL, defaults to EXPIRATION_AGE, Age of last key usage (in days) to send notifications, must be lower than or equal to EXPIRATION_AGE | | MSG_TITLE | Title of the notification message | | MSG_TEXT | Instructions on key rotation | | SLACK_URL | Incoming webhook to send notifications to |