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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aws_cmp_secrets = aws_cmp_secrets.cmp_secrets:main
@@ -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