From 4a07a79ec7f5fa9c6f7f123aa9a9b690f2643530 Mon Sep 17 00:00:00 2001 From: Joseph Flinn <58369717+joseph-flinn@users.noreply.github.com> Date: Thu, 7 Mar 2024 07:54:19 -0800 Subject: [PATCH] Refactor Workflow Linter (#231) * Refactoring the linter into an opinionated reformatter * migrating how the rules are created for a more streamlined experience * removing the keyword from the step model since there are no linting tests run on it or any of it's children * Switch from pydantic to dataclasses * Update to use the correct loader (Model.from_dict({})). Add extra data tests * Got to a somewhat working breakdown of the rules (not including checking the actions) * Major additions, test writing, and rule import framework * update pip lockfile * Build out the majority of the rules * Add sub commands to manage the list of approved actions * Add test coverage and get test coverage of the rules to 100% * Update Python version in lint-ci to match refactored linter * Removing old files. Update verbosity * Finish in-code documentation * Run black over the code * Clean up the unused tests * Expanded docs on how to add a new Rule * Remove old linter * First linting error fixes * Fix all non-rule linter issues * Switch to the cleaner json.load() function * Fix unit tests * Fix linting issues on job_environment_prefix * Fix linting issues for RuleNameCapitalized * Fix linting warnings for RuleNameExists * Fix unit tests. Time to add some githooks... * Update with linter findings for RuleStepUsesApproved * Remove 'message' attribute from Rule base class. Fix linting findings for RuleStepPinned * Fix linter findings * Finish linter finding cleanup * Reformat * Fix type hinting * Move lint-workflow to vs directory restore lint-workflow * Fix tests after type fixes. Add tests to CI * Add new workflow path to the PR trigger * Update trigger to all pull_request events instead of push events * Update workflow job names to be more helpful * Switch back to python 3.9 for v1 linter * Fix edge case where 'env' doesn't exist in a job * Remove the end-to-end testing since it fails (as expected) and prevents a successful CI run * Working on packaging with Hatch * Update to Nix Unstable packages to allow for editable paths * Updating file structure. Tests passing * Got to installable and importable module * Switch setting from a python file to a yaml file * Update README with the new yaml settings * Debugging E2E tests. Need to handle creating the Jobs that don't have Steps (see test_a.yaml) * Save the latest vim session * Fixed the complex workflow loading * Update the JobRunnerVersionPinned rule to support callable workflows * Fix the lint command to be compatible with callable workflows * Format code and fix type annotations * Clean up of local opinionated development environment * Moving actions sub command to be in beta since we haven't decided to adopt that pattern * Update all spelling errors identified and path issues (Thanks @vgrassia!) * Add hacky safety measure to alert on GitHub Response Schema changes * Fixed spelling issues --- .github/workflows/lint-ci.yml | 35 +- lint-workflow-v2/.gitignore | 7 + lint-workflow-v2/.yamllint.yml | 36 + lint-workflow-v2/Pipfile | 24 + lint-workflow-v2/Pipfile.lock | 830 ++++++++++++++++++ lint-workflow-v2/README.md | 146 +++ lint-workflow-v2/Taskfile.yml | 71 ++ lint-workflow-v2/action.yml | 32 + lint-workflow-v2/actions.json.bak | 262 ++++++ lint-workflow-v2/pylintrc | 401 +++++++++ lint-workflow-v2/pyproject.toml | 45 + lint-workflow-v2/pyproject.toml.tpl | 33 + lint-workflow-v2/settings.yaml | 8 + .../bitwarden_workflow_linter/__about__.py | 1 + .../src/bitwarden_workflow_linter/__init__.py | 0 .../src/bitwarden_workflow_linter/actions.py | 217 +++++ .../src/bitwarden_workflow_linter/cli.py | 55 ++ .../default_actions.json | 262 ++++++ .../default_settings.yaml | 8 + .../src/bitwarden_workflow_linter/lint.py | 173 ++++ .../src/bitwarden_workflow_linter/load.py | 146 +++ .../models/__init__.py | 0 .../bitwarden_workflow_linter/models/job.py | 56 ++ .../bitwarden_workflow_linter/models/step.py | 48 + .../models/workflow.py | 45 + .../src/bitwarden_workflow_linter/rule.py | 101 +++ .../rules/__init__.py | 0 .../rules/job_environment_prefix.py | 74 ++ .../rules/name_capitalized.py | 56 ++ .../rules/name_exists.py | 59 ++ .../rules/pinned_job_runner.py | 54 ++ .../rules/step_approved.py | 103 +++ .../rules/step_pinned.py | 100 +++ .../src/bitwarden_workflow_linter/utils.py | 180 ++++ lint-workflow-v2/tests/__init__.py | 0 lint-workflow-v2/tests/conftest.py | 3 + lint-workflow-v2/tests/fixtures/test-alt.yml | 24 + .../tests/fixtures/test-min-incorrect.yaml | 9 + lint-workflow-v2/tests/fixtures/test-min.yaml | 13 + lint-workflow-v2/tests/fixtures/test.yml | 49 ++ lint-workflow-v2/tests/fixtures/test_a.yaml | 27 + lint-workflow-v2/tests/rules/__init__.py | 0 .../rules/test_job_environment_prefix.py | 110 +++ .../tests/rules/test_name_capitalized.py | 107 +++ .../tests/rules/test_name_exists.py | 75 ++ .../tests/rules/test_pinned_job_runner.py | 65 ++ .../tests/rules/test_step_approved.py | 113 +++ .../tests/rules/test_step_pinned.py | 104 +++ lint-workflow-v2/tests/test_job.py | 82 ++ lint-workflow-v2/tests/test_lint.py | 47 + lint-workflow-v2/tests/test_load.py | 94 ++ lint-workflow-v2/tests/test_rule.py | 140 +++ lint-workflow-v2/tests/test_step.py | 78 ++ lint-workflow-v2/tests/test_utils.py | 35 + lint-workflow-v2/tests/test_workflow.py | 100 +++ 55 files changed, 4940 insertions(+), 3 deletions(-) create mode 100644 lint-workflow-v2/.gitignore create mode 100644 lint-workflow-v2/.yamllint.yml create mode 100644 lint-workflow-v2/Pipfile create mode 100644 lint-workflow-v2/Pipfile.lock create mode 100644 lint-workflow-v2/README.md create mode 100644 lint-workflow-v2/Taskfile.yml create mode 100644 lint-workflow-v2/action.yml create mode 100644 lint-workflow-v2/actions.json.bak create mode 100644 lint-workflow-v2/pylintrc create mode 100644 lint-workflow-v2/pyproject.toml create mode 100644 lint-workflow-v2/pyproject.toml.tpl create mode 100644 lint-workflow-v2/settings.yaml create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/__about__.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/__init__.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/actions.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/cli.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/default_actions.json create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/default_settings.yaml create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/lint.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/load.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/models/__init__.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/models/job.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/models/step.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/models/workflow.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/rule.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/rules/__init__.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/rules/job_environment_prefix.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/rules/name_capitalized.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/rules/name_exists.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/rules/pinned_job_runner.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/rules/step_approved.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/rules/step_pinned.py create mode 100644 lint-workflow-v2/src/bitwarden_workflow_linter/utils.py create mode 100644 lint-workflow-v2/tests/__init__.py create mode 100644 lint-workflow-v2/tests/conftest.py create mode 100644 lint-workflow-v2/tests/fixtures/test-alt.yml create mode 100644 lint-workflow-v2/tests/fixtures/test-min-incorrect.yaml create mode 100644 lint-workflow-v2/tests/fixtures/test-min.yaml create mode 100644 lint-workflow-v2/tests/fixtures/test.yml create mode 100644 lint-workflow-v2/tests/fixtures/test_a.yaml create mode 100644 lint-workflow-v2/tests/rules/__init__.py create mode 100644 lint-workflow-v2/tests/rules/test_job_environment_prefix.py create mode 100644 lint-workflow-v2/tests/rules/test_name_capitalized.py create mode 100644 lint-workflow-v2/tests/rules/test_name_exists.py create mode 100644 lint-workflow-v2/tests/rules/test_pinned_job_runner.py create mode 100644 lint-workflow-v2/tests/rules/test_step_approved.py create mode 100644 lint-workflow-v2/tests/rules/test_step_pinned.py create mode 100644 lint-workflow-v2/tests/test_job.py create mode 100644 lint-workflow-v2/tests/test_lint.py create mode 100644 lint-workflow-v2/tests/test_load.py create mode 100644 lint-workflow-v2/tests/test_rule.py create mode 100644 lint-workflow-v2/tests/test_step.py create mode 100644 lint-workflow-v2/tests/test_utils.py create mode 100644 lint-workflow-v2/tests/test_workflow.py diff --git a/.github/workflows/lint-ci.yml b/.github/workflows/lint-ci.yml index e61701fc..ee3f0eb3 100644 --- a/.github/workflows/lint-ci.yml +++ b/.github/workflows/lint-ci.yml @@ -3,14 +3,15 @@ name: CI-Lint on: - push: + pull_request: paths: - "lint-workflow/**" + - "lint-workflow-v2/**" workflow_dispatch: {} jobs: - CI: - name: CI + ci-lint: + name: CI workflow-linter (v1) runs-on: ubuntu-22.04 steps: - name: Checkout @@ -31,3 +32,31 @@ jobs: - name: Test lint working-directory: lint-workflow run: pipenv run pytest tests + + + ci-lint-v2: + name: CI workflow-linter (v2) + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Set up Python + uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + with: + python-version: "3.11" + + - name: Install dependencies + working-directory: lint-workflow-v2 + run: | + python -m pip install --upgrade pip + pip install pipenv + pipenv install --dev + + - name: Test lint + working-directory: lint-workflow-v2 + run: pipenv run pytest tests --cov=src + + - name: Check type hinting + working-directory: lint-workflow-v2 + run: pipenv run pytype src diff --git a/lint-workflow-v2/.gitignore b/lint-workflow-v2/.gitignore new file mode 100644 index 00000000..1e69ed6a --- /dev/null +++ b/lint-workflow-v2/.gitignore @@ -0,0 +1,7 @@ +.coverage +dist + +## Dev Environments +Session.vim +flake.nix +flake.lock diff --git a/lint-workflow-v2/.yamllint.yml b/lint-workflow-v2/.yamllint.yml new file mode 100644 index 00000000..bed41fc5 --- /dev/null +++ b/lint-workflow-v2/.yamllint.yml @@ -0,0 +1,36 @@ +--- + +extends: default + +rules: + braces: + level: warning + brackets: + level: warning + colons: + level: warning + commas: + level: warning + comments: + min-spaces-from-content: 1 + empty-lines: + level: warning + hyphens: + level: warning + indentation: + level: warning + spaces: 2 + key-duplicates: + level: warning + line-length: + level: warning + max: 120 + new-line-at-end-of-file: + level: warning + new-lines: + level: warning + trailing-spaces: + level: warning + truthy: + check-keys: false + level: warning diff --git a/lint-workflow-v2/Pipfile b/lint-workflow-v2/Pipfile new file mode 100644 index 00000000..8dc6f95b --- /dev/null +++ b/lint-workflow-v2/Pipfile @@ -0,0 +1,24 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +pyyaml = "*" +urllib3 = "*" +pydantic = "*" +"ruamel.yaml" = "*" +dataclasses-json = "*" + +[dev-packages] +black = "*" +pytest = "*" +coverage = "*" +pytest-cov = "*" +pylint = "*" +pytype = "*" +hatchling = "*" +build = "*" + +[requires] +python_version = "3.11" diff --git a/lint-workflow-v2/Pipfile.lock b/lint-workflow-v2/Pipfile.lock new file mode 100644 index 00000000..54e4bd9f --- /dev/null +++ b/lint-workflow-v2/Pipfile.lock @@ -0,0 +1,830 @@ +{ + "_meta": { + "hash": { + "sha256": "7e51933f1987a5e1d1182f4cec2a1c6f84194a17ac6653bb3674da0fe26dfb7f" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.11" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "annotated-types": { + "hashes": [ + "sha256:0641064de18ba7a25dee8f96403ebc39113d0cb953a01429249d5c7564666a43", + "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d" + ], + "markers": "python_version >= '3.8'", + "version": "==0.6.0" + }, + "dataclasses-json": { + "hashes": [ + "sha256:73696ebf24936560cca79a2430cbc4f3dd23ac7bf46ed17f38e5e5e7657a6377", + "sha256:f90578b8a3177f7552f4e1a6e535e84293cd5da421fcce0642d49c0d7bdf8df2" + ], + "index": "pypi", + "version": "==0.6.4" + }, + "marshmallow": { + "hashes": [ + "sha256:4c1daff273513dc5eb24b219a8035559dc573c8f322558ef85f5438ddd1236dd", + "sha256:c21d4b98fee747c130e6bc8f45c4b3199ea66bc00c12ee1f639f0aeca034d5e9" + ], + "markers": "python_version >= '3.8'", + "version": "==3.20.2" + }, + "mypy-extensions": { + "hashes": [ + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.0" + }, + "packaging": { + "hashes": [ + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + ], + "markers": "python_version >= '3.7'", + "version": "==23.2" + }, + "pydantic": { + "hashes": [ + "sha256:1440966574e1b5b99cf75a13bec7b20e3512e8a61b894ae252f56275e2c465ae", + "sha256:ae887bd94eb404b09d86e4d12f93893bdca79d766e738528c6fa1c849f3c6bcf" + ], + "index": "pypi", + "version": "==2.6.0" + }, + "pydantic-core": { + "hashes": [ + "sha256:06f0d5a1d9e1b7932477c172cc720b3b23c18762ed7a8efa8398298a59d177c7", + "sha256:07982b82d121ed3fc1c51faf6e8f57ff09b1325d2efccaa257dd8c0dd937acca", + "sha256:0f478ec204772a5c8218e30eb813ca43e34005dff2eafa03931b3d8caef87d51", + "sha256:102569d371fadc40d8f8598a59379c37ec60164315884467052830b28cc4e9da", + "sha256:10dca874e35bb60ce4f9f6665bfbfad050dd7573596608aeb9e098621ac331dc", + "sha256:150ba5c86f502c040b822777e2e519b5625b47813bd05f9273a8ed169c97d9ae", + "sha256:1661c668c1bb67b7cec96914329d9ab66755911d093bb9063c4c8914188af6d4", + "sha256:1a2fe7b00a49b51047334d84aafd7e39f80b7675cad0083678c58983662da89b", + "sha256:1ae8048cba95f382dba56766525abca438328455e35c283bb202964f41a780b0", + "sha256:20f724a023042588d0f4396bbbcf4cffd0ddd0ad3ed4f0d8e6d4ac4264bae81e", + "sha256:2133b0e412a47868a358713287ff9f9a328879da547dc88be67481cdac529118", + "sha256:21e3298486c4ea4e4d5cc6fb69e06fb02a4e22089304308817035ac006a7f506", + "sha256:21ebaa4bf6386a3b22eec518da7d679c8363fb7fb70cf6972161e5542f470798", + "sha256:23632132f1fd608034f1a56cc3e484be00854db845b3a4a508834be5a6435a6f", + "sha256:2d5bea8012df5bb6dda1e67d0563ac50b7f64a5d5858348b5c8cb5043811c19d", + "sha256:300616102fb71241ff477a2cbbc847321dbec49428434a2f17f37528721c4948", + "sha256:30a8259569fbeec49cfac7fda3ec8123486ef1b729225222f0d41d5f840b476f", + "sha256:399166f24c33a0c5759ecc4801f040dbc87d412c1a6d6292b2349b4c505effc9", + "sha256:3fac641bbfa43d5a1bed99d28aa1fded1984d31c670a95aac1bf1d36ac6ce137", + "sha256:42c29d54ed4501a30cd71015bf982fa95e4a60117b44e1a200290ce687d3e640", + "sha256:462d599299c5971f03c676e2b63aa80fec5ebc572d89ce766cd11ca8bcb56f3f", + "sha256:4eebbd049008eb800f519578e944b8dc8e0f7d59a5abb5924cc2d4ed3a1834ff", + "sha256:502c062a18d84452858f8aea1e520e12a4d5228fc3621ea5061409d666ea1706", + "sha256:5317c04349472e683803da262c781c42c5628a9be73f4750ac7d13040efb5d2d", + "sha256:5511f962dd1b9b553e9534c3b9c6a4b0c9ded3d8c2be96e61d56f933feef9e1f", + "sha256:561be4e3e952c2f9056fba5267b99be4ec2afadc27261505d4992c50b33c513c", + "sha256:601d3e42452cd4f2891c13fa8c70366d71851c1593ed42f57bf37f40f7dca3c8", + "sha256:644904600c15816a1f9a1bafa6aab0d21db2788abcdf4e2a77951280473f33e1", + "sha256:653a5dfd00f601a0ed6654a8b877b18d65ac32c9d9997456e0ab240807be6cf7", + "sha256:694a5e9f1f2c124a17ff2d0be613fd53ba0c26de588eb4bdab8bca855e550d95", + "sha256:71b4a48a7427f14679f0015b13c712863d28bb1ab700bd11776a5368135c7d60", + "sha256:72bf9308a82b75039b8c8edd2be2924c352eda5da14a920551a8b65d5ee89253", + "sha256:735dceec50fa907a3c314b84ed609dec54b76a814aa14eb90da31d1d36873a5e", + "sha256:73802194f10c394c2bedce7a135ba1d8ba6cff23adf4217612bfc5cf060de34c", + "sha256:780daad9e35b18d10d7219d24bfb30148ca2afc309928e1d4d53de86822593dc", + "sha256:8655f55fe68c4685673265a650ef71beb2d31871c049c8b80262026f23605ee3", + "sha256:877045a7969ace04d59516d5d6a7dee13106822f99a5d8df5e6822941f7bedc8", + "sha256:87bce04f09f0552b66fca0c4e10da78d17cb0e71c205864bab4e9595122cb9d9", + "sha256:8d4dfc66abea3ec6d9f83e837a8f8a7d9d3a76d25c9911735c76d6745950e62c", + "sha256:8ec364e280db4235389b5e1e6ee924723c693cbc98e9d28dc1767041ff9bc388", + "sha256:8fa00fa24ffd8c31fac081bf7be7eb495be6d248db127f8776575a746fa55c95", + "sha256:920c4897e55e2881db6a6da151198e5001552c3777cd42b8a4c2f72eedc2ee91", + "sha256:920f4633bee43d7a2818e1a1a788906df5a17b7ab6fe411220ed92b42940f818", + "sha256:9795f56aa6b2296f05ac79d8a424e94056730c0b860a62b0fdcfe6340b658cc8", + "sha256:98f0edee7ee9cc7f9221af2e1b95bd02810e1c7a6d115cfd82698803d385b28f", + "sha256:99c095457eea8550c9fa9a7a992e842aeae1429dab6b6b378710f62bfb70b394", + "sha256:99d3a433ef5dc3021c9534a58a3686c88363c591974c16c54a01af7efd741f13", + "sha256:99f9a50b56713a598d33bc23a9912224fc5d7f9f292444e6664236ae471ddf17", + "sha256:9c46e556ee266ed3fb7b7a882b53df3c76b45e872fdab8d9cf49ae5e91147fd7", + "sha256:9f5d37ff01edcbace53a402e80793640c25798fb7208f105d87a25e6fcc9ea06", + "sha256:a0b4cfe408cd84c53bab7d83e4209458de676a6ec5e9c623ae914ce1cb79b96f", + "sha256:a497be217818c318d93f07e14502ef93d44e6a20c72b04c530611e45e54c2196", + "sha256:ac89ccc39cd1d556cc72d6752f252dc869dde41c7c936e86beac5eb555041b66", + "sha256:adf28099d061a25fbcc6531febb7a091e027605385de9fe14dd6a97319d614cf", + "sha256:afa01d25769af33a8dac0d905d5c7bb2d73c7c3d5161b2dd6f8b5b5eea6a3c4c", + "sha256:b1fc07896fc1851558f532dffc8987e526b682ec73140886c831d773cef44b76", + "sha256:b49c604ace7a7aa8af31196abbf8f2193be605db6739ed905ecaf62af31ccae0", + "sha256:b9f3e0bffad6e238f7acc20c393c1ed8fab4371e3b3bc311020dfa6020d99212", + "sha256:ba07646f35e4e49376c9831130039d1b478fbfa1215ae62ad62d2ee63cf9c18f", + "sha256:bd88f40f2294440d3f3c6308e50d96a0d3d0973d6f1a5732875d10f569acef49", + "sha256:c0be58529d43d38ae849a91932391eb93275a06b93b79a8ab828b012e916a206", + "sha256:c45f62e4107ebd05166717ac58f6feb44471ed450d07fecd90e5f69d9bf03c48", + "sha256:c56da23034fe66221f2208c813d8aa509eea34d97328ce2add56e219c3a9f41c", + "sha256:c94b5537bf6ce66e4d7830c6993152940a188600f6ae044435287753044a8fe2", + "sha256:cebf8d56fee3b08ad40d332a807ecccd4153d3f1ba8231e111d9759f02edfd05", + "sha256:d0bf6f93a55d3fa7a079d811b29100b019784e2ee6bc06b0bb839538272a5610", + "sha256:d195add190abccefc70ad0f9a0141ad7da53e16183048380e688b466702195dd", + "sha256:d25ef0c33f22649b7a088035fd65ac1ce6464fa2876578df1adad9472f918a76", + "sha256:d6cbdf12ef967a6aa401cf5cdf47850559e59eedad10e781471c960583f25aa1", + "sha256:d8c032ccee90b37b44e05948b449a2d6baed7e614df3d3f47fe432c952c21b60", + "sha256:daff04257b49ab7f4b3f73f98283d3dbb1a65bf3500d55c7beac3c66c310fe34", + "sha256:e83ebbf020be727d6e0991c1b192a5c2e7113eb66e3def0cd0c62f9f266247e4", + "sha256:ed3025a8a7e5a59817b7494686d449ebfbe301f3e757b852c8d0d1961d6be864", + "sha256:f1936ef138bed2165dd8573aa65e3095ef7c2b6247faccd0e15186aabdda7f66", + "sha256:f5247a3d74355f8b1d780d0f3b32a23dd9f6d3ff43ef2037c6dcd249f35ecf4c", + "sha256:fa496cd45cda0165d597e9d6f01e36c33c9508f75cf03c0a650018c5048f578e", + "sha256:fb4363e6c9fc87365c2bc777a1f585a22f2f56642501885ffc7942138499bf54", + "sha256:fb4370b15111905bf8b5ba2129b926af9470f014cb0493a67d23e9d7a48348e8", + "sha256:fbec2af0ebafa57eb82c18c304b37c86a8abddf7022955d1742b3d5471a6339e" + ], + "markers": "python_version >= '3.8'", + "version": "==2.16.1" + }, + "pyyaml": { + "hashes": [ + "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", + "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", + "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", + "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", + "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", + "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", + "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", + "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", + "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", + "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", + "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", + "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", + "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", + "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", + "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", + "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", + "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", + "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", + "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", + "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", + "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", + "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", + "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", + "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", + "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", + "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", + "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", + "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", + "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", + "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", + "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", + "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", + "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", + "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", + "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", + "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", + "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", + "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", + "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", + "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", + "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", + "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", + "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", + "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", + "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", + "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", + "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", + "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", + "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", + "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", + "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" + ], + "index": "pypi", + "version": "==6.0.1" + }, + "ruamel.yaml": { + "hashes": [ + "sha256:61917e3a35a569c1133a8f772e1226961bf5a1198bea7e23f06a0841dea1ab0e", + "sha256:a013ac02f99a69cdd6277d9664689eb1acba07069f912823177c5eced21a6ada" + ], + "index": "pypi", + "version": "==0.18.5" + }, + "ruamel.yaml.clib": { + "hashes": [ + "sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d", + "sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001", + "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462", + "sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9", + "sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe", + "sha256:1758ce7d8e1a29d23de54a16ae867abd370f01b5a69e1a3ba75223eaa3ca1a1b", + "sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b", + "sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615", + "sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62", + "sha256:25ac8c08322002b06fa1d49d1646181f0b2c72f5cbc15a85e80b4c30a544bb15", + "sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b", + "sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1", + "sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9", + "sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675", + "sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899", + "sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7", + "sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7", + "sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312", + "sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa", + "sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91", + "sha256:75e1ed13e1f9de23c5607fe6bd1aeaae21e523b32d83bb33918245361e9cc51b", + "sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6", + "sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3", + "sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334", + "sha256:84b554931e932c46f94ab306913ad7e11bba988104c5cff26d90d03f68258cd5", + "sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3", + "sha256:955eae71ac26c1ab35924203fda6220f84dce57d6d7884f189743e2abe3a9fbe", + "sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c", + "sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed", + "sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337", + "sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880", + "sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f", + "sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d", + "sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248", + "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d", + "sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf", + "sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512", + "sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069", + "sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb", + "sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942", + "sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d", + "sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31", + "sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92", + "sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5", + "sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28", + "sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d", + "sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1", + "sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2", + "sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875", + "sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412" + ], + "markers": "python_version < '3.13' and platform_python_implementation == 'CPython'", + "version": "==0.2.8" + }, + "typing-extensions": { + "hashes": [ + "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", + "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" + ], + "markers": "python_version >= '3.8'", + "version": "==4.9.0" + }, + "typing-inspect": { + "hashes": [ + "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", + "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78" + ], + "version": "==0.9.0" + }, + "urllib3": { + "hashes": [ + "sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20", + "sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224" + ], + "index": "pypi", + "version": "==2.2.0" + } + }, + "develop": { + "astroid": { + "hashes": [ + "sha256:4a61cf0a59097c7bb52689b0fd63717cd2a8a14dc9f1eee97b82d814881c8c91", + "sha256:d6e62862355f60e716164082d6b4b041d38e2a8cf1c7cd953ded5108bac8ff5c" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==3.0.2" + }, + "attrs": { + "hashes": [ + "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", + "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1" + ], + "markers": "python_version >= '3.7'", + "version": "==23.2.0" + }, + "black": { + "hashes": [ + "sha256:0269dfdea12442022e88043d2910429bed717b2d04523867a85dacce535916b8", + "sha256:07204d078e25327aad9ed2c64790d681238686bce254c910de640c7cc4fc3aa6", + "sha256:08b34e85170d368c37ca7bf81cf67ac863c9d1963b2c1780c39102187ec8dd62", + "sha256:1a95915c98d6e32ca43809d46d932e2abc5f1f7d582ffbe65a5b4d1588af7445", + "sha256:2588021038bd5ada078de606f2a804cadd0a3cc6a79cb3e9bb3a8bf581325a4c", + "sha256:2fa6a0e965779c8f2afb286f9ef798df770ba2b6cee063c650b96adec22c056a", + "sha256:34afe9da5056aa123b8bfda1664bfe6fb4e9c6f311d8e4a6eb089da9a9173bf9", + "sha256:3897ae5a21ca132efa219c029cce5e6bfc9c3d34ed7e892113d199c0b1b444a2", + "sha256:40657e1b78212d582a0edecafef133cf1dd02e6677f539b669db4746150d38f6", + "sha256:48b5760dcbfe5cf97fd4fba23946681f3a81514c6ab8a45b50da67ac8fbc6c7b", + "sha256:5242ecd9e990aeb995b6d03dc3b2d112d4a78f2083e5a8e86d566340ae80fec4", + "sha256:5cdc2e2195212208fbcae579b931407c1fa9997584f0a415421748aeafff1168", + "sha256:5d7b06ea8816cbd4becfe5f70accae953c53c0e53aa98730ceccb0395520ee5d", + "sha256:7258c27115c1e3b5de9ac6c4f9957e3ee2c02c0b39222a24dc7aa03ba0e986f5", + "sha256:854c06fb86fd854140f37fb24dbf10621f5dab9e3b0c29a690ba595e3d543024", + "sha256:a21725862d0e855ae05da1dd25e3825ed712eaaccef6b03017fe0853a01aa45e", + "sha256:a83fe522d9698d8f9a101b860b1ee154c1d25f8a82ceb807d319f085b2627c5b", + "sha256:b3d64db762eae4a5ce04b6e3dd745dcca0fb9560eb931a5be97472e38652a161", + "sha256:e298d588744efda02379521a19639ebcd314fba7a49be22136204d7ed1782717", + "sha256:e2c8dfa14677f90d976f68e0c923947ae68fa3961d61ee30976c388adc0b02c8", + "sha256:ecba2a15dfb2d97105be74bbfe5128bc5e9fa8477d8c46766505c1dda5883aac", + "sha256:fc1ec9aa6f4d98d022101e015261c056ddebe3da6a8ccfc2c792cbe0349d48b7" + ], + "index": "pypi", + "version": "==24.1.1" + }, + "build": { + "hashes": [ + "sha256:538aab1b64f9828977f84bc63ae570b060a8ed1be419e7870b8b4fc5e6ea553b", + "sha256:589bf99a67df7c9cf07ec0ac0e5e2ea5d4b37ac63301c4986d1acb126aa83f8f" + ], + "index": "pypi", + "version": "==1.0.3" + }, + "click": { + "hashes": [ + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.7" + }, + "coverage": { + "hashes": [ + "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61", + "sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1", + "sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7", + "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7", + "sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75", + "sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd", + "sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35", + "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04", + "sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6", + "sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042", + "sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166", + "sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1", + "sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d", + "sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c", + "sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66", + "sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70", + "sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1", + "sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676", + "sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630", + "sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a", + "sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74", + "sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad", + "sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19", + "sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6", + "sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448", + "sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018", + "sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218", + "sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756", + "sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54", + "sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45", + "sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628", + "sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968", + "sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d", + "sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25", + "sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60", + "sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950", + "sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06", + "sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295", + "sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b", + "sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c", + "sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc", + "sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74", + "sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1", + "sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee", + "sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011", + "sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156", + "sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766", + "sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5", + "sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581", + "sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016", + "sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c", + "sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3" + ], + "index": "pypi", + "version": "==7.4.1" + }, + "dill": { + "hashes": [ + "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca", + "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7" + ], + "markers": "python_version >= '3.11'", + "version": "==0.3.8" + }, + "editables": { + "hashes": [ + "sha256:309627d9b5c4adc0e668d8c6fa7bac1ba7c8c5d415c2d27f60f081f8e80d1de2", + "sha256:61e5ffa82629e0d8bfe09bc44a07db3c1ab8ed1ce78a6980732870f19b5e7d4c" + ], + "markers": "python_version >= '3.7'", + "version": "==0.5" + }, + "hatchling": { + "hashes": [ + "sha256:21e8c13f8458b219a91cb84e5b61c15bf786695d1c4fabc29e91e78f94bfe892", + "sha256:bba440453a224e7d4478457fa2e8d8c3633765bafa02975a6b53b9bf917980bc" + ], + "index": "pypi", + "version": "==1.21.1" + }, + "importlab": { + "hashes": [ + "sha256:124cfa00e8a34fefe8aac1a5e94f56c781b178c9eb61a1d3f60f7e03b77338d3", + "sha256:b3893853b1f6eb027da509c3b40e6787e95dd66b4b66f1b3613aad77556e1465" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==0.8.1" + }, + "iniconfig": { + "hashes": [ + "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", + "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "isort": { + "hashes": [ + "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", + "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==5.13.2" + }, + "jinja2": { + "hashes": [ + "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa", + "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.3" + }, + "libcst": { + "hashes": [ + "sha256:003e5e83a12eed23542c4ea20fdc8de830887cc03662432bb36f84f8c4841b81", + "sha256:0acbacb9a170455701845b7e940e2d7b9519db35a86768d86330a0b0deae1086", + "sha256:0bf69cbbab5016d938aac4d3ae70ba9ccb3f90363c588b3b97be434e6ba95403", + "sha256:2d37326bd6f379c64190a28947a586b949de3a76be00176b0732c8ee87d67ebe", + "sha256:3a07ecfabbbb8b93209f952a365549e65e658831e9231649f4f4e4263cad24b1", + "sha256:3ebbb9732ae3cc4ae7a0e97890bed0a57c11d6df28790c2b9c869f7da653c7c7", + "sha256:4bc745d0c06420fe2644c28d6ddccea9474fb68a2135904043676deb4fa1e6bc", + "sha256:5297a16e575be8173185e936b7765c89a3ca69d4ae217a4af161814a0f9745a7", + "sha256:5f1cd308a4c2f71d5e4eec6ee693819933a03b78edb2e4cc5e3ad1afd5fb3f07", + "sha256:63f75656fd733dc20354c46253fde3cf155613e37643c3eaf6f8818e95b7a3d1", + "sha256:73c086705ed34dbad16c62c9adca4249a556c1b022993d511da70ea85feaf669", + "sha256:75816647736f7e09c6120bdbf408456f99b248d6272277eed9a58cf50fb8bc7d", + "sha256:78b7a38ec4c1c009ac39027d51558b52851fb9234669ba5ba62283185963a31c", + "sha256:7ccaf53925f81118aeaadb068a911fac8abaff608817d7343da280616a5ca9c1", + "sha256:82d1271403509b0a4ee6ff7917c2d33b5a015f44d1e208abb1da06ba93b2a378", + "sha256:8ae11eb1ea55a16dc0cdc61b41b29ac347da70fec14cc4381248e141ee2fbe6c", + "sha256:8afb6101b8b3c86c5f9cec6b90ab4da16c3c236fe7396f88e8b93542bb341f7c", + "sha256:8c1f2da45f1c45634090fd8672c15e0159fdc46853336686959b2d093b6e10fa", + "sha256:97fbc73c87e9040e148881041fd5ffa2a6ebf11f64b4ccb5b52e574b95df1a15", + "sha256:99fdc1929703fd9e7408aed2e03f58701c5280b05c8911753a8d8619f7dfdda5", + "sha256:9dffa1795c2804d183efb01c0f1efd20a7831db6a21a0311edf90b4100d67436", + "sha256:bca1841693941fdd18371824bb19a9702d5784cd347cb8231317dbdc7062c5bc", + "sha256:c653d9121d6572d8b7f8abf20f88b0a41aab77ff5a6a36e5a0ec0f19af0072e8", + "sha256:c8f26250f87ca849a7303ed7a4fd6b2c7ac4dec16b7d7e68ca6a476d7c9bfcdb", + "sha256:cc9b6ac36d7ec9db2f053014ea488086ca2ed9c322be104fbe2c71ca759da4bb", + "sha256:d22d1abfe49aa60fc61fa867e10875a9b3024ba5a801112f4d7ba42d8d53242e", + "sha256:d68c34e3038d3d1d6324eb47744cbf13f2c65e1214cf49db6ff2a6603c1cd838", + "sha256:e3d8cf974cfa2487b28f23f56c4bff90d550ef16505e58b0dca0493d5293784b", + "sha256:f36f592e035ef84f312a12b75989dde6a5f6767fe99146cdae6a9ee9aff40dd0", + "sha256:f561c9a84eca18be92f4ad90aa9bd873111efbea995449301719a1a7805dbc5c", + "sha256:fe41b33aa73635b1651f64633f429f7aa21f86d2db5748659a99d9b7b1ed2a90" + ], + "markers": "python_version >= '3.8'", + "version": "==1.1.0" + }, + "markupsafe": { + "hashes": [ + "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", + "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff", + "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", + "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3", + "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532", + "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f", + "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", + "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df", + "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4", + "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", + "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", + "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", + "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8", + "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371", + "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2", + "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465", + "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52", + "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6", + "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", + "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad", + "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", + "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0", + "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029", + "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", + "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a", + "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", + "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5", + "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", + "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf", + "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9", + "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", + "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", + "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3", + "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", + "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46", + "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc", + "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a", + "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", + "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900", + "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", + "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea", + "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", + "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5", + "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e", + "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", + "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f", + "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50", + "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", + "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", + "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4", + "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff", + "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2", + "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46", + "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", + "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf", + "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", + "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", + "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab", + "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd", + "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.5" + }, + "mccabe": { + "hashes": [ + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, + "mypy-extensions": { + "hashes": [ + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.0" + }, + "networkx": { + "hashes": [ + "sha256:4f33f68cb2afcf86f28a45f43efc27a9386b535d567d2127f8f61d51dec58d36", + "sha256:de346335408f84de0eada6ff9fafafff9bcda11f0a0dfaa931133debb146ab61" + ], + "markers": "python_version >= '3.8'", + "version": "==3.1" + }, + "ninja": { + "hashes": [ + "sha256:18302d96a5467ea98b68e1cae1ae4b4fb2b2a56a82b955193c637557c7273dbd", + "sha256:185e0641bde601e53841525c4196278e9aaf4463758da6dd1e752c0a0f54136a", + "sha256:376889c76d87b95b5719fdd61dd7db193aa7fd4432e5d52d2e44e4c497bdbbee", + "sha256:3e0f9be5bb20d74d58c66cc1c414c3e6aeb45c35b0d0e41e8d739c2c0d57784f", + "sha256:73b93c14046447c7c5cc892433d4fae65d6364bec6685411cb97a8bcf815f93a", + "sha256:7563ce1d9fe6ed5af0b8dd9ab4a214bf4ff1f2f6fd6dc29f480981f0f8b8b249", + "sha256:76482ba746a2618eecf89d5253c0d1e4f1da1270d41e9f54dfbd91831b0f6885", + "sha256:84502ec98f02a037a169c4b0d5d86075eaf6afc55e1879003d6cab51ced2ea4b", + "sha256:95da904130bfa02ea74ff9c0116b4ad266174fafb1c707aa50212bc7859aebf1", + "sha256:9d793b08dd857e38d0b6ffe9e6b7145d7c485a42dcfea04905ca0cdb6017cc3c", + "sha256:9df724344202b83018abb45cb1efc22efd337a1496514e7e6b3b59655be85205", + "sha256:aad34a70ef15b12519946c5633344bc775a7656d789d9ed5fdb0d456383716ef", + "sha256:d491fc8d89cdcb416107c349ad1e3a735d4c4af5e1cb8f5f727baca6350fdaea", + "sha256:ecf80cf5afd09f14dcceff28cb3f11dc90fb97c999c89307aea435889cb66877", + "sha256:fa2ba9d74acfdfbfbcf06fad1b8282de8a7a8c481d9dee45c859a8c93fcc1082" + ], + "version": "==1.11.1.1" + }, + "packaging": { + "hashes": [ + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" + ], + "markers": "python_version >= '3.7'", + "version": "==23.2" + }, + "pathspec": { + "hashes": [ + "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", + "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" + ], + "markers": "python_version >= '3.8'", + "version": "==0.12.1" + }, + "platformdirs": { + "hashes": [ + "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068", + "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768" + ], + "markers": "python_version >= '3.8'", + "version": "==4.2.0" + }, + "pluggy": { + "hashes": [ + "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981", + "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be" + ], + "markers": "python_version >= '3.8'", + "version": "==1.4.0" + }, + "pycnite": { + "hashes": [ + "sha256:7d02eb0ec4b405d8812ce053434dacfc2335dcd458ab58a1a8bf64f72d40bd76", + "sha256:ad8616982beecc39f2090999aa8fe0b044b1f6733ec39484cb5e0900b3c88aa1" + ], + "markers": "python_version >= '3.8'", + "version": "==2023.10.11" + }, + "pydot": { + "hashes": [ + "sha256:408a47913ea7bd5d2d34b274144880c1310c4aee901f353cf21fe2e526a4ea28", + "sha256:60246af215123fa062f21cd791be67dda23a6f280df09f68919e637a1e4f3235" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "pylint": { + "hashes": [ + "sha256:58c2398b0301e049609a8429789ec6edf3aabe9b6c5fec916acd18639c16de8b", + "sha256:7a1585285aefc5165db81083c3e06363a27448f6b467b3b0f30dbd0ac1f73810" + ], + "index": "pypi", + "version": "==3.0.3" + }, + "pyparsing": { + "hashes": [ + "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb", + "sha256:ede28a1a32462f5a9705e07aea48001a08f7cf81a021585011deba701581a0db" + ], + "markers": "python_full_version >= '3.6.8'", + "version": "==3.1.1" + }, + "pyproject-hooks": { + "hashes": [ + "sha256:283c11acd6b928d2f6a7c73fa0d01cb2bdc5f07c57a2eeb6e83d5e56b97976f8", + "sha256:f271b298b97f5955d53fb12b72c1fb1948c22c1a6b70b315c54cedaca0264ef5" + ], + "markers": "python_version >= '3.7'", + "version": "==1.0.0" + }, + "pytest": { + "hashes": [ + "sha256:249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c", + "sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6" + ], + "index": "pypi", + "version": "==8.0.0" + }, + "pytest-cov": { + "hashes": [ + "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6", + "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a" + ], + "index": "pypi", + "version": "==4.1.0" + }, + "pytype": { + "hashes": [ + "sha256:0339eb1dc696ab253bb627cd3673f1ad3f485e50103240edc36793ab89465193", + "sha256:1d87b5b4a931d62b4a7ea43f7be340dcb7bc2c3b27889e75f609e434cbc7b5f9", + "sha256:20c22a482af5dc338a52b9d1b461062fd354b54d5457e381550196b6f32141bb", + "sha256:28ab1536f5abe05c704e872c03df1d3a875c1775a5efe94f54f83cf07a4124bc", + "sha256:2cbaa77cd20bc51c73d0558aba9cea0a3cc43502e5a70cc692ad4272d15b338c", + "sha256:888d2fda3a4b6b2c427ab589a016939df3ae4c7139dbef5c022337b44b9e27b5", + "sha256:906b88fd475817783f1998afedd3cd269416920fdf86166a4c6d8c87b90da8e3", + "sha256:9324778305cf869e0d8379d1f8d549f85aed215fdad33023b80be0fc77decd97", + "sha256:aa62ca9890069f87a292a95816bea1ff1ac789f945a3230d6b2f03e2d90a9bb0", + "sha256:bcd0f87ab381f3a1f3d0cac90524ac6bf7f43d9c949b59db46031072cbbafe6c", + "sha256:ca4624812e07610654a9052c515f7a3de44c44a6fb3be37df9746561d7465b72", + "sha256:d01e4b210154e6d62eeb09837e5512cc3a47cb3108dcb8484f6799fb03e91ba9", + "sha256:f544a252363e93d64f7716100a251b0cbea906cac88518ba5fc29d55eec1c1d6" + ], + "index": "pypi", + "version": "==2024.1.24" + }, + "pyyaml": { + "hashes": [ + "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", + "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", + "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", + "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", + "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", + "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", + "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", + "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", + "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", + "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", + "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", + "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", + "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", + "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", + "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", + "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", + "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", + "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", + "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", + "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", + "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", + "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", + "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", + "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", + "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", + "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", + "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", + "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", + "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", + "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", + "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", + "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", + "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", + "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", + "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", + "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", + "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", + "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", + "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", + "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", + "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", + "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", + "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", + "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", + "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", + "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", + "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", + "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", + "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", + "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", + "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" + ], + "index": "pypi", + "version": "==6.0.1" + }, + "tabulate": { + "hashes": [ + "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", + "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f" + ], + "markers": "python_version >= '3.7'", + "version": "==0.9.0" + }, + "toml": { + "hashes": [ + "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", + "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" + ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.10.2" + }, + "tomlkit": { + "hashes": [ + "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4", + "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.12.3" + }, + "trove-classifiers": { + "hashes": [ + "sha256:854aba3358f3cf10e5c0916aa533f5a39e27aadd8ade26a54cdc2a93257e39c4", + "sha256:bfdfe60bbf64985c524416afb637ecc79c558e0beb4b7f52b0039e01044b0229" + ], + "version": "==2024.1.31" + }, + "typing-extensions": { + "hashes": [ + "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", + "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" + ], + "markers": "python_version >= '3.8'", + "version": "==4.9.0" + }, + "typing-inspect": { + "hashes": [ + "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", + "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78" + ], + "version": "==0.9.0" + } + } +} diff --git a/lint-workflow-v2/README.md b/lint-workflow-v2/README.md new file mode 100644 index 00000000..12dac3e1 --- /dev/null +++ b/lint-workflow-v2/README.md @@ -0,0 +1,146 @@ +# Bitwarden Workflow Linter + +## Installation + +## PyPi +``` +Not yet implemented +``` + +### Locally +``` +git clone git@github.com:bitwarden/gh-actions.git +cd gh-actions/lint-workflow-v2 + +pip install -e . +``` + +## Usage +### Setup settings.yaml + +If a non-default configuration is desired (different than `src/bitwarden_workflow_linter/default_settings.yaml`), copy +the below and create a `settings.yaml` in the directory that `bwwl` will be running from. + +```yaml +enabled_rules: + - bitwarden_workflow_linter.rules.name_exists.RuleNameExists + - bitwarden_workflow_linter.rules.name_capitalized.RuleNameCapitalized + - bitwarden_workflow_linter.rules.pinned_job_runner.RuleJobRunnerVersionPinned + - bitwarden_workflow_linter.rules.job_environment_prefix.RuleJobEnvironmentPrefix + - bitwarden_workflow_linter.rules.step_pinned.RuleStepUsesPinned + +approved_actions_path: default_actions.json +``` + + +``` +usage: bwwl [-h] [-v] {lint,actions} ... + +positional arguments: + {lint,actions} + lint Verify that a GitHub Action Workflow follows all of the Rules. + actions Add or Update Actions in the pre-approved list. + +options: + -h, --help show this help message and exit + -v, --verbose +``` + +## Development +### Requirements + +- Python 3.11 +- pipenv + +### Setup + +``` +pipenv install --dev +pipenv shell +``` + +### Testing + +All built-in `src/bitwarden_workflow_linter/rules` should have 100% code coverage and we should shoot for an overall coverage of 80%+. +We are lax on the +[imperative shell](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell) +(code interacting with other systems; ie. disk, network, etc), but we strive to maintain a high coverage over the +functional core (objects and models). + +``` +pipenv shell +pytest tests --cov=src +``` + +### Code Reformatting + +We adhere to PEP8 and use `black` to maintain this adherence. `black` should be run on any change being merged +to `main`. + +``` +pipenv shell +black . +``` + +### Linting + +We loosely use [Google's Python style guide](https://google.github.io/styleguide/pyguide.html), but yield to +`black` when there is a conflict + +``` +pipenv shell +pylint --rcfile pylintrc src/ tests/ +``` + +### Add a new Rule + +A new Rule is created by extending the Rule base class and overriding the `fn(obj: Union[Workflow, Job, Step])` method. +Available attributes of `Workflows`, `Jobs` and `Steps` can be found in their definitons under `src/models`. + +For a simple example, we'll take a look at enforcing the existence of the `name` key in a Job. This is already done by +default with the src.rules.name_exists.RuleNameExists, but provides a simple enough example to walk through. + +```python +from typing import Union, Tuple + +from ..rule import Rule +from ..models.job import Job +from ..models.workflow import Workflow +from ..models.step import Step +from ..utils import LintLevels, Settings + + +class RuleJobNameExists(Rule): + def __init__(self, settings: Settings = None) -> None: + self.message = "name must exist" + self.on_fail: LintLevels = LintLevels.ERROR + self.compatibility: List[Union[Workflow, Job, Step]] = [Job] + self.settings: Settings = settings + + def fn(self, obj: Job) -> Tuple[bool, str]: + """ """ + if obj.name is not None: + return True, "" + return False, self.message +``` + +By default, a new Rule needs five things: + +- `self.message`: The message to return to the user on a lint failure +- `self.on_fail`: The level of failure on a lint failure (NONE, WARNING, ERROR). + NONE and WARNING will exit with a code of 0 (unless using `strict` mode for WARNING). + ERROR will exit with a non-zero exit code +- `self.compatibility`: The list of objects this rule is compatible with. This is used to create separate instances of + the Rule for each object in the Rules collection. +- `self.settings`: In general, this should default to what is shown here, but allows for overrides +- `self.fn`: The function doing the actual work to check the object and enforce the standard. + +`fn` can be as simple or as complex as it needs to be to run a check on a _single_ object. This linter currently does +not support Rules that check against multiple objects at a time OR file level formatting (one empty between each step or +two empty lines between each job) + + +### ToDo + +- [ ] Add Rule to assert correct format for single line run + diff --git a/lint-workflow-v2/Taskfile.yml b/lint-workflow-v2/Taskfile.yml new file mode 100644 index 00000000..00cc69c4 --- /dev/null +++ b/lint-workflow-v2/Taskfile.yml @@ -0,0 +1,71 @@ +# https://taskfile.dev + +version: '3' + +tasks: + fmt: + silent: true + cmds: + - pipenv run black . + + lint: + silent: true + cmds: + - pipenv run pylint --rcflie pylintrc {{.CLI_ARGS}} + + type: + silent: true + cmds: + - pipenv run pytype src + + update: + silent: true + cmds: + - | + deps=$(pipenv requirements --exclude-markers | tail -n +2 | awk '{print "\t\""$0"\","}') + export DEPS=$(printf "$deps") + envsubst < pyproject.toml.tpl > pyproject.toml + + install: + silent: true + cmds: + - task: update + - pipenv run python -m pip install -e . + + test:unit: + cmds: + - pipenv run pytest tests + + test:unit:single: + cmds: + - pipenv run pytest {{.CLI_ARGS}} + + test:cov: + cmds: + - pipenv run pytest --cov-report term --cov=src tests + + test:cov:detailed: + cmds: + - pipenv run pytest --cov-report term-missing --cov=src tests + + test:e2e:lint: + cmds: + - pipenv run bwwl lint --files tests/fixtures + + test:e2e:lint:single: + cmds: + - pipenv run bwwl lint --files tests/fixtures/test_a.yml + + test:e2e:actions:add: + cmds: + - pipenv run bwwl actions --output test.json add bitwarden/sm-action + + test:e2e:actions:update: + cmds: + - pipenv run bwwl actions --output test.json update + + dist: + silent: true + cmds: + - task: update + - pipenv run python -m build diff --git a/lint-workflow-v2/action.yml b/lint-workflow-v2/action.yml new file mode 100644 index 00000000..0ecde6ff --- /dev/null +++ b/lint-workflow-v2/action.yml @@ -0,0 +1,32 @@ +name: 'Lint Workflow' +description: 'Lints GitHub Actions Workflow' +inputs: + workflows: + description: "Path to workflow file(s)" + required: true +runs: + using: "composite" + steps: + - name: Install dependencies + run: pip install --user yamllint + shell: bash + + - name: Setup + id: setup + run: | + FORMAT_PATH=$(echo ${{ inputs.workflows }} | sed 's/ *$//') + echo "path=$FORMAT_PATH" >> $GITHUB_OUTPUT + shell: bash + + - name: Python lint + run: python ${{ github.action_path }}/lint.py "${{ steps.setup.outputs.path }}" + shell: bash + + - name: YAML lint + run: | + WORKFLOWS=($(echo "${{ steps.setup.outputs.path }}" | tr ' ' '\n')) + for WORKFLOW in "${WORKFLOWS[@]}"; do + yamllint -f colored -c ${{ github.action_path }}/.yamllint.yml $WORKFLOW + done + shell: bash + working-directory: ${{ github.workspace }} diff --git a/lint-workflow-v2/actions.json.bak b/lint-workflow-v2/actions.json.bak new file mode 100644 index 00000000..f601cb89 --- /dev/null +++ b/lint-workflow-v2/actions.json.bak @@ -0,0 +1,262 @@ +{ + "Asana/create-app-attachment-github-action": { + "name": "Asana/create-app-attachment-github-action", + "sha": "affc72d57bac733d864d4189ed69a9cbd61a9e4f", + "version": "v1.3" + }, + "Azure/functions-action": { + "name": "Azure/functions-action", + "sha": "238dc3c45bb1b04e5d16ff9e75cddd1d86753bd6", + "version": "v1.5.1" + }, + "Azure/get-keyvault-secrets": { + "name": "Azure/get-keyvault-secrets", + "sha": "b5c723b9ac7870c022b8c35befe620b7009b336f", + "version": "v1" + }, + "Azure/login": { + "name": "Azure/login", + "sha": "de95379fe4dadc2defb305917eaa7e5dde727294", + "version": "v1.5.1" + }, + "Swatinem/rust-cache": { + "name": "Swatinem/rust-cache", + "sha": "a95ba195448af2da9b00fb742d14ffaaf3c21f43", + "version": "v2.7.0" + }, + "SwiftDocOrg/github-wiki-publish-action": { + "name": "SwiftDocOrg/github-wiki-publish-action", + "sha": "a87db85ed06e4431be29cfdcb22b9653881305d0", + "version": "1.0.0" + }, + "SwiftDocOrg/swift-doc": { + "name": "SwiftDocOrg/swift-doc", + "sha": "f935ebfe524a0ff27bda07dadc3662e3e45b5125", + "version": "1.0.0-rc.1" + }, + "act10ns/slack": { + "name": "act10ns/slack", + "sha": "ed1309ab9862e57e9e583e51c7889486b9a00b0f", + "version": "v2.0.0" + }, + "actions/cache": { + "name": "actions/cache", + "sha": "704facf57e6136b1bc63b828d79edcd491f0ee84", + "version": "v3.3.2" + }, + "actions/checkout": { + "name": "actions/checkout", + "sha": "b4ffde65f46336ab88eb53be808477a3936bae11", + "version": "v4.1.1" + }, + "actions/delete-package-versions": { + "name": "actions/delete-package-versions", + "sha": "0d39a63126868f5eefaa47169615edd3c0f61e20", + "version": "v4.1.1" + }, + "actions/download-artifact": { + "name": "actions/download-artifact", + "sha": "f44cd7b40bfd40b6aa1cc1b9b5b7bf03d3c67110", + "version": "v4.1.0" + }, + "actions/github-script": { + "name": "actions/github-script", + "sha": "60a0d83039c74a4aee543508d2ffcb1c3799cdea", + "version": "v7.0.1" + }, + "actions/labeler": { + "name": "actions/labeler", + "sha": "8558fd74291d67161a8a78ce36a881fa63b766a9", + "version": "v5.0.0" + }, + "actions/setup-dotnet": { + "name": "actions/setup-dotnet", + "sha": "4d6c8fcf3c8f7a60068d26b594648e99df24cee3", + "version": "v4.0.0" + }, + "actions/setup-java": { + "name": "actions/setup-java", + "sha": "387ac29b308b003ca37ba93a6cab5eb57c8f5f93", + "version": "v4.0.0" + }, + "actions/setup-node": { + "name": "actions/setup-node", + "sha": "b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8", + "version": "v4.0.1" + }, + "actions/setup-python": { + "name": "actions/setup-python", + "sha": "0a5c61591373683505ea898e09a3ea4f39ef2b9c", + "version": "v5.0.0" + }, + "actions/stale": { + "name": "actions/stale", + "sha": "28ca1036281a5e5922ead5184a1bbf96e5fc984e", + "version": "v9.0.0" + }, + "actions/upload-artifact": { + "name": "actions/upload-artifact", + "sha": "c7d193f32edcb7bfad88892161225aeda64e9392", + "version": "v4.0.0" + }, + "android-actions/setup-android": { + "name": "android-actions/setup-android", + "sha": "07976c6290703d34c16d382cb36445f98bb43b1f", + "version": "v3.2.0" + }, + "azure/webapps-deploy": { + "name": "azure/webapps-deploy", + "sha": "145a0687697df1d8a28909569f6e5d86213041f9", + "version": "v3.0.0" + }, + "bitwarden/sm-action": { + "name": "bitwarden/sm-action", + "sha": "92d1d6a4f26a89a8191c83ab531a53544578f182", + "version": "v2.0.0" + }, + "checkmarx/ast-github-action": { + "name": "checkmarx/ast-github-action", + "sha": "72d549beebd0bc5bbafa559f198161b6ce7c03df", + "version": "2.0.21" + }, + "chrnorm/deployment-action": { + "name": "chrnorm/deployment-action", + "sha": "d42cde7132fcec920de534fffc3be83794335c00", + "version": "v2.0.5" + }, + "chrnorm/deployment-status": { + "name": "chrnorm/deployment-status", + "sha": "2afb7d27101260f4a764219439564d954d10b5b0", + "version": "v2.0.1" + }, + "chromaui/action": { + "name": "chromaui/action", + "sha": "80bf5911f28005ed208f15b7268843b79ca0e23a", + "version": "v1" + }, + "cloudflare/pages-action": { + "name": "cloudflare/pages-action", + "sha": "f0a1cd58cd66095dee69bfa18fa5efd1dde93bca", + "version": "v1.5.0" + }, + "convictional/trigger-workflow-and-wait": { + "name": "convictional/trigger-workflow-and-wait", + "sha": "f69fa9eedd3c62a599220f4d5745230e237904be", + "version": "v1.6.5" + }, + "crazy-max/ghaction-import-gpg": { + "name": "crazy-max/ghaction-import-gpg", + "sha": "01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4", + "version": "v6.1.0" + }, + "crowdin/github-action": { + "name": "crowdin/github-action", + "sha": "fdc55cdc519e86e32c22a07528d649277f1127f2", + "version": "v1.16.0" + }, + "dawidd6/action-download-artifact": { + "name": "dawidd6/action-download-artifact", + "sha": "e7466d1a7587ed14867642c2ca74b5bcc1e19a2d", + "version": "v3.0.0" + }, + "dawidd6/action-homebrew-bump-formula": { + "name": "dawidd6/action-homebrew-bump-formula", + "sha": "75ed025ff3ad1d617862838b342b06d613a0ddf3", + "version": "v3.10.1" + }, + "digitalocean/action-doctl": { + "name": "digitalocean/action-doctl", + "sha": "e5cb5b0cde9789f79c5115c2c4d902f38a708804", + "version": "v2.5.0" + }, + "docker/build-push-action": { + "name": "docker/build-push-action", + "sha": "4a13e500e55cf31b7a5d59a38ab2040ab0f42f56", + "version": "v5.1.0" + }, + "docker/setup-buildx-action": { + "name": "docker/setup-buildx-action", + "sha": "f95db51fddba0c2d1ec667646a06c2ce06100226", + "version": "v3.0.0" + }, + "docker/setup-qemu-action": { + "name": "docker/setup-qemu-action", + "sha": "68827325e0b33c7199eb31dd4e31fbe9023e06e3", + "version": "v3.0.0" + }, + "dorny/test-reporter": { + "name": "dorny/test-reporter", + "sha": "afe6793191b75b608954023a46831a3fe10048d4", + "version": "v1.7.0" + }, + "dtolnay/rust-toolchain": { + "name": "dtolnay/rust-toolchain", + "sha": "1482605bfc5719782e1267fd0c0cc350fe7646b8", + "version": "v1" + }, + "futureware-tech/simulator-action": { + "name": "futureware-tech/simulator-action", + "sha": "bfa03d93ec9de6dacb0c5553bbf8da8afc6c2ee9", + "version": "v3" + }, + "hashicorp/setup-packer": { + "name": "hashicorp/setup-packer", + "sha": "ecc5516821087666a672c0d280a0084ea6d9aafd", + "version": "v2.0.1" + }, + "macauley/action-homebrew-bump-cask": { + "name": "macauley/action-homebrew-bump-cask", + "sha": "445c42390d790569d938f9068d01af39ca030feb", + "version": "v1.0.0" + }, + "microsoft/setup-msbuild": { + "name": "microsoft/setup-msbuild", + "sha": "1ff57057b5cfdc39105cd07a01d78e9b0ea0c14c", + "version": "v1.3.1" + }, + "ncipollo/release-action": { + "name": "ncipollo/release-action", + "sha": "6c75be85e571768fa31b40abf38de58ba0397db5", + "version": "v1.13.0" + }, + "peter-evans/close-issue": { + "name": "peter-evans/close-issue", + "sha": "276d7966e389d888f011539a86c8920025ea0626", + "version": "v3.0.1" + }, + "ruby/setup-ruby": { + "name": "ruby/setup-ruby", + "sha": "360dc864d5da99d54fcb8e9148c14a84b90d3e88", + "version": "v1.165.1" + }, + "samuelmeuli/action-snapcraft": { + "name": "samuelmeuli/action-snapcraft", + "sha": "d33c176a9b784876d966f80fb1b461808edc0641", + "version": "v2.1.1" + }, + "snapcore/action-build": { + "name": "snapcore/action-build", + "sha": "2096990827aa966f773676c8a53793c723b6b40f", + "version": "v1.2.0" + }, + "sonarsource/sonarcloud-github-action": { + "name": "sonarsource/sonarcloud-github-action", + "sha": "49e6cd3b187936a73b8280d59ffd9da69df63ec9", + "version": "v2.1.1" + }, + "stackrox/kube-linter-action": { + "name": "stackrox/kube-linter-action", + "sha": "ca0d55b925470deb5b04b556e6c4276ea94d03c3", + "version": "v1.0.4" + }, + "tj-actions/changed-files": { + "name": "tj-actions/changed-files", + "sha": "716b1e13042866565e00e85fd4ec490e186c4a2f", + "version": "v41.0.1" + }, + "yogevbd/enforce-label-action": { + "name": "yogevbd/enforce-label-action", + "sha": "a3c219da6b8fa73f6ba62b68ff09c469b3a1c024", + "version": "2.2.2" + } +} diff --git a/lint-workflow-v2/pylintrc b/lint-workflow-v2/pylintrc new file mode 100644 index 00000000..e2378102 --- /dev/null +++ b/lint-workflow-v2/pylintrc @@ -0,0 +1,401 @@ +# This Pylint rcfile contains a best-effort configuration to uphold the +# best-practices and style described in the Google Python style guide: +# https://google.github.io/styleguide/pyguide.html +# +# Its canonical open-source location is: +# https://google.github.io/styleguide/pylintrc + +[MAIN] + +# Files or directories to be skipped. They should be base names, not paths. +ignore=third_party + +# Files or directories matching the regex patterns are skipped. The regex +# matches against base names, not paths. +ignore-patterns= + +# Pickle collected data for later comparisons. +persistent=no + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Use multiple processes to speed up Pylint. +jobs=4 + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=R, + abstract-method, + apply-builtin, + arguments-differ, + attribute-defined-outside-init, + backtick, + bad-option-value, + basestring-builtin, + buffer-builtin, + c-extension-no-member, + consider-using-enumerate, + cmp-builtin, + cmp-method, + coerce-builtin, + coerce-method, + delslice-method, + div-method, + eq-without-hash, + execfile-builtin, + file-builtin, + filter-builtin-not-iterating, + fixme, + getslice-method, + global-statement, + hex-method, + idiv-method, + implicit-str-concat, + import-error, + import-self, + import-star-module-level, + input-builtin, + intern-builtin, + invalid-str-codec, + locally-disabled, + long-builtin, + long-suffix, + map-builtin-not-iterating, + misplaced-comparison-constant, + missing-function-docstring, + metaclass-assignment, + next-method-called, + next-method-defined, + no-absolute-import, + no-init, # added + no-member, + no-name-in-module, + no-self-use, + nonzero-method, + oct-method, + old-division, + old-ne-operator, + old-octal-literal, + old-raise-syntax, + parameter-unpacking, + print-statement, + raising-string, + range-builtin-not-iterating, + raw_input-builtin, + rdiv-method, + reduce-builtin, + relative-import, + reload-builtin, + round-builtin, + setslice-method, + signature-differs, + standarderror-builtin, + suppressed-message, + sys-max-int, + trailing-newlines, + unichr-builtin, + unicode-builtin, + unnecessary-pass, + unpacking-in-except, + useless-else-on-loop, + useless-suppression, + using-cmp-argument, + wrong-import-order, + xrange-builtin, + zip-builtin-not-iterating, + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=no + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + + +[BASIC] + +# Good variable names which should always be accepted, separated by a comma +good-names=main,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty,cached_property.cached_property,cached_property.threaded_cached_property,cached_property.cached_property_with_ttl,cached_property.threaded_cached_property_with_ttl + +# Regular expression matching correct function names +function-rgx=^(?:(?PsetUp|tearDown|setUpModule|tearDownModule)|(?P_?[A-Z][a-zA-Z0-9]*)|(?P_?[a-z][a-z0-9_]*))$ + +# Regular expression matching correct variable names +variable-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct constant names +const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ + +# Regular expression matching correct attribute names +attr-rgx=^_{0,2}[a-z][a-z0-9_]*$ + +# Regular expression matching correct argument names +argument-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct class names +class-rgx=^_?[A-Z][a-zA-Z0-9]*$ + +# Regular expression matching correct module names +module-rgx=^(_?[a-z][a-z0-9_]*|__init__)$ + +# Regular expression matching correct method names +method-rgx=(?x)^(?:(?P_[a-z0-9_]+__|runTest|setUp|tearDown|setUpTestCase|tearDownTestCase|setupSelf|tearDownClass|setUpClass|(test|assert)_*[A-Z0-9][a-zA-Z0-9_]*|next)|(?P_{0,2}[A-Z][a-zA-Z0-9_]*)|(?P_{0,2}[a-z][a-z0-9_]*))$ + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=(__.*__|main|test.*|.*test|.*Test)$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=12 + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=88 + +# TODO(https://github.com/pylint-dev/pylint/issues/3352): Direct pylint to exempt +# lines made too long by directives to pytype. + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=(?x)( + ^\s*(\#\ )??$| + ^\s*(from\s+\S+\s+)?import\s+.+$) + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=yes + +# Maximum number of lines in a module +max-module-lines=99999 + +# String used as indentation unit. The internal Google style guide mandates 2 +# spaces. Google's externaly-published style guide says 4, consistent with +# PEP 8. Here, we use 2 spaces, for conformity with many open-sourced Google +# projects (like TensorFlow). + +# Overriden to 4 to conform with black +indent-string=' ' + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=TODO + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=yes + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_) + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six,six.moves,past.builtins,future.builtins,functools + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging,absl.logging,tensorflow.io.logging + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub, + TERMIOS, + Bastion, + rexec, + sets + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant, absl + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls, + class_ + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs diff --git a/lint-workflow-v2/pyproject.toml b/lint-workflow-v2/pyproject.toml new file mode 100644 index 00000000..cf65d04f --- /dev/null +++ b/lint-workflow-v2/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "bitwarden_workflow_linter" +dynamic = ["version"] +authors = ["Bitwarden Inc"] +description = "Custom GitHub Action Workflow Linter" +readme = "README.md" +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: OS Independent", +] + +dependencies = [ + "annotated-types==0.6.0", + "dataclasses-json==0.6.4", + "marshmallow==3.20.2", + "mypy-extensions==1.0.0", + "packaging==23.2", + "pydantic==2.6.0", + "pydantic-core==2.16.1", + "pyyaml==6.0.1", + "ruamel.yaml==0.18.5", + "ruamel.yaml.clib==0.2.8", + "typing-extensions==4.9.0", + "typing-inspect==0.9.0", + "urllib3==2.2.0", +] + +[project.urls] +Homepage = "https://github.com/bitwarden/gh-actions/tree/main/lint-workflow-v2" +Issues = "https://github.com/bitwarden/gh-actions/issues" + +[project.scripts] +bwwl = "bitwarden_workflow_linter.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/bitwarden_workflow_linter"] + +[tool.hatch.version] +path = "src/bitwarden_workflow_linter/__about__.py" diff --git a/lint-workflow-v2/pyproject.toml.tpl b/lint-workflow-v2/pyproject.toml.tpl new file mode 100644 index 00000000..472b8471 --- /dev/null +++ b/lint-workflow-v2/pyproject.toml.tpl @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "bitwarden_workflow_linter" +dynamic = ["version"] +authors = ["Bitwarden Inc"] +description = "Custom GitHub Action Workflow Linter" +readme = "README.md" +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: OS Independent", +] + +dependencies = [ +$DEPS +] + +[project.urls] +Homepage = "https://github.com/bitwarden/gh-actions/tree/main/lint-workflow-v2" +Issues = "https://github.com/bitwarden/gh-actions/issues" + +[project.scripts] +bwwl = "bitwarden_workflow_linter.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/bitwarden_workflow_linter"] + +[tool.hatch.version] +path = "src/bitwarden_workflow_linter/__about__.py" diff --git a/lint-workflow-v2/settings.yaml b/lint-workflow-v2/settings.yaml new file mode 100644 index 00000000..8b21d5cc --- /dev/null +++ b/lint-workflow-v2/settings.yaml @@ -0,0 +1,8 @@ +enabled_rules: + - bitwarden_workflow_linter.rules.name_exists.RuleNameExists + - bitwarden_workflow_linter.rules.name_capitalized.RuleNameCapitalized + - bitwarden_workflow_linter.rules.pinned_job_runner.RuleJobRunnerVersionPinned + - bitwarden_workflow_linter.rules.job_environment_prefix.RuleJobEnvironmentPrefix + - bitwarden_workflow_linter.rules.step_pinned.RuleStepUsesPinned + +approved_actions_path: default_actions.json diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/__about__.py b/lint-workflow-v2/src/bitwarden_workflow_linter/__about__.py new file mode 100644 index 00000000..cf04467c --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/__about__.py @@ -0,0 +1 @@ +version = "0.0.3" diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/__init__.py b/lint-workflow-v2/src/bitwarden_workflow_linter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/actions.py b/lint-workflow-v2/src/bitwarden_workflow_linter/actions.py new file mode 100644 index 00000000..88698127 --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/actions.py @@ -0,0 +1,217 @@ +"""Module providing Actions subcommand to manage list of pre-approved Actions.""" + +import argparse +import json +import logging +import os +import urllib3 as urllib + +from dataclasses import asdict +from typing import Optional, Tuple, Union + +from .utils import Colors, Settings, Action + + +class GitHubApiSchemaError(Exception): + """A generic Exception to catch redefinitions of GitHub Api Schema changes.""" + + pass + + +class ActionsCmd: + """Command to manage the pre-approved list of Actions + + This class contains logic to manage the list of pre-approved actions + to include: + - updating the action data in the list + - adding a new pre-approved action to the list with the data from the + latest release + + This class also includes supporting logic to interact with GitHub + + """ + + def __init__(self, settings: Optional[Settings] = None) -> None: + """Initialize the ActionsCmd class. + + Args: + settings: + A Settings object that contains any default, overridden, or custom settings + required anywhere in the application. + """ + self.settings = settings + + @staticmethod + def extend_parser( + subparsers: argparse._SubParsersAction, + ) -> argparse._SubParsersAction: + """Extends the CLI subparser with the options for ActionCmd. + + Add 'actions add' and 'actions update' to the CLI as subcommands + along with the options and arguments for each. + + Args: + subparsers: + The main argument parser to add subcommands and arguments to + """ + parser_actions = subparsers.add_parser( + "actions", help="!!BETA!!\nAdd or Update Actions in the pre-approved list." + ) + parser_actions.add_argument( + "-o", "--output", action="store", default="actions.json" + ) + subparsers_actions = parser_actions.add_subparsers( + required=True, dest="actions_command" + ) + subparsers_actions.add_parser("update", help="update action versions") + parser_actions_add = subparsers_actions.add_parser( + "add", help="add action to approved list" + ) + parser_actions_add.add_argument("name", help="action name [git owner/repo]") + + return subparsers + + def get_github_api_response( + self, url: str, action_name: str + ) -> Union[urllib.response.BaseHTTPResponse, None]: + """Call GitHub API with error logging without throwing an exception.""" + + http = urllib.PoolManager() + headers = {"user-agent": "bw-linter"} + + if os.getenv("GITHUB_TOKEN", None): + headers["Authorization"] = f"Token {os.environ['GITHUB_TOKEN']}" + + response = http.request("GET", url, headers=headers) + + if response.status == 403 and response.reason == "rate limit exceeded": + logging.error( + "Failed to call GitHub API for action: %s due to rate limit exceeded.", + action_name, + ) + return None + + if response.status == 401 and response.reason == "Unauthorized": + logging.error( + "Failed to call GitHub API for action: %s: %s.", + action_name, + response.data, + ) + return None + + return response + + def exists(self, action: Action) -> bool: + """Takes an action id and checks if the action repository exists.""" + + url = f"https://api.github.com/repos/{action.name}" + response = self.get_github_api_response(url, action.name) + + if response is None: + # Handle exceeding GitHub API limit by returning that the action exists + # without actually checking to prevent false errors on linter output. Only + # show it as an linter error. + return True + + if response.status == 404: + return False + + return True + + def get_latest_version(self, action: Action) -> Action | None: + """Gets the latest version of the Action to compare against.""" + + try: + # Get tag from latest release + response = self.get_github_api_response( + f"https://api.github.com/repos/{action.name}/releases/latest", + action.name, + ) + if not response: + return None + + tag_name = json.loads(response.data)["tag_name"] + + # Get the URL to the commit for the tag + response = self.get_github_api_response( + f"https://api.github.com/repos/{action.name}/git/ref/tags/{tag_name}", + action.name, + ) + if not response: + return None + + if json.loads(response.data)["object"]["type"] == "commit": + sha = json.loads(response.data)["object"]["sha"] + else: + url = json.loads(response.data)["object"]["url"] + # Follow the URL and get the commit sha for tags + response = self.get_github_api_response(url, action.name) + if not response: + return None + + sha = json.loads(response.data)["object"]["sha"] + except KeyError as err: + raise GitHubApiSchemaError( + f"Error with the GitHub API Response Schema for either /releases or /tags: {err}" + ) + + return Action(name=action.name, version=tag_name, sha=sha) + + def save_actions(self, updated_actions: dict[str, Action], filename: str) -> None: + """Save Actions to disk. + + This is used to track the list of approved actions. + """ + with open(filename, "w", encoding="utf8") as action_file: + converted_updated_actions = { + name: asdict(action) for name, action in updated_actions.items() + } + action_file.write( + json.dumps(converted_updated_actions, indent=2, sort_keys=True) + ) + + def add(self, new_action_name: str, filename: str) -> int: + """Subcommand to add a new Action to the list of approved Actions. + + 'actions add' will add an Action and all of its metadata and dump all + approved actions (including the new one) to either the default JSON file + or the one provided by '--output' + """ + print("Actions: add") + updated_actions = self.settings.approved_actions + proposed_action = Action(name=new_action_name) + + if self.exists(proposed_action): + latest = self.get_latest_version(proposed_action) + if latest: + updated_actions[latest.name] = latest + + self.save_actions(updated_actions, filename) + return 0 + + def update(self, filename: str) -> int: + """Subcommand to update all of the versions of the approved actions. + + 'actions update' will update all of the approved actions to the newest + version and dump all of the new data to either the default JSON file or + the one provided by '--output' + """ + print("Actions: update") + updated_actions = {} + for action in self.settings.approved_actions.values(): + if self.exists(action): + latest_release = self.get_latest_version(action) + if action != latest_release: + print( + ( + f" - {action.name} \033[{Colors.yellow}changed\033[0m: " + f"({action.version}, {action.sha}) => (" + f"{latest_release.version}, {latest_release.sha})" + ) + ) + else: + print(f" - {action.name} \033[{Colors.green}ok\033[0m") + updated_actions[action.name] = latest_release + + self.save_actions(updated_actions, filename) + return 0 diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/cli.py b/lint-workflow-v2/src/bitwarden_workflow_linter/cli.py new file mode 100644 index 00000000..6f9e662a --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/cli.py @@ -0,0 +1,55 @@ +"""This is the entrypoint module for the workflow-linter CLI.""" + +import argparse +import sys + +from typing import List, Optional + +from .actions import ActionsCmd +from .lint import LinterCmd +from .utils import Settings + + +local_settings = Settings.factory() + + +def main(input_args: Optional[List[str]] = None) -> int: + """CLI utility to lint GitHub Action Workflows. + + A CLI utility to enforce coding standards on GitHub Action workflows. The + utility also provides other subcommands to assist with other workflow + maintenance tasks; such as maintaining the list of approved GitHub Actions. + """ + linter_cmd = LinterCmd(settings=local_settings) + actions_cmd = ActionsCmd(settings=local_settings) + + # Read arguments from command line. + parser = argparse.ArgumentParser(prog="bwwl") + parser.add_argument("-v", "--verbose", action="store_true", default=False) + subparsers = parser.add_subparsers(required=True, dest="command") + + subparsers = LinterCmd.extend_parser(subparsers) + subparsers = ActionsCmd.extend_parser(subparsers) + + # Pull the arguments from the command line + input_args = sys.argv[1:] + if not input_args: + raise SystemExit(parser.print_help()) + + args = parser.parse_args(input_args) + + if args.command == "lint": + return linter_cmd.run(args.files, args.strict) + + if args.command == "actions": + print(f"{'-'*50}\n!!bwwl actions is in BETA!!\n{'-'*50}") + if args.actions_command == "add": + return actions_cmd.add(args.name, args.output) + if args.actions_command == "update": + return actions_cmd.update(args.output) + + return -1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/default_actions.json b/lint-workflow-v2/src/bitwarden_workflow_linter/default_actions.json new file mode 100644 index 00000000..f601cb89 --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/default_actions.json @@ -0,0 +1,262 @@ +{ + "Asana/create-app-attachment-github-action": { + "name": "Asana/create-app-attachment-github-action", + "sha": "affc72d57bac733d864d4189ed69a9cbd61a9e4f", + "version": "v1.3" + }, + "Azure/functions-action": { + "name": "Azure/functions-action", + "sha": "238dc3c45bb1b04e5d16ff9e75cddd1d86753bd6", + "version": "v1.5.1" + }, + "Azure/get-keyvault-secrets": { + "name": "Azure/get-keyvault-secrets", + "sha": "b5c723b9ac7870c022b8c35befe620b7009b336f", + "version": "v1" + }, + "Azure/login": { + "name": "Azure/login", + "sha": "de95379fe4dadc2defb305917eaa7e5dde727294", + "version": "v1.5.1" + }, + "Swatinem/rust-cache": { + "name": "Swatinem/rust-cache", + "sha": "a95ba195448af2da9b00fb742d14ffaaf3c21f43", + "version": "v2.7.0" + }, + "SwiftDocOrg/github-wiki-publish-action": { + "name": "SwiftDocOrg/github-wiki-publish-action", + "sha": "a87db85ed06e4431be29cfdcb22b9653881305d0", + "version": "1.0.0" + }, + "SwiftDocOrg/swift-doc": { + "name": "SwiftDocOrg/swift-doc", + "sha": "f935ebfe524a0ff27bda07dadc3662e3e45b5125", + "version": "1.0.0-rc.1" + }, + "act10ns/slack": { + "name": "act10ns/slack", + "sha": "ed1309ab9862e57e9e583e51c7889486b9a00b0f", + "version": "v2.0.0" + }, + "actions/cache": { + "name": "actions/cache", + "sha": "704facf57e6136b1bc63b828d79edcd491f0ee84", + "version": "v3.3.2" + }, + "actions/checkout": { + "name": "actions/checkout", + "sha": "b4ffde65f46336ab88eb53be808477a3936bae11", + "version": "v4.1.1" + }, + "actions/delete-package-versions": { + "name": "actions/delete-package-versions", + "sha": "0d39a63126868f5eefaa47169615edd3c0f61e20", + "version": "v4.1.1" + }, + "actions/download-artifact": { + "name": "actions/download-artifact", + "sha": "f44cd7b40bfd40b6aa1cc1b9b5b7bf03d3c67110", + "version": "v4.1.0" + }, + "actions/github-script": { + "name": "actions/github-script", + "sha": "60a0d83039c74a4aee543508d2ffcb1c3799cdea", + "version": "v7.0.1" + }, + "actions/labeler": { + "name": "actions/labeler", + "sha": "8558fd74291d67161a8a78ce36a881fa63b766a9", + "version": "v5.0.0" + }, + "actions/setup-dotnet": { + "name": "actions/setup-dotnet", + "sha": "4d6c8fcf3c8f7a60068d26b594648e99df24cee3", + "version": "v4.0.0" + }, + "actions/setup-java": { + "name": "actions/setup-java", + "sha": "387ac29b308b003ca37ba93a6cab5eb57c8f5f93", + "version": "v4.0.0" + }, + "actions/setup-node": { + "name": "actions/setup-node", + "sha": "b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8", + "version": "v4.0.1" + }, + "actions/setup-python": { + "name": "actions/setup-python", + "sha": "0a5c61591373683505ea898e09a3ea4f39ef2b9c", + "version": "v5.0.0" + }, + "actions/stale": { + "name": "actions/stale", + "sha": "28ca1036281a5e5922ead5184a1bbf96e5fc984e", + "version": "v9.0.0" + }, + "actions/upload-artifact": { + "name": "actions/upload-artifact", + "sha": "c7d193f32edcb7bfad88892161225aeda64e9392", + "version": "v4.0.0" + }, + "android-actions/setup-android": { + "name": "android-actions/setup-android", + "sha": "07976c6290703d34c16d382cb36445f98bb43b1f", + "version": "v3.2.0" + }, + "azure/webapps-deploy": { + "name": "azure/webapps-deploy", + "sha": "145a0687697df1d8a28909569f6e5d86213041f9", + "version": "v3.0.0" + }, + "bitwarden/sm-action": { + "name": "bitwarden/sm-action", + "sha": "92d1d6a4f26a89a8191c83ab531a53544578f182", + "version": "v2.0.0" + }, + "checkmarx/ast-github-action": { + "name": "checkmarx/ast-github-action", + "sha": "72d549beebd0bc5bbafa559f198161b6ce7c03df", + "version": "2.0.21" + }, + "chrnorm/deployment-action": { + "name": "chrnorm/deployment-action", + "sha": "d42cde7132fcec920de534fffc3be83794335c00", + "version": "v2.0.5" + }, + "chrnorm/deployment-status": { + "name": "chrnorm/deployment-status", + "sha": "2afb7d27101260f4a764219439564d954d10b5b0", + "version": "v2.0.1" + }, + "chromaui/action": { + "name": "chromaui/action", + "sha": "80bf5911f28005ed208f15b7268843b79ca0e23a", + "version": "v1" + }, + "cloudflare/pages-action": { + "name": "cloudflare/pages-action", + "sha": "f0a1cd58cd66095dee69bfa18fa5efd1dde93bca", + "version": "v1.5.0" + }, + "convictional/trigger-workflow-and-wait": { + "name": "convictional/trigger-workflow-and-wait", + "sha": "f69fa9eedd3c62a599220f4d5745230e237904be", + "version": "v1.6.5" + }, + "crazy-max/ghaction-import-gpg": { + "name": "crazy-max/ghaction-import-gpg", + "sha": "01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4", + "version": "v6.1.0" + }, + "crowdin/github-action": { + "name": "crowdin/github-action", + "sha": "fdc55cdc519e86e32c22a07528d649277f1127f2", + "version": "v1.16.0" + }, + "dawidd6/action-download-artifact": { + "name": "dawidd6/action-download-artifact", + "sha": "e7466d1a7587ed14867642c2ca74b5bcc1e19a2d", + "version": "v3.0.0" + }, + "dawidd6/action-homebrew-bump-formula": { + "name": "dawidd6/action-homebrew-bump-formula", + "sha": "75ed025ff3ad1d617862838b342b06d613a0ddf3", + "version": "v3.10.1" + }, + "digitalocean/action-doctl": { + "name": "digitalocean/action-doctl", + "sha": "e5cb5b0cde9789f79c5115c2c4d902f38a708804", + "version": "v2.5.0" + }, + "docker/build-push-action": { + "name": "docker/build-push-action", + "sha": "4a13e500e55cf31b7a5d59a38ab2040ab0f42f56", + "version": "v5.1.0" + }, + "docker/setup-buildx-action": { + "name": "docker/setup-buildx-action", + "sha": "f95db51fddba0c2d1ec667646a06c2ce06100226", + "version": "v3.0.0" + }, + "docker/setup-qemu-action": { + "name": "docker/setup-qemu-action", + "sha": "68827325e0b33c7199eb31dd4e31fbe9023e06e3", + "version": "v3.0.0" + }, + "dorny/test-reporter": { + "name": "dorny/test-reporter", + "sha": "afe6793191b75b608954023a46831a3fe10048d4", + "version": "v1.7.0" + }, + "dtolnay/rust-toolchain": { + "name": "dtolnay/rust-toolchain", + "sha": "1482605bfc5719782e1267fd0c0cc350fe7646b8", + "version": "v1" + }, + "futureware-tech/simulator-action": { + "name": "futureware-tech/simulator-action", + "sha": "bfa03d93ec9de6dacb0c5553bbf8da8afc6c2ee9", + "version": "v3" + }, + "hashicorp/setup-packer": { + "name": "hashicorp/setup-packer", + "sha": "ecc5516821087666a672c0d280a0084ea6d9aafd", + "version": "v2.0.1" + }, + "macauley/action-homebrew-bump-cask": { + "name": "macauley/action-homebrew-bump-cask", + "sha": "445c42390d790569d938f9068d01af39ca030feb", + "version": "v1.0.0" + }, + "microsoft/setup-msbuild": { + "name": "microsoft/setup-msbuild", + "sha": "1ff57057b5cfdc39105cd07a01d78e9b0ea0c14c", + "version": "v1.3.1" + }, + "ncipollo/release-action": { + "name": "ncipollo/release-action", + "sha": "6c75be85e571768fa31b40abf38de58ba0397db5", + "version": "v1.13.0" + }, + "peter-evans/close-issue": { + "name": "peter-evans/close-issue", + "sha": "276d7966e389d888f011539a86c8920025ea0626", + "version": "v3.0.1" + }, + "ruby/setup-ruby": { + "name": "ruby/setup-ruby", + "sha": "360dc864d5da99d54fcb8e9148c14a84b90d3e88", + "version": "v1.165.1" + }, + "samuelmeuli/action-snapcraft": { + "name": "samuelmeuli/action-snapcraft", + "sha": "d33c176a9b784876d966f80fb1b461808edc0641", + "version": "v2.1.1" + }, + "snapcore/action-build": { + "name": "snapcore/action-build", + "sha": "2096990827aa966f773676c8a53793c723b6b40f", + "version": "v1.2.0" + }, + "sonarsource/sonarcloud-github-action": { + "name": "sonarsource/sonarcloud-github-action", + "sha": "49e6cd3b187936a73b8280d59ffd9da69df63ec9", + "version": "v2.1.1" + }, + "stackrox/kube-linter-action": { + "name": "stackrox/kube-linter-action", + "sha": "ca0d55b925470deb5b04b556e6c4276ea94d03c3", + "version": "v1.0.4" + }, + "tj-actions/changed-files": { + "name": "tj-actions/changed-files", + "sha": "716b1e13042866565e00e85fd4ec490e186c4a2f", + "version": "v41.0.1" + }, + "yogevbd/enforce-label-action": { + "name": "yogevbd/enforce-label-action", + "sha": "a3c219da6b8fa73f6ba62b68ff09c469b3a1c024", + "version": "2.2.2" + } +} diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/default_settings.yaml b/lint-workflow-v2/src/bitwarden_workflow_linter/default_settings.yaml new file mode 100644 index 00000000..8b21d5cc --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/default_settings.yaml @@ -0,0 +1,8 @@ +enabled_rules: + - bitwarden_workflow_linter.rules.name_exists.RuleNameExists + - bitwarden_workflow_linter.rules.name_capitalized.RuleNameCapitalized + - bitwarden_workflow_linter.rules.pinned_job_runner.RuleJobRunnerVersionPinned + - bitwarden_workflow_linter.rules.job_environment_prefix.RuleJobEnvironmentPrefix + - bitwarden_workflow_linter.rules.step_pinned.RuleStepUsesPinned + +approved_actions_path: default_actions.json diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/lint.py b/lint-workflow-v2/src/bitwarden_workflow_linter/lint.py new file mode 100644 index 00000000..ebc18165 --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/lint.py @@ -0,0 +1,173 @@ +"""Module providing Lint subcommand to run custom linting rules against GitHub Action +Workflows.""" + +import argparse +import os + +from functools import reduce +from typing import Optional + +from .load import WorkflowBuilder, Rules +from .utils import LintFinding, Settings + + +class LinterCmd: + """Command to lint GitHub Action Workflow files + + This class contains logic to lint workflows that are passed in. + Supporting logic is supplied to: + - build out the list of Rules desired + - select and validate the workflow files to lint + """ + + def __init__(self, settings: Optional[Settings] = None) -> None: + """Initailize the LinterCmd class. + + Args: + settings: + A Settings object that contains any default, overridden, or custom settings + required anywhere in the application. + """ + self.rules = Rules(settings=settings) + + @staticmethod + def extend_parser( + subparsers: argparse._SubParsersAction, + ) -> argparse._SubParsersAction: + """Extends the CLI subparser with the options for LintCmd. + + Add 'lint' as a subcommand along with its options and arguments + + Args: + subparsers: + The main argument parser to add subcommands and arguments to + """ + parser_lint = subparsers.add_parser( + "lint", + help="Verify that a GitHub Action Workflow follows all of the Rules.", + ) + parser_lint.add_argument( + "-s", + "--strict", + action="store_true", + help="return non-zero exit code on warnings as well as errors", + ) + parser_lint.add_argument("-f", "--files", action="append", help="files to lint") + parser_lint.add_argument( + "--output", + action="store", + help="output format: [stdout|json|md]", + default="stdout", + ) + return subparsers + + def get_max_error_level(self, findings: list[LintFinding]) -> int: + """Get max error level from list of findings. + + Compute the maximum error level to determine the exit code required. + # if max(error) return exit(1); else return exit(0) + + Args: + findings: + All of the findings that the linter found while linting a workflow. + + Return: + The numeric value of the maximum lint finding + """ + if len(findings) == 0: + return 0 + return max(findings, key=lambda finding: finding.level.code).level.code + + def lint_file(self, filename: str) -> int: + """Lint a single workflow. + + Run all of the Workflow, Job, and Step level rules that have been enabled. + + Args: + filename: + The name of the file that contains the workflow to lint + + Returns: + The maximum error level found in the file (none, warning, error) to + calculate the exit code from. + """ + findings = [] + max_error_level = 0 + + print(f"Linting: {filename}") + workflow = WorkflowBuilder.build(filename) + + for rule in self.rules.workflow: + findings.append(rule.execute(workflow)) + + for _, job in workflow.jobs.items(): + for rule in self.rules.job: + findings.append(rule.execute(job)) + + if job.steps is not None: + for step in job.steps: + for rule in self.rules.step: + findings.append(rule.execute(step)) + + findings = list(filter(lambda a: a is not None, findings)) + + if len(findings) > 0: + for finding in findings: + print(f" - {finding}") + print() + + max_error_level = self.get_max_error_level(findings) + + return max_error_level + + def generate_files(self, files: list[str]) -> list[str]: + """Generate the list of files to lint. + + Searches the list of directory and/or files taken from the CLI. + + Args: + files: + list of file names or directory names. + + Returns: + A sorted set of all workflow files in the path(s) specified. + """ + workflow_files = [] + for path in files: + if os.path.isfile(path): + workflow_files.append(path) + elif os.path.isdir(path): + for subdir, _, files in os.walk(path): + for filename in files: + filepath = subdir + os.sep + filename + if filepath.endswith((".yml", ".yaml")): + workflow_files.append(filepath) + + return sorted(set(workflow_files)) + + def run(self, input_files: list[str], strict: bool = False) -> int: + """Execute the LinterCmd. + + Args: + input_files: + list of file names or directory names. + strict: + fail on WARNING instead of succeed + + Returns + The return_code for the entire CLI to indicate success/failure + """ + files = self.generate_files(input_files) + + if len(input_files) > 0: + return_code = reduce( + lambda a, b: a if a > b else b, map(self.lint_file, files) + ) + + if return_code == 1 and not strict: + return_code = 0 + + return return_code + else: + print(f'File(s)/Directory: "{input_files}" does not exist, exiting.') + return -1 diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/load.py b/lint-workflow-v2/src/bitwarden_workflow_linter/load.py new file mode 100644 index 00000000..7f863725 --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/load.py @@ -0,0 +1,146 @@ +"""Module to load for Workflows and Rules.""" + +import importlib + +from typing import List, Optional + +from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap + +from .models.job import Job +from .models.step import Step +from .models.workflow import Workflow +from .rule import Rule +from .utils import Settings + + +yaml = YAML() + + +class WorkflowBuilderError(Exception): + """Exception to indicate an error with the WorkflowBuilder.""" + + pass + + +class WorkflowBuilder: + """Collection of methods to build Workflow objects.""" + + @classmethod + def __load_workflow_from_file(cls, filename: str) -> CommentedMap: + """Load YAML from disk. + + Args: + filename: + The name of the YAML file to read. + + Returns: + A CommentedMap that contains the dict() representation of the + YAML file. It includes the comments as a part of their respective + objects (depending on their location in the file). + """ + with open(filename, encoding="utf8") as file: + return yaml.load(file) + + @classmethod + def __build_workflow(cls, loaded_yaml: CommentedMap) -> Workflow: + """Parse the YAML and build out the workflow to run Rules against. + + Args: + loaded_yaml: + YAML that was loaded from either code or a file + + Returns + A Workflow to run linting Rules against + """ + return Workflow.init("", loaded_yaml) + + @classmethod + def build( + cls, + filename: Optional[str] = None, + workflow: Optional[CommentedMap] = None, + from_file: bool = True, + ) -> Workflow: + """Build a Workflow from either code or a file. + + This is a method that assists in testing by abstracting the disk IO + and allows for passing in a YAML object in code. + + Args: + filename: + The name of the file to load the YAML workflow from + yaml: + Pre-loaded YAML of a workflow + from_file: + Flag to determine if the YAML has already been loaded or needs to + be loaded from disk + """ + if from_file and filename is not None: + return cls.__build_workflow(cls.__load_workflow_from_file(filename)) + elif not from_file and workflow is not None: + return cls.__build_workflow(workflow) + + raise WorkflowBuilderError( + "The workflow must either be built from a file or from a CommentedMap" + ) + + +class LoadRulesError(Exception): + """Exception to indicate an error with loading rules.""" + + pass + + +class Rules: + """A collection of all of the types of rules. + + Rules is used as a collection of which Rules apply to which parts of the + workflow. It also assists in making sure the Rules that apply to multiple + types are not skipped. + """ + + workflow: List[Rule] = [] + job: List[Rule] = [] + step: List[Rule] = [] + + def __init__(self, settings: Settings) -> None: + """Initializes the Rules + + Args: + settings: + A Settings object that contains any default, overridden, or custom settings + required anywhere in the application. + """ + # [TODO]: data resiliency + for rule in settings.enabled_rules: + module_name = rule.split(".") + module_name = ".".join(module_name[:-1]) + rule_name = rule.split(".")[-1] + + try: + rule_class = getattr(importlib.import_module(module_name), rule_name) + rule_inst = rule_class(settings=settings) + + if Workflow in rule_inst.compatibility: + self.workflow.append(rule_inst) + if Job in rule_inst.compatibility: + self.job.append(rule_inst) + if Step in rule_inst.compatibility: + self.step.append(rule_inst) + except LoadRulesError as err: + print(f"Error loading: {rule}\n{err}") + + def list(self) -> None: + """Print the loaded Rules.""" + print("===== Loaded Rules =====") + print("workflow rules:") + for rule in self.workflow: + print(f" - {type(rule).__name__}") + print("job rules:") + for rule in self.job: + print(f" - {type(rule).__name__}") + print("step rules:") + for rule in self.step: + print(f" - {type(rule).__name__}") + print("========================\n") diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/models/__init__.py b/lint-workflow-v2/src/bitwarden_workflow_linter/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/models/job.py b/lint-workflow-v2/src/bitwarden_workflow_linter/models/job.py new file mode 100644 index 00000000..8915ba33 --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/models/job.py @@ -0,0 +1,56 @@ +"""Representation for a job in a GitHub Action workflow.""" + +from dataclasses import dataclass, field +from typing import List, Optional, Self + +from dataclasses_json import config, dataclass_json, Undefined +from ruamel.yaml.comments import CommentedMap + +from .step import Step + + +@dataclass_json(undefined=Undefined.EXCLUDE) +@dataclass +class Job: + """Represents a job in a GitHub Action workflow. + + This object contains all of the data that is required to run the current linting + Rules against. If a new Rule requires a key that is missing, the attribute should + be added to this class to make it available for use in linting. + """ + + runs_on: Optional[str] = field(metadata=config(field_name="runs-on"), default=None) + key: Optional[str] = None + name: Optional[str] = None + env: Optional[CommentedMap] = None + steps: Optional[List[Step]] = None + uses: Optional[str] = None + uses_path: Optional[str] = None + uses_ref: Optional[str] = None + uses_with: Optional[CommentedMap] = field( + metadata=config(field_name="with"), default=None + ) + + @classmethod + def init(cls: Self, key: str, data: CommentedMap) -> Self: + """Custom dataclass constructor to map job data to a Job.""" + init_data = { + "key": key, + "name": data["name"] if "name" in data else None, + "runs-on": data["runs-on"] if "runs-on" in data else None, + "env": data["env"] if "env" in data else None, + } + + new_job = cls.from_dict(init_data) + + if "steps" in data: + new_job.steps = [ + Step.init(idx, new_job.key, step_data) + for idx, step_data in enumerate(data["steps"]) + ] + else: + new_job.uses = data["uses"].replace("\n", "") + if "@" in new_job.uses: + new_job.uses_path, new_job.uses_ref = new_job.uses.split("@") + + return new_job diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/models/step.py b/lint-workflow-v2/src/bitwarden_workflow_linter/models/step.py new file mode 100644 index 00000000..e18f89d3 --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/models/step.py @@ -0,0 +1,48 @@ +"""Representation for a job step in a GitHub Action workflow.""" + +from dataclasses import dataclass, field +from typing import Optional, Self + +from dataclasses_json import config, dataclass_json, Undefined +from ruamel.yaml.comments import CommentedMap + + +@dataclass_json(undefined=Undefined.EXCLUDE) +@dataclass +class Step: + """Represents a step in a GitHub Action workflow job. + + This object contains all of the data that is required to run the current linting + Rules against. If a new Rule requires a key that is missing, the attribute should + be added to this class to make it available for use in linting. + """ + + key: Optional[int] = None + job: Optional[str] = None + name: Optional[str] = None + env: Optional[CommentedMap] = None + uses: Optional[str] = None + uses_path: Optional[str] = None + uses_ref: Optional[str] = None + uses_comment: Optional[str] = None + uses_version: Optional[str] = None + uses_with: Optional[CommentedMap] = field( + metadata=config(field_name="with"), default=None + ) + run: Optional[str] = None + + @classmethod + def init(cls: Self, idx: int, job: str, data: CommentedMap) -> Self: + """Custom dataclass constructor to map a job step data to a Step.""" + new_step = cls.from_dict(data) + + new_step.key = idx + new_step.job = job + + if "uses" in data.ca.items and data.ca.items["uses"][2]: + new_step.uses_comment = data.ca.items["uses"][2].value.replace("\n", "") + if "@" in new_step.uses: + new_step.uses_path, new_step.uses_ref = new_step.uses.split("@") + new_step.uses_version = new_step.uses_comment.split(" ")[-1] + + return new_step diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/models/workflow.py b/lint-workflow-v2/src/bitwarden_workflow_linter/models/workflow.py new file mode 100644 index 00000000..9c909695 --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/models/workflow.py @@ -0,0 +1,45 @@ +"""Representation for an entire GitHub Action workflow.""" + +from dataclasses import dataclass +from typing import Dict, Optional, Self + +from dataclasses_json import dataclass_json, Undefined +from ruamel.yaml.comments import CommentedMap + +from .job import Job + + +@dataclass_json(undefined=Undefined.EXCLUDE) +@dataclass +class Workflow: + """Represents an entire workflow in a GitHub Action workflow. + + This object contains all of the data that is required to run the current linting + Rules against. If a new Rule requires a key that is missing, the attribute should + be added to this class to make it available for use in linting. + + See src/models/job.py for an example if the key in the workflow data does not map + one-to-one in the model (ex. 'with' => 'uses_with') + """ + + key: str = "" + name: Optional[str] = None + on: Optional[CommentedMap] = None + jobs: Optional[Dict[str, Job]] = None + + @classmethod + def init(cls: Self, key: str, data: CommentedMap) -> Self: + init_data = { + "key": key, + "name": data["name"] if "name" in data else None, + "on": data["on"] if "on" in data else None, + } + + new_workflow = cls.from_dict(init_data) + + new_workflow.jobs = { + str(job_key): Job.init(job_key, job) + for job_key, job in data["jobs"].items() + } + + return new_workflow diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/rule.py b/lint-workflow-v2/src/bitwarden_workflow_linter/rule.py new file mode 100644 index 00000000..c48a8b6d --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/rule.py @@ -0,0 +1,101 @@ +"""Base Rule class to build rules by extending.""" + +from typing import List, Optional, Tuple, Union + +from .models.workflow import Workflow +from .models.job import Job +from .models.step import Step +from .utils import LintFinding, LintLevels, Settings + + +class RuleExecutionException(Exception): + """Exception for the Base Rule class.""" + + pass + + +class Rule: + """Base class of a Rule to extend to create a linting Rule.""" + + on_fail: LintLevels = LintLevels.ERROR + compatibility: List[Union[Workflow, Job, Step]] = [Workflow, Job, Step] + settings: Optional[Settings] = None + + def fn(self, obj: Union[Workflow, Job, Step]) -> Tuple[bool, str]: + """Execute the Rule (this should be overridden in the extending class. + + Args: + obj: + The object that the Rule is to be run against + + Returns: + The success/failure of the result of the Rule ran on the input. + """ + return False, f"{obj.name}: " + + def build_lint_message(self, message: str, obj: Union[Workflow, Job, Step]) -> str: + """Build the lint failure message. + + Build the lint failure message depending on the type of object that the + Rule is being run against. + + Args: + message: + The message body of the failure + obj: + The object the Rule is being run against + + Returns: + The type specific failure message + """ + obj_type = type(obj) + + if obj_type == Step: + return f"{obj_type.__name__} [{obj.job}.{obj.key}] => {message}" + elif obj_type == Job: + return f"{obj_type.__name__} [{obj.key}] => {message}" + else: + return f"{obj_type.__name__} => {message}" + + def execute(self, obj: Union[Workflow, Job, Step]) -> Union[LintFinding, None]: + """Wrapper function to execute the overridden self.fn(). + + Run the Rule against the object and return the results. The result + could be an Exception message where the Rule cannot be run against + the object for whatever reason. If an exception doesn't occur, the + result is linting success or failure. + + Args: + obj: + The object the Rule is being run against + + Returns: + A LintFinding object that contains the message to print to the user + and a LintLevel that contains the level of error to calculate the + exit code with. + """ + message = None + + if type(obj) not in self.compatibility: + return LintFinding( + self.build_lint_message( + f"{type(obj).__name__} not compatible with {type(self).__name__}", + obj, + ), + LintLevels.ERROR, + ) + + try: + passed, message = self.fn(obj) + + if passed: + return None + except RuleExecutionException as err: + return LintFinding( + self.build_lint_message( + f"failed to apply {type(self).__name__}\n{err}", obj + ), + LintLevels.ERROR, + ) + + return LintFinding(self.build_lint_message(message, obj), self.on_fail) diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/rules/__init__.py b/lint-workflow-v2/src/bitwarden_workflow_linter/rules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/rules/job_environment_prefix.py b/lint-workflow-v2/src/bitwarden_workflow_linter/rules/job_environment_prefix.py new file mode 100644 index 00000000..ec031752 --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/rules/job_environment_prefix.py @@ -0,0 +1,74 @@ +"""A Rule to enforce prefixes environment variables.""" + +from typing import Union, Optional, Tuple, List + +from ..models.job import Job +from ..models.step import Step +from ..models.workflow import Workflow +from ..rule import Rule +from ..utils import LintLevels, Settings + + +class RuleJobEnvironmentPrefix(Rule): + """Rule to enforce specific prefixes for environment variables. + + Automated testing is not easily written for GitHub Action Workflows. CI can also + get complicated really quickly and take up hundreds of lines. All of this can + make it very difficult to debug and troubleshoot, especially when environment + variables can be set in four different places: Workflow level, Job level, Step + level, and inside a shell Step. + + To alleviate some of the pain, we have decided that all Job level environment + variables should be prefixed with an underscore. All Workflow environment + variables are normally at the top of the file and Step level ones are pretty + visible when debugging a shell Step. + """ + + def __init__(self, settings: Optional[Settings] = None) -> None: + """RuleJobEnvironmentPrefix constructor to override the Rule class. + + Args: + settings: + A Settings object that contains any default, overridden, or custom settings + required anywhere in the application. + """ + self.message = "Job environment vars should start with an underscore:" + self.on_fail = LintLevels.ERROR + self.compatibility = [Job] + self.settings = settings + + def fn(self, obj: Job) -> Tuple[bool, str]: + """Enforces the underscore prefix standard on job envs. + + Example: + --- + on: + workflow_dispatch: + + jobs: + job-key: + runs-on: ubuntu-22.04 + env: + _TEST_ENV: "test" + steps: + - run: echo test + + All keys under jobs.job-key.env should be prefixed with an underscore + as in _TEST_ENV. + + See tests/rules/test_job_environment_prefix.py for examples of + incorrectly named environment variables. + """ + correct = True + + if obj.env: + offending_keys = [] + for key in obj.env.keys(): + if key[0] != "_": + offending_keys.append(key) + correct = False + + if correct: + return True, "" + + return False, f"{self.message} ({' ,'.join(offending_keys)})" diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/rules/name_capitalized.py b/lint-workflow-v2/src/bitwarden_workflow_linter/rules/name_capitalized.py new file mode 100644 index 00000000..d27dc98c --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/rules/name_capitalized.py @@ -0,0 +1,56 @@ +"""A Rule to enforce all 'name' values start with a capital letter.""" + +from typing import Optional, Tuple, Union + +from ..models.job import Job +from ..models.step import Step +from ..models.workflow import Workflow +from ..rule import Rule +from ..utils import LintLevels, Settings + + +class RuleNameCapitalized(Rule): + """Rule to enforce all 'name' values start with a capital letter. + + A simple standard to help keep uniformity in naming. + """ + + def __init__(self, settings: Optional[Settings] = None) -> None: + """Constructor for RuleNameCapitalized to override the Rule class. + + Args: + settings: + A Settings object that contains any default, overridden, or custom settings + required anywhere in the application. + """ + self.message = "name must capitalized" + self.on_fail = LintLevels.ERROR + self.settings = settings + + def fn(self, obj: Union[Workflow, Job, Step]) -> Tuple[bool, str]: + """Enforces capitalization of the first letter of any name key. + + Example: + --- + name: Test Workflow + + on: + workflow_dispatch: + + jobs: + job-key: + name: Test + runs-on: ubuntu-latest + steps: + - name: Test + run: echo test + + 'Test Workflow', 'Test', and 'Test' all start with a capital letter. + + See tests/rules/test_name_capitalized.py for examples of incorrectly + capitalized names. This Rule DOES NOT enforce that the name exists. + It only enforces capitalization IF it does. + """ + if obj.name: + return obj.name[0].isupper(), self.message + return True, "" # Force passing if obj.name doesn't exist diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/rules/name_exists.py b/lint-workflow-v2/src/bitwarden_workflow_linter/rules/name_exists.py new file mode 100644 index 00000000..94fc9237 --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/rules/name_exists.py @@ -0,0 +1,59 @@ +"""A Rule to enforce that a 'name' key exists.""" + +from typing import Optional, Tuple, Union + +from ..models.workflow import Workflow +from ..models.job import Job +from ..models.step import Step +from ..rule import Rule +from ..utils import LintLevels, Settings + + +class RuleNameExists(Rule): + """Rule to enforce a 'name' key exists for every object in GitHub Actions. + + For pipeline run troubleshooting and debugging, it is helpful to have a + name to immediately identify a Workflow, Job, or Step while moving between + run and the code. + + It also helps with uniformity of runs. + """ + + def __init__(self, settings: Optional[Settings] = None) -> None: + """Constructor for RuleNameCapitalized to override Rule class. + + Args: + settings: + A Settings object that contains any default, overridden, or custom settings + required anywhere in the application. + """ + self.message = "name must exist" + self.on_fail = LintLevels.ERROR + self.settings = settings + + def fn(self, obj: Union[Workflow, Job, Step]) -> Tuple[bool, str]: + """Enforces the existence of names. + + Example: + --- + name: Test Workflow + + on: + workflow_dispatch: + + jobs: + job-key: + name: Test + runs-on: ubuntu-latest + steps: + - name: Test + run: echo test + + 'Test Workflow', 'Test', and 'Test' all exist. + + See tests/rules/test_name_exists.py for examples where a name does not + exist. + """ + if obj.name is not None: + return True, "" + return False, self.message diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/rules/pinned_job_runner.py b/lint-workflow-v2/src/bitwarden_workflow_linter/rules/pinned_job_runner.py new file mode 100644 index 00000000..b84d2c92 --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/rules/pinned_job_runner.py @@ -0,0 +1,54 @@ +"""A Rule to enforce pinning runners to a specific OS version.""" + +from typing import List, Optional, Tuple, Union + +from ..models.job import Job +from ..models.step import Step +from ..models.workflow import Workflow +from ..rule import Rule +from ..utils import LintLevels, Settings + + +class RuleJobRunnerVersionPinned(Rule): + """Rule to enforce pinned Runner OS versions. + + Using `*-latest` versions will update automatically and has broken all of + our workflows in the past. To avoid this and prevent a single event from + breaking the majority of our pipelines, we pin the versions. + """ + + def __init__(self, settings: Optional[Settings] = None) -> None: + """Constructor for RuleJobRunnerVersionPinned to override Rule class. + + Args: + settings: + A Settings object that contains any default, overridden, or custom settings + required anywhere in the application. + """ + self.message = "Workflow runner must be pinned" + self.on_fail = LintLevels.ERROR + self.compatibility = [Job] + self.settings = settings + + def fn(self, obj: Job) -> Tuple[bool, str]: + """Enforces runners are pinned to a version + + Example: + --- + on: + workflow_dispatch: + + jobs: + job-key: + runs-on: ubuntu-22.04 + steps: + - run: echo test + + call-workflow: + uses: bitwarden/server/.github/workflows/workflow-linter.yml@master + + 'runs-on' is pinned to '22.04' instead of 'latest' + """ + if obj.runs_on is not None and "latest" in obj.runs_on: + return False, self.message + return True, "" diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/rules/step_approved.py b/lint-workflow-v2/src/bitwarden_workflow_linter/rules/step_approved.py new file mode 100644 index 00000000..ac6d831a --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/rules/step_approved.py @@ -0,0 +1,103 @@ +"""A Rule to enforce the use of a list of pre-approved Actions.""" + +from typing import List, Optional, Tuple, Union + +from ..models.job import Job +from ..models.step import Step +from ..models.workflow import Workflow +from ..rule import Rule +from ..utils import LintLevels, Settings + + +class RuleStepUsesApproved(Rule): + """Rule to enforce that all Actions have been pre-approved. + + To limit the surface area of a supply chain attack in our pipelines, all Actions + are required to pass a security review and be added to the pre-approved list to + check against. + """ + + def __init__(self, settings: Optional[Settings] = None) -> None: + """Constructor for RuleStepUsesApproved to override Rule class. + + Args: + settings: + A Settings object that contains any default, overridden, or custom settings + required anywhere in the application. + """ + self.on_fail = LintLevels.WARNING + self.compatibility = [Step] + self.settings = settings + + def skip(self, obj: Step) -> bool: + """Skip this Rule on some Steps. + + This Rule does not apply to a few types of Steps. These + Rules are skipped. + """ + ## Force pass for any shell steps + if not obj.uses: + return True + + ## Force pass for any local actions + if "@" not in obj.uses: + return True + + ## Force pass for any bitwarden/gh-actions + if obj.uses.startswith("bitwarden/gh-actions"): + return True + + return False + + def fn(self, obj: Step) -> Tuple[bool, str]: + """Enforces all externally used Actions are on the pre-approved list. + + The pre-approved list allows tight auditing on what Actions are trusted + and allowed to be run in our environments. This helps mitigate risks + against supply chain attacks in our pipelines. + + Example: + --- + on: + workflow_dispatch: + + jobs: + job-key: + runs-on: ubuntu-22.04 + steps: + - name: Checkout Branch + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Test Bitwarden Action + uses: bitwarden/gh-actions/get-keyvault-secrets@main + + - name: Test Local Action + uses: ./actions/test-action + + - name: Test Run Action + run: echo "test" + + In this example, 'actions/checkout' must be on the pre-approved list + and the metadata must match in order to succeed. The other three + Steps will be skipped. + """ + if self.skip(obj): + return True, "" + + # Actions in bitwarden/gh-actions are auto-approved + if obj.uses and not obj.uses_path in self.settings.approved_actions: + return False, ( + f"New Action detected: {obj.uses_path}\nFor security purposes, " + "actions must be reviewed and be on the pre-approved list" + ) + + action = self.settings.approved_actions[obj.uses_path] + + if obj.uses_version != action.version or obj.uses_ref != action.sha: + return False, ( + "Action is out of date. Please update to:\n" + f" commit: {action.version}" + f" version: {action.sha}" + ) + + return True, "" diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/rules/step_pinned.py b/lint-workflow-v2/src/bitwarden_workflow_linter/rules/step_pinned.py new file mode 100644 index 00000000..295a3f70 --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/rules/step_pinned.py @@ -0,0 +1,100 @@ +"""A Rule to enforce Actions are pinned correctly.""" + +from typing import List, Optional, Tuple, Union + +from ..models.job import Job +from ..models.workflow import Workflow +from ..models.step import Step +from ..rule import Rule +from ..utils import LintLevels, Settings + + +class RuleStepUsesPinned(Rule): + """Rule to contain the enforcement logic for pinning Actions versions. + + Definition of Internal Action: + An Action that exists in the `bitwarden/gh-actions` GitHub Repository. + + For any external Action (any Action that does not fit the above definition of + an Internal Action), to mitigate the risks of supply chain attacks in our CI + pipelines, we pin any use of an Action to a specific hash that has been verified + and pre-approved after a security audit of the version of the Action. + + All Internal Actions, should be pinned to 'main'. This prevents Renovate from + spamming a bunch of PRs across all of our repos when `bitwarden/gh-actions` is + updated. + """ + + def __init__(self, settings: Optional[Settings] = None) -> None: + """Constructor for RuleStepUsesPinned to override base Rule. + + Args: + settings: + A Settings object that contains any default, overridden, or custom settings + required anywhere in the application. + """ + self.on_fail = LintLevels.ERROR + self.compatibility = [Step] + self.settings = settings + + def skip(self, obj: Step) -> bool: + """Skip this Rule on some Steps. + + This Rule does not apply to a few types of Steps. These + Rules are skipped. + """ + if not obj.uses: + return True + + ## Force pass for any local actions + if "@" not in obj.uses: + return True + + return False + + def fn(self, obj: Step) -> Tuple[bool, str]: + """Enforces all Actions to be pinned in a specific way. + + Pinning external Action hashes prevents unknown updates that could + break the pipelines or be the entry point to a supply chain attack. + + Pinning internal Actions to branches allow for less updates as work + is done on those repos. This is mainly to support our Action + monorepo architecture of our Actions. + + Example: + - name: Checkout Branch + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Test Bitwarden Action + uses: bitwarden/gh-actions/get-keyvault-secrets@main + + - name: Test Local Action + uses: ./actions/test-action + + - name: Test Run Action + run: echo "test" + + In this example, 'actions/checkout' must be pinned to the full commit + of the tag while 'bitwarden/gh-actions/get-keyvault-secrets' must be + pinned to 'main'. The other two Steps will be skipped. + """ + if self.skip(obj): + return True, "" + + path, ref = obj.uses.split("@") + + if path.startswith("bitwarden/gh-actions"): + if ref == "main": + return True, "" + return False, "Please pin to main" + + try: + int(ref, 16) + except ValueError: + return False, "Please pin the action to a commit sha" + + if len(ref) != 40: + return False, "Please use the full commit sha to pin the action" + + return True, "" diff --git a/lint-workflow-v2/src/bitwarden_workflow_linter/utils.py b/lint-workflow-v2/src/bitwarden_workflow_linter/utils.py new file mode 100644 index 00000000..c0e3c704 --- /dev/null +++ b/lint-workflow-v2/src/bitwarden_workflow_linter/utils.py @@ -0,0 +1,180 @@ +"""Module of a collection of random utilities.""" + +import importlib.resources +import json +import os +import sys + +from dataclasses import dataclass +from enum import Enum +from typing import Optional, Self, TypeVar + +from ruamel.yaml import YAML + + +yaml = YAML() + + +@dataclass +class Colors: + """Class containing color codes for printing strings to output.""" + + black = "30m" + red = "31m" + green = "32m" + yellow = "33m" + blue = "34m" + magenta = "35m" + cyan = "36m" + white = "37m" + + +@dataclass +class LintLevel: + """Class to contain the numeric level and color of linting.""" + + code: int + color: Colors + + +class LintLevels(LintLevel, Enum): + """Collection of the different types of LintLevels available.""" + + NONE = 0, Colors.white + WARNING = 1, Colors.yellow + ERROR = 2, Colors.red + + +class LintFinding: + """Represents a problem detected by linting.""" + + def __init__(self, description: str, level: LintLevels) -> None: + self.description = description + self.level = level + + def __str__(self) -> str: + """String representation of the class. + + Returns: + String representation of itself. + """ + return ( + f"\033[{self.level.color}{self.level.name.lower()}\033[0m " + f"{self.description}" + ) + + +@dataclass +class Action: + """Collection of the metadata associated with a GitHub Action.""" + + name: str + version: str = "" + sha: str = "" + + def __eq__(self, other: Self) -> bool: + """Override Action equality. + + Args: + other: + Another Action type object to compare + + Return + The state of equality + """ + return ( + self.name == other.name + and self.version == other.version + and self.sha == other.sha + ) + + def __ne__(self, other: Self) -> bool: + """Override Action unequality. + + Args: + other: + Another Action type object to compare + + Return + The negation of the state of equality + """ + return not self.__eq__(other) + + +class SettingsError(Exception): + """Custom Exception to indicate an error with loading Settings.""" + + pass + + +SettingsFromFactory = TypeVar("SettingsFromFactory", bound="Settings") + + +class Settings: + """Class that contains configuration-as-code for any portion of the app.""" + + enabled_rules: list[str] + approved_actions: dict[str, Action] + + def __init__( + self, + enabled_rules: Optional[list[str]] = None, + approved_actions: Optional[dict[str, dict[str, str]]] = None, + ) -> None: + """Settings object that can be overridden in settings.py. + + Args: + enabled_rules: + All of the python modules that implement a Rule to be run against + the workflows. These must be available somewhere on the PYTHONPATH + approved_actions: + The colleciton of GitHub Actions that are pre-approved to be used + in any workflow (Required by src.rules.step_approved) + """ + if enabled_rules is None: + enabled_rules = [] + + if approved_actions is None: + approved_actions = {} + + self.enabled_rules = enabled_rules + self.approved_actions = { + name: Action(**action) for name, action in approved_actions.items() + } + + @staticmethod + def factory() -> SettingsFromFactory: + with ( + importlib.resources.files("bitwarden_workflow_linter") + .joinpath("default_settings.yaml") + .open("r", encoding="utf-8") as file + ): + settings = yaml.load(file) + + settings_filename = "settings.yaml" + local_settings = None + + if os.path.exists(settings_filename): + with open(settings_filename, encoding="utf8") as settings_file: + local_settings = yaml.load(settings_file) + + if local_settings: + settings.update(local_settings) + + if settings["approved_actions_path"] == "default_actions.json": + with ( + importlib.resources.files("bitwarden_workflow_linter") + .joinpath("default_actions.json") + .open("r", encoding="utf-8") as file + ): + settings["approved_actions"] = json.load(file) + else: + with open( + settings["approved_actions_path"], "r", encoding="utf8" + ) as action_file: + settings["approved_actions"] = json.load(action_file) + + return Settings( + enabled_rules=settings["enabled_rules"], + approved_actions=settings["approved_actions"], + ) diff --git a/lint-workflow-v2/tests/__init__.py b/lint-workflow-v2/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lint-workflow-v2/tests/conftest.py b/lint-workflow-v2/tests/conftest.py new file mode 100644 index 00000000..af48cf42 --- /dev/null +++ b/lint-workflow-v2/tests/conftest.py @@ -0,0 +1,3 @@ +"""Shared configuration for tests.""" + +FIXTURE_DIR = "./tests/fixtures" diff --git a/lint-workflow-v2/tests/fixtures/test-alt.yml b/lint-workflow-v2/tests/fixtures/test-alt.yml new file mode 100644 index 00000000..57822a21 --- /dev/null +++ b/lint-workflow-v2/tests/fixtures/test-alt.yml @@ -0,0 +1,24 @@ +--- +name: Lint Test File, DO NOT USE + +on: + workflow_dispatch: + inputs: {} + +jobs: + test-normal-action: + name: Download Latest + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b + + - run: | + echo test + + test-local-action: + name: Testing a local action call + runs-on: ubuntu-20.04 + steps: + - name: local-action + uses: ./version-bump diff --git a/lint-workflow-v2/tests/fixtures/test-min-incorrect.yaml b/lint-workflow-v2/tests/fixtures/test-min-incorrect.yaml new file mode 100644 index 00000000..5ef34058 --- /dev/null +++ b/lint-workflow-v2/tests/fixtures/test-min-incorrect.yaml @@ -0,0 +1,9 @@ +--- +on: + workflow_dispatch: + +jobs: + job-key: + runs-on: ubuntu-latest + steps: + - run: echo test diff --git a/lint-workflow-v2/tests/fixtures/test-min.yaml b/lint-workflow-v2/tests/fixtures/test-min.yaml new file mode 100644 index 00000000..a641f914 --- /dev/null +++ b/lint-workflow-v2/tests/fixtures/test-min.yaml @@ -0,0 +1,13 @@ +--- +name: Test Workflow + +on: + workflow_dispatch: + +jobs: + job-key: + name: Test + runs-on: ubuntu-latest + steps: + - name: Test + run: echo test diff --git a/lint-workflow-v2/tests/fixtures/test.yml b/lint-workflow-v2/tests/fixtures/test.yml new file mode 100644 index 00000000..ca1af310 --- /dev/null +++ b/lint-workflow-v2/tests/fixtures/test.yml @@ -0,0 +1,49 @@ +--- +name: crowdin Pull + +on: + workflow_dispatch: + inputs: {} + schedule: + - cron: "0 0 * * 5" + +jobs: + crowdin-pull: + name: Pull + runs-on: ubuntu-20.04 + env: + _CROWDIN_PROJECT_ID: "308189" + steps: + - name: Checkout repo + uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b # v2.3.4 + + + - name: Log in to Azure - CI subscription + uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7 + with: + creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }} + + - name: Retrieve GitHub PAT secrets + id: retrieve-secret-pat + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: "bitwarden-ci" + secrets: "crowdin-api-token" + + - uses: crowdin/github-action@e39093fd75daae7859c68eded4b43d42ec78d8ea # v1.3.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} + with: + config: crowdin.yml + crowdin_branch_name: master + upload_sources: false + upload_translations: false + download_translations: true + github_user_name: "github-actions" + github_user_email: "<>" + commit_message: "Autosync the updated translations" + localization_branch_name: crowdin-auto-sync + create_pull_request: true + pull_request_title: "Autosync Crowdin Translations" + pull_request_body: "Autosync the updated translations" diff --git a/lint-workflow-v2/tests/fixtures/test_a.yaml b/lint-workflow-v2/tests/fixtures/test_a.yaml new file mode 100644 index 00000000..bd0cfb24 --- /dev/null +++ b/lint-workflow-v2/tests/fixtures/test_a.yaml @@ -0,0 +1,27 @@ +--- +name: Lint Test File, DO NOT USE + +on: + workflow_dispatch: + inputs: {} + +jobs: + call-workflow: + uses: bitwarden/server/.github/workflows/workflow-linter.yml@master + + test-normal-action: + name: Download Latest + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b + + - run: | + echo test + + test-local-action: + name: Testing a local action call + runs-on: ubuntu-20.04 + steps: + - name: local-action + uses: ./version-bump diff --git a/lint-workflow-v2/tests/rules/__init__.py b/lint-workflow-v2/tests/rules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lint-workflow-v2/tests/rules/test_job_environment_prefix.py b/lint-workflow-v2/tests/rules/test_job_environment_prefix.py new file mode 100644 index 00000000..89ae7d5c --- /dev/null +++ b/lint-workflow-v2/tests/rules/test_job_environment_prefix.py @@ -0,0 +1,110 @@ +"""Test src/bitwarden_workflow_linter/rules/job_environment_prefix.""" + +import pytest + +from ruamel.yaml import YAML + +from src.bitwarden_workflow_linter.load import WorkflowBuilder +from src.bitwarden_workflow_linter.rules.job_environment_prefix import ( + RuleJobEnvironmentPrefix, +) + +yaml = YAML() + + +@pytest.fixture(name="correct_workflow") +def fixture_correct_workflow(): + workflow = """\ +--- +on: + workflow_dispatch: + +jobs: + job-key: + runs-on: ubuntu-22.04 + env: + _TEST_ENV: "test" + steps: + - run: echo test +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +@pytest.fixture(name="no_env_workflow") +def fixture_no_env_workflow(): + workflow = """\ +--- +on: + workflow_dispatch: + +jobs: + job-key: + runs-on: ubuntu-22.04 + steps: + - run: echo test +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +@pytest.fixture(name="missing_prefix_workflow") +def fixture_missing_prefix_workflow(): + workflow = """\ +--- +on: + workflow_dispatch: + +jobs: + job-key: + runs-on: ubuntu-22.04 + env: + TEST_ENV: "test" + steps: + - run: echo test +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +@pytest.fixture(name="rule") +def fixture_rule(): + return RuleJobEnvironmentPrefix() + + +def test_rule_on_correct_workflow(rule, correct_workflow): + obj = correct_workflow.jobs["job-key"] + + result, message = rule.fn(correct_workflow.jobs["job-key"]) + assert result is True + assert message == "" + + finding = rule.execute(obj) + assert finding is None + + +def test_rule_on_no_env_workflow(rule, no_env_workflow): + obj = no_env_workflow.jobs["job-key"] + + result, message = rule.fn(no_env_workflow.jobs["job-key"]) + assert result is True + assert message == "" + + finding = rule.execute(obj) + assert finding is None + + +def test_rule_on_missing_prefix_workflow(rule, missing_prefix_workflow): + obj = missing_prefix_workflow.jobs["job-key"] + + result, message = rule.fn(obj) + assert result is False + assert "TEST_ENV" in message + + finding = rule.execute(obj) + assert "TEST_ENV" in finding.description + + +def test_fail_compatibility(rule, correct_workflow): + finding = rule.execute(correct_workflow) + assert "Workflow not compatible with" in finding.description + + finding = rule.execute(correct_workflow.jobs["job-key"].steps[0]) + assert "Step not compatible with" in finding.description diff --git a/lint-workflow-v2/tests/rules/test_name_capitalized.py b/lint-workflow-v2/tests/rules/test_name_capitalized.py new file mode 100644 index 00000000..1e573b5b --- /dev/null +++ b/lint-workflow-v2/tests/rules/test_name_capitalized.py @@ -0,0 +1,107 @@ +"""Test src/bitwarden_workflow_linter/rules/name_capitalized.py.""" + +import pytest + +from ruamel.yaml import YAML + +from src.bitwarden_workflow_linter.load import WorkflowBuilder +from src.bitwarden_workflow_linter.rules.name_capitalized import RuleNameCapitalized + +yaml = YAML() + + +@pytest.fixture(name="correct_workflow") +def fixture_correct_workflow(): + workflow = """\ +--- +name: Test Workflow + +on: + workflow_dispatch: + +jobs: + job-key: + name: Test + runs-on: ubuntu-latest + steps: + - name: Test + run: echo test +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +@pytest.fixture(name="incorrect_workflow") +def fixture_incorrect_workflow(): + workflow = """\ +--- +name: test +on: + workflow_dispatch: + +jobs: + job-key: + name: test + runs-on: ubuntu-latest + steps: + - name: test + run: echo test +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +@pytest.fixture(name="missing_name_workflow") +def fixture_missing_name_workflow(): + workflow = """\ +--- +on: + workflow_dispatch: + +jobs: + job-key: + runs-on: ubuntu-latest + steps: + - run: echo test +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +@pytest.fixture(name="rule") +def fixture_rule(): + return RuleNameCapitalized() + + +def test_rule_on_correct_workflow(rule, correct_workflow): + result, _ = rule.fn(correct_workflow) + assert result is True + + result, _ = rule.fn(correct_workflow.jobs["job-key"]) + assert result is True + + result, _ = rule.fn(correct_workflow.jobs["job-key"].steps[0]) + assert result is True + + +def test_rule_on_incorrect_workflow_name(rule, incorrect_workflow): + result, _ = rule.fn(incorrect_workflow) + assert result is False + + +def test_rule_on_incorrect_job_name(rule, incorrect_workflow): + result, _ = rule.fn(incorrect_workflow.jobs["job-key"]) + assert result is False + + +def test_rule_on_incorrect_step_name(rule, incorrect_workflow): + result, _ = rule.fn(incorrect_workflow.jobs["job-key"].steps[0]) + assert result is False + + +def test_rule_on_missing_names(rule, missing_name_workflow): + result, _ = rule.fn(missing_name_workflow) + assert result is True + + result, _ = rule.fn(missing_name_workflow.jobs["job-key"]) + assert result is True + + result, _ = rule.fn(missing_name_workflow.jobs["job-key"].steps[0]) + assert result is True diff --git a/lint-workflow-v2/tests/rules/test_name_exists.py b/lint-workflow-v2/tests/rules/test_name_exists.py new file mode 100644 index 00000000..02048dad --- /dev/null +++ b/lint-workflow-v2/tests/rules/test_name_exists.py @@ -0,0 +1,75 @@ +"""Test src/bitwarden_workflow_linter/rules/name_exists.py.""" + +import pytest + +from ruamel.yaml import YAML + +from src.bitwarden_workflow_linter.load import WorkflowBuilder +from src.bitwarden_workflow_linter.rules.name_exists import RuleNameExists + + +yaml = YAML() + + +@pytest.fixture(name="correct_workflow") +def fixture_correct_workflow(): + workflow = """\ +--- +name: Test Workflow + +on: + workflow_dispatch: + +jobs: + job-key: + name: Test + runs-on: ubuntu-latest + steps: + - name: Test + run: echo test +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +@pytest.fixture(name="incorrect_workflow") +def fixture_incorrect_workflow(): + workflow = """\ +--- +on: + workflow_dispatch: + +jobs: + job-key: + runs-on: ubuntu-latest + steps: + - run: echo test +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +@pytest.fixture(name="rule") +def fixture_rule(): + return RuleNameExists() + + +def test_rule_on_correct_workflow(rule, correct_workflow): + result, _ = rule.fn(correct_workflow) + assert result is True + + result, _ = rule.fn(correct_workflow.jobs["job-key"]) + assert result is True + + result, _ = rule.fn(correct_workflow.jobs["job-key"].steps[0]) + assert result is True + + +def test_rule_on_incorrect_workflow(rule, incorrect_workflow): + print(f"Workflow name: {incorrect_workflow.name}") + result, _ = rule.fn(incorrect_workflow) + assert result is False + + result, _ = rule.fn(incorrect_workflow.jobs["job-key"]) + assert result is False + + result, _ = rule.fn(incorrect_workflow.jobs["job-key"].steps[0]) + assert result is False diff --git a/lint-workflow-v2/tests/rules/test_pinned_job_runner.py b/lint-workflow-v2/tests/rules/test_pinned_job_runner.py new file mode 100644 index 00000000..5db43d0a --- /dev/null +++ b/lint-workflow-v2/tests/rules/test_pinned_job_runner.py @@ -0,0 +1,65 @@ +"""Test src/bitwarden_workflow_linter/rules/pinned_job_runner.py.""" + +import pytest + +from ruamel.yaml import YAML + +from src.bitwarden_workflow_linter.load import WorkflowBuilder +from src.bitwarden_workflow_linter.rules.pinned_job_runner import ( + RuleJobRunnerVersionPinned, +) + +yaml = YAML() + + +@pytest.fixture(name="correct_runner") +def fixture_correct_runner(): + workflow = """\ +--- +on: + workflow_dispatch: + +jobs: + job-key: + runs-on: ubuntu-22.04 + steps: + - run: echo test + + call-workflow: + uses: bitwarden/server/.github/workflows/workflow-linter.yml@master +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +@pytest.fixture(name="incorrect_runner") +def fixture_incorrect_runner(): + workflow = """\ +--- +on: + workflow_dispatch: + +jobs: + job-key: + runs-on: ubuntu-latest + steps: + - run: echo test +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +@pytest.fixture(name="rule") +def fixture_rule(): + return RuleJobRunnerVersionPinned() + + +def test_rule_on_correct_runner(rule, correct_runner): + result, _ = rule.fn(correct_runner.jobs["job-key"]) + assert result is True + + result, _ = rule.fn(correct_runner.jobs["call-workflow"]) + assert result is True + + +def test_rule_on_incorrect_runner(rule, incorrect_runner): + result, _ = rule.fn(incorrect_runner.jobs["job-key"]) + assert result is False diff --git a/lint-workflow-v2/tests/rules/test_step_approved.py b/lint-workflow-v2/tests/rules/test_step_approved.py new file mode 100644 index 00000000..5f1f0942 --- /dev/null +++ b/lint-workflow-v2/tests/rules/test_step_approved.py @@ -0,0 +1,113 @@ +"""Test src/bitwarden_workflow_linter/rules/step_approved.py.""" + +import pytest + +from ruamel.yaml import YAML + +from src.bitwarden_workflow_linter.load import WorkflowBuilder +from src.bitwarden_workflow_linter.rules.step_approved import RuleStepUsesApproved +from src.bitwarden_workflow_linter.utils import Settings + + +yaml = YAML() + + +@pytest.fixture(name="settings") +def fixture_settings(): + return Settings( + approved_actions={ + "actions/checkout": { + "name": "actions/checkout", + "version": "v4.1.1", + "sha": "b4ffde65f46336ab88eb53be808477a3936bae11", + }, + "actions/download-artifact": { + "name": "actions/download-artifact", + "version": "v4.1.0", + "sha": "f44cd7b40bfd40b6aa1cc1b9b5b7bf03d3c67110", + }, + } + ) + + +@pytest.fixture(name="correct_workflow") +def fixture_correct_workflow(): + workflow = """\ +--- +on: + workflow_dispatch: + +jobs: + job-key: + runs-on: ubuntu-22.04 + steps: + - name: Checkout Branch + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Test Bitwarden Action + uses: bitwarden/gh-actions/get-keyvault-secrets@main + + - name: Test Local Action + uses: ./actions/test-action + + - name: Test Run Action + run: echo "test" +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +@pytest.fixture(name="incorrect_workflow") +def fixture_incorrect_workflow(): + workflow = """\ +--- +on: + workflow_dispatch: + +jobs: + job-key: + runs-on: ubuntu-22.04 + steps: + - name: Checkout Branch + uses: joseph-flinn/action-DNE@main + + - name: Out of date action + uses: actions/download-artifact@7a1cd3216ca9260cd8022db641d960b1db4d1be4 # v4.0.0 +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +@pytest.fixture(name="rule") +def fixture_rule(settings): + return RuleStepUsesApproved(settings=settings) + + +def test_rule_on_correct_workflow(rule, correct_workflow): + result, _ = rule.fn(correct_workflow.jobs["job-key"].steps[0]) + assert result is True + + result, _ = rule.fn(correct_workflow.jobs["job-key"].steps[1]) + assert result is True + + result, _ = rule.fn(correct_workflow.jobs["job-key"].steps[2]) + assert result is True + + result, _ = rule.fn(correct_workflow.jobs["job-key"].steps[3]) + assert result is True + + +def test_rule_on_incorrect_workflow(rule, incorrect_workflow): + result, message = rule.fn(incorrect_workflow.jobs["job-key"].steps[0]) + assert result is False + assert "New Action detected" in message + + result, message = rule.fn(incorrect_workflow.jobs["job-key"].steps[1]) + assert result is False + assert "Action is out of date" in message + + +def test_fail_compatibility(rule, correct_workflow): + finding = rule.execute(correct_workflow) + assert "Workflow not compatible with" in finding.description + + finding = rule.execute(correct_workflow.jobs["job-key"]) + assert "Job not compatible with" in finding.description diff --git a/lint-workflow-v2/tests/rules/test_step_pinned.py b/lint-workflow-v2/tests/rules/test_step_pinned.py new file mode 100644 index 00000000..b73e0c58 --- /dev/null +++ b/lint-workflow-v2/tests/rules/test_step_pinned.py @@ -0,0 +1,104 @@ +"""Test src/bitwarden_workflow_linter/rules/step_pinned.py.""" + +import pytest + +from ruamel.yaml import YAML + +from src.bitwarden_workflow_linter.load import WorkflowBuilder +from src.bitwarden_workflow_linter.rules.step_pinned import RuleStepUsesPinned + +yaml = YAML() + + +@pytest.fixture(name="correct_workflow") +def fixture_correct_workflow(): + workflow = """\ +--- +on: + workflow_dispatch: + +jobs: + job-key: + runs-on: ubuntu-22.04 + steps: + - name: Test 3rd Party Action + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Test Internal Action + uses: bitwarden/gh-actions/get-keyvault-secrets@main + + - name: Test Local Action + uses: ./actions/test-action + + - name: Test Run Action + run: echo "test" +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +@pytest.fixture(name="incorrect_workflow") +def fixture_incorrect_workflow(): + workflow = """\ +--- +on: + workflow_dispatch: + +jobs: + job-key: + runs-on: ubuntu-22.04 + steps: + - name: Test External Branch + uses: actions/checkout@main + + - name: Test Incorrect Hex + uses: actions/checkout@b4ffde + + - name: Test Internal Commit + uses: bitwarden/gh-actions/get-keyvault-secrets@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +@pytest.fixture(name="rule") +def fixture_rule(): + return RuleStepUsesPinned() + + +def test_rule_on_correct_workflow(rule, correct_workflow): + result, _ = rule.fn(correct_workflow.jobs["job-key"].steps[0]) + assert result is True + + result, _ = rule.fn(correct_workflow.jobs["job-key"].steps[1]) + assert result is True + + result, _ = rule.fn(correct_workflow.jobs["job-key"].steps[2]) + assert result is True + + result, _ = rule.fn(correct_workflow.jobs["job-key"].steps[3]) + assert result is True + + +def test_rule_on_incorrect_workflow_external_branch(rule, incorrect_workflow): + result, message = rule.fn(incorrect_workflow.jobs["job-key"].steps[0]) + assert result is False + assert "Please pin the action" in message + + +def test_rule_on_incorrect_workflow_hex(rule, incorrect_workflow): + result, message = rule.fn(incorrect_workflow.jobs["job-key"].steps[1]) + assert result is False + assert "Please use the full commit sha" in message + + +def test_rule_on_incorrect_workflow_internal_commit(rule, incorrect_workflow): + result, message = rule.fn(incorrect_workflow.jobs["job-key"].steps[2]) + assert result is False + assert "Please pin to main" in message + + +def test_fail_compatibility(rule, correct_workflow): + finding = rule.execute(correct_workflow) + assert "Workflow not compatible with" in finding.description + + finding = rule.execute(correct_workflow.jobs["job-key"]) + assert "Job not compatible with" in finding.description diff --git a/lint-workflow-v2/tests/test_job.py b/lint-workflow-v2/tests/test_job.py new file mode 100644 index 00000000..f0a91105 --- /dev/null +++ b/lint-workflow-v2/tests/test_job.py @@ -0,0 +1,82 @@ +"""Test src/bitwarden_workflow_linter/models/job.py.""" + +import pytest + +from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap + +from src.bitwarden_workflow_linter.models.job import Job +from src.bitwarden_workflow_linter.models.step import Step + + +yaml = YAML() + + +@pytest.fixture(name="workflow_yaml") +def fixture_workflow_yaml(): + return yaml.load( + """\ +--- +name: test +on: + workflow_dispatch: + pull_request: + +jobs: + job-key: + name: Test + runs-on: ubuntu-latest + steps: + - name: Test + run: echo test + + call-workflow: + uses: bitwarden/server/.github/workflows/workflow-linter.yml@master + + test-normal-action: + name: Download Latest + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b + + - run: | + echo test + + test-local-action: + name: Testing a local action call + runs-on: ubuntu-20.04 + steps: + - name: local-action + uses: ./version-bump +""" + ) + + +def test_job_default(workflow_yaml): + default_job_data = workflow_yaml["jobs"]["job-key"] + default_job = Job.init("default-job", default_job_data) + + assert default_job.key == "default-job" + assert default_job.name == "Test" + assert default_job.runs_on == "ubuntu-latest" + assert default_job.env is None + assert len(default_job.steps) == 1 + + +def test_uses_job(workflow_yaml): + call_job_data = workflow_yaml["jobs"]["call-workflow"] + call_job = Job.init("call-job", call_job_data) + + assert call_job.key == "call-job" + assert call_job.uses is not None + + +def test_job_extra_kwargs(workflow_yaml): + extra_data_job = workflow_yaml["jobs"]["job-key"] + extra_data_job["extra"] = "This should not exist" + + job = Job.init("job-key", extra_data_job) + + with pytest.raises(Exception): + assert job.extra == "test" diff --git a/lint-workflow-v2/tests/test_lint.py b/lint-workflow-v2/tests/test_lint.py new file mode 100644 index 00000000..68ce399c --- /dev/null +++ b/lint-workflow-v2/tests/test_lint.py @@ -0,0 +1,47 @@ +"""Test src/bitwarden_workflow_linter/lint.py.""" + +import pytest + +from src.bitwarden_workflow_linter.lint import LinterCmd +from src.bitwarden_workflow_linter.utils import Settings, LintFinding, LintLevels + + +@pytest.fixture(name="settings") +def fixture_settings(): + return Settings() + + +def test_get_max_error_level(settings): + linter = LinterCmd(settings=settings) + + assert ( + linter.get_max_error_level( + [ + LintFinding(description="", level=LintLevels.WARNING), + LintFinding(description="", level=LintLevels.WARNING), + ] + ) + == 1 + ) + + assert ( + linter.get_max_error_level( + [ + LintFinding(description="", level=LintLevels.ERROR), + LintFinding(description="", level=LintLevels.ERROR), + ] + ) + == 2 + ) + + assert ( + linter.get_max_error_level( + [ + LintFinding(description="", level=LintLevels.ERROR), + LintFinding(description="", level=LintLevels.ERROR), + LintFinding(description="", level=LintLevels.WARNING), + LintFinding(description="", level=LintLevels.WARNING), + ] + ) + == 2 + ) diff --git a/lint-workflow-v2/tests/test_load.py b/lint-workflow-v2/tests/test_load.py new file mode 100644 index 00000000..cedc2017 --- /dev/null +++ b/lint-workflow-v2/tests/test_load.py @@ -0,0 +1,94 @@ +"""Tests src/bitwarden_workflow_linter/load.py.""" + +import pytest + +from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap + +from .conftest import FIXTURE_DIR + +from src.bitwarden_workflow_linter.load import WorkflowBuilder +from src.bitwarden_workflow_linter.models.workflow import Workflow + + +yaml = YAML() + + +@pytest.fixture(name="workflow_filename") +def fixture_workflow_filename(): + return f"{FIXTURE_DIR}/test.yml" + + +@pytest.fixture(name="simple_workflow_yaml") +def fixture_simple_workflow_yaml(): + return yaml.load( + """\ +--- +name: test +on: + workflow_dispatch: + +jobs: + job-key: + name: Test + runs-on: ubuntu-latest + steps: + - name: Test + run: echo test +""" + ) + + +@pytest.fixture(name="complex_workflow_yaml") +def fixture_complex_workflow_yaml(): + return yaml.load( + """\ +--- +name: test +on: + workflow_dispatch: + +jobs: + job-key: + name: Test + runs-on: ubuntu-latest + steps: + - name: Test + run: echo test + + call-workflow: + uses: bitwarden/server/.github/workflows/workflow-linter.yml@master + + test-normal-action: + name: Download Latest + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b + + - run: | + echo test + + test-local-action: + name: Testing a local action call + runs-on: ubuntu-20.04 + steps: + - name: local-action + uses: ./version-bump +""" + ) + + +def test_load_workflow_from_file(workflow_filename: str) -> None: + workflow = WorkflowBuilder.build(workflow_filename) + assert isinstance(workflow, Workflow) + + +def test_load_simple_workflow_from_yaml(simple_workflow_yaml: CommentedMap) -> None: + workflow = WorkflowBuilder.build(workflow=simple_workflow_yaml, from_file=False) + assert isinstance(workflow, Workflow) + + +def test_load_complex_workflow_from_yaml(complex_workflow_yaml: CommentedMap) -> None: + workflow = WorkflowBuilder.build(workflow=complex_workflow_yaml, from_file=False) + assert isinstance(workflow, Workflow) diff --git a/lint-workflow-v2/tests/test_rule.py b/lint-workflow-v2/tests/test_rule.py new file mode 100644 index 00000000..44dba8c5 --- /dev/null +++ b/lint-workflow-v2/tests/test_rule.py @@ -0,0 +1,140 @@ +"""Tests src/bitwarden_workflow_linter/rule.py.""" + +import pytest +from typing import Union + +from ruamel.yaml import YAML + +from src.bitwarden_workflow_linter.load import WorkflowBuilder +from src.bitwarden_workflow_linter.models.job import Job +from src.bitwarden_workflow_linter.models.step import Step +from src.bitwarden_workflow_linter.models.workflow import Workflow +from src.bitwarden_workflow_linter.rule import Rule, RuleExecutionException + + +yaml = YAML() + + +@pytest.fixture(name="correct_workflow") +def fixture_correct_workflow(): + workflow = """\ +--- +name: Test Workflow + +on: + workflow_dispatch: + +jobs: + job-key: + name: Test + runs-on: ubuntu-latest + steps: + - name: Test + uses: actions/checkout@main +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +@pytest.fixture(name="incorrect_workflow") +def fixture_incorrect_workflow(): + workflow = """\ +--- +on: + workflow_dispatch: + +jobs: + job-key: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@main +""" + return WorkflowBuilder.build(workflow=yaml.load(workflow), from_file=False) + + +class RuleStep(Rule): + def __init__(self): + self.message = "test" + self.on_fail = "error" + self.compatibility = [Step] + + +class RuleNameExists(Rule): + def __init__(self): + self.message = "name must exist" + self.on_fail = "error" + + def fn(self, obj: Union[Workflow, Job, Step]) -> bool: + print(f"{type(self).__name__}\n{obj}") + return obj.name is not None, self.message + + +class RuleException(Rule): + def __init__(self): + self.message = "should raise Exception" + self.on_fail = "error" + + def fn(self, obj: Union[Workflow, Job, Step]) -> bool: + raise RuleExecutionException("test Exception") + + +@pytest.fixture(name="step_rule") +def fixture_step_rule(): + return RuleStep() + + +@pytest.fixture(name="exists_rule") +def fixture_exists_rule(): + return RuleNameExists() + + +@pytest.fixture(name="exception_rule") +def fixture_exception_rule(): + return RuleException() + + +def test_build_lint_message(step_rule, correct_workflow): + assert step_rule.build_lint_message("test", correct_workflow) == "Workflow => test" + + assert ( + step_rule.build_lint_message("test", correct_workflow.jobs["job-key"]) + == "Job [job-key] => test" + ) + + assert ( + step_rule.build_lint_message("test", correct_workflow.jobs["job-key"].steps[0]) + == "Step [job-key.0] => test" + ) + + +def test_rule_compatibility(step_rule, correct_workflow): + assert "not compatible" in step_rule.execute(correct_workflow).description + assert ( + "not compatible" + in step_rule.execute(correct_workflow.jobs["job-key"]).description + ) + assert ( + "not compatible" + not in step_rule.execute(correct_workflow.jobs["job-key"].steps[0]).description + ) + + +def test_correct_rule_execution(exists_rule, correct_workflow): + assert exists_rule.execute(correct_workflow) is None + assert exists_rule.execute(correct_workflow.jobs["job-key"]) is None + assert exists_rule.execute(correct_workflow.jobs["job-key"].steps[0]) is None + + +def test_incorrect_rule_execution(exists_rule, incorrect_workflow): + assert "name must exist" in exists_rule.execute(incorrect_workflow).description + assert ( + "name must exist" + in exists_rule.execute(incorrect_workflow.jobs["job-key"]).description + ) + assert ( + "name must exist" + in exists_rule.execute(incorrect_workflow.jobs["job-key"].steps[0]).description + ) + + +def test_exception_rule_execution(exception_rule, incorrect_workflow): + assert "failed to apply" in exception_rule.execute(incorrect_workflow).description diff --git a/lint-workflow-v2/tests/test_step.py b/lint-workflow-v2/tests/test_step.py new file mode 100644 index 00000000..23625d16 --- /dev/null +++ b/lint-workflow-v2/tests/test_step.py @@ -0,0 +1,78 @@ +"""Test src/bitwarden_workflow_linter/models/step.py.""" + +import json +import pytest + +from ruamel.yaml import YAML + +from src.bitwarden_workflow_linter.models.step import Step + + +@pytest.fixture(name="default_step") +def fixture_default_step(): + step_str = """\ +name: Default Step +run: echo "test" +""" + yaml = YAML() + step_yaml = yaml.load(step_str) + return Step.init(0, "default", step_yaml) + + +@pytest.fixture(name="uses_step") +def fixture_uses_step(): + step_str = """\ +name: Download Artifacts +uses: bitwarden/download-artifacts@main # v1.0.0 +with: + workflow: upload-test-artifacts.yml + artifacts: artifact + path: artifact + branch: main + +""" + yaml = YAML() + step_yaml = yaml.load(step_str) + return Step.init(0, "default", step_yaml) + + +def test_step_default(default_step): + assert default_step.key == 0 + assert default_step.job == "default" + assert default_step.name == "Default Step" + assert default_step.env is None + assert default_step.uses is None + assert default_step.uses_with is None + assert default_step.run == 'echo "test"' + + +def test_step_no_keyword_field(default_step): + assert default_step.uses_with is None + assert "uses_with" not in default_step.to_json() + + +def test_step_extra_kwargs(default_step): + with pytest.raises(Exception): + assert default_step.extra == "test" + + +def test_step_keyword_field(uses_step): + expected_response = { + "workflow": "upload-test-artifacts.yml", + "artifacts": "artifact", + "path": "artifact", + "branch": "main", + } + + step_json = uses_step.to_json() + assert uses_step.key == 0 + assert "uses_with" not in step_json + assert "with" in step_json + assert json.loads(uses_step.to_json())["with"] == expected_response + + +def test_step_comment(uses_step): + assert uses_step.key == 0 + assert uses_step.job == "default" + assert uses_step.uses_comment is not None + assert uses_step.uses_comment == "# v1.0.0" diff --git a/lint-workflow-v2/tests/test_utils.py b/lint-workflow-v2/tests/test_utils.py new file mode 100644 index 00000000..baf75d2d --- /dev/null +++ b/lint-workflow-v2/tests/test_utils.py @@ -0,0 +1,35 @@ +"""Tests src/bitwarden_workflow_linter/utils.py.""" + +from src.bitwarden_workflow_linter.utils import Action, Colors, LintFinding, LintLevels + + +def test_action_eq(): + action_def = {"name": "bitwarden/sm-action", "version": "1.0.0", "sha": "some-sha"} + + action_a = Action(**action_def) + action_b = Action(**action_def) + + assert (action_a == action_b) is True + assert (action_a != action_b) is False + + +def test_action_ne(): + action_a = Action(name="bitwarden/sm-action", version="1.0.0", sha="some-sha") + action_b = Action(name="bitwarden/sm-action", version="1.1.0", sha="some-other-sha") + + assert (action_a == action_b) is False + assert (action_a != action_b) is True + + +def test_lint_level(): + warning = LintLevels.WARNING + assert warning.code == 1 + assert warning.color == Colors.yellow + + +def test_lint_finding(): + warning = LintFinding(description="", level=LintLevels.WARNING) + assert str(warning) == "\x1b[33mwarning\x1b[0m " + + error = LintFinding(description="", level=LintLevels.ERROR) + assert str(error) == "\x1b[31merror\x1b[0m " diff --git a/lint-workflow-v2/tests/test_workflow.py b/lint-workflow-v2/tests/test_workflow.py new file mode 100644 index 00000000..da35f1f2 --- /dev/null +++ b/lint-workflow-v2/tests/test_workflow.py @@ -0,0 +1,100 @@ +"""Test src/bitwarden_workflow_linter/models/workflow.py.""" + +import pytest + +from ruamel.yaml import YAML +from ruamel.yaml.comments import CommentedMap + +from src.bitwarden_workflow_linter.models.job import Job +from src.bitwarden_workflow_linter.models.step import Step +from src.bitwarden_workflow_linter.models.workflow import Workflow + + +yaml = YAML() + + +@pytest.fixture(name="simple_workflow_yaml") +def fixture_simple_workflow_yaml(): + return yaml.load( + """\ +--- +name: test +on: + workflow_dispatch: + +jobs: + job-key: + name: Test + runs-on: ubuntu-latest + steps: + - name: Test + run: echo test +""" + ) + + +@pytest.fixture(name="complex_workflow_yaml") +def fixture_complex_workflow_yaml(): + return yaml.load( + """\ +--- +name: test +on: + workflow_dispatch: + pull_request: + +jobs: + job-key: + name: Test + runs-on: ubuntu-latest + steps: + - name: Test + run: echo test + + call-workflow: + uses: bitwarden/server/.github/workflows/workflow-linter.yml@master + + test-normal-action: + name: Download Latest + runs-on: ubuntu-20.04 + steps: + - name: Checkout + uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b + + - run: | + echo test + + test-local-action: + name: Testing a local action call + runs-on: ubuntu-20.04 + steps: + - name: local-action + uses: ./version-bump +""" + ) + + +def test_simple_workflow(simple_workflow_yaml): + workflow = Workflow.init("", simple_workflow_yaml) + + assert workflow.name == "test" + assert len(workflow.on.keys()) == 1 + assert len(workflow.jobs.keys()) == 1 + + +def test_complex_workflow(complex_workflow_yaml): + workflow = Workflow.init("", complex_workflow_yaml) + + assert workflow.name == "test" + assert len(workflow.on.keys()) == 2 + assert len(workflow.jobs.keys()) == 4 + + +def test_workflow_extra_kwargs(simple_workflow_yaml): + extra_data_workflow = simple_workflow_yaml + extra_data_workflow["extra"] = "This should not exist" + + workflow = Workflow.init("", extra_data_workflow) + + with pytest.raises(Exception): + assert workflow.extra == "test"