aws-cmp-secrets 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aws_cmp_secrets/cmp_secrets.py +305 -0
- aws_cmp_secrets-0.0.1.dist-info/METADATA +67 -0
- aws_cmp_secrets-0.0.1.dist-info/RECORD +7 -0
- aws_cmp_secrets-0.0.1.dist-info/WHEEL +5 -0
- aws_cmp_secrets-0.0.1.dist-info/entry_points.txt +2 -0
- aws_cmp_secrets-0.0.1.dist-info/licenses/LICENSE +21 -0
- aws_cmp_secrets-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Utility for comparison of secrets in AWS Secrets Manager.
|
|
3
|
+
|
|
4
|
+
MIT License
|
|
5
|
+
|
|
6
|
+
Copyright (c) 2025 Zdenek Styblik
|
|
7
|
+
|
|
8
|
+
See LICENSE for details.
|
|
9
|
+
"""
|
|
10
|
+
import argparse
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
from typing import Dict
|
|
17
|
+
from typing import List
|
|
18
|
+
|
|
19
|
+
import boto3
|
|
20
|
+
|
|
21
|
+
MAX_ATTEMPTS = 4
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def calc_log_level(count: int) -> int:
|
|
25
|
+
"""Return logging log level as int based on count."""
|
|
26
|
+
log_level = 40 - max(count, 0) * 10
|
|
27
|
+
log_level = max(log_level, 10)
|
|
28
|
+
return log_level
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmp_secrets(data1: Any, data2: Any, mask_secrets: bool) -> int:
|
|
32
|
+
"""Compare secrets and printout differences, if there are any."""
|
|
33
|
+
if not isinstance(data1, type(data2)):
|
|
34
|
+
print("a: is of type '{}'".format(type(data1)))
|
|
35
|
+
print("b: is of type '{}'".format(type(data2)))
|
|
36
|
+
print("ERROR: cannot compare secrets of different types.")
|
|
37
|
+
return 2
|
|
38
|
+
|
|
39
|
+
if not isinstance(data1, dict):
|
|
40
|
+
# secret1 and secret2 are of the same type, but not dict.
|
|
41
|
+
if data1 == data2:
|
|
42
|
+
print("No differences were found.")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
print("a: '{}'".format(data1 if not mask_secrets else "**MASKED**"))
|
|
46
|
+
print("b: '{}'".format(data2 if not mask_secrets else "**MASKED**"))
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
data_set1 = {(key, value) for key, value in data1.items()}
|
|
50
|
+
data_set2 = {(key, value) for key, value in data2.items()}
|
|
51
|
+
diff = data_set1.symmetric_difference(data_set2)
|
|
52
|
+
if not diff:
|
|
53
|
+
print("No differences were found.")
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
scratch = {item[0] for item in diff}
|
|
57
|
+
for key in sorted(scratch):
|
|
58
|
+
printout_dict_diff(data1, key, "a", mask_secrets)
|
|
59
|
+
printout_dict_diff(data2, key, "b", mask_secrets)
|
|
60
|
+
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_secret(
|
|
65
|
+
secrets_cli, secret_name: str, version_id: str = ""
|
|
66
|
+
) -> Dict[Any, Any] | str:
|
|
67
|
+
"""Return decrypted data stored in AWS Secrets Manager."""
|
|
68
|
+
kwargs = {"SecretId": secret_name}
|
|
69
|
+
if version_id:
|
|
70
|
+
kwargs["VersionId"] = version_id
|
|
71
|
+
|
|
72
|
+
rsp = secrets_cli.get_secret_value(**kwargs)
|
|
73
|
+
# Decrypts secret using the associated KMS CMK.
|
|
74
|
+
if "SecretString" in rsp:
|
|
75
|
+
data = rsp["SecretString"]
|
|
76
|
+
else:
|
|
77
|
+
# Decode binary secret
|
|
78
|
+
data = base64.b64decode(rsp["SecretBinary"])
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
secret_data = json.loads(data)
|
|
82
|
+
except json.decoder.JSONDecodeError:
|
|
83
|
+
secret_data = data
|
|
84
|
+
|
|
85
|
+
return secret_data
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_mfa_devices(iam_cli, user_name: str) -> List:
|
|
89
|
+
"""Get MFA devices for IAM user identified by user name."""
|
|
90
|
+
paginator = iam_cli.get_paginator("list_mfa_devices")
|
|
91
|
+
response_iterator = paginator.paginate(UserName=user_name)
|
|
92
|
+
mfa_devices = []
|
|
93
|
+
for page in response_iterator:
|
|
94
|
+
devices = page.get("MFADevices", [])
|
|
95
|
+
for device in devices:
|
|
96
|
+
mfa_devices.append(device)
|
|
97
|
+
|
|
98
|
+
return mfa_devices
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def choose_mfa_device(mfa_count: int, max_attempts: int) -> int:
|
|
102
|
+
"""Ask user which MFA device to use and return its index."""
|
|
103
|
+
if mfa_count < 1:
|
|
104
|
+
raise ValueError("mfa_count cannot be less than 1")
|
|
105
|
+
|
|
106
|
+
if max_attempts < 1:
|
|
107
|
+
raise ValueError("max_attempts cannot be less than 1")
|
|
108
|
+
|
|
109
|
+
mfa_idx = -1
|
|
110
|
+
attempt = 0
|
|
111
|
+
while True:
|
|
112
|
+
attempt = attempt + 1
|
|
113
|
+
mfa_idx = -1
|
|
114
|
+
if attempt > max_attempts:
|
|
115
|
+
raise ValueError("Max attempts reached - giving up")
|
|
116
|
+
|
|
117
|
+
user_input = input(
|
|
118
|
+
"Choose which MFA device you want to use(0..{:d}): ".format(
|
|
119
|
+
mfa_count - 1
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
try:
|
|
123
|
+
mfa_idx = int(user_input)
|
|
124
|
+
except ValueError:
|
|
125
|
+
print("Entered MFA value '{}' is invalid".format(user_input))
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
if mfa_idx < 0 or mfa_idx > mfa_count - 1:
|
|
129
|
+
print("Entered MFA value '{}' is out of bounds".format(mfa_idx))
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
return mfa_idx
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def mfa_auth(logger, aws_session, aws_region_name: str):
|
|
138
|
+
"""Perform MFA auth and return new Session with temporary credentials."""
|
|
139
|
+
iam_cli = aws_session.client("iam")
|
|
140
|
+
rsp_user = iam_cli.get_user()
|
|
141
|
+
logger.debug("User rsp: %s", rsp_user)
|
|
142
|
+
user_name = rsp_user["User"]["UserName"]
|
|
143
|
+
|
|
144
|
+
mfa_devices = get_mfa_devices(iam_cli, user_name)
|
|
145
|
+
if not mfa_devices:
|
|
146
|
+
print("There are no MFA devices - skip MFA auth.")
|
|
147
|
+
return aws_session
|
|
148
|
+
|
|
149
|
+
mfa_devices = dict(enumerate(mfa_devices))
|
|
150
|
+
print("MFA devices:")
|
|
151
|
+
for idx, mfa_device in mfa_devices.items():
|
|
152
|
+
print("{:d}: {}".format(idx, mfa_device["SerialNumber"]))
|
|
153
|
+
|
|
154
|
+
mfa_idx = choose_mfa_device(len(mfa_devices), MAX_ATTEMPTS)
|
|
155
|
+
mfa_sn = mfa_devices[mfa_idx]["SerialNumber"]
|
|
156
|
+
mfa_token = input("Enter MFA token: ")
|
|
157
|
+
sts_cli = aws_session.client("sts")
|
|
158
|
+
rsp = sts_cli.get_session_token(
|
|
159
|
+
DurationSeconds=900, # min is 900sec
|
|
160
|
+
SerialNumber=mfa_sn, # MFA S/N
|
|
161
|
+
TokenCode=mfa_token, # MFA token
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
aws_tmp_session = boto3.Session(
|
|
165
|
+
aws_access_key_id=rsp["Credentials"]["AccessKeyId"],
|
|
166
|
+
aws_secret_access_key=rsp["Credentials"]["SecretAccessKey"],
|
|
167
|
+
aws_session_token=rsp["Credentials"]["SessionToken"],
|
|
168
|
+
region_name=aws_region_name,
|
|
169
|
+
)
|
|
170
|
+
print("---")
|
|
171
|
+
return aws_tmp_session
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main():
|
|
175
|
+
"""Get secrets from AWS, compare them and printout results."""
|
|
176
|
+
args = parse_args()
|
|
177
|
+
logging.basicConfig(level=args.log_level, stream=sys.stdout)
|
|
178
|
+
logger = logging.getLogger("aws_secrets_manager_diff")
|
|
179
|
+
|
|
180
|
+
aws_session = boto3.Session(region_name=args.aws_region_name)
|
|
181
|
+
if args.interactive:
|
|
182
|
+
aws_session = mfa_auth(logger, aws_session, args.aws_region_name)
|
|
183
|
+
|
|
184
|
+
secrets_cli = aws_session.client("secretsmanager")
|
|
185
|
+
try:
|
|
186
|
+
data1 = get_secret(secrets_cli, args.secret_name1, args.version_id1)
|
|
187
|
+
data2 = get_secret(secrets_cli, args.secret_name2, args.version_id2)
|
|
188
|
+
except secrets_cli.exceptions.ClientError as error:
|
|
189
|
+
print("ERROR: Failed to get one of the secrets due to exception.")
|
|
190
|
+
if error.response["Error"]["Code"] == "AccessDeniedException":
|
|
191
|
+
print(
|
|
192
|
+
"HINT: try to turn on interactive mode and authenticate "
|
|
193
|
+
" with MFA device."
|
|
194
|
+
)
|
|
195
|
+
print("HINT: this might be required by IAM policy.")
|
|
196
|
+
|
|
197
|
+
raise
|
|
198
|
+
|
|
199
|
+
printout_header(args.secret_name1, args.version_id1, "a")
|
|
200
|
+
printout_header(args.secret_name2, args.version_id2, "b")
|
|
201
|
+
retval = cmp_secrets(data1, data2, args.mask_secrets)
|
|
202
|
+
sys.exit(retval)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def parse_args() -> argparse.Namespace:
|
|
206
|
+
"""Return parsed CLI args."""
|
|
207
|
+
parser = argparse.ArgumentParser(
|
|
208
|
+
allow_abbrev=False,
|
|
209
|
+
description="Utility for comparison of secrets in AWS Secrets Manager.",
|
|
210
|
+
epilog="AWS cmp secrets by Zdenek Styblik",
|
|
211
|
+
)
|
|
212
|
+
parser.add_argument(
|
|
213
|
+
"--region",
|
|
214
|
+
default=None,
|
|
215
|
+
dest="aws_region_name",
|
|
216
|
+
type=str,
|
|
217
|
+
help="Name of AWS region.",
|
|
218
|
+
)
|
|
219
|
+
parser.add_argument(
|
|
220
|
+
"-i",
|
|
221
|
+
"--interactive",
|
|
222
|
+
action="store_true",
|
|
223
|
+
default=False,
|
|
224
|
+
help="Turns on interactive mode and asks for MFA token.",
|
|
225
|
+
)
|
|
226
|
+
parser.add_argument(
|
|
227
|
+
"-m",
|
|
228
|
+
"--mask-secrets",
|
|
229
|
+
action="store_true",
|
|
230
|
+
default=False,
|
|
231
|
+
dest="mask_secrets",
|
|
232
|
+
help="Don't show secret values.",
|
|
233
|
+
)
|
|
234
|
+
parser.add_argument(
|
|
235
|
+
"-s1",
|
|
236
|
+
"--secret1",
|
|
237
|
+
dest="secret_name1",
|
|
238
|
+
required=True,
|
|
239
|
+
type=str,
|
|
240
|
+
help="Name or ARN of 2nd Secrets Manager secret.",
|
|
241
|
+
)
|
|
242
|
+
parser.add_argument(
|
|
243
|
+
"--version-id1",
|
|
244
|
+
default="",
|
|
245
|
+
dest="version_id1",
|
|
246
|
+
type=str,
|
|
247
|
+
help="Version ID of 1st secret.",
|
|
248
|
+
)
|
|
249
|
+
parser.add_argument(
|
|
250
|
+
"-s2",
|
|
251
|
+
"--secret2",
|
|
252
|
+
dest="secret_name2",
|
|
253
|
+
required=True,
|
|
254
|
+
type=str,
|
|
255
|
+
help="Name or ARN of 2nd Secrets Manager secret.",
|
|
256
|
+
)
|
|
257
|
+
parser.add_argument(
|
|
258
|
+
"--version-id2",
|
|
259
|
+
default="",
|
|
260
|
+
dest="version_id2",
|
|
261
|
+
type=str,
|
|
262
|
+
help="Version ID of 2nd secret.",
|
|
263
|
+
)
|
|
264
|
+
parser.add_argument(
|
|
265
|
+
"-v",
|
|
266
|
+
"--verbose",
|
|
267
|
+
action="count",
|
|
268
|
+
default=0,
|
|
269
|
+
help="Increase log level verbosity. Can be passed multiple times.",
|
|
270
|
+
)
|
|
271
|
+
args = parser.parse_args()
|
|
272
|
+
args.log_level = calc_log_level(args.verbose)
|
|
273
|
+
return args
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def printout_dict_diff(
|
|
277
|
+
data: Dict[Any, Any],
|
|
278
|
+
key: str,
|
|
279
|
+
prefix: str,
|
|
280
|
+
mask_secrets: bool,
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Printout of difference for given dict and key."""
|
|
283
|
+
if key in data:
|
|
284
|
+
a_key = key
|
|
285
|
+
if mask_secrets:
|
|
286
|
+
a_value = "**MASKED**"
|
|
287
|
+
else:
|
|
288
|
+
a_value = data.get(key, "**ABSENT**")
|
|
289
|
+
else:
|
|
290
|
+
a_key = "**ABSENT**"
|
|
291
|
+
a_value = "**ABSENT**"
|
|
292
|
+
|
|
293
|
+
print("{:s}: '{}':'{}'".format(prefix, a_key, a_value))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def printout_header(secret_name: str, ver_id: str, prefix: str) -> None:
|
|
297
|
+
"""Printout header."""
|
|
298
|
+
if not ver_id:
|
|
299
|
+
ver_id = "AWSCURRENT"
|
|
300
|
+
|
|
301
|
+
print("{:s}: secret '{:s}'@'{:s}'".format(prefix, secret_name, ver_id))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
main()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aws_cmp_secrets
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Compare secrets stored in AWS Secrets Manager
|
|
5
|
+
Author: Zdenek Styblik
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/zstyblik/aws-cmp-secrets.git
|
|
8
|
+
Project-URL: Issues, https://github.com/zstyblik/aws-cmp-secrets/issues
|
|
9
|
+
Keywords: aws
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: boto3==1.40.45
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# aws-cmp-secrets
|
|
21
|
+
|
|
22
|
+
`aws_cmp_secrets` is a script which enables you to compare secrets stored in AWS
|
|
23
|
+
Secrets Manager and show difference between the two. It also supports MFA
|
|
24
|
+
authentication(interactive), should policy require it.
|
|
25
|
+
|
|
26
|
+
It utilizes [boto3] under the hood. Also, you need AWS credentials in your
|
|
27
|
+
environment(one way or another) and appropriate permissions to access secrets
|
|
28
|
+
you want to compare.
|
|
29
|
+
|
|
30
|
+
**NOTE** that CLI args and output format might change in the future(probably in
|
|
31
|
+
10 years from now or so). Ideas to make output format more useable and
|
|
32
|
+
user-friendly are welcome, I think.
|
|
33
|
+
|
|
34
|
+
## Example usage
|
|
35
|
+
|
|
36
|
+
```Bash
|
|
37
|
+
# with interactive MFA authentication and version IDs
|
|
38
|
+
aws_cmp_secrets \
|
|
39
|
+
-i \
|
|
40
|
+
-s1 my/secret/foo \
|
|
41
|
+
--version-id1 12345678-1234-1234-1234-123456789012 \
|
|
42
|
+
-s2 my/secret/foo \
|
|
43
|
+
--version-id2 12345678-1234-1234-1234-333333333333
|
|
44
|
+
MFA devices:
|
|
45
|
+
0: arn:aws:iam::111111111111:mfa/mfa-device1
|
|
46
|
+
Choose which MFA device you want to use(0..0): 0
|
|
47
|
+
Enter MFA token: 123456
|
|
48
|
+
---
|
|
49
|
+
a: secret 'my/secret/foo'@'12345678-1234-1234-1234-123456789012'
|
|
50
|
+
b: secret 'my/secret/foo'@'12345678-1234-1234-1234-333333333333'
|
|
51
|
+
a: 'SOME_KEY':'foo_value'
|
|
52
|
+
b: 'SOME_KEY':'bar_value'
|
|
53
|
+
a: 'OTHER_KEY':'some_value'
|
|
54
|
+
b: '**ABSENT**':'**ABSENT**'
|
|
55
|
+
|
|
56
|
+
# with secret values masked
|
|
57
|
+
aws_cmp_secrets \
|
|
58
|
+
-m \
|
|
59
|
+
-s1 my/secret/foo \
|
|
60
|
+
-s2 my/secret/bar
|
|
61
|
+
a: secret 'my/secret/foo'@'AWSCURRENT'
|
|
62
|
+
b: secret 'my/secret/bar'@'AWSCURRENT'
|
|
63
|
+
a: 'SOME_KEY':'**MASKED**'
|
|
64
|
+
b: 'SOME_KEY':'**MASKED**'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
[boto3]: https://pypi.org/project/boto3/
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
aws_cmp_secrets/cmp_secrets.py,sha256=WE2g63NwXWI0iQw3xcsVohDLjKu7nOG66xM9K-woHK8,9002
|
|
2
|
+
aws_cmp_secrets-0.0.1.dist-info/licenses/LICENSE,sha256=qIDfHIIwb5NH-IEoigF2y4_sIX7m3dfn_DaYdNQS7jw,1071
|
|
3
|
+
aws_cmp_secrets-0.0.1.dist-info/METADATA,sha256=2DUO8h2fL8ipKHGhm8KcV1Iwh4R7pizt-ewwxcoo7Rc,2147
|
|
4
|
+
aws_cmp_secrets-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
5
|
+
aws_cmp_secrets-0.0.1.dist-info/entry_points.txt,sha256=CZSyZYuQIBqfm9aiYByq6qvI-TbKoIJ2MI32C57ZP2M,69
|
|
6
|
+
aws_cmp_secrets-0.0.1.dist-info/top_level.txt,sha256=yp0hmPWGdPuH1_JXY-Qdv0f0WRjXSTRDy2wI8lV6_94,16
|
|
7
|
+
aws_cmp_secrets-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Zdenek Styblik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aws_cmp_secrets
|