Skip to content

Commit dad70c5

Browse files
committed
Initial commit
0 parents  commit dad70c5

11 files changed

+560
-0
lines changed

.gitignore

+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# App-specific
2+
3+
.idea
4+
5+
# Byte-compiled / optimized / DLL files
6+
__pycache__/
7+
*.py[cod]
8+
*$py.class
9+
10+
# C extensions
11+
*.so
12+
13+
# Distribution / packaging
14+
.Python
15+
build/
16+
develop-eggs/
17+
dist/
18+
downloads/
19+
eggs/
20+
.eggs/
21+
lib/
22+
lib64/
23+
parts/
24+
sdist/
25+
var/
26+
wheels/
27+
pip-wheel-metadata/
28+
share/python-wheels/
29+
*.egg-info/
30+
.installed.cfg
31+
*.egg
32+
MANIFEST
33+
34+
# PyInstaller
35+
# Usually these files are written by a python script from a template
36+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
37+
*.manifest
38+
*.spec
39+
40+
# Installer logs
41+
pip-log.txt
42+
pip-delete-this-directory.txt
43+
44+
# Unit test / coverage reports
45+
htmlcov/
46+
.tox/
47+
.nox/
48+
.coverage
49+
.coverage.*
50+
.cache
51+
nosetests.xml
52+
coverage.xml
53+
*.cover
54+
*.py,cover
55+
.hypothesis/
56+
.pytest_cache/
57+
58+
# Translations
59+
*.mo
60+
*.pot
61+
62+
# Django stuff:
63+
*.log
64+
local_settings.py
65+
db.sqlite3
66+
db.sqlite3-journal
67+
68+
# Flask stuff:
69+
instance/
70+
.webassets-cache
71+
72+
# Scrapy stuff:
73+
.scrapy
74+
75+
# Sphinx documentation
76+
docs/_build/
77+
78+
# PyBuilder
79+
target/
80+
81+
# Jupyter Notebook
82+
.ipynb_checkpoints
83+
84+
# IPython
85+
profile_default/
86+
ipython_config.py
87+
88+
# pyenv
89+
.python-version
90+
91+
# pipenv
92+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
94+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
95+
# install all needed dependencies.
96+
#Pipfile.lock
97+
98+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
99+
__pypackages__/
100+
101+
# Celery stuff
102+
celerybeat-schedule
103+
celerybeat.pid
104+
105+
# SageMath parsed files
106+
*.sage.py
107+
108+
# Environments
109+
.env
110+
.venv
111+
env/
112+
venv/
113+
ENV/
114+
env.bak/
115+
venv.bak/
116+
117+
# Spyder project settings
118+
.spyderproject
119+
.spyproject
120+
121+
# Rope project settings
122+
.ropeproject
123+
124+
# mkdocs documentation
125+
/site
126+
127+
# mypy
128+
.mypy_cache/
129+
.dmypy.json
130+
dmypy.json
131+
132+
# Pyre type checker
133+
.pyre/

LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2022 Logan Bibby
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.

README.md

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Marvin, the AWS MFA helper bot
2+
3+
Marvin is a Python CLI for refreshing and checking your AWS sessions that require MFA.
4+
5+
## Installation
6+
7+
_Note: Marvin relies on the `aws` CLI to be installed and accessible from PATH._
8+
9+
1. Clone this repo and go into the directory
10+
2. Create a virtual environment (`python -m venv .venv`) and activate (`source .venv/bin/activate` or `.venv\Scripts\activate` on Windows)
11+
3. Install requirements (`pip install -r requirements.txt`)
12+
13+
## AWS Credentials File
14+
15+
For each of the profiles you want to manage with Marvin, it must be already setup in `~/.aws/credentials`. Each profile requires two profiles in that file: the session profile and key profile.
16+
17+
The key profile requires the following settings: `aws_access_key_id`, `aws_secret_access_key`, and `mfa_serial` (your MFA device ARN).
18+
19+
The session profile is named based on the key profile's name followed by `_fetch` -- so a key profile called `default` would have a session profile called `default_fetch`.
20+
21+
Expirations and other data is saved in `~/.aws/config`.
22+
23+
## Commands
24+
25+
All commands run from `src/run.py` and can be invoked using `python src/run.py <command>` or `src/run.py` if the file has executable permission.
26+
27+
### `check`
28+
Check a profile's expiration.
29+
30+
Required argument: `key_profile`
31+
32+
Optional parameter: `--session-profile`
33+
34+
### `refresh`
35+
Refresh a session's token.
36+
37+
Required argument: `key_profile`
38+
39+
Optional parameters: `--session-profile`, `--duration` (in seconds, defaults to 36 hours), and `--token` (an MFA token).
40+
41+
## To-Do
42+
43+
* Better integration with `aws`
44+
* Ability to list profiles
45+
* Check status of all profiles
46+
47+
## Version
48+
49+
* `0.1` - Initial release

requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
click==8.1.3

src/marvin/__init__.py

Whitespace-only changes.

src/marvin/aws.py

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import subprocess
2+
import json
3+
import re
4+
5+
import click
6+
7+
from .exceptions import *
8+
9+
10+
error_response = re.compile(r"^An error occurred \((\w+)\) when calling the \w+ operation: (.+)$")
11+
12+
13+
class AWSResponse(object):
14+
def __init__(self, cmd, cmd_group, output, is_json=True, cmd_name=None, cmd_opts=None, cmd_pmt=None):
15+
self.command = cmd
16+
self.command_group = cmd_group
17+
self.command_name = cmd_name
18+
self.command_options = cmd_opts
19+
self.command_parameters = cmd_pmt
20+
self.output = output
21+
22+
self.value = self.output.stdout.strip()
23+
24+
self.is_success = True
25+
self.error_text = None
26+
self.error_code = None
27+
28+
error = error_response.match(self.value)
29+
30+
if error:
31+
self.is_success = False
32+
self.error_text = error.group(2)
33+
self.error_code = error.group(1)
34+
elif is_json:
35+
if self.value.startswith("{") or self.value.startswith("["):
36+
self.value = json.loads(self.value)
37+
else:
38+
self.is_success = False
39+
self.error_text = self.value
40+
self.error_code = "invalid_json_response"
41+
42+
43+
class AWSClient(object):
44+
CONFIGURE = "configure"
45+
STS = "sts"
46+
47+
def __init__(self, debug=True):
48+
self.aws_cli = "aws"
49+
self.debug = debug
50+
51+
def run_command(self, cmd_group, cmd_name=None, *args, **kwargs):
52+
cmd = [self.aws_cli, cmd_group]
53+
54+
if cmd_name:
55+
cmd.append(cmd_name)
56+
57+
if len(args):
58+
cmd += args
59+
60+
for key, value in kwargs.items():
61+
key = key.replace("_", "-")
62+
cmd.append(f'--{key}="{value}"')
63+
64+
cmd = " ".join(cmd)
65+
66+
if self.debug:
67+
click.echo(f"Running command: {cmd}")
68+
69+
output = subprocess.run(
70+
cmd,
71+
text=True,
72+
stdout=subprocess.PIPE,
73+
stderr=subprocess.STDOUT,
74+
shell=True
75+
)
76+
77+
response = AWSResponse(cmd=cmd, cmd_group=cmd_group, cmd_name=cmd_name, cmd_opts=args, cmd_pmt=kwargs,
78+
output=output, is_json=kwargs.get("output") == "json")
79+
80+
if self.debug:
81+
click.echo(f'Command output: "{response.value}"')
82+
83+
return response
84+
85+
def get_profile_value(self, profile, key):
86+
return self.run_command(self.CONFIGURE, "get", key, profile=profile).value
87+
88+
def set_profile_value(self, profile, key, value):
89+
return self.run_command(self.CONFIGURE, "set", key, value, profile=profile).is_success
90+
91+
def get_session_token(self, profile, token, duration, mfa_device):
92+
response = self.run_command(self.STS, "get-session-token", profile=profile, output="json",
93+
duration_seconds=duration, token_code=token, serial_number=mfa_device)
94+
95+
if not response.is_success:
96+
if response.error_code == "AccessDenied" and "MultiFactorAuthentication failed" in response.error_text:
97+
raise InvalidToken()
98+
if "Invalid length for parameter TokenCode" in response.error_text:
99+
raise InvalidTokenLength(response.error_text[-2:].strip())
100+
101+
return response
102+
103+
104+
client = AWSClient()

src/marvin/cli.py

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import click
2+
3+
from .client import Client
4+
from .aws import client as aws
5+
from .exceptions import *
6+
7+
8+
banner = """
9+
__ __ _______ ______ __ __ ___ __ _
10+
| |_| | _ | _ | | | | | | | | |
11+
| | |_| | | || | |_| | | |_| |
12+
| | | |_||_| | | |
13+
| | | __ | | | _ |
14+
| ||_|| | _ | | | || || | | | |
15+
|_| |_|__| |__|___| |_| |___| |___|_| |__|
16+
"""
17+
18+
19+
@click.group
20+
@click.option("-d", "--debug", is_flag=True)
21+
def cli(debug):
22+
click.secho(banner, fg="magenta")
23+
click.secho(
24+
"Hello! My name is Marvin and I'll handle your\nAWS multi-factor authentication session for AWS CLI.",
25+
fg="magenta"
26+
)
27+
click.echo("")
28+
29+
aws.debug = debug
30+
31+
32+
@cli.command()
33+
@click.argument("key_profile")
34+
@click.option("--session-profile", default=None)
35+
@click.option("--token")
36+
@click.option("--duration", type=int)
37+
def refresh(**kwargs):
38+
client = Client(**kwargs)
39+
40+
if client.is_refresh_needed and not client.mfa_token:
41+
client.prompt_for_mfa_token()
42+
43+
while True:
44+
try:
45+
click.echo("")
46+
if client.update_session_token():
47+
click.secho(f"Your session information for {client.key_profile.name} has been updated!", fg="green")
48+
click.secho(f"The session will expire in {client.key_profile.expires_in_hours} hours", fg="green")
49+
else:
50+
click.secho("Unable to refresh your session", fg="red")
51+
break
52+
except InvalidToken as e:
53+
click.secho(str(e), fg="red")
54+
click.echo("")
55+
client.prompt_for_mfa_token()
56+
57+
58+
@cli.command()
59+
@click.argument("key_profile")
60+
@click.option("--session-profile", default=None)
61+
def check(**kwargs):
62+
client = Client(**kwargs)
63+
64+
if client.is_refresh_needed:
65+
click.secho(f"Your {client.session_name} session is active and will expire in "
66+
f"{client.key_profile.expires_in_hours} hours",
67+
fg="green")
68+
else:
69+
click.secho(f"Your {client.session_name} session expired {abs(client.key_profile.expires_in_hours)} hours ago "
70+
f"and needs to be refreshed.", fg="red")

0 commit comments

Comments
 (0)