Merge pull request #162 from Dunedan/improve-aws-credential-searching

Improve searching for configured AWS credentials
diff --git a/README.md b/README.md
index 603e08c..b68dd22 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,12 @@
 - `check-xml` - Attempts to load all xml files to verify syntax.
 - `check-yaml` - Attempts to load all yaml files to verify syntax.
 - `debug-statements` - Check for pdb / ipdb / pudb statements in code.
-- `detect-aws-credentials` - Checks for the existence of AWS secrets that you have set up with the AWS CLI.
+- `detect-aws-credentials` - Checks for the existence of AWS secrets that you
+  have set up with the AWS CLI.
+  The following arguments are available:
+  - `--credentials-file` - additional AWS CLI style configuration file in a
+    non-standard location to fetch configured credentials from. Can be repeated
+    multiple times.
 - `detect-private-key` - Checks for the existence of private keys.
 - `double-quote-string-fixer` - This hook replaces double quoted strings
   with single quoted strings.
diff --git a/pre_commit_hooks/detect_aws_credentials.py b/pre_commit_hooks/detect_aws_credentials.py
index 9dda217..a7847b9 100644
--- a/pre_commit_hooks/detect_aws_credentials.py
+++ b/pre_commit_hooks/detect_aws_credentials.py
@@ -7,62 +7,120 @@
 from six.moves import configparser
 
 
-def get_your_keys(credentials_file):
-    """reads the secret keys in your credentials file in order to be able to
-    look for them in the submitted code.
+def get_aws_credential_files_from_env():
+    """Extract credential file paths from environment variables."""
+    files = set()
+    for env_var in (
+        'AWS_CONFIG_FILE', 'AWS_CREDENTIAL_FILE', 'AWS_SHARED_CREDENTIALS_FILE',
+        'BOTO_CONFIG'
+    ):
+        if env_var in os.environ:
+            files.add(os.environ[env_var])
+    return files
+
+
+def get_aws_secrets_from_env():
+    """Extract AWS secrets from environment variables."""
+    keys = set()
+    for env_var in (
+        'AWS_SECRET_ACCESS_KEY', 'AWS_SECURITY_TOKEN', 'AWS_SESSION_TOKEN'
+    ):
+        if env_var in os.environ:
+            keys.add(os.environ[env_var])
+    return keys
+
+
+def get_aws_secrets_from_file(credentials_file):
+    """Extract AWS secrets from configuration files.
+
+    Read an ini-style configuration file and return a set with all found AWS
+    secret access keys.
     """
     aws_credentials_file_path = os.path.expanduser(credentials_file)
     if not os.path.exists(aws_credentials_file_path):
-        return None
+        return set()
 
     parser = configparser.ConfigParser()
-    parser.read(aws_credentials_file_path)
+    try:
+        parser.read(aws_credentials_file_path)
+    except configparser.MissingSectionHeaderError:
+        return set()
 
     keys = set()
     for section in parser.sections():
-        keys.add(parser.get(section, 'aws_secret_access_key'))
+        for var in (
+            'aws_secret_access_key', 'aws_security_token',
+            'aws_session_token'
+        ):
+            try:
+                keys.add(parser.get(section, var))
+            except configparser.NoOptionError:
+                pass
     return keys
 
 
 def check_file_for_aws_keys(filenames, keys):
+    """Check if files contain AWS secrets.
+
+    Return a list of all files containing AWS secrets and keys found, with all
+    but the first four characters obfuscated to ease debugging.
+    """
     bad_files = []
 
     for filename in filenames:
         with open(filename, 'r') as content:
             text_body = content.read()
-            if any(key in text_body for key in keys):
-                # naively match the entire file, low chance of incorrect collision
-                bad_files.append(filename)
-
+            for key in keys:
+                # naively match the entire file, low chance of incorrect
+                # collision
+                if key in text_body:
+                    bad_files.append({'filename': filename,
+                                      'key': key[:4] + '*' * 28})
     return bad_files
 
 
 def main(argv=None):
     parser = argparse.ArgumentParser()
-    parser.add_argument('filenames', nargs='*', help='Filenames to run')
+    parser.add_argument('filenames', nargs='+', help='Filenames to run')
     parser.add_argument(
         '--credentials-file',
-        default='~/.aws/credentials',
+        dest='credential_files',
+        action='append',
+        default=['~/.aws/config', '~/.aws/credentials', '/etc/boto.cfg',
+                 '~/.boto'],
         help=(
-            'location of aws credentials file from which to get the secret '
-            "keys we're looking for"
-        ),
+            'Location of additional AWS credential files from which to get '
+            'secret keys from'
+        )
     )
     args = parser.parse_args(argv)
-    keys = get_your_keys(args.credentials_file)
+
+    credential_files = set(args.credential_files)
+
+    # Add the credentials files configured via environment variables to the set
+    # of files to to gather AWS secrets from.
+    credential_files |= get_aws_credential_files_from_env()
+
+    keys = set()
+    for credential_file in credential_files:
+        keys |= get_aws_secrets_from_file(credential_file)
+
+    # Secrets might be part of environment variables, so add such secrets to
+    # the set of keys.
+    keys |= get_aws_secrets_from_env()
+
     if not keys:
         print(
-            'No aws keys were configured at {0}\n'
-            'Configure them with --credentials-file'.format(
-                args.credentials_file,
-            ),
+            'No AWS keys were found in the configured credential files and '
+            'environment variables.\nPlease ensure you have the correct '
+            'setting for --credentials-file'
         )
         return 2
 
     bad_filenames = check_file_for_aws_keys(args.filenames, keys)
     if bad_filenames:
         for bad_file in bad_filenames:
-            print('AWS secret key found: {0}'.format(bad_file))
+            print('AWS secret found in {filename}: {key}'.format(**bad_file))
         return 1
     else:
         return 0
diff --git a/testing/resources/sample_aws_credentials b/testing/resources/aws_config_with_multiple_sections.ini
similarity index 72%
rename from testing/resources/sample_aws_credentials
rename to testing/resources/aws_config_with_multiple_sections.ini
index a79b021..ca6a8a3 100644
--- a/testing/resources/sample_aws_credentials
+++ b/testing/resources/aws_config_with_multiple_sections.ini
@@ -1,4 +1,4 @@
-# this is an aws credentials configuration file. obviously not real credentials :P
+# file with AWS access key ids, AWS secret access keys and AWS session tokens in multiple sections
 [default]
 aws_access_key_id = AKIASLARTIBARTFAST11
 aws_secret_access_key = 7xebzorgm5143ouge9gvepxb2z70bsb2rtrh099e
@@ -8,3 +8,5 @@
 [staging]
 aws_access_key_id = AKIAJIMMINYCRICKET0A
 aws_secret_access_key = ixswosj8gz3wuik405jl9k3vdajsnxfhnpui38ez
+[test]
+aws_session_token = foo
\ No newline at end of file
diff --git a/testing/resources/aws_config_with_secret.ini b/testing/resources/aws_config_with_secret.ini
new file mode 100644
index 0000000..bb55017
--- /dev/null
+++ b/testing/resources/aws_config_with_secret.ini
@@ -0,0 +1,4 @@
+# file with an AWS access key id and an AWS secret access key
+[production]
+aws_access_key_id = AKIAVOGONSVOGONS0042
+aws_secret_access_key = z2rpgs5uit782eapz5l1z0y2lurtsyyk6hcfozlb
diff --git a/testing/resources/aws_config_with_secret_and_session_token.ini b/testing/resources/aws_config_with_secret_and_session_token.ini
new file mode 100644
index 0000000..4bd675d
--- /dev/null
+++ b/testing/resources/aws_config_with_secret_and_session_token.ini
@@ -0,0 +1,5 @@
+# file with an AWS access key id, an AWS secret access key and an AWS session token
+[production]
+aws_access_key_id = AKIAVOGONSVOGONS0042
+aws_secret_access_key = z2rpgs5uit782eapz5l1z0y2lurtsyyk6hcfozlb
+aws_session_token = foo
\ No newline at end of file
diff --git a/testing/resources/aws_config_with_session_token.ini b/testing/resources/aws_config_with_session_token.ini
new file mode 100644
index 0000000..e07f2ac
--- /dev/null
+++ b/testing/resources/aws_config_with_session_token.ini
@@ -0,0 +1,3 @@
+# file with an AWS session token
+[production]
+aws_session_token = foo
\ No newline at end of file
diff --git a/testing/resources/aws_config_without_secrets.ini b/testing/resources/aws_config_without_secrets.ini
new file mode 100644
index 0000000..26d1692
--- /dev/null
+++ b/testing/resources/aws_config_without_secrets.ini
@@ -0,0 +1,3 @@
+# file with an AWS access key id but no AWS secret access key
+[production]
+aws_access_key_id = AKIASLARTIBARTFAST11
diff --git a/testing/resources/with_no_secrets.txt b/testing/resources/with_no_secrets.txt
deleted file mode 100644
index d9ab505..0000000
--- a/testing/resources/with_no_secrets.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-# file with an access key but no secrets
-# you'll notice it is a redacted section of sample_aws_credentials
-
-[production]
-aws_access_key_id = AKIASLARTIBARTFAST11
diff --git a/testing/resources/with_secrets.txt b/testing/resources/with_secrets.txt
deleted file mode 100644
index 0018225..0000000
--- a/testing/resources/with_secrets.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-#file with a secret key, you'll notice it is a section of sample_aws_credentials
-
-[production]
-aws_access_key_id = AKIAVOGONSVOGONS0042
-aws_secret_access_key = z2rpgs5uit782eapz5l1z0y2lurtsyyk6hcfozlb
diff --git a/tests/detect_aws_credentials_test.py b/tests/detect_aws_credentials_test.py
index 66513fe..9c2fda7 100644
--- a/tests/detect_aws_credentials_test.py
+++ b/tests/detect_aws_credentials_test.py
@@ -1,38 +1,132 @@
 import pytest
+from mock import patch
 
+from pre_commit_hooks.detect_aws_credentials import get_aws_credential_files_from_env
+from pre_commit_hooks.detect_aws_credentials import get_aws_secrets_from_env
+from pre_commit_hooks.detect_aws_credentials import get_aws_secrets_from_file
 from pre_commit_hooks.detect_aws_credentials import main
 from testing.util import get_resource_path
 
 
-# Input filename, expected return value
-TESTS = (
-    ('with_no_secrets.txt', 0),
-    ('with_secrets.txt', 1),
-    ('nonsense.txt', 0),
-    ('ok_json.json', 0),
+@pytest.mark.parametrize(
+    ('env_vars', 'values'),
+    (
+        ({}, set()),
+        ({'AWS_DUMMY_KEY': '/foo'}, set()),
+        ({'AWS_CONFIG_FILE': '/foo'}, {'/foo'}),
+        ({'AWS_CREDENTIAL_FILE': '/foo'}, {'/foo'}),
+        ({'AWS_SHARED_CREDENTIALS_FILE': '/foo'}, {'/foo'}),
+        ({'BOTO_CONFIG': '/foo'}, {'/foo'}),
+        ({'AWS_DUMMY_KEY': '/foo', 'AWS_CONFIG_FILE': '/bar'}, {'/bar'}),
+        (
+            {
+                'AWS_DUMMY_KEY': '/foo', 'AWS_CONFIG_FILE': '/bar',
+                'AWS_CREDENTIAL_FILE': '/baz'
+            },
+            {'/bar', '/baz'}
+        ),
+        (
+            {
+                'AWS_CONFIG_FILE': '/foo', 'AWS_CREDENTIAL_FILE': '/bar',
+                'AWS_SHARED_CREDENTIALS_FILE': '/baz'
+            },
+            {'/foo', '/bar', '/baz'}
+        ),
+    ),
 )
+def test_get_aws_credentials_file_from_env(env_vars, values):
+    """Test that reading credential files names from environment variables works."""
+    with patch.dict('os.environ', env_vars, clear=True):
+        assert get_aws_credential_files_from_env() == values
 
 
-@pytest.mark.parametrize(('filename', 'expected_retval'), TESTS)
+@pytest.mark.parametrize(
+    ('env_vars', 'values'),
+    (
+        ({}, set()),
+        ({'AWS_DUMMY_KEY': 'foo'}, set()),
+        ({'AWS_SECRET_ACCESS_KEY': 'foo'}, {'foo'}),
+        ({'AWS_SECURITY_TOKEN': 'foo'}, {'foo'}),
+        ({'AWS_SESSION_TOKEN': 'foo'}, {'foo'}),
+        ({'AWS_DUMMY_KEY': 'foo', 'AWS_SECRET_ACCESS_KEY': 'bar'}, {'bar'}),
+        (
+            {'AWS_SECRET_ACCESS_KEY': 'foo', 'AWS_SECURITY_TOKEN': 'bar'},
+            {'foo', 'bar'}
+        ),
+    ),
+)
+def test_get_aws_secrets_from_env(env_vars, values):
+    """Test that reading secrets from environment variables works."""
+    with patch.dict('os.environ', env_vars, clear=True):
+        assert get_aws_secrets_from_env() == values
+
+
+@pytest.mark.parametrize(
+    ('filename', 'expected_keys'),
+    (
+        (
+            'aws_config_with_secret.ini',
+            {'z2rpgs5uit782eapz5l1z0y2lurtsyyk6hcfozlb'}
+        ),
+        ('aws_config_with_session_token.ini', {'foo'}),
+        ('aws_config_with_secret_and_session_token.ini',
+         {'z2rpgs5uit782eapz5l1z0y2lurtsyyk6hcfozlb', 'foo'}),
+        (
+            'aws_config_with_multiple_sections.ini',
+            {
+                '7xebzorgm5143ouge9gvepxb2z70bsb2rtrh099e',
+                'z2rpgs5uit782eapz5l1z0y2lurtsyyk6hcfozlb',
+                'ixswosj8gz3wuik405jl9k3vdajsnxfhnpui38ez',
+                'foo'
+            }
+        ),
+        ('aws_config_without_secrets.ini', set()),
+        ('nonsense.txt', set()),
+        ('ok_json.json', set()),
+    ),
+)
+def test_get_aws_secrets_from_file(filename, expected_keys):
+    """Test that reading secrets from files works."""
+    keys = get_aws_secrets_from_file(get_resource_path(filename))
+    assert keys == expected_keys
+
+
+@pytest.mark.parametrize(
+    ('filename', 'expected_retval'),
+    (
+        ('aws_config_with_secret.ini', 1),
+        ('aws_config_with_session_token.ini', 1),
+        ('aws_config_with_multiple_sections.ini', 1),
+        ('aws_config_without_secrets.ini', 0),
+        ('nonsense.txt', 0),
+        ('ok_json.json', 0),
+    ),
+)
 def test_detect_aws_credentials(filename, expected_retval):
+    """Test if getting configured AWS secrets from files to be checked in works."""
+
     # with a valid credentials file
     ret = main((
         get_resource_path(filename),
-        "--credentials-file=testing/resources/sample_aws_credentials",
+        "--credentials-file=testing/resources/aws_config_with_multiple_sections.ini",
     ))
     assert ret == expected_retval
 
 
-def test_non_existent_credentials(capsys):
-    # with a non-existent credentials file
+@patch('pre_commit_hooks.detect_aws_credentials.get_aws_secrets_from_file')
+@patch('pre_commit_hooks.detect_aws_credentials.get_aws_secrets_from_env')
+def test_non_existent_credentials(mock_secrets_env, mock_secrets_file, capsys):
+    """Test behavior with no configured AWS secrets."""
+    mock_secrets_env.return_value = set()
+    mock_secrets_file.return_value = set()
     ret = main((
-        get_resource_path('with_secrets.txt'),
+        get_resource_path('aws_config_without_secrets.ini'),
         "--credentials-file=testing/resources/credentailsfilethatdoesntexist"
     ))
     assert ret == 2
     out, _ = capsys.readouterr()
     assert out == (
-        'No aws keys were configured at '
-        'testing/resources/credentailsfilethatdoesntexist\n'
-        'Configure them with --credentials-file\n'
+        'No AWS keys were found in the configured credential files '
+        'and environment variables.\nPlease ensure you have the '
+        'correct setting for --credentials-file\n'
     )