diff --git a/.dockerignore b/.dockerignore index 3c6d2266..9200728a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,35 +1,8 @@ -**/__pycache__ -**/.venv -**/.classpath -**/.dockerignore -**/.env -**/.git -**/.gitignore -**/.git-blame-ignore-revs -**/.project -**/.settings -**/.toolstarget -**/.tox -**/.vs -**/.vscode -**/.github -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/env -**/venv -**/docs -**/tests -**/bin -**/charts -**/docker-compose* -**/compose* -**/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -AUTHORS.md -LICENSE.txt -README.md +** +.** +!.dockerignore +!requirements.txt +!AUTHORS.md +!LICENSE.txt +!README.md +!tokendito/*.py diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..fdb39998 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,70 @@ +name: Publish to Dockerhub +on: + push: + branches: + - main + tags: + - '[0-9]+.[0-9]+.[0-9]+' + +env: + REGISTRY: docker.io + IMAGE_NAME: tokendito/tokendito + +jobs: + dockerhubpublish: + name: Build and Publish Docker Container + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 2 + matrix: + include: + - { platform: "linux/arm64", platform-tag: "arm64" } + - { platform: "linux/amd64", platform-tag: "amd64" } + permissions: + contents: read + packages: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: tokendito + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + - name: Build container + uses: docker/build-push-action@v4 + with: + context: . + push: false + load: true + platforms: ${{ matrix.platform }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + env: + DOCKER_CONTENT_TRUST: 1 + - name: Sign and push container image + uses: sudo-bot/action-docker-sign@latest + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + with: + image-ref: "${{ steps.meta.outputs.tags }}" + private-key-id: "${{ secrets.DOCKER_PRIVATE_KEY_ID }}" + private-key: "${{ secrets.DOCKER_PRIVATE_KEY }}" + private-key-passphrase: "${{ secrets.DOCKER_PRIVATE_KEY_PASSPHRASE }}" diff --git a/.github/workflows/release.yml b/.github/workflows/pypi.yml similarity index 100% rename from .github/workflows/release.yml rename to .github/workflows/pypi.yml diff --git a/Dockerfile b/Dockerfile index 66eae952..b99c6d90 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,14 +7,13 @@ ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 # Install pip requirements -COPY requirements.txt . -RUN python -m pip install -r requirements.txt +COPY . /app +RUN python -m pip install -r /app/requirements.txt WORKDIR /app -COPY . /app # Creates a non-root user with an explicit UID and adds permission to access the /app folder RUN adduser -u 5678 --disabled-password --gecos "" tokendito && chown -R tokendito /app USER tokendito -ENTRYPOINT ["python", "tokendito/tokendito.py"] \ No newline at end of file +ENTRYPOINT ["python", "tokendito/tokendito.py"] diff --git a/README.md b/README.md index 2b16484b..2439bae1 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Consult [additional notes](https://github.com/dowjones/tokendito/blob/main/docs/ ## Requirements -- Python 3.7+ +- Python 3.7+, or a working Docker environment - AWS account(s) federated with Okta Tokendito is compatible with Python 3 and can be installed with either @@ -61,20 +61,20 @@ guide](https://github.com/dowjones/tokendito/blob/main/docs/README.md#multi-tile ## Docker -Using Docker eliminates the need to install tokendito and its requirements. +Using Docker eliminates the need to install tokendito and its requirements. We are providing experimental Docker image support in [Dockerhub](https://hub.docker.com/r/tokendito/tokendito) -### Building the container image +### Running the container image + +Run tokendito with the `docker run` command. Tokendito supports [DCT](https://docs.docker.com/engine/security/trust/), and we encourage you to enforce image signature validation before running any containers. ``` txt -docker image build --pull --tag "tokendito:latest" . +export DOCKER_CONTENT_TRUST=1 ``` -### Running the container image - -Run tokendito with the `docker run` command +then ``` txt -docker run tokendito --version +docker run --rm -it tokendito --version ``` You must map a volume in the Docker command to allow tokendito to write AWS credentials to your local system for use. This is done with the `-v` flag. See [Docker documentation](https://docs.docker.com/engine/reference/commandline/run/#-mount-volume--v---read-only) for help setting the syntax. The following directories are used by tokendito and should be considered when mapping volumes: @@ -84,23 +84,23 @@ You must map a volume in the Docker command to allow tokendito to write AWS cred These can be covered by mapping a single volume to both the host and container users' home directories (`/home/tokendito/` is the home directory in the container and must be explicitly defined). You may also map multiple volumes if you have custom configuration locations and require granularity. -Be sure to set the `-ti` flags to enable an interactive terminal session. +Be sure to set the `-it` flags to enable an interactive terminal session. ``` txt -docker run -ti -v ${home}:/home/tokendito/ tokendito +docker run --rm -it -v ${home}:/home/tokendito/ tokendito ``` Tokendito command line arguments are supported as well. ``` txt -docker run -ti -v ${home}:/home/tokendito/ tokendito ` - --okta-tile https://acme.okta.com/home/amazon_aws/000000000000000000x0/123 ` - --username username@example.com ` - --okta-mfa push ` - --aws-output json ` - --aws-region us-east-1 ` - --aws-profile my-profile-name ` - --aws-role-arn arn:aws:iam::000000000000:role/role-name +docker run --rm -it -v ${home}:/home/tokendito/ tokendito \ + --okta-tile https://acme.okta.com/home/amazon_aws/000000000000000000x0/123 \ + --username username@example.com \ + --okta-mfa push \ + --aws-output json \ + --aws-region us-east-1 \ + --aws-profile my-profile-name \ + --aws-role-arn arn:aws:iam::000000000000:role/role-name \ ``` Tokendito profiles are supported while using containers provided the proper volume mapping exists. diff --git a/docs/README.md b/docs/README.md index fa680fa7..0a5e6173 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,7 +59,7 @@ Or you can put your parameters into a single [profile](tokendito.ini.md) and ref okta_aws_tile = https://acme.oktapreview.com/home/amazon_aws/b07384d113edec49eaa6/123 okta_username = jane.doe@acme.com okta_mfa = push -role_arn = arn:aws:iam::123456789000:role/engineer +aws_role_arn = arn:aws:iam::123456789000:role/engineer ``` And execute: diff --git a/requirements.txt b/requirements.txt index 7a106b8f..3ea3a668 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ beautifulsoup4>=4.6.0 botocore>=1.12.36 +certifi>=2022.12.07 # This can be removed when requests updates its requirements from 2017.4.17 to >=2022.12.07 platformdirs>=2.5.4 requests>=2.19.0 diff --git a/tests/unit_test.py b/tests/unit_test.py index 2419e165..38f1db9a 100644 --- a/tests/unit_test.py +++ b/tests/unit_test.py @@ -53,6 +53,38 @@ def test_import_location(): assert imported_path.startswith(local_path) +@pytest.mark.xfail( + sys.platform == "win32", reason="Windows does not always handle NULL stdin correctly." +) +def test_tty_assertion(): + """Test the availability of stdin.""" + import os + import sys + from tokendito.user import tty_assertion + + # Save for reuse + old_stdin = sys.stdin + # Test for NoneType + with pytest.raises(SystemExit) as err: + sys.stdin = None + tty_assertion() + assert err.value.code == 1 + + # Test for null descriptor + with pytest.raises(SystemExit) as err: + sys.stdin = open(os.devnull, "w") + tty_assertion() + assert err.value.code == 1 + + sys.stdin = old_stdin + # Test for closed descriptor + with pytest.raises(SystemExit) as err: + sys.stdin = old_stdin + os.close(sys.stdin.fileno()) + tty_assertion() + assert err.value.code == 1 + + def test_semver_version(): """Ensure the package version is semver compliant.""" from tokendito import __version__ as version @@ -64,6 +96,7 @@ def test_get_username(mocker): """Test whether data sent is the same as data returned.""" from tokendito import user + mocker.patch("tokendito.user.tty_assertion", return_value=True) mocker.patch("tokendito.user.input", return_value="pytest_patched") val = user.get_username() @@ -74,6 +107,7 @@ def test_get_password(mocker): """Test whether data sent is the same as data returned.""" from tokendito import user + mocker.patch("tokendito.user.tty_assertion", return_value=True) mocker.patch("getpass.getpass", return_value="pytest_patched") val = user.get_password() @@ -159,6 +193,7 @@ def test_collect_integer(mocker, value, expected): """Test whether integers from the user are retrieved.""" from tokendito import user + mocker.patch("tokendito.user.tty_assertion", return_value=True) mocker.patch("tokendito.user.input", return_value=value) assert user.collect_integer(10) == expected @@ -175,6 +210,7 @@ def test_get_org(mocker, url, expected): """Test Org URL.""" from tokendito import user + mocker.patch("tokendito.user.tty_assertion", return_value=True) mocker.patch("tokendito.user.input", return_value=url) assert user.get_org() == expected @@ -197,6 +233,7 @@ def test_get_tile(mocker, url, expected): """Test get tile URL.""" from tokendito import user + mocker.patch("tokendito.user.tty_assertion", return_value=True) mocker.patch("tokendito.user.input", return_value=url) assert user.get_tile() == expected @@ -249,6 +286,7 @@ def test_get_input(mocker): """Check if provided input is return unmodified.""" from tokendito import user + mocker.patch("tokendito.user.tty_assertion", return_value=True) mocker.patch("tokendito.user.input", return_value="pytest_patched") assert user.get_input() == "pytest_patched" @@ -433,6 +471,7 @@ def test_set_passcode(mocker): """Check if numerical passcode can handle leading zero values.""" from tokendito import duo + mocker.patch("tokendito.user.tty_assertion", return_value=True) mocker.patch("tokendito.user.input", return_value="0123456") assert duo.set_passcode({"factor": "passcode"}) == "0123456" @@ -1055,6 +1094,7 @@ def test_get_mfa_response(): def test_config_object(): """Test proper initialization of the Config object.""" import json + import sys from tokendito import Config # Test for invalid assignments to the object @@ -1098,6 +1138,11 @@ def test_config_object(): # Check that default values from the original object are kept assert pytest_config.get_defaults()["aws"]["region"] == pytest_config.aws["region"] + # Check that we set encoding correctly when there is no stdin + sys.stdin = None + pytest_config = Config() + assert pytest_config.user["encoding"] == "utf-8" + def test_loglevel_collected_from_env(monkeypatch): """Ensure that the loglevel collected from env vars.""" @@ -1186,14 +1231,16 @@ def test_get_interactive_profile_name(mocker, default, submit, expected): """Test getting the AWS profile name form user input.""" from tokendito import user + mocker.patch("tokendito.user.tty_assertion", return_value=True) mocker.patch("tokendito.user.input", return_value=submit) assert user.get_interactive_profile_name(default) == expected -def test_get_interactive_profile_name_invalid_input(monkeypatch): +def test_get_interactive_profile_name_invalid_input(mocker, monkeypatch): """Test reprompting the AWS profile name form user on invalid input.""" from tokendito import user + mocker.patch("tokendito.user.tty_assertion", return_value=True) # provided inputs inputs = iter(["_this_is_invalid", "str with space", "1StartsWithNum", "valid"]) diff --git a/tokendito/__init__.py b/tokendito/__init__.py index b9668110..1dbb45f0 100644 --- a/tokendito/__init__.py +++ b/tokendito/__init__.py @@ -21,6 +21,10 @@ class Config(object): """Creates configuration variables for the application.""" + _default_encoding = "utf-8" + if getattr(sys, "stdin") is not None: + _default_encoding = sys.stdin.encoding + # Instantiated objects can get Class defaults with get_defaults() _defaults = dict( user=dict( @@ -29,7 +33,7 @@ class Config(object): user_config_dir(appname=__title__, appauthor=False), f"{__title__}.ini" ), config_profile="default", - encoding=sys.stdin.encoding, + encoding=_default_encoding, loglevel="INFO", log_output_file="", mask_items=[], diff --git a/tokendito/user.py b/tokendito/user.py index 5805db28..ad03a5b5 100644 --- a/tokendito/user.py +++ b/tokendito/user.py @@ -752,7 +752,6 @@ def get_interactive_config(tile=None, org=None, username=""): # We need either one of these two: while not validate_okta_org(org) and not validate_okta_tile(tile): - print("\n\nPlease enter either your Organization URL, a tile URL, or both.") org = get_org() tile = get_tile() @@ -857,6 +856,7 @@ def get_password(): res = "" logger.debug("Set password.") + tty_assertion() while res == "": password = getpass.getpass() res = password @@ -1041,12 +1041,26 @@ def validate_input(value, valid_range): return integer_validation +def tty_assertion(): + """Ensure that a TTY is present.""" + try: + assert os.isatty(sys.stdin.fileno()) is True + except (AttributeError, AssertionError, EOFError, OSError, RuntimeError): + logger.error( + "sys.stdin is not available, and interactive invocation requires stdin to be present. " + "Please check the --help argument and documentation for more details.", + ) + sys.exit(1) + + def get_input(prompt="-> "): """Collect user input for TOTP. :param prompt: optional string with prompt. :return user_input: raw from user. """ + tty_assertion() + user_input = input(f"{prompt}") logger.debug(f"User input: {user_input}")