From d00e92f3e61c41283fc3968d2e3e42ee13aa95b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20Gr=C3=BCbel?= Date: Wed, 4 Jan 2023 22:44:34 +0100 Subject: [PATCH] whorf v2 (#6) Co-authored-by: Nimrod Kor --- .github/workflows/pr.yml | 51 ++- .github/workflows/release.yml | 8 +- .gitignore | 7 + .pre-commit-config.yaml | 22 + CONTRIBUTING.md | 184 ++++++++ Dockerfile | 3 +- Pipfile | 16 +- Pipfile.lock | 467 +++++++++++++++----- README.md | 6 +- app/__init__.py | 0 app/checkov_whorf.py | 75 ++++ app/consts.py | 12 + app/models.py | 18 + app/utils.py | 104 +++++ app/validate.py | 138 ++++++ app/whorf.py | 76 ++++ extra_stubs/flask_apscheduler/__init__.pyi | 5 + extra_stubs/flask_apscheduler/scheduler.pyi | 17 + k8s/admissionconfiguration.yaml | 2 +- k8s/checkovconfig.yaml | 5 - k8s/deployment.yaml | 22 +- k8s/whorfconfig.yaml | 86 +++- pyproject.toml | 39 ++ setup.sh | 70 ++- tests/conftest.py | 211 +++++++++ test/test.yaml => tests/nginx.yaml | 0 tests/request.json | 175 ++++++++ tests/test_validate.py | 151 +++++++ tests/test_whorf.py | 50 +++ whorf.py | 162 ------- wsgi.py | 2 +- 31 files changed, 1855 insertions(+), 329 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 CONTRIBUTING.md create mode 100644 app/__init__.py create mode 100644 app/checkov_whorf.py create mode 100644 app/consts.py create mode 100644 app/models.py create mode 100644 app/utils.py create mode 100644 app/validate.py create mode 100644 app/whorf.py create mode 100644 extra_stubs/flask_apscheduler/__init__.pyi create mode 100644 extra_stubs/flask_apscheduler/scheduler.pyi create mode 100644 pyproject.toml mode change 100644 => 100755 setup.sh create mode 100644 tests/conftest.py rename test/test.yaml => tests/nginx.yaml (100%) create mode 100644 tests/request.json create mode 100644 tests/test_validate.py create mode 100644 tests/test_whorf.py delete mode 100644 whorf.py diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index bec905f..2aaf0a5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -5,8 +5,57 @@ on: permissions: read-all +env: + MIN_PYTHON_VERSION: "3.10" + jobs: - tests: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b # v3 + - uses: actions/setup-python@5ccb29d8773c3f3f653e1705f474dfaa8a06a912 # v4 + with: + python-version: ${{ env.MIN_PYTHON_VERSION }} + - name: pre-commit + uses: pre-commit/action@646c83fcd040023954eafda54b4db0192ce70507 # v3 + mypy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b # v3 + - uses: actions/setup-python@5ccb29d8773c3f3f653e1705f474dfaa8a06a912 # v4 + with: + python-version: ${{ env.MIN_PYTHON_VERSION }} + - name: Install pipenv + run: | + python -m pip install --no-cache-dir --upgrade pipenv + - name: Install dependencies + run: | + pipenv --python ${{ env.MIN_PYTHON_VERSION }} + pipenv install --dev + - name: Run Mypy + run: | + pipenv run mypy + + unit-tests: + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b # v3 + - uses: actions/setup-python@5ccb29d8773c3f3f653e1705f474dfaa8a06a912 # v4 + with: + python-version: ${{ env.MIN_PYTHON_VERSION }} + - name: Install pipenv + run: | + python -m pip install --no-cache-dir --upgrade pipenv + - name: Install dependencies + run: | + pipenv --python ${{ env.MIN_PYTHON_VERSION }} + pipenv install --dev + - name: Test with pytest + run: | + pipenv run python -m pytest tests + + docker-build: runs-on: ubuntu-latest env: DH_IMAGE_NAME: bridgecrew/whorf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca640a5..8d4116b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: major_version: ${{ steps.version.outputs.major_version }} steps: - uses: actions/checkout@755da8c3cf115ac066823e79a1e1788f8940201b # v3 - - uses: actions/setup-python@2c3dd9e7e29afd70cc0950079bde6c979d1f69f9 # v4 + - uses: actions/setup-python@5ccb29d8773c3f3f653e1705f474dfaa8a06a912 # v4 with: python-version: ${{ env.PYTHON_VERSION }} @@ -34,8 +34,8 @@ jobs: run: | version=$(curl -s curl -s https://api.github.com/repos/bridgecrewio/checkov/tags | jq -r '.[0].name') echo "version=$version" >> $GITHUB_OUTPUT - - # grab major version for later image tag usage + + # grab major version for later image tag usage major_version=$(echo "${version}" | head -c1) echo "major_version=$major_version" >> $GITHUB_OUTPUT - name: Update checkov dependency @@ -119,7 +119,7 @@ jobs: # sign image cosign sign ${{ env.DH_IMAGE_NAME }}@${{ steps.docker_push.outputs.digest }} cosign sign -f ${{ env.GHCR_IMAGE_NAME }}@${{ steps.docker_push.outputs.digest }} - + # attest SBOM cosign attest \ --type cyclonedx \ diff --git a/.gitignore b/.gitignore index 82d34da..cd445cc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ certs/ debug/ +# local development +local/ +config/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -131,3 +135,6 @@ dmypy.json # Pyre type checker .pyre/ + +# ruff +.ruff_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9884449 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-json + - id: check-toml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: name-tests-test + args: ["--django"] + - id: trailing-whitespace + - repo: https://github.com/psf/black + rev: 22.12.0 + hooks: + - id: black + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.0.206 + hooks: + - id: ruff + args: + - --fix diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dab43aa --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,184 @@ +# Contributing + +The developer guide is for anyone wanting to contribute directly to the `whorf` project. + + +## Work locally + +To work locally you either need access to a remote Kubernetes cluster or setup one locally via [minikube](https://minikube.sigs.k8s.io/docs/start/) or similar and [kubectl](https://kubernetes.io/docs/tasks/tools/) to interact with the cluster. + +Then you can deploy the Kubernetes manifest via the `setup.sh` script by leveraging the local development mode. +```shell + WHORF_LOCAL=true ./setup.sh [cluster name] [api key] +``` + +This will create a `local` folder with all the templates adjusted to given inputs. + +> **Note** +> +> If `minikube start` results in an error like this +> ```shell +> [kubelet-check] Initial timeout of 40s passed. +> +> Unfortunately, an error has occurred: +> timed out waiting for the condition +> +> ... +> ``` +> +> then rerunning it with setting an older Kubernetes version may help +> ```shell +> minikube delete --all +> minikube start --kubernetes-version='1.24.9' +> ``` + +### Image + +If you want to test your own version of the container image, then first build the image. + +> **Note** +> +> If `minikube` is used, then you need to reuse its built-in Docker daemon +> ```shell +> eval $(minikube docker-env) +> docker build -t whorf . +> ``` + +Adjust the `image` and `imagePullPolicy` in the `deployment.yaml` in your `local` folder. + +ex. +```yaml + spec: + containers: + - name: webhook + securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + image: whorf # <-- change here + imagePullPolicy: Never # <-- change here + resources: + ... +``` + +and redeploy it +```shell +kubectl apply -f local/deployment.yaml +``` + +> **Note** +> +> If only the image itself changed, then you need to restart the deployment rollout +> ```shell +> kubectl rollout restart deploy validation-webhook -n bridgecrew +> ``` + +### Logs + +To see the logs of the container in tail mode +```shell +kubectl logs -f -l app=validate -n bridgecrew +``` + +### Test deployment + +To easily test, if the admission controller is working as expected, just deploy the local `tests/nginx.yaml` and you will get following response +```shell +kubectl apply -f tests/nginx.yaml + +Error from server: error when creating "nginx.yaml": admission webhook "validate.bridgecrew.svc" denied the request: Checkov found 4 issues in violation of admission policy. +CKV_K8S_16: + Description: Container should not be privileged + Guidance: https://docs.bridgecrew.io/docs/bc_k8s_15 +CKV_K8S_21: + Description: The default namespace should not be used + Guidance: https://docs.bridgecrew.io/docs/bc_k8s_20 +CKV_K8S_23: + Description: Minimize the admission of root containers + Guidance: https://docs.bridgecrew.io/docs/bc_k8s_22 +CKV_K8S_20: + Description: Containers should not run with allowPrivilegeEscalation + Guidance: https://docs.bridgecrew.io/docs/bc_k8s_19 +Checkov found 76 total issues in this manifest. +Checkov found 43 CVEs in container images of which are 2 critical, 1 high, 6 medium and 34 low. +Checkov found 17 license violations in container images. +``` + +## Work locally without Kubernetes + +Since the container image runs a Gunicorn web server with a Flask application you can just startup the Flask application locally and invoke the endpoint via `curl` or similar. + +> **Note** +> +> When using PyCharm Professional then you can easily configure a [Flask Server run configuration](https://www.jetbrains.com/help/pycharm/run-debug-configuration-flask-server.html). +> +> When using PyCharm CE then you can use this run configuration and just need to adjust the `SCRIPT_NAME` to point it against your virtual env path +> ```xml +> +> +> +> +> +> ``` + +Additionally, you need to add the config files for `checkov` and `whorf` to a local `config` folder. + +`config/.checkov.yaml` +```yaml +branch: master +repo-id: k8sac/cluster +framework: kubernetes +hard-fail-on: +- CKV_K8S_16 +- CKV_K8S_20 +- CKV_K8S_23 +``` + +`config/whorf.yaml` +```yaml +ignores-namespaces: + - bridgecrew + - kube-system +upload-interval-in-min: 5 +``` + +After starting the Flask application you can just invoke the `validate` endpoint with the `request.json` file under the `tests` folder. +```shell +curl -s -X POST --data "@tests/request.json" -H 'Content-Type: application/json' http://127.0.0.1:5000/validate | jq -r .response.status.message + +Checkov found 3 issues in violation of admission policy. +CKV_K8S_20: + Description: Containers should not run with allowPrivilegeEscalation + Guidance: https://docs.bridgecrew.io/docs/bc_k8s_19 +CKV_K8S_16: + Description: Container should not be privileged + Guidance: https://docs.bridgecrew.io/docs/bc_k8s_15 +CKV_K8S_23: + Description: Minimize the admission of root containers + Guidance: https://docs.bridgecrew.io/docs/bc_k8s_22 +Checkov found 15 total issues in this manifest. +``` diff --git a/Dockerfile b/Dockerfile index 83a3841..ebd3d3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,8 @@ RUN set -eux; \ rm -f requirements.txt; \ pip uninstall -y pipenv -COPY whorf.py wsgi.py ./ +COPY wsgi.py ./ +COPY app ./app # create the app user RUN set -eux; \ diff --git a/Pipfile b/Pipfile index 2c693f0..7088178 100644 --- a/Pipfile +++ b/Pipfile @@ -5,17 +5,17 @@ name = "pypi" [packages] checkov = "==2.2.234" -click = "==8.0.1" -colorama = "==0.4.4" -flask = "==2.0.1" -itsdangerous = "==2.0.1" -jinja2 = "==3.0.1" -markupsafe = "==2.0.1" -python-dotenv = "==0.18.0" -werkzeug = "==2.0.1" +flask = "==2.2.2" +flask-apscheduler = "==1.12.4" +python-dotenv = "==0.21.0" gunicorn = "==20.1.0" [dev-packages] +mypy = "*" +pre-commit = "*" +pytest = "*" +pytest-mock = "*" +types-pyyaml = "*" [requires] python_version = "3.10" diff --git a/Pipfile.lock b/Pipfile.lock index 07c9592..3581e0e 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "0cb0ed8e42c7b28c624f1444455019c05bc4e0120a37b2a1848d30adf1eb064c" + "sha256": "7cd7730fc0df3d2511d06d02fa69ecb3254ef02796b0a04764e44c68d5d93cf4" }, "pipfile-spec": 6, "requires": { @@ -132,6 +132,14 @@ "markers": "python_version >= '3.7'", "version": "==1.3.1" }, + "apscheduler": { + "hashes": [ + "sha256:b2bea0309569da53a7261bfa0ce19c67ddbfe151bda776a6a907579fdbd3eb2a", + "sha256:c8c618241dbb2785ed5a687504b14cb1851d6f7b5a4edf3a51e39cc6a069967a" + ], + "markers": "python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==3.9.1.post1" + }, "argcomplete": { "hashes": [ "sha256:6372ad78c89d662035101418ae253668445b391755cfe94ea52f1b9d22425b20", @@ -190,19 +198,19 @@ }, "boto3": { "hashes": [ - "sha256:4cfd7e05e4033dbca2cc59bcfdafbdaef9d83dc3c0448917569b301d85766d9d", - "sha256:75c995a04723f23e35e16ea491ed91a1345e2fa6492678a216488512308dada1" + "sha256:0335af7a6deee3538672a6c6b2718a513e62070e84c7addfb610ee1aa45e664a", + "sha256:5c462ceddb3e51d1cd4239b4a84744b766a4f2db5afdc370f11d2d15f52a9a07" ], "markers": "python_version >= '3.7'", - "version": "==1.26.42" + "version": "==1.26.43" }, "botocore": { "hashes": [ - "sha256:d05c62f64e76194c40f598f5f7c804ec50d9820e9f03f6e0198558e4ace167c4", - "sha256:f52f9dbd7ad42b3528c1052086c1a7b6122a018f919afdb604f2889caefe8092" + "sha256:a801e40f5f14c1b2fb3c0b2c438b546f07805d53d57d8dc135ebce4fdce901bd", + "sha256:dc60385c56b960aa75ef05cdcf808e6c07ad04b1b392d1abd6fc405a16d85826" ], "markers": "python_version >= '3.7'", - "version": "==1.29.42" + "version": "==1.29.43" }, "cached-property": { "hashes": [ @@ -313,11 +321,11 @@ }, "click": { "hashes": [ - "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a", - "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6" + "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", + "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" ], - "index": "pypi", - "version": "==8.0.1" + "markers": "python_version >= '3.7'", + "version": "==8.1.3" }, "click-option-group": { "hashes": [ @@ -337,11 +345,11 @@ }, "colorama": { "hashes": [ - "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b", - "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2" + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" ], - "index": "pypi", - "version": "==0.4.4" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==0.4.6" }, "configargparse": { "hashes": [ @@ -406,11 +414,18 @@ }, "flask": { "hashes": [ - "sha256:1c4c257b1892aec1398784c63791cbaa43062f1f7aeb555c4da961b20ee68f55", - "sha256:a6209ca15eb63fc9385f38e452704113d679511d9574d09b2cf9183ae7d20dc9" + "sha256:642c450d19c4ad482f96729bd2a8f6d32554aa1e231f4f6b4e7e5264b16cca2b", + "sha256:b9c46cc36662a7949f34b52d8ec7bb59c0d74ba08ba6cb9ce9adc1d8676d9526" ], "index": "pypi", - "version": "==2.0.1" + "version": "==2.2.2" + }, + "flask-apscheduler": { + "hashes": [ + "sha256:681dae34dc6cc9403ce674795e53abd0bff540472129cfd3d3c93e0e1d502da8" + ], + "index": "pypi", + "version": "==1.12.4" }, "frozenlist": { "hashes": [ @@ -534,19 +549,19 @@ }, "itsdangerous": { "hashes": [ - "sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c", - "sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0" + "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44", + "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a" ], - "index": "pypi", - "version": "==2.0.1" + "markers": "python_version >= '3.7'", + "version": "==2.1.2" }, "jinja2": { "hashes": [ - "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4", - "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4" + "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", + "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" ], - "index": "pypi", - "version": "==3.0.1" + "markers": "python_version >= '3.7'", + "version": "==3.1.2" }, "jmespath": { "hashes": [ @@ -587,78 +602,49 @@ }, "markupsafe": { "hashes": [ - "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", - "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", - "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", - "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194", - "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", - "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", - "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724", - "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", - "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646", - "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", - "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6", - "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a", - "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6", - "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad", - "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", - "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38", - "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac", - "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", - "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6", - "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047", - "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", - "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", - "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b", - "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", - "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", - "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a", - "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", - "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1", - "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9", - "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864", - "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", - "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee", - "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f", - "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", - "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", - "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", - "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", - "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b", - "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", - "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86", - "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6", - "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", - "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", - "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", - "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28", - "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e", - "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", - "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", - "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f", - "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d", - "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", - "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", - "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145", - "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", - "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c", - "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1", - "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a", - "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207", - "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", - "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53", - "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd", - "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134", - "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85", - "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9", - "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", - "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", - "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", - "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", - "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" + "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003", + "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88", + "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5", + "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7", + "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a", + "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603", + "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1", + "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135", + "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247", + "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6", + "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601", + "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77", + "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02", + "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e", + "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63", + "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f", + "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980", + "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b", + "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812", + "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff", + "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96", + "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1", + "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925", + "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a", + "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6", + "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e", + "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f", + "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4", + "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f", + "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3", + "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c", + "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a", + "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417", + "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a", + "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a", + "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37", + "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452", + "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933", + "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a", + "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7" ], - "index": "pypi", - "version": "==2.0.1" + "markers": "python_version >= '3.7'", + "version": "==2.1.1" }, "multidict": { "hashes": [ @@ -952,11 +938,26 @@ }, "python-dotenv": { "hashes": [ - "sha256:dd8fe852847f4fbfadabf6183ddd4c824a9651f02d51714fa075c95561959c7d", - "sha256:effaac3c1e58d89b3ccb4d04a40dc7ad6e0275fda25fd75ae9d323e2465e202d" + "sha256:1684eb44636dd462b66c3ee016599815514527ad99965de77f43e0944634a7e5", + "sha256:b77d08274639e3d34145dfa6c7008e66df0f04b7be7a75fd0d5292c191d79045" ], "index": "pypi", - "version": "==0.18.0" + "version": "==0.21.0" + }, + "pytz": { + "hashes": [ + "sha256:7ccfae7b4b2c067464a6733c6261673fdb8fd1be905460396b97a073e9fa683a", + "sha256:93007def75ae22f7cd991c84e02d434876818661f8df9ad5df9e950ff4e52cfd" + ], + "version": "==2022.7" + }, + "pytz-deprecation-shim": { + "hashes": [ + "sha256:8314c9692a636c8eb3bda879b9f119e350e93223ae83e70e80c31675a0fdc1a6", + "sha256:af097bae1b616dde5c5744441e2ddc69e74dfdcb0c263129610d85b87445a59d" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==0.1.0.post0" }, "pyyaml": { "hashes": [ @@ -1208,6 +1209,22 @@ "markers": "python_version >= '3.7'", "version": "==4.4.0" }, + "tzdata": { + "hashes": [ + "sha256:2b88858b0e3120792a3c0635c23daf36a7d7eeeca657c323da299d2094402a0d", + "sha256:fe5f866eddd8b96e9fcba978f8e503c909b19ea7efda11e52e39494bad3a7bfa" + ], + "markers": "python_version >= '3.6'", + "version": "==2022.7" + }, + "tzlocal": { + "hashes": [ + "sha256:89885494684c929d9191c57aa27502afc87a579be5cdd3225c77c463ea043745", + "sha256:ee5842fa3a795f023514ac2d801c4a81d1743bbe642e3940143326b3a00addd7" + ], + "markers": "python_version >= '3.6'", + "version": "==4.2" + }, "update-checker": { "hashes": [ "sha256:6a2d45bb4ac585884a6b03f9eade9161cedd9e8111545141e9aa9058932acb13", @@ -1240,11 +1257,11 @@ }, "werkzeug": { "hashes": [ - "sha256:1de1db30d010ff1af14a009224ec49ab2329ad2cde454c8a708130642d579c42", - "sha256:6c1ec500dcdba0baa27600f6a22f6333d8b662d22027ff9f6202e3367413caa8" + "sha256:7ea2d48322cc7c0f8b3a215ed73eabd7b5d75d0b50e31ab006286ccff9e00b8f", + "sha256:f979ab81f58d7318e064e99c4506445d60135ac5cd2e177a2de0089bfd4c9bd5" ], - "index": "pypi", - "version": "==2.0.1" + "markers": "python_version >= '3.7'", + "version": "==2.2.2" }, "yarl": { "hashes": [ @@ -1335,5 +1352,245 @@ "version": "==3.11.0" } }, - "develop": {} + "develop": { + "attrs": { + "hashes": [ + "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836", + "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99" + ], + "markers": "python_version >= '3.6'", + "version": "==22.2.0" + }, + "cfgv": { + "hashes": [ + "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426", + "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736" + ], + "markers": "python_full_version >= '3.6.1'", + "version": "==3.3.1" + }, + "distlib": { + "hashes": [ + "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46", + "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e" + ], + "version": "==0.3.6" + }, + "exceptiongroup": { + "hashes": [ + "sha256:327cbda3da756e2de031a3107b81ab7b3770a602c4d16ca618298c526f4bec1e", + "sha256:bcb67d800a4497e1b404c2dd44fca47d3b7a5e5433dbab67f96c1a685cdfdf23" + ], + "markers": "python_version < '3.11'", + "version": "==1.1.0" + }, + "filelock": { + "hashes": [ + "sha256:7b319f24340b51f55a2bf7a12ac0755a9b03e718311dac567a0f4f7fabd2f5de", + "sha256:f58d535af89bb9ad5cd4df046f741f8553a418c01a7856bf0d173bbc9f6bd16d" + ], + "markers": "python_version >= '3.7'", + "version": "==3.9.0" + }, + "identify": { + "hashes": [ + "sha256:0bc96b09c838310b6fcfcc61f78a981ea07f94836ef6ef553da5bb5d4745d662", + "sha256:e8a400c3062d980243d27ce10455a52832205649bbcaf27ffddb3dfaaf477bad" + ], + "markers": "python_version >= '3.7'", + "version": "==2.5.12" + }, + "iniconfig": { + "hashes": [ + "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", + "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" + ], + "version": "==1.1.1" + }, + "mypy": { + "hashes": [ + "sha256:0714258640194d75677e86c786e80ccf294972cc76885d3ebbb560f11db0003d", + "sha256:0c8f3be99e8a8bd403caa8c03be619544bc2c77a7093685dcf308c6b109426c6", + "sha256:0cca5adf694af539aeaa6ac633a7afe9bbd760df9d31be55ab780b77ab5ae8bf", + "sha256:1c8cd4fb70e8584ca1ed5805cbc7c017a3d1a29fb450621089ffed3e99d1857f", + "sha256:1f7d1a520373e2272b10796c3ff721ea1a0712288cafaa95931e66aa15798813", + "sha256:209ee89fbb0deed518605edddd234af80506aec932ad28d73c08f1400ef80a33", + "sha256:26efb2fcc6b67e4d5a55561f39176821d2adf88f2745ddc72751b7890f3194ad", + "sha256:37bd02ebf9d10e05b00d71302d2c2e6ca333e6c2a8584a98c00e038db8121f05", + "sha256:3a700330b567114b673cf8ee7388e949f843b356a73b5ab22dd7cff4742a5297", + "sha256:3c0165ba8f354a6d9881809ef29f1a9318a236a6d81c690094c5df32107bde06", + "sha256:3d80e36b7d7a9259b740be6d8d906221789b0d836201af4234093cae89ced0cd", + "sha256:4175593dc25d9da12f7de8de873a33f9b2b8bdb4e827a7cae952e5b1a342e243", + "sha256:4307270436fd7694b41f913eb09210faff27ea4979ecbcd849e57d2da2f65305", + "sha256:5e80e758243b97b618cdf22004beb09e8a2de1af481382e4d84bc52152d1c476", + "sha256:641411733b127c3e0dab94c45af15fea99e4468f99ac88b39efb1ad677da5711", + "sha256:652b651d42f155033a1967739788c436491b577b6a44e4c39fb340d0ee7f0d70", + "sha256:6d7464bac72a85cb3491c7e92b5b62f3dcccb8af26826257760a552a5e244aa5", + "sha256:74e259b5c19f70d35fcc1ad3d56499065c601dfe94ff67ae48b85596b9ec1461", + "sha256:7d17e0a9707d0772f4a7b878f04b4fd11f6f5bcb9b3813975a9b13c9332153ab", + "sha256:901c2c269c616e6cb0998b33d4adbb4a6af0ac4ce5cd078afd7bc95830e62c1c", + "sha256:98e781cd35c0acf33eb0295e8b9c55cdbef64fcb35f6d3aa2186f289bed6e80d", + "sha256:a12c56bf73cdab116df96e4ff39610b92a348cc99a1307e1da3c3768bbb5b135", + "sha256:ac6e503823143464538efda0e8e356d871557ef60ccd38f8824a4257acc18d93", + "sha256:b8472f736a5bfb159a5e36740847808f6f5b659960115ff29c7cecec1741c648", + "sha256:b86ce2c1866a748c0f6faca5232059f881cda6dda2a893b9a8373353cfe3715a", + "sha256:bc9ec663ed6c8f15f4ae9d3c04c989b744436c16d26580eaa760ae9dd5d662eb", + "sha256:c9166b3f81a10cdf9b49f2d594b21b31adadb3d5e9db9b834866c3258b695be3", + "sha256:d13674f3fb73805ba0c45eb6c0c3053d218aa1f7abead6e446d474529aafc372", + "sha256:de32edc9b0a7e67c2775e574cb061a537660e51210fbf6006b0b36ea695ae9bb", + "sha256:e62ebaad93be3ad1a828a11e90f0e76f15449371ffeecca4a0a0b9adc99abcef" + ], + "index": "pypi", + "version": "==0.991" + }, + "mypy-extensions": { + "hashes": [ + "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", + "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" + ], + "version": "==0.4.3" + }, + "nodeenv": { + "hashes": [ + "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e", + "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==1.7.0" + }, + "packaging": { + "hashes": [ + "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3", + "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3" + ], + "markers": "python_version >= '3.7'", + "version": "==22.0" + }, + "platformdirs": { + "hashes": [ + "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490", + "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2" + ], + "markers": "python_version >= '3.7'", + "version": "==2.6.2" + }, + "pluggy": { + "hashes": [ + "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", + "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" + ], + "markers": "python_version >= '3.6'", + "version": "==1.0.0" + }, + "pre-commit": { + "hashes": [ + "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658", + "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad" + ], + "index": "pypi", + "version": "==2.21.0" + }, + "pytest": { + "hashes": [ + "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71", + "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59" + ], + "index": "pypi", + "version": "==7.2.0" + }, + "pytest-mock": { + "hashes": [ + "sha256:f4c973eeae0282963eb293eb173ce91b091a79c1334455acfac9ddee8a1c784b", + "sha256:fbbdb085ef7c252a326fd8cdcac0aa3b1333d8811f131bdcc701002e1be7ed4f" + ], + "index": "pypi", + "version": "==3.10.0" + }, + "pyyaml": { + "hashes": [ + "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf", + "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", + "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", + "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", + "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", + "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", + "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", + "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", + "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", + "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", + "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", + "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", + "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782", + "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", + "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", + "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", + "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", + "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", + "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1", + "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", + "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", + "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", + "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", + "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", + "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", + "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d", + "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", + "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", + "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7", + "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", + "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", + "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", + "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358", + "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", + "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", + "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", + "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", + "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f", + "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", + "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" + ], + "markers": "python_version >= '3.6'", + "version": "==6.0" + }, + "setuptools": { + "hashes": [ + "sha256:57f6f22bde4e042978bcd50176fdb381d7c21a9efa4041202288d3737a0c6a54", + "sha256:a7620757bf984b58deaf32fc8a4577a9bbc0850cf92c20e1ce41c38c19e5fb75" + ], + "markers": "python_version >= '3.7'", + "version": "==65.6.3" + }, + "tomli": { + "hashes": [ + "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + ], + "markers": "python_version < '3.11'", + "version": "==2.0.1" + }, + "types-pyyaml": { + "hashes": [ + "sha256:1e94e80aafee07a7e798addb2a320e32956a373f376655128ae20637adb2655b", + "sha256:6840819871c92deebe6a2067fb800c11b8a063632eb4e3e755914e7ab3604e83" + ], + "index": "pypi", + "version": "==6.0.12.2" + }, + "typing-extensions": { + "hashes": [ + "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa", + "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e" + ], + "markers": "python_version >= '3.7'", + "version": "==4.4.0" + }, + "virtualenv": { + "hashes": [ + "sha256:ce3b1684d6e1a20a3e5ed36795a97dfc6af29bc3970ca8dab93e11ac6094b3c4", + "sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058" + ], + "markers": "python_version >= '3.6'", + "version": "==20.17.1" + } + } } diff --git a/README.md b/README.md index a6e4f1c..85cad43 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Whorf A K8s admission controller for security and operational best practices (Based on [Checkov](https://checkov.io)) -Whorf is your last line of defence against deploying vulnerable or misconfigured kubernetes objects. +Whorf is your last line of defence against deploying vulnerable or misconfigured kubernetes objects. ## Install It is easily deployed by simply running the setup.sh script. This will download the default kubernetes objects into a local bridgecrew directory. It will customise to your local requirements and deploy into the kubernetes cluster currently in context @@ -30,7 +30,7 @@ kubectl delete -f bridgecrew ## Customisation of Checks for Validation After installation the check which would block a kubernetes object from being deployed are created and deployed as a kubernetes ConfigMap. -The default checks are only a small subset of the entire kubernetes range focusing only on root and privileged access and capabilities. +The default checks are only a small subset of the entire kubernetes range focusing only on root and privileged access and capabilities. These can be found in the file checkovconfig.yaml. The default example is below where k8sac/cluster would be replaced with k8sac/'your cluster name' @@ -79,4 +79,4 @@ E.g. ``` # kubernetes related config k8s.properties: | - ignores-namespaces=kube-system,bridgecrew \ No newline at end of file + ignores-namespaces=kube-system,bridgecrew diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/checkov_whorf.py b/app/checkov_whorf.py new file mode 100644 index 0000000..0ea5af8 --- /dev/null +++ b/app/checkov_whorf.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +import yaml +from checkov.main import Checkov + +from app.consts import CHECKOV_CONFIG_PATH + +if TYPE_CHECKING: + from logging import Logger + + from checkov.common.output.baseline import Baseline + from checkov.common.runners.runner_registry import RunnerRegistry + + +class CheckovWhorf(Checkov): + def __init__(self, logger: Logger, argv: list[str]) -> None: + super().__init__(argv=argv) + + self.logger = logger + + def upload_results( + self, + root_folder: str, + files: list[str] | None = None, + excluded_paths: list[str] | None = None, + included_paths: list[str] | None = None, + git_configuration_folders: list[str] | None = None, + ) -> None: + # don't upload results with every run + return + + def upload_results_periodically(self, root_folder: str) -> None: + """Used to upload results on a periodic basis""" + + super().upload_results(root_folder=root_folder) + + def print_results( + self, + runner_registry: RunnerRegistry, + url: str | None = None, + created_baseline_path: str | None = None, + baseline: Baseline | None = None, + ) -> Literal[0, 1]: + # just don't print anything to stdout + return 0 + + def update_config(self) -> None: + conf = yaml.safe_load(CHECKOV_CONFIG_PATH.read_text()) + + for param, value in conf.items(): + flag_attr = param.replace("-", "_") + if hasattr(self.config, flag_attr): + value = [value] if flag_attr == "framework" and not isinstance(value, list) else value + setattr(self.config, flag_attr, value) + else: + self.logger.error(f"Parameter {param} is not supported") + + def scan_file(self, file: str) -> None: + """Scan the given file""" + + self.config.file = [file] + self.run() + + self.logger.info(f"Successfully scanned file {file}") + + def scan_directory(self, directory: str) -> None: + """Scan the given directory""" + + self.config.directory = [directory] + self.run() + self.upload_results_periodically(root_folder=directory) + + self.logger.info(f"Successfully scanned directory {directory} and uploaded results") diff --git a/app/consts.py b/app/consts.py new file mode 100644 index 0000000..f932ee2 --- /dev/null +++ b/app/consts.py @@ -0,0 +1,12 @@ +import os +import re +from pathlib import Path + +LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO").upper() + +CHECKOV_CONFIG_PATH = Path("config/.checkov.yaml") +MANIFEST_ROOT_PATH = Path("/tmp") +WHORF_CONFIG_PATH = Path("config/whorf.yaml") + +DEFAULT_CHECKOV_ARGS = ["--framework", "kubernetes", "--repo-id", "k8s_ac/cluster"] +UUID_PATTERN = re.compile(r"\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b") diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..13a7365 --- /dev/null +++ b/app/models.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from flask import Flask + + +@dataclass +class WhorfConfig: + ignores_namespaces: list[str] # a list of namespaces to ignore requests from + upload_interval_in_min: str = "*/30" # every 30 minutes + + def init_app(self, app: Flask) -> None: + """Register whorf config to a Flask application instance""" + + app.extensions["whorf"] = self diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..4727e72 --- /dev/null +++ b/app/utils.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import json +import os +import shutil +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import yaml +from checkov.common.bridgecrew.wrapper import reduce_scan_reports +from flask import current_app as webhook +from flask import jsonify + +from app.consts import MANIFEST_ROOT_PATH, WHORF_CONFIG_PATH +from app.models import WhorfConfig + +if TYPE_CHECKING: + from checkov.common.output.report import Report + from flask import Response + + +def admission_response(*, allowed: bool, uid: str, message: str) -> Response: + return jsonify( + { + "apiVersion": "admission.k8s.io/v1", + "kind": "AdmissionReview", + "response": { + "allowed": allowed, + "uid": uid, + "status": {"code": 200 if allowed else 403, "message": message}, + }, + } + ) + + +def get_whorf_config() -> WhorfConfig: + """Parse the whorf config file""" + + if WHORF_CONFIG_PATH.exists(): + whorf_conf = yaml.safe_load(WHORF_CONFIG_PATH.read_text()) + else: + # use legacy properties + whorf_conf = parse_config("config/k8s.properties") + + return WhorfConfig( + ignores_namespaces=whorf_conf.get("ignores-namespaces") or [], + upload_interval_in_min=f"*/{whorf_conf.get('upload-interval-in-min') or 5}", + ) + + +def parse_config(configfile: str) -> dict[str, list[str]]: + cf = {} + with open(configfile) as myfile: + for line in myfile: + name, var = line.partition("=")[::2] + cf[name.strip()] = list(var.strip().split(",")) + return cf + + +def to_dict(obj: Any) -> Any: + if hasattr(obj, "attribute_map"): + result = {} + for k, v in obj.attribute_map.items(): + val = getattr(obj, k) + if val is not None: + result[v] = to_dict(val) + return result + elif type(obj) == list: + return [to_dict(x) for x in obj] + elif type(obj) == datetime: + return str(obj) + else: + return obj + + +def cleanup_directory(path: Path) -> None: + """Deletes all content of given directory, but not the directory itself""" + + if not path.exists(): + return + + for entry in os.scandir(path): + try: + if entry.is_dir(follow_symlinks=False): + shutil.rmtree(entry) + else: + os.remove(entry) + except Exception: + webhook.logger.error(f"Failed to delete {entry}", exc_info=True) + + +def check_debug_mode(request_info: dict[str, Any], uid: str, scan_reports: list[Report]) -> None: + # check the debug env. If 'yes' we don't delete the evidence of the scan. Just in case it's misbehaving. + # to activate add an env DEBUG:yes to the deployment manifest + debug = os.getenv("DEBUG") + if isinstance(debug, str) and debug.lower() == "yes": + # write original request and scan report to file system + request_file_path = MANIFEST_ROOT_PATH / f"{uid}-req.json" + request_file_path.write_text(json.dumps(request_info)) + + reduced_scan_reports = reduce_scan_reports(scan_reports) + scan_reports_file_path = MANIFEST_ROOT_PATH / f"{uid}-req-reports.json" + scan_reports_file_path.write_text(json.dumps(reduced_scan_reports)) diff --git a/app/validate.py b/app/validate.py new file mode 100644 index 0000000..9474b98 --- /dev/null +++ b/app/validate.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, cast + +from checkov.common.bridgecrew.check_type import CheckType +from checkov.common.bridgecrew.severities import BcSeverities +from flask import current_app as webhook + +from app.consts import UUID_PATTERN +from app.utils import admission_response + +if TYPE_CHECKING: + from checkov.common.output.report import Report + from flask import Response + + from app.checkov_whorf import CheckovWhorf + + +def validate_k8s_request(namespace: str, uid: str) -> Response | None: + # Check/Sanitise UID to make sure it's a k8s request and only a k8s request as it is used for file naming + if re.match(UUID_PATTERN, uid): + webhook.logger.info("Valid UID Found, continuing") + else: + message = "Invalid UID. Aborting validation" + webhook.logger.error("K8s UID failed security checks. Request rejected!") + return admission_response(allowed=False, uid=uid, message=message) + + # check we're not in a system namespace + if namespace in webhook.extensions["whorf"].ignores_namespaces: + message = "Namespace in ignore list. Ignoring validation" + webhook.logger.error("Namespace in ignore list. Ignoring validation!") + return admission_response(allowed=True, uid=uid, message=message) + + return None + + +def process_passed_checks(ckv_whorf: CheckovWhorf, uid: str, obj_kind_name: str) -> Response: + """Invoked when no Kubernetes related issues were found""" + + message = [] + + k8s_message = generate_k8s_output(reports=ckv_whorf.scan_reports) + message.extend(k8s_message) + + sca_message = generate_sca_output(reports=ckv_whorf.scan_reports) + message.extend(sca_message) + + webhook.logger.info(f"Object {obj_kind_name} passed security checks. Allowing the request.") + return admission_response(allowed=True, uid=uid, message="\n".join(message)) + + +def process_failed_checks(ckv_whorf: CheckovWhorf, uid: str, obj_kind_name: str) -> Response: + """Invoked when Kubernetes related issues were found""" + + message = [] + + if ckv_whorf.config.hard_fail_on: + hard_fails = {} + try: + for report in ckv_whorf.scan_reports: + for check in report.failed_checks: + if check.check_id in ckv_whorf.config.hard_fail_on: + hard_fails[check.check_id] = f"\n Description: {check.check_name}" + if check.guideline: + hard_fails[check.check_id] += f"\n Guidance: {check.guideline}" + elif check.bc_check_id in ckv_whorf.config.hard_fail_on: + hard_fails[check.check_id] = f"\n Description: {check.check_name}" + if check.guideline: + hard_fails[check.check_id] += f"\n Guidance: {check.guideline}" + finally: + webhook.logger.error("hard fail error") + + if hard_fails: + message.append(f"Checkov found {len(hard_fails)} issues in violation of admission policy.") + + for ckv in hard_fails: + message.append(f"{ckv}:{hard_fails[ckv]}") + + k8s_message = generate_k8s_output(reports=ckv_whorf.scan_reports) + message.extend(k8s_message) + + sca_message = generate_sca_output(reports=ckv_whorf.scan_reports) + message.extend(sca_message) + + webhook.logger.error(f"Object {obj_kind_name} failed security checks. Request rejected!") + return admission_response(allowed=False, uid=uid, message="\n".join(message)) + + +def generate_k8s_output(reports: list[Report]) -> list[str]: + """Counts the failed checks to generate a message output""" + + k8s_report = next((report for report in reports if report.check_type == CheckType.KUBERNETES), None) + if not k8s_report: + return [] + + issue_count = cast(int, k8s_report.get_summary()["failed"]) + + message = [f"Checkov found {issue_count} total issues in this manifest."] + + return message + + +def generate_sca_output(reports: list[Report]) -> list[str]: + """Extracts the CVEs and License violations to generate a message output""" + + sca_image_report = next((report for report in reports if report.check_type == CheckType.SCA_IMAGE), None) + if not sca_image_report: + return [] + + license_count = 0 + cve_count = 0 + cve_severities = { + BcSeverities.CRITICAL: 0, + BcSeverities.HIGH: 0, + BcSeverities.MEDIUM: 0, + BcSeverities.LOW: 0, + } + + for check in sca_image_report.failed_checks: # TODO: differentiate between different images + if check.check_id.startswith("BC_LIC_"): + license_count += 1 + elif check.check_id.startswith("BC_VUL_"): + cve_count += 1 + if check.severity: + if check.severity.name in cve_severities: + cve_severities[check.severity.name] += 1 + else: + webhook.logger.warning(f"Unexpected severity {check.severity.name} received") + else: + webhook.logger.warning(f"Unexpected check ID {check.check_id} received") + + message = [ + f"Checkov found {cve_count} CVEs in container images of which are {cve_severities[BcSeverities.CRITICAL]} critical, {cve_severities[BcSeverities.HIGH]} high, {cve_severities[BcSeverities.MEDIUM]} medium and {cve_severities[BcSeverities.LOW]} low.", + f"Checkov found {license_count} license violations in container images.", + ] + + return message diff --git a/app/whorf.py b/app/whorf.py new file mode 100644 index 0000000..2c6adbb --- /dev/null +++ b/app/whorf.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any, cast + +import yaml +from checkov.common.bridgecrew.check_type import CheckType +from flask import Flask, request +from flask_apscheduler import APScheduler + +from app.checkov_whorf import CheckovWhorf +from app.consts import DEFAULT_CHECKOV_ARGS, LOG_LEVEL, MANIFEST_ROOT_PATH +from app.utils import check_debug_mode, cleanup_directory, get_whorf_config, to_dict +from app.validate import process_failed_checks, process_passed_checks, validate_k8s_request + +if TYPE_CHECKING: + from flask import Response + +webhook = Flask(__name__) +webhook.logger.setLevel(LOG_LEVEL) + +scheduler = APScheduler() +scheduler.init_app(webhook) +scheduler.start() + +whorf_conf = get_whorf_config() +whorf_conf.init_app(webhook) + + +@webhook.route("/", methods=["GET"]) +def root() -> str: + return "

Ready!

" + + +@webhook.route("/validate", methods=["POST"]) +def validate() -> Response: + request_info = cast("dict[str, Any]", request.get_json()) + webhook.logger.debug(json.dumps(request_info, indent=4)) + + namespace = request_info["request"].get("namespace") + uid = request_info["request"].get("uid") + + if response := validate_k8s_request(namespace=namespace, uid=uid): + # either namespace or UID was wrong + return response + + manifest_file_path = MANIFEST_ROOT_PATH / f"{uid}-req.yaml" + manifest_file_path.write_text(yaml.dump(to_dict(request_info["request"]["object"]))) + + webhook.logger.info(f"Start scanning file {manifest_file_path}") + + ckv_whorf = CheckovWhorf(logger=webhook.logger, argv=DEFAULT_CHECKOV_ARGS) + ckv_whorf.update_config() + ckv_whorf.scan_file(file=str(manifest_file_path)) + + check_debug_mode(request_info=request_info, uid=uid, scan_reports=ckv_whorf.scan_reports) + + obj_kind_name = ( + f'{request_info["request"]["object"]["kind"]}/{request_info["request"]["object"]["metadata"]["name"]}' + ) + + if any(report.failed_checks for report in ckv_whorf.scan_reports if report.check_type == CheckType.KUBERNETES): + return process_failed_checks(ckv_whorf=ckv_whorf, uid=uid, obj_kind_name=obj_kind_name) + else: + return process_passed_checks(ckv_whorf=ckv_whorf, uid=uid, obj_kind_name=obj_kind_name) + + +@scheduler.task("cron", id="scan", minute=whorf_conf.upload_interval_in_min) +def scan_periodic() -> None: + webhook.logger.info(f"Start scanning directory {MANIFEST_ROOT_PATH}") + + ckv_whorf = CheckovWhorf(logger=webhook.logger, argv=DEFAULT_CHECKOV_ARGS) + ckv_whorf.update_config() + ckv_whorf.scan_directory(str(MANIFEST_ROOT_PATH)) + + cleanup_directory(MANIFEST_ROOT_PATH) diff --git a/extra_stubs/flask_apscheduler/__init__.pyi b/extra_stubs/flask_apscheduler/__init__.pyi new file mode 100644 index 0000000..d1d37ba --- /dev/null +++ b/extra_stubs/flask_apscheduler/__init__.pyi @@ -0,0 +1,5 @@ +from .scheduler import APScheduler + +__all__ = [ + "APScheduler", +] diff --git a/extra_stubs/flask_apscheduler/scheduler.pyi b/extra_stubs/flask_apscheduler/scheduler.pyi new file mode 100644 index 0000000..26c0696 --- /dev/null +++ b/extra_stubs/flask_apscheduler/scheduler.pyi @@ -0,0 +1,17 @@ +from typing import Callable, Any, ParamSpec, TypeVar, overload + +from apscheduler.schedulers.base import BaseScheduler # type:ignore[import] +from flask import Flask + +_F = TypeVar("_F", bound=Callable[..., Any]) +_T = TypeVar("_T") +_P = ParamSpec("_P") + +class APScheduler: + def __init__(self, scheduler: BaseScheduler | None = ..., app: Flask | None = ...) -> None: ... + @overload + def task(self, func: Callable[_P, _T]) -> Callable[_P, _T]: ... + @overload + def task(self, trigger: str, *, id: str, minute: str | None = None) -> Callable[[_F], _F]: ... + def init_app(self, app: Flask) -> None: ... + def start(self, paused: bool = ...) -> None: ... diff --git a/k8s/admissionconfiguration.yaml b/k8s/admissionconfiguration.yaml index 2deab9a..205e026 100644 --- a/k8s/admissionconfiguration.yaml +++ b/k8s/admissionconfiguration.yaml @@ -12,7 +12,7 @@ webhooks: namespaceSelector: matchExpressions: - key: whorf.ignore - operator: DoesNotExist + operator: DoesNotExist rules: - apiGroups: ["*"] resources: diff --git a/k8s/checkovconfig.yaml b/k8s/checkovconfig.yaml index 4cda373..26824cd 100644 --- a/k8s/checkovconfig.yaml +++ b/k8s/checkovconfig.yaml @@ -8,9 +8,6 @@ data: .checkov.yaml: | branch: master repo-id: k8sac/cluster - download-external-modules: false - evaluate-variables: true - external-modules-download-path: .external_modules framework: kubernetes hard-fail-on: - CKV_K8S_1 @@ -30,5 +27,3 @@ data: - CKV_K8S_27 - CKV_K8S_39 - CKV_K8S_49 - output: - - json diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index b4b5657..9f97ea9 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -19,8 +19,6 @@ spec: metadata: labels: app: validate - annotations: - seccomp.security.alpha.kubernetes.io/pod: "docker/default" spec: containers: - name: webhook @@ -44,14 +42,14 @@ spec: exec: command: - /bin/sh - - -c + - -c - "pidof -x gunicorn" livenessProbe: initialDelaySeconds: 30 exec: command: - /bin/sh - - -c + - -c - "pidof -x gunicorn" ports: - containerPort: 8443 @@ -59,12 +57,12 @@ spec: - name: BC_SOURCE value: admissionController - name: CKV_GITHUB_CONFIG_FETCH_DATA - value: "False" + value: "False" - name: DEBUG valueFrom: configMapKeyRef: name: whorfconfig # The ConfigMap this value comes from. - key: debug # Are we in debug mode? + key: debug # Are we in debug mode? volumeMounts: - name: bridgecrew-secret readOnly: true @@ -73,7 +71,7 @@ spec: readOnly: true mountPath: "/certs" - name: "config" - mountPath: "/app/config" + mountPath: "/app/config" - name: "apptmp" mountPath: "/app/tmp" - name: "tmp" @@ -83,6 +81,8 @@ spec: runAsNonRoot: true runAsUser: 11000 runAsGroup: 11000 + seccompProfile: + type: RuntimeDefault volumes: - name: bridgecrew-secret secret: @@ -91,15 +91,15 @@ spec: secret: secretName: admission-tls - name: "config" - projected: + projected: sources: - configMap: name: "checkovconfig" - configMap: - name: "whorfconfig" + name: "whorfconfig" items: - - key: "k8s.properties" - path: "k8s.properties" + - key: "whorf.yaml" + path: "whorf.yaml" - emptyDir: {} name: apptmp - emptyDir: {} diff --git a/k8s/whorfconfig.yaml b/k8s/whorfconfig.yaml index 753e224..45b0a62 100644 --- a/k8s/whorfconfig.yaml +++ b/k8s/whorfconfig.yaml @@ -4,8 +4,86 @@ metadata: name: whorfconfig namespace: bridgecrew data: - # debug will save the parsed manifests to the file system for later inspection + # debug will save the parsed manifests to the file system for later inspection debug: "no" - # kubernetes related config - k8s.properties: | - ignores-namespaces=kube-system,bridgecrew + # whorf specific config + whorf.yaml: | + ignore-namespaces: + - amazon-cloudwatch + - argocd + - assisted-installer + - aws-for-fluent-bit + - bridgecrew + - external-dns + - karpenter + - kube-apiserver + - kube-node-lease + - kube-public + - kube-system + - kubecost + - openshift + - openshift-apiserver + - openshift-apiserver-operator + - openshift-authentication + - openshift-authentication-operator + - openshift-cloud-controller-manager + - openshift-cloud-controller-manager-operator + - openshift-cloud-credential-operator + - openshift-cloud-network-config-controller + - openshift-cluster-csi-drivers + - openshift-cluster-machine-approver + - openshift-cluster-node-tuning-operator + - openshift-cluster-samples-operator + - openshift-cluster-storage-operator + - openshift-cluster-version + - openshift-config + - openshift-config-managed + - openshift-config-operator + - openshift-console + - openshift-console-operator + - openshift-console-user-settings + - openshift-controller-manager + - openshift-controller-manager-operator + - openshift-dns + - openshift-dns-operator + - openshift-etcd + - openshift-etcd-operator + - openshift-gitops + - openshift-host-network + - openshift-image-registry + - openshift-infra + - openshift-ingress + - openshift-ingress-canary + - openshift-ingress-operator + - openshift-insights + - openshift-kni-infra + - openshift-kube-apiserver + - openshift-kube-apiserver-operator + - openshift-kube-controller-manager + - openshift-kube-controller-manager-operator + - openshift-kube-scheduler + - openshift-kube-scheduler-operator + - openshift-kube-storage-version-migrator + - openshift-kube-storage-version-migrator-operator + - openshift-local-storage + - openshift-machine-api + - openshift-machine-config-operator + - openshift-marketplace + - openshift-monitoring + - openshift-multus + - openshift-network-diagnostics + - openshift-network-operator + - openshift-node + - openshift-oauth-apiserver + - openshift-openstack-infra + - openshift-operator-lifecycle-manager + - openshift-operators + - openshift-ovirt-infra + - openshift-pipelines + - openshift-sdn + - openshift-service-ca + - openshift-service-ca-operator + - openshift-user-workload-monitoring + - openshift-vsphere-infra + - vpa + upload-interval-in-min: 30 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4eaf62c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[tool.black] +line-length = 120 + +[tool.mypy] +mypy_path = "extra_stubs" + +files = "app" + +strict = true +pretty = true +show_error_codes = true + +[tool.ruff] +line-length = 120 + +select = [ + "A", + "ARG", + "B", + "C4", + "E", + "F", + "FBT", + "I", + "N", + "PGH", + "RUF", + "S", + "SIM", + "T10", + "T20", + "UP", + "W", + "YTT", +] +ignore = ["ARG002", "E501"] +per-file-ignores = { "tests/**/*" = ["S101"] } + +target-version = "py310" diff --git a/setup.sh b/setup.sh old mode 100644 new mode 100755 index fea98cd..aa859a0 --- a/setup.sh +++ b/setup.sh @@ -3,29 +3,48 @@ # # Sets up the files required to deploy the Bridgecrew Checkov admission controller in a cluster +whorf_local=false +if [ ! -z "${WHORF_LOCAL}" ] && [ "${WHORF_LOCAL}" = "true" ]; then + whorf_local=true +fi + set -euo pipefail -date=$(date '+%Y%m%d%H%M%S') -echo $date +if $whorf_local ; then + k8sdir="local" + mkdir -p $k8sdir +else + date=$(date '+%Y%m%d%H%M%S') + echo $date -codedir=bridgecrew$date -mkdir $codedir -k8sdir="$(dirname "$0")/${codedir}" -certdir="$(mktemp -d)" + codedir=bridgecrew$date + mkdir $codedir + k8sdir="$(dirname "$0")/${codedir}" +fi -# Get the files we need -deployment=https://raw.githubusercontent.com/bridgecrewio/checkov/master/admissioncontroller/k8s/deployment.yaml -configmap=https://raw.githubusercontent.com/bridgecrewio/checkov/master/admissioncontroller/k8s/checkovconfig.yaml -admissionregistration=https://raw.githubusercontent.com/bridgecrewio/checkov/master/admissioncontroller/k8s/admissionconfiguration.yaml -service=https://raw.githubusercontent.com/bridgecrewio/checkov/master/admissioncontroller/k8s/service.yaml -whorfconfigmap=https://raw.githubusercontent.com/bridgecrewio/checkov/master/admissioncontroller/k8s/whorfconfig.yaml +certdir="$(mktemp -d)" -curl -o $k8sdir/deployment.yaml $deployment -curl -o $k8sdir/service.yaml $service -curl -o $k8sdir/whorfconfig.yaml $whorfconfigmap -# Pop these into the temp directory as we'll make some customisations pipe in into the k8s dir -curl -o $certdir/checkovconfig.yaml $configmap -curl -o $certdir/admissionconfiguration.yaml $admissionregistration +if $whorf_local ; then + cp k8s/deployment.yaml $k8sdir/ + cp k8s/service.yaml $k8sdir/ + cp k8s/whorfconfig.yaml $k8sdir/ + cp k8s/checkovconfig.yaml $certdir/ + cp k8s/admissionconfiguration.yaml $certdir/ +else + # Get the files we need + deployment=https://raw.githubusercontent.com/bridgecrewio/checkov/master/admissioncontroller/k8s/deployment.yaml + configmap=https://raw.githubusercontent.com/bridgecrewio/checkov/master/admissioncontroller/k8s/checkovconfig.yaml + admissionregistration=https://raw.githubusercontent.com/bridgecrewio/checkov/master/admissioncontroller/k8s/admissionconfiguration.yaml + service=https://raw.githubusercontent.com/bridgecrewio/checkov/master/admissioncontroller/k8s/service.yaml + whorfconfigmap=https://raw.githubusercontent.com/bridgecrewio/checkov/master/admissioncontroller/k8s/whorfconfig.yaml + + curl -o $k8sdir/deployment.yaml $deployment + curl -o $k8sdir/service.yaml $service + curl -o $k8sdir/whorfconfig.yaml $whorfconfigmap + # Pop these into the temp directory as we'll make some customisations pipe in into the k8s dir + curl -o $certdir/checkovconfig.yaml $configmap + curl -o $certdir/admissionconfiguration.yaml $admissionregistration +fi # the namespace ns=bridgecrew @@ -33,12 +52,15 @@ kubectl create ns $ns --dry-run=client -o yaml | sed '/^metadata:/p; s/^metadat # the cluster (repository name) cluster=$1 -# the bridgecrew platform api key +# the bridgecrew platform api key bcapikey=$2 # Generate keys into a temporary directory. echo "Generating TLS certs ..." -/usr/local/opt/openssl/bin/openssl req -x509 -sha256 -newkey rsa:2048 -keyout $certdir/webhook.key -out $certdir/webhook.crt -days 1024 -nodes -addext "subjectAltName = DNS.1:validate.$ns.svc" +openssl req -x509 -sha256 -newkey rsa:2048 -keyout $certdir/webhook.key -out $certdir/webhook.crt -days 1024 -nodes \ + -extensions SAN \ + -config <(cat /etc/ssl/openssl.cnf \ + <(printf "[SAN]\nsubjectAltName=DNS.1:validate.$ns.svc")) kubectl create secret generic admission-tls -n bridgecrew --type=Opaque --from-file=$certdir/webhook.key --from-file=$certdir/webhook.crt --dry-run=client -o yaml > $k8sdir/secret.yaml @@ -57,7 +79,7 @@ sed -e 's@${CA_PEM_B64}@'"$ca_pem_b64"'@g' "${certdir}/admissionconfiguration.ya sed -e 's@cluster@'"$cluster"'@g' "${certdir}/checkovconfig.yaml" > "${k8sdir}/checkovconfig.yaml" # Apply everything in the bridgecrew directory in the correct order -kubectl apply -f $k8sdir/namespace.yaml +kubectl apply -f $k8sdir/namespace.yaml kubectl apply -f $k8sdir/secret-apikey.yaml kubectl apply -f $k8sdir/secret.yaml kubectl apply -f $k8sdir/checkovconfig.yaml @@ -67,9 +89,11 @@ kubectl apply -f $k8sdir/deployment.yaml kubectl apply -f $k8sdir/admissionconfiguration.yaml # Delete the key directory to prevent abuse (DO NOT USE THESE KEYS ANYWHERE ELSE). -rm -rf "$certdir" +if ! $whorf_local ; then + rm -rf "$certdir" +fi # Wait for the deployment to be available echo "Waiting for deployment to be Ready..." -kubectl wait --for=condition=Available deployment/validation-webhook -n bridgecrew +kubectl wait --for=condition=Available deployment/validation-webhook --timeout=60s -n bridgecrew echo "The webhook server has been deployed and configured!" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bac47f6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,211 @@ +from pathlib import Path + +import pytest +from checkov.common.bridgecrew.severities import BcSeverities, Severities +from checkov.common.models.enums import CheckResult +from checkov.common.output.record import SCA_LICENSE_CHECK_NAME, SCA_PACKAGE_SCAN_CHECK_NAME, Record +from pytest_mock import MockerFixture + +import app.checkov_whorf +import app.utils + + +@pytest.fixture() +def webhook(mocker: MockerFixture, tmp_path: Path): + checkov_conf_path = tmp_path / ".checkov.yaml" + checkov_conf_path.write_text("framework: kubernetes") + whorf_conf_path = tmp_path / "whorf.yaml" + whorf_conf_path.write_text("ignores-namespaces:\n - default") + + mocker.patch.object(app.checkov_whorf, "CHECKOV_CONFIG_PATH", checkov_conf_path) + mocker.patch.object(app.utils, "WHORF_CONFIG_PATH", whorf_conf_path) + + from app.whorf import webhook + + yield webhook + + +@pytest.fixture() +def license_record() -> Record: + return Record( + check_id="BC_LIC_2", + bc_check_id="BC_LIC_2", + check_class="checkov.common.bridgecrew.vulnerability_scanning.image_scanner.ImageScanner", + check_name=SCA_LICENSE_CHECK_NAME, + check_result={"result": CheckResult.FAILED}, + code_block=[(0, "gettext: 0.21-4")], + evaluations=None, + file_line_range=[0, 0], + file_path="/13b390aa-ea59-48ef-9fb8-069bf0430dce-req.yaml (nginx lines:1-98 (sha256:1403e55ab3))", + resource="13b390aa-ea59-48ef-9fb8-069bf0430dce-req.yaml (nginx lines:1-98 (sha256:1403e55ab3)).gettext", + file_abs_path="/path/to/13b390aa-ea59-48ef-9fb8-069bf0430dce-req.yaml", + vulnerability_details={ + "package_name": "gettext", + "package_version": "0.21-4", + "license": "GPL", + "status": "FAILED", + "policy": "BC_LIC_2", + "package_type": "os", + }, + ) + + +@pytest.fixture() +def k8s_record() -> Record: + return Record( + check_id="CKV_K8S_16", + bc_check_id="BC_K8S_15", + check_class="checkov.kubernetes.checks.resource.k8s.PrivilegedContainers", + check_name="Container should not be privileged", + check_result={"result": CheckResult.FAILED}, + code_block=[ + (1, "apiVersion: apps/v1\n"), + (2, "kind: Deployment\n"), + (3, "metadata:\n"), + (4, " annotations:\n"), + ( + 5, + ' kubectl.kubernetes.io/last-applied-configuration: \'{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"annotations":{},"creationTimestamp":null,"labels":{"app":"nginx","ops":"thing"},"name":"nginx","namespace":"nginx"},"spec":{"replicas":1,"selector":{"matchLabels":{"app":"nginx"}},"strategy":{},"template":{"metadata":{"creationTimestamp":null,"labels":{"app":"nginx"}},"spec":{"containers":[{"image":"nginx","name":"nginx","resources":{},"securityContext":{"privileged":true}}]}}}}\n', + ), + (6, "\n"), + (7, " '\n"), + (8, " creationTimestamp: '2022-12-21T16:08:29Z'\n"), + (9, " generation: 1\n"), + (10, " labels:\n"), + (11, " app: nginx\n"), + (12, " ops: thing\n"), + (13, " managedFields:\n"), + (14, " - apiVersion: apps/v1\n"), + (15, " fieldsType: FieldsV1\n"), + (16, " fieldsV1:\n"), + (17, " f:metadata:\n"), + (18, " f:annotations:\n"), + (19, " .: {}\n"), + (20, " f:kubectl.kubernetes.io/last-applied-configuration: {}\n"), + (21, " f:labels:\n"), + (22, " .: {}\n"), + (23, " f:app: {}\n"), + (24, " f:ops: {}\n"), + (25, " f:spec:\n"), + (26, " f:progressDeadlineSeconds: {}\n"), + (27, " f:replicas: {}\n"), + (28, " f:revisionHistoryLimit: {}\n"), + (29, " f:selector: {}\n"), + (30, " f:strategy:\n"), + (31, " f:rollingUpdate:\n"), + (32, " .: {}\n"), + (33, " f:maxSurge: {}\n"), + (34, " f:maxUnavailable: {}\n"), + (35, " f:type: {}\n"), + (36, " f:template:\n"), + (37, " f:metadata:\n"), + (38, " f:labels:\n"), + (39, " .: {}\n"), + (40, " f:app: {}\n"), + (41, " f:spec:\n"), + (42, " f:containers:\n"), + (43, ' k:{"name":"nginx"}:\n'), + (44, " .: {}\n"), + (45, " f:image: {}\n"), + (46, " f:imagePullPolicy: {}\n"), + (47, " f:name: {}\n"), + (48, " f:resources: {}\n"), + (49, " f:securityContext:\n"), + (50, " .: {}\n"), + (51, " f:privileged: {}\n"), + (52, " f:terminationMessagePath: {}\n"), + (53, " f:terminationMessagePolicy: {}\n"), + (54, " f:dnsPolicy: {}\n"), + (55, " f:restartPolicy: {}\n"), + (56, " f:schedulerName: {}\n"), + (57, " f:securityContext: {}\n"), + (58, " f:terminationGracePeriodSeconds: {}\n"), + (59, " manager: kubectl-client-side-apply\n"), + (60, " operation: Update\n"), + (61, " time: '2022-12-21T16:08:29Z'\n"), + (62, " name: nginx\n"), + (63, " namespace: nginx\n"), + (64, " uid: 68b18e67-195d-4676-a214-a1b9859431dc\n"), + (65, "spec:\n"), + (66, " progressDeadlineSeconds: 600\n"), + (67, " replicas: 1\n"), + (68, " revisionHistoryLimit: 10\n"), + (69, " selector:\n"), + (70, " matchLabels:\n"), + (71, " app: nginx\n"), + (72, " strategy:\n"), + (73, " rollingUpdate:\n"), + (74, " maxSurge: 25%\n"), + (75, " maxUnavailable: 25%\n"), + (76, " type: RollingUpdate\n"), + (77, " template:\n"), + (78, " metadata:\n"), + (79, " creationTimestamp: null\n"), + (80, " labels:\n"), + (81, " app: nginx\n"), + (82, " spec:\n"), + (83, " containers:\n"), + (84, " - image: nginx\n"), + (85, " imagePullPolicy: Always\n"), + (86, " name: nginx\n"), + (87, " resources: {}\n"), + (88, " securityContext:\n"), + (89, " privileged: true\n"), + (90, " terminationMessagePath: /dev/termination-log\n"), + (91, " terminationMessagePolicy: File\n"), + (92, " dnsPolicy: ClusterFirst\n"), + (93, " restartPolicy: Always\n"), + (94, " schedulerName: default-scheduler\n"), + (95, " securityContext: {}\n"), + (96, " terminationGracePeriodSeconds: 30\n"), + (97, "status: {}\n"), + ], + evaluations=None, + file_abs_path="/path/to/13b390aa-ea59-48ef-9fb8-069bf0430dce-req.yaml", + file_line_range=[1, 97], + file_path="/13b390aa-ea59-48ef-9fb8-069bf0430dce-req.yaml", + resource="Deployment.nginx.nginx", + severity=None, + ) + + +@pytest.fixture() +def package_record() -> Record: + return Record( + check_id="BC_VUL_1", + bc_check_id="BC_CVE_2022_3970", + check_class="checkov.common.bridgecrew.vulnerability_scanning.image_scanner.ImageScanner", + check_name=SCA_PACKAGE_SCAN_CHECK_NAME, + check_result={"result": CheckResult.FAILED}, + code_block=[(0, "tiff: 4.2.0-1+deb11u1")], + evaluations=None, + file_abs_path="/path/to/13b390aa-ea59-48ef-9fb8-069bf0430dce-req.yaml", + file_line_range=[0, 0], + file_path="/13b390aa-ea59-48ef-9fb8-069bf0430dce-req.yaml (nginx lines:1-98 (sha256:1403e55ab3))", + resource="13b390aa-ea59-48ef-9fb8-069bf0430dce-req.yaml (nginx lines:1-98 (sha256:1403e55ab3)).tiff", + severity=Severities[BcSeverities.CRITICAL], + vulnerability_details={ + "id": "CVE-2022-3970", + "severity": "critical", + "package_name": "tiff", + "package_version": "4.2.0-1+deb11u1", + "package_type": "os", + "link": "https://security-tracker.debian.org/tracker/CVE-2022-3970", + "cvss": 9.8, + "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", + "description": "A vulnerability was found in LibTIFF.", + "risk_factors": [ + "Attack vector: network", + "Critical severity", + "Recent vulnerability", + "Attack complexity: low", + ], + "published_date": "2022-11-13T08:15:00Z", + "licenses": "Unknown", + "root_package_name": None, + "root_package_version": None, + "status": "open", + "lowest_fixed_version": "N/A", + "fixed_versions": [], + }, + ) diff --git a/test/test.yaml b/tests/nginx.yaml similarity index 100% rename from test/test.yaml rename to tests/nginx.yaml diff --git a/tests/request.json b/tests/request.json new file mode 100644 index 0000000..1e6726b --- /dev/null +++ b/tests/request.json @@ -0,0 +1,175 @@ +{ + "kind": "AdmissionReview", + "apiVersion": "admission.k8s.io/v1", + "request": { + "uid": "13b390aa-ea59-48ef-9fb8-069bf0430dce", + "kind": { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + "resource": { + "group": "apps", + "version": "v1", + "resource": "deployments" + }, + "requestKind": { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + "requestResource": { + "group": "apps", + "version": "v1", + "resource": "deployments" + }, + "name": "nginx", + "namespace": "nginx", + "operation": "CREATE", + "userInfo": { + "username": "docker-for-desktop", + "groups": [ + "system:masters", + "system:authenticated" + ] + }, + "object": { + "kind": "Deployment", + "apiVersion": "apps/v1", + "metadata": { + "name": "nginx", + "namespace": "nginx", + "uid": "68b18e67-195d-4676-a214-a1b9859431dc", + "generation": 1, + "creationTimestamp": "2022-12-21T16:08:29Z", + "labels": { + "app": "nginx", + "ops": "thing" + }, + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"apps/v1\",\"kind\":\"Deployment\",\"metadata\":{\"annotations\":{},\"creationTimestamp\":null,\"labels\":{\"app\":\"nginx\",\"ops\":\"thing\"},\"name\":\"nginx\",\"namespace\":\"nginx\"},\"spec\":{\"replicas\":1,\"selector\":{\"matchLabels\":{\"app\":\"nginx\"}},\"strategy\":{},\"template\":{\"metadata\":{\"creationTimestamp\":null,\"labels\":{\"app\":\"nginx\"}},\"spec\":{\"containers\":[{\"image\":\"nginx\",\"name\":\"nginx\",\"resources\":{},\"securityContext\":{\"privileged\":true}}]}}}}\n" + }, + "managedFields": [ + { + "manager": "kubectl-client-side-apply", + "operation": "Update", + "apiVersion": "apps/v1", + "time": "2022-12-21T16:08:29Z", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:annotations": { + ".": {}, + "f:kubectl.kubernetes.io/last-applied-configuration": {} + }, + "f:labels": { + ".": {}, + "f:app": {}, + "f:ops": {} + } + }, + "f:spec": { + "f:progressDeadlineSeconds": {}, + "f:replicas": {}, + "f:revisionHistoryLimit": {}, + "f:selector": {}, + "f:strategy": { + "f:rollingUpdate": { + ".": {}, + "f:maxSurge": {}, + "f:maxUnavailable": {} + }, + "f:type": {} + }, + "f:template": { + "f:metadata": { + "f:labels": { + ".": {}, + "f:app": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"nginx\"}": { + ".": {}, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:resources": {}, + "f:securityContext": { + ".": {}, + "f:privileged": {} + }, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:terminationGracePeriodSeconds": {} + } + } + } + } + } + ] + }, + "spec": { + "replicas": 1, + "selector": { + "matchLabels": { + "app": "nginx" + } + }, + "template": { + "metadata": { + "creationTimestamp": null, + "labels": { + "app": "nginx" + } + }, + "spec": { + "containers": [ + { + "name": "nginx", + "image": "nginx", + "resources": {}, + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "Always", + "securityContext": { + "privileged": true + } + } + ], + "restartPolicy": "Always", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "securityContext": {}, + "schedulerName": "default-scheduler" + } + }, + "strategy": { + "type": "RollingUpdate", + "rollingUpdate": { + "maxUnavailable": "25%", + "maxSurge": "25%" + } + }, + "revisionHistoryLimit": 10, + "progressDeadlineSeconds": 600 + }, + "status": {} + }, + "oldObject": null, + "dryRun": false, + "options": { + "kind": "CreateOptions", + "apiVersion": "meta.k8s.io/v1", + "fieldManager": "kubectl-client-side-apply", + "fieldValidation": "Strict" + } + } +} diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 index 0000000..dd33b3d --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import logging + +from checkov.common.bridgecrew.check_type import CheckType +from checkov.common.output.report import Report + +from app.checkov_whorf import CheckovWhorf +from app.consts import DEFAULT_CHECKOV_ARGS +from app.validate import ( + generate_sca_output, + process_failed_checks, + process_passed_checks, + validate_k8s_request, +) + + +def test_process_passed_checks(webhook) -> None: + # given + ckv_whorf = CheckovWhorf(logger=logging.getLogger(), argv=DEFAULT_CHECKOV_ARGS) + + report = Report(check_type=CheckType.KUBERNETES) + ckv_whorf.scan_reports = [report] + + # when + with webhook.app_context(): + response = process_passed_checks( + ckv_whorf=ckv_whorf, uid="13b390aa-ea59-48ef-9fb8-069bf0430dce", obj_kind_name="Deployment/nginx" + ) + + # then + assert response.json == { + "apiVersion": "admission.k8s.io/v1", + "kind": "AdmissionReview", + "response": { + "allowed": True, + "status": {"code": 200, "message": "Checkov found 0 total issues in this manifest."}, + "uid": "13b390aa-ea59-48ef-9fb8-069bf0430dce", + }, + } + + +def test_process_failed_checks(webhook, k8s_record, license_record, package_record) -> None: + # given + ckv_whorf = CheckovWhorf(logger=logging.getLogger(), argv=DEFAULT_CHECKOV_ARGS) + + k8s_report = Report(check_type=CheckType.KUBERNETES) + k8s_report.add_record(k8s_record) + sca_report = Report(check_type=CheckType.SCA_IMAGE) + sca_report.add_record(license_record) + sca_report.add_record(package_record) + + ckv_whorf.scan_reports = [k8s_report, sca_report] + + # when + with webhook.app_context(): + response = process_failed_checks( + ckv_whorf=ckv_whorf, uid="13b390aa-ea59-48ef-9fb8-069bf0430dce", obj_kind_name="Deployment/nginx" + ) + + # then + assert response.json == { + "apiVersion": "admission.k8s.io/v1", + "kind": "AdmissionReview", + "response": { + "allowed": False, + "status": { + "code": 403, + "message": "\n".join( + [ + "Checkov found 1 total issues in this manifest.", + "Checkov found 1 CVEs in container images of which are 1 critical, 0 high, 0 medium and 0 low.", + "Checkov found 1 license violations in container images.", + ] + ), + }, + "uid": "13b390aa-ea59-48ef-9fb8-069bf0430dce", + }, + } + + +def test_generate_sca_output(webhook, license_record, package_record) -> None: + # given + report = Report(check_type=CheckType.SCA_IMAGE) + report.add_record(license_record) + report.add_record(package_record) + + # when + with webhook.app_context(): + message = generate_sca_output(reports=[report]) + + # then + assert message == [ + "Checkov found 1 CVEs in container images of which are 1 critical, 0 high, 0 medium and 0 low.", + "Checkov found 1 license violations in container images.", + ] + + +def test_validate_k8s_request(webhook) -> None: + # given + namespace = "my-namespace" + uid = "13b390aa-ea59-48ef-9fb8-069bf0430dce" + + # when + with webhook.app_context(): + response = validate_k8s_request(namespace=namespace, uid=uid) + + # then + assert response is None + + +def test_validate_k8s_request_with_invalid_uid(webhook) -> None: + # given + namespace = "my-namespace" + uid = "invalid" + + # when + with webhook.app_context(): + response = validate_k8s_request(namespace=namespace, uid=uid) + + # then + assert response.json == { + "apiVersion": "admission.k8s.io/v1", + "kind": "AdmissionReview", + "response": { + "allowed": False, + "status": {"code": 403, "message": "Invalid UID. Aborting validation"}, + "uid": "invalid", + }, + } + + +def test_validate_k8s_request_with_ignore_namespace(webhook) -> None: + # given + namespace = "default" + uid = "13b390aa-ea59-48ef-9fb8-069bf0430dce" + + # when + with webhook.app_context(): + response = validate_k8s_request(namespace=namespace, uid=uid) + + # then + assert response.json == { + "apiVersion": "admission.k8s.io/v1", + "kind": "AdmissionReview", + "response": { + "allowed": True, + "status": {"code": 200, "message": "Namespace in ignore list. Ignoring validation"}, + "uid": "13b390aa-ea59-48ef-9fb8-069bf0430dce", + }, + } diff --git a/tests/test_whorf.py b/tests/test_whorf.py new file mode 100644 index 0000000..451f8a2 --- /dev/null +++ b/tests/test_whorf.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest +from pytest_mock import MockerFixture + +import app.checkov_whorf +import app.utils + +if TYPE_CHECKING: + from flask.testing import FlaskClient + + +@pytest.fixture() +def client(mocker: MockerFixture, tmp_path: Path) -> FlaskClient: + checkov_conf_path = tmp_path / ".checkov.yaml" + checkov_conf_path.write_text("framework: kubernetes") + whorf_conf_path = tmp_path / "whorf.yaml" + whorf_conf_path.write_text("ignores-namespaces:\n - default") + + mocker.patch.object(app.checkov_whorf, "CHECKOV_CONFIG_PATH", checkov_conf_path) + mocker.patch.object(app.utils, "WHORF_CONFIG_PATH", whorf_conf_path) + + from app.whorf import webhook + + return webhook.test_client() + + +@pytest.fixture() +def request_info() -> dict[str, Any]: + return json.loads((Path(__file__).parent / "request.json").read_text()) + + +def test_validate(client: FlaskClient, request_info) -> None: + # when + response = client.post("/validate", json=request_info) + + # then + assert response.json == { + "apiVersion": "admission.k8s.io/v1", + "kind": "AdmissionReview", + "response": { + "allowed": False, + "uid": "13b390aa-ea59-48ef-9fb8-069bf0430dce", + "status": {"code": 403, "message": "Checkov found 15 total issues in this manifest."}, + }, + } diff --git a/whorf.py b/whorf.py deleted file mode 100644 index 74f944f..0000000 --- a/whorf.py +++ /dev/null @@ -1,162 +0,0 @@ -from flask import Flask, request, jsonify -from os import remove, getenv -import logging -import json -import subprocess -import yaml -from datetime import datetime -import re - -webhook = Flask(__name__) - -webhook.logger.setLevel(logging.INFO) - - -@webhook.route('/', methods=['GET']) -def hello(): - return "

Ready!

" - - -@webhook.route('/validate', methods=['POST']) -def validating_webhook(): - request_info = request.get_json() - uid = request_info["request"].get("uid") - - checkovconfig = "config/.checkov.yaml" - configfile = "config/k8s.properties" - - whorfconfig = getConfig(configfile) - - # Process config variables - # a list of namespaces to ignore requests from - ignore_list = whorfconfig['ignores-namespaces'] - - # Check/Sanitise UID to make sure it's a k8s request and only a k8s request as it is used for filenaming - # UUID pattern match regex - pattern = r'\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b' - if re.match(pattern, uid): - webhook.logger.error("Valid UID Found, continuing") - else: - response = 'Invalid UID. Aborting validation' - webhook.logger.error('K8s UID failed security checks. Request rejected!') - return admission_response(False, uid, response) - - # check we're not in the kube-system namespace - namespace = request_info["request"].get("namespace") - if namespace in ignore_list: - response = 'Namespace in ignore list. Ignoring validation' - webhook.logger.error('Namespace in ignore list. Ignoring validation!') - return admission_response(True, uid, response) - - jsonfile = "tmp/" + uid + "-req.json" - yamlfile = "tmp/" + uid + "-req.yaml" - - ff = open(jsonfile, 'w+') - yf = open(yamlfile, 'w+') - json.dump(request_info, ff) - yaml.dump(todict(request_info["request"]["object"]), yf) - - print("Running checkov") - cp = subprocess.run( - ["checkov", "--config-file", checkovconfig, "-f", yamlfile], - universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) - - checkovresults = json.loads(cp.stdout) - - # check the debug env. If 'yes' we don't delete the evidence of the scan. Just in case it's misbehaving. - # to active add an env DEBUG:yes to the deployment manifest - if (getenv("DEBUG")) is not None: - debug = getenv("DEBUG") - if (debug.lower() != "yes"): - remove(jsonfile) - remove(yamlfile) - - obj_kind_name = ( - f'{request_info["request"]["object"]["kind"]}/' - f'{request_info["request"]["object"]["metadata"]["name"]}' - ) - - if cp.returncode != 0: - - # open configfile to check for hard fail CKVs - with open(checkovconfig, 'r') as config: - cf = yaml.safe_load(config) - - response = "" - if "hard-fail-on" in cf: - hard_fails = {} - try: - for ckv in cf["hard-fail-on"]: - for fail in checkovresults["results"]["failed_checks"]: - if (ckv == fail["check_id"]): - hard_fails[ckv] = f"\n Description: {fail['check_name']}" - if fail['guideline'] != "": - hard_fails[ckv] += f"\n Guidance: {fail['guideline']}" - - finally: - - webhook.logger.error("hard fail error") - - if (len(hard_fails) > 0): - response = f"\nCheckov found {len(hard_fails)} issues in violation of admission policy.\n" - - for ckv in hard_fails: - response = response + f"{ckv}:{hard_fails[ckv]}\n" - - response = response + f"Checkov found {checkovresults['summary']['failed']} total issues in this manifest.\n" - response = response + f"\nFor complete details: {checkovresults['url']}\n" - - webhook.logger.error(f'Object {obj_kind_name} failed security checks. Request rejected!') - return admission_response(False, uid, response) - - else: - webhook.logger.info(f'Object {obj_kind_name} passed security checks. Allowing the request.') - admission_resp_msg = ( - f'Checkov found {checkovresults["summary"]["failed"]} issues. None in violation of admission policy. ' - f'{checkovresults["summary"]["failed"]} issues in this manifest!' - ) - return admission_response(True, uid, admission_resp_msg) - - -def todict(obj): - if hasattr(obj, 'attribute_map'): - result = {} - for k, v in obj.attribute_map.items(): - val = getattr(obj, k) - if val is not None: - result[v] = todict(val) - return result - elif type(obj) == list: - return [todict(x) for x in obj] - elif type(obj) == datetime: - return str(obj) - else: - return obj - - -def admission_response(allowed, uid, message): - return jsonify({"apiVersion": "admission.k8s.io/v1", - "kind": "AdmissionReview", - "response": { - "allowed": allowed, - "uid": uid, - "status": { - "code": 403, - "message": message - } - } - }) - - -def getConfig(configfile): - cf = {} - with open(configfile) as myfile: - for line in myfile: - name, var = line.partition("=")[::2] - cf[name.strip()] = list(var.strip().split(',')) - return cf - - -if __name__ == '__main__': - webhook.run(host='0.0.0.0', port=1701) diff --git a/wsgi.py b/wsgi.py index 30388a9..b997265 100644 --- a/wsgi.py +++ b/wsgi.py @@ -1,4 +1,4 @@ -from whorf import webhook +from app.whorf import webhook if __name__ == "__main__": webhook.run()