├── .dockerignore ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── feature-request.yml ├── dependabot.yml └── workflows │ ├── build.yml │ ├── dependency-review.yml │ ├── markdown.yml │ └── release.yml ├── .gitignore ├── .release.yml ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── docker-compose.yml ├── docs └── process-flow.md ├── ghasreview ├── __init__.py ├── __main__.py ├── app.py ├── client.py ├── flask_githubapp │ ├── README.md │ ├── __init__.py │ └── core.py ├── models │ ├── __init__.py │ ├── codescanning.py │ ├── dependabot.py │ └── secretscanning.py └── setup.py └── gunicorn_config.py /.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | .env 3 | config/ 4 | 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This project is maintained with love by: 2 | 3 | * @advanced-security/oss-maintainers 4 | * @geekmasher 5 | * @theztefan 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: ["bug", "triage"] 5 | assignees: 6 | - GeekMasher 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for taking the time to fill out this bug report! 12 | - type: textarea 13 | id: what-happened 14 | attributes: 15 | label: What happened? 16 | description: Also tell us, what did you expect to happen? 17 | placeholder: Tell us what you see! 18 | value: "A bug happened!" 19 | validations: 20 | required: true 21 | - type: dropdown 22 | id: version 23 | attributes: 24 | label: Version 25 | description: What version of our software are you running? 26 | options: 27 | - v2 (current major version) 28 | - v3 (alpha version) 29 | default: 0 30 | validations: 31 | required: true 32 | - type: dropdown 33 | id: workflow 34 | attributes: 35 | label: Where are you experiencing the issue? 36 | multiple: true 37 | options: 38 | - GitHub Actions 39 | - CLI 40 | - Non-Actions CI (using CLI) 41 | - type: textarea 42 | id: logs 43 | attributes: 44 | label: Relevant log output 45 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 46 | render: shell 47 | - type: checkboxes 48 | id: terms 49 | attributes: 50 | label: Code of Conduct 51 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/advanced-security/policy-as-code/blob/main/CODE_OF_CONDUCT.md) 52 | options: 53 | - label: I agree to follow this project's Code of Conduct 54 | required: true 55 | 56 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: File a Feature Request 3 | title: "[Feat]: " 4 | labels: ["feature", "enhancement"] 5 | assignees: 6 | - GeekMasher 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for taking the time to fill out this feature request! 12 | - type: textarea 13 | id: feature 14 | attributes: 15 | label: What new feature are you looking for / expect? 16 | description: Also tell us, what did you expect to happen? 17 | placeholder: Tell us what you see! 18 | value: "Here is my feature!" 19 | validations: 20 | required: true 21 | - type: dropdown 22 | id: expectations 23 | attributes: 24 | label: Expectations 25 | description: What is your expectation of this feature request? 26 | options: 27 | - Nice to have 28 | - Opt-in Feature 29 | - Required / Manditory Feature 30 | default: 0 31 | validations: 32 | required: true 33 | - type: checkboxes 34 | id: terms 35 | attributes: 36 | label: Code of Conduct 37 | description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/advanced-security/policy-as-code/blob/main/CODE_OF_CONDUCT.md) 38 | options: 39 | - label: I agree to follow this project's Code of Conduct 40 | required: true 41 | 42 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # ---------- GitHub Actions ---------- 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | reviewers: 9 | - "GeekMasher" 10 | - "advanced-security/oss-maintainers" 11 | target-branch: "main" 12 | commit-message: 13 | prefix: deps 14 | prefix-development: chore 15 | labels: 16 | - "Dependencies" 17 | groups: 18 | production-dependencies: 19 | dependency-type: "production" 20 | development-dependencies: 21 | dependency-type: "development" 22 | 23 | # ---------- Rust / Cargo ---------- 24 | - package-ecosystem: "cargo" 25 | directory: "/" 26 | schedule: 27 | interval: "weekly" 28 | reviewers: 29 | - "GeekMasher" 30 | - "advanced-security/oss-maintainers" 31 | target-branch: "main" 32 | commit-message: 33 | prefix: deps 34 | prefix-development: chore 35 | labels: 36 | - "Dependencies" 37 | groups: 38 | production-dependencies: 39 | dependency-type: "production" 40 | development-dependencies: 41 | dependency-type: "development" 42 | 43 | # ---------- Python / Pip ---------- 44 | - package-ecosystem: "pip" 45 | directory: "/" 46 | schedule: 47 | interval: "weekly" 48 | reviewers: 49 | - "GeekMasher" 50 | - "advanced-security/oss-maintainers" 51 | target-branch: "main" 52 | commit-message: 53 | prefix: deps 54 | prefix-development: chore 55 | labels: 56 | - "Dependencies" 57 | groups: 58 | production-dependencies: 59 | dependency-type: "production" 60 | development-dependencies: 61 | dependency-type: "development" 62 | 63 | # ---------- Docker ---------- 64 | - package-ecosystem: "docker" 65 | directory: "/" 66 | schedule: 67 | interval: weekly 68 | reviewers: 69 | - "GeekMasher" 70 | - "advanced-security/oss-maintainers" 71 | target-branch: "main" 72 | commit-message: 73 | prefix: deps 74 | prefix-development: chore 75 | labels: 76 | - "Dependencies" 77 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: [main, develop] 6 | pull_request: 7 | branches: [main, develop] 8 | schedule: 9 | - cron: 0 14 * * * 10 | 11 | jobs: 12 | container: 13 | uses: advanced-security/reusable-workflows/.github/workflows/container-security.yml@main 14 | secrets: inherit 15 | 16 | test-e2e: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: read 20 | packages: write 21 | 22 | strategy: 23 | matrix: 24 | os: [ubuntu-latest] 25 | python-version: ["3.10", "3.11", "3.12", "3.13"] 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | 31 | - name: Setup Python 32 | uses: actions/setup-python@v5 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pipenv 39 | pipenv install 40 | 41 | - name: Run tests 42 | run: | 43 | pipenv run test-e2e 44 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: Dependency Review 2 | 3 | on: 4 | pull_request: 5 | branches: [ "main" ] 6 | 7 | permissions: 8 | contents: read 9 | pull-requests: write 10 | 11 | jobs: 12 | dependency-review: 13 | uses: advanced-security/reusable-workflows/.github/workflows/dependency-review.yml@main 14 | secrets: inherit 15 | -------------------------------------------------------------------------------- /.github/workflows/markdown.yml: -------------------------------------------------------------------------------- 1 | name: "Markdown Lint" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | lint: 11 | uses: advanced-security/reusable-workflows/.github/workflows/markdown-lint.yml@main 12 | secrets: inherit -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | workflow_dispatch: 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build-and-push-image: 14 | uses: advanced-security/reusable-workflows/.github/workflows/container.yml@main 15 | secrets: inherit 16 | permissions: 17 | id-token: write 18 | contents: write 19 | packages: write 20 | attestations: write 21 | security-events: write 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .env 3 | config/ 4 | samples/ 5 | .build.json 6 | test.py 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | pip-wheel-metadata/ 31 | share/python-wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .nox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *.cover 57 | *.py,cover 58 | .hypothesis/ 59 | .pytest_cache/ 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | local_settings.py 68 | db.sqlite3 69 | db.sqlite3-journal 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | docs/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | .DS_Store -------------------------------------------------------------------------------- /.release.yml: -------------------------------------------------------------------------------- 1 | name: "ghas-reviewer-app" 2 | repository: "advanced-security/ghas-reviewer-app" 3 | version: "0.6.2" 4 | 5 | excludes: 6 | - "/flask_githubapp/" 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-alpine 2 | 3 | ARG user=python 4 | ARG home=/home/$user 5 | 6 | RUN adduser \ 7 | --disabled-password \ 8 | --home $home \ 9 | $user 10 | 11 | USER python 12 | ENV PATH="${PATH}:/home/${user}/.local/bin" 13 | 14 | WORKDIR /ghasreview 15 | 16 | COPY . . 17 | 18 | ENV PYTHONPATH="/ghasreview" 19 | RUN python3 -m pip install pipenv && \ 20 | python3 -m pipenv sync --system 21 | 22 | CMD ["pipenv", "run", "production"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 GitHub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | flask = "*" 8 | ghapi = "*" 9 | gunicorn = "*" 10 | ghastoolkit = "*" 11 | "github3.py" = "*" 12 | python-dotenv = "*" 13 | 14 | [dev-packages] 15 | black = "*" 16 | 17 | [scripts] 18 | main = "python -m ghasreview --debug" 19 | fmt = "python -m black ." 20 | lint = "python -m black --check ." 21 | # Run flask app 22 | watch = "gunicorn ghasreview.app:app --reload --bind 0.0.0.0:9000" 23 | develop = "gunicorn ghasreview.app:app --bind 0.0.0.0:9000 --log-level=debug --workers=4" 24 | production = "gunicorn ghasreview.app:app --config gunicorn_config.py" 25 | # Tests 26 | test-e2e = "python -m ghasreview --test-mode" 27 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "3ec69a939e757a7344639b3e2497263e82ec8edd51b6599d4685b5a6c515ad4d" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": {}, 8 | "sources": [ 9 | { 10 | "name": "pypi", 11 | "url": "https://pypi.org/simple", 12 | "verify_ssl": true 13 | } 14 | ] 15 | }, 16 | "default": { 17 | "blinker": { 18 | "hashes": [ 19 | "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", 20 | "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc" 21 | ], 22 | "markers": "python_version >= '3.9'", 23 | "version": "==1.9.0" 24 | }, 25 | "certifi": { 26 | "hashes": [ 27 | "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", 28 | "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9" 29 | ], 30 | "markers": "python_version >= '3.6'", 31 | "version": "==2024.8.30" 32 | }, 33 | "cffi": { 34 | "hashes": [ 35 | "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", 36 | "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", 37 | "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1", 38 | "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", 39 | "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", 40 | "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", 41 | "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", 42 | "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", 43 | "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", 44 | "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", 45 | "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc", 46 | "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", 47 | "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", 48 | "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", 49 | "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", 50 | "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", 51 | "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", 52 | "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", 53 | "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", 54 | "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b", 55 | "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", 56 | "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", 57 | "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c", 58 | "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", 59 | "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", 60 | "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", 61 | "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8", 62 | "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1", 63 | "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", 64 | "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", 65 | "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", 66 | "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", 67 | "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", 68 | "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", 69 | "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", 70 | "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", 71 | "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", 72 | "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", 73 | "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", 74 | "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", 75 | "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", 76 | "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", 77 | "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", 78 | "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964", 79 | "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", 80 | "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", 81 | "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", 82 | "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", 83 | "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", 84 | "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", 85 | "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", 86 | "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", 87 | "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", 88 | "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", 89 | "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", 90 | "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", 91 | "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", 92 | "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9", 93 | "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", 94 | "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", 95 | "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", 96 | "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", 97 | "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", 98 | "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", 99 | "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", 100 | "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", 101 | "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b" 102 | ], 103 | "markers": "python_version >= '3.8'", 104 | "version": "==1.17.1" 105 | }, 106 | "charset-normalizer": { 107 | "hashes": [ 108 | "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621", 109 | "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", 110 | "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", 111 | "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", 112 | "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", 113 | "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", 114 | "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", 115 | "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d", 116 | "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", 117 | "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", 118 | "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", 119 | "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", 120 | "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab", 121 | "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be", 122 | "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", 123 | "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", 124 | "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0", 125 | "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2", 126 | "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62", 127 | "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62", 128 | "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", 129 | "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", 130 | "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", 131 | "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", 132 | "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455", 133 | "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858", 134 | "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", 135 | "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", 136 | "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", 137 | "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", 138 | "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", 139 | "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea", 140 | "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", 141 | "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", 142 | "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", 143 | "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", 144 | "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd", 145 | "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", 146 | "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242", 147 | "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee", 148 | "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", 149 | "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", 150 | "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51", 151 | "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", 152 | "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8", 153 | "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", 154 | "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613", 155 | "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742", 156 | "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", 157 | "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", 158 | "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", 159 | "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", 160 | "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", 161 | "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", 162 | "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", 163 | "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", 164 | "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417", 165 | "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", 166 | "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", 167 | "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca", 168 | "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", 169 | "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", 170 | "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149", 171 | "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41", 172 | "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574", 173 | "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", 174 | "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f", 175 | "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", 176 | "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654", 177 | "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3", 178 | "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19", 179 | "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", 180 | "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578", 181 | "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", 182 | "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", 183 | "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51", 184 | "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", 185 | "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", 186 | "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a", 187 | "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", 188 | "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade", 189 | "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", 190 | "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", 191 | "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6", 192 | "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", 193 | "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", 194 | "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6", 195 | "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2", 196 | "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12", 197 | "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf", 198 | "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", 199 | "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7", 200 | "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", 201 | "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", 202 | "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b", 203 | "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", 204 | "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", 205 | "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4", 206 | "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", 207 | "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", 208 | "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", 209 | "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748", 210 | "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", 211 | "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", 212 | "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482" 213 | ], 214 | "markers": "python_full_version >= '3.7.0'", 215 | "version": "==3.4.0" 216 | }, 217 | "click": { 218 | "hashes": [ 219 | "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", 220 | "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" 221 | ], 222 | "markers": "python_version >= '3.7'", 223 | "version": "==8.1.8" 224 | }, 225 | "cryptography": { 226 | "hashes": [ 227 | "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390", 228 | "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41", 229 | "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", 230 | "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5", 231 | "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", 232 | "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d", 233 | "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", 234 | "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", 235 | "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5", 236 | "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", 237 | "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", 238 | "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79", 239 | "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", 240 | "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", 241 | "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", 242 | "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", 243 | "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562", 244 | "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", 245 | "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", 246 | "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3", 247 | "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d", 248 | "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471", 249 | "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", 250 | "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa", 251 | "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb", 252 | "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", 253 | "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", 254 | "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa", 255 | "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", 256 | "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", 257 | "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", 258 | "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615", 259 | "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", 260 | "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7", 261 | "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308" 262 | ], 263 | "markers": "python_version >= '3.7' and python_full_version not in '3.9.0, 3.9.1'", 264 | "version": "==44.0.2" 265 | }, 266 | "fastcore": { 267 | "hashes": [ 268 | "sha256:67739747f0f714d02944957271a11dcb4a9dd40012615e6f9d19add3e5d9e041", 269 | "sha256:d23e63aff4bcd06dde24cdf9f7ea13c48e7d274ae89e52e826df33c64869c976" 270 | ], 271 | "markers": "python_version >= '3.9'", 272 | "version": "==1.8.0" 273 | }, 274 | "flask": { 275 | "hashes": [ 276 | "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", 277 | "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e" 278 | ], 279 | "index": "pypi", 280 | "markers": "python_version >= '3.9'", 281 | "version": "==3.1.1" 282 | }, 283 | "ghapi": { 284 | "hashes": [ 285 | "sha256:64fdd9f06d8e3373065c42c2a03e067e2bbb9ca18b583cd6e38a28aaad0224f6", 286 | "sha256:b3d96bf18fcaa2cb7131bad9de2948e2a1c2bb226377a25826f6c80950c57854" 287 | ], 288 | "index": "pypi", 289 | "markers": "python_version >= '3.7'", 290 | "version": "==1.0.6" 291 | }, 292 | "ghastoolkit": { 293 | "hashes": [ 294 | "sha256:02307e3293184336350ea4fe4e622478ae1ca6a7ae6baea918a5f702be7d33df", 295 | "sha256:28bb710fabe8315c86054e0bbb472d5b1fdede0e5f032b4505d144e97da1022f" 296 | ], 297 | "index": "pypi", 298 | "markers": "python_version >= '3.9'", 299 | "version": "==0.15.1" 300 | }, 301 | "github3.py": { 302 | "hashes": [ 303 | "sha256:30d571076753efc389edc7f9aaef338a4fcb24b54d8968d5f39b1342f45ddd36", 304 | "sha256:a89af7de25650612d1da2f0609622bcdeb07ee8a45a1c06b2d16a05e4234e753" 305 | ], 306 | "markers": "python_version >= '3.7'", 307 | "version": "==4.0.1" 308 | }, 309 | "gunicorn": { 310 | "hashes": [ 311 | "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", 312 | "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec" 313 | ], 314 | "index": "pypi", 315 | "markers": "python_version >= '3.7'", 316 | "version": "==23.0.0" 317 | }, 318 | "idna": { 319 | "hashes": [ 320 | "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", 321 | "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" 322 | ], 323 | "markers": "python_version >= '3.6'", 324 | "version": "==3.10" 325 | }, 326 | "importlib-metadata": { 327 | "hashes": [ 328 | "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", 329 | "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd" 330 | ], 331 | "markers": "python_version >= '3.9'", 332 | "version": "==8.7.0" 333 | }, 334 | "itsdangerous": { 335 | "hashes": [ 336 | "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", 337 | "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173" 338 | ], 339 | "markers": "python_version >= '3.8'", 340 | "version": "==2.2.0" 341 | }, 342 | "jinja2": { 343 | "hashes": [ 344 | "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", 345 | "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" 346 | ], 347 | "markers": "python_version >= '3.7'", 348 | "version": "==3.1.6" 349 | }, 350 | "markupsafe": { 351 | "hashes": [ 352 | "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", 353 | "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", 354 | "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", 355 | "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", 356 | "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", 357 | "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", 358 | "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", 359 | "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", 360 | "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", 361 | "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", 362 | "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", 363 | "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", 364 | "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", 365 | "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", 366 | "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", 367 | "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", 368 | "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", 369 | "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", 370 | "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", 371 | "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", 372 | "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", 373 | "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", 374 | "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", 375 | "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", 376 | "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", 377 | "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", 378 | "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", 379 | "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", 380 | "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", 381 | "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", 382 | "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", 383 | "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", 384 | "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", 385 | "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", 386 | "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", 387 | "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", 388 | "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", 389 | "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", 390 | "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", 391 | "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", 392 | "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", 393 | "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", 394 | "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", 395 | "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", 396 | "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", 397 | "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", 398 | "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", 399 | "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", 400 | "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", 401 | "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", 402 | "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", 403 | "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", 404 | "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", 405 | "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", 406 | "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", 407 | "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", 408 | "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", 409 | "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", 410 | "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", 411 | "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", 412 | "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50" 413 | ], 414 | "markers": "python_version >= '3.9'", 415 | "version": "==3.0.2" 416 | }, 417 | "packaging": { 418 | "hashes": [ 419 | "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", 420 | "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" 421 | ], 422 | "markers": "python_version >= '3.8'", 423 | "version": "==24.2" 424 | }, 425 | "pycparser": { 426 | "hashes": [ 427 | "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", 428 | "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc" 429 | ], 430 | "markers": "python_version >= '3.8'", 431 | "version": "==2.22" 432 | }, 433 | "pyjwt": { 434 | "extras": [ 435 | "crypto" 436 | ], 437 | "hashes": [ 438 | "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", 439 | "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb" 440 | ], 441 | "markers": "python_version >= '3.9'", 442 | "version": "==2.10.1" 443 | }, 444 | "python-dateutil": { 445 | "hashes": [ 446 | "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", 447 | "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" 448 | ], 449 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", 450 | "version": "==2.9.0.post0" 451 | }, 452 | "python-dotenv": { 453 | "hashes": [ 454 | "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", 455 | "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d" 456 | ], 457 | "index": "pypi", 458 | "markers": "python_version >= '3.9'", 459 | "version": "==1.1.0" 460 | }, 461 | "pyyaml": { 462 | "hashes": [ 463 | "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff", 464 | "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", 465 | "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", 466 | "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", 467 | "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", 468 | "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", 469 | "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", 470 | "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", 471 | "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", 472 | "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", 473 | "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a", 474 | "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", 475 | "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", 476 | "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", 477 | "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", 478 | "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", 479 | "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", 480 | "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a", 481 | "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", 482 | "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", 483 | "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", 484 | "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", 485 | "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", 486 | "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", 487 | "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", 488 | "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", 489 | "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", 490 | "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", 491 | "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", 492 | "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706", 493 | "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", 494 | "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", 495 | "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", 496 | "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083", 497 | "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", 498 | "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", 499 | "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", 500 | "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", 501 | "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", 502 | "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", 503 | "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", 504 | "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", 505 | "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", 506 | "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", 507 | "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5", 508 | "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d", 509 | "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", 510 | "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", 511 | "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", 512 | "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", 513 | "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", 514 | "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", 515 | "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4" 516 | ], 517 | "markers": "python_version >= '3.8'", 518 | "version": "==6.0.2" 519 | }, 520 | "ratelimit": { 521 | "hashes": [ 522 | "sha256:af8a9b64b821529aca09ebaf6d8d279100d766f19e90b5059ac6a718ca6dee42" 523 | ], 524 | "version": "==2.2.1" 525 | }, 526 | "requests": { 527 | "hashes": [ 528 | "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", 529 | "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" 530 | ], 531 | "markers": "python_version >= '3.8'", 532 | "version": "==2.32.3" 533 | }, 534 | "semantic-version": { 535 | "hashes": [ 536 | "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c", 537 | "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177" 538 | ], 539 | "markers": "python_version >= '2.7'", 540 | "version": "==2.10.0" 541 | }, 542 | "six": { 543 | "hashes": [ 544 | "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", 545 | "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" 546 | ], 547 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", 548 | "version": "==1.17.0" 549 | }, 550 | "uritemplate": { 551 | "hashes": [ 552 | "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0", 553 | "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e" 554 | ], 555 | "markers": "python_version >= '3.6'", 556 | "version": "==4.1.1" 557 | }, 558 | "urllib3": { 559 | "hashes": [ 560 | "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", 561 | "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" 562 | ], 563 | "markers": "python_version >= '3.8'", 564 | "version": "==2.2.3" 565 | }, 566 | "werkzeug": { 567 | "hashes": [ 568 | "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", 569 | "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746" 570 | ], 571 | "markers": "python_version >= '3.9'", 572 | "version": "==3.1.3" 573 | }, 574 | "zipp": { 575 | "hashes": [ 576 | "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", 577 | "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931" 578 | ], 579 | "markers": "python_version >= '3.9'", 580 | "version": "==3.21.0" 581 | } 582 | }, 583 | "develop": { 584 | "black": { 585 | "hashes": [ 586 | "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", 587 | "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", 588 | "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", 589 | "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", 590 | "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", 591 | "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", 592 | "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", 593 | "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", 594 | "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", 595 | "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", 596 | "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", 597 | "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", 598 | "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0", 599 | "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", 600 | "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", 601 | "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", 602 | "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355", 603 | "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", 604 | "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e", 605 | "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", 606 | "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", 607 | "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f" 608 | ], 609 | "index": "pypi", 610 | "markers": "python_version >= '3.9'", 611 | "version": "==25.1.0" 612 | }, 613 | "click": { 614 | "hashes": [ 615 | "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", 616 | "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" 617 | ], 618 | "markers": "python_version >= '3.7'", 619 | "version": "==8.1.8" 620 | }, 621 | "mypy-extensions": { 622 | "hashes": [ 623 | "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", 624 | "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" 625 | ], 626 | "markers": "python_version >= '3.5'", 627 | "version": "==1.0.0" 628 | }, 629 | "packaging": { 630 | "hashes": [ 631 | "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", 632 | "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f" 633 | ], 634 | "markers": "python_version >= '3.8'", 635 | "version": "==24.2" 636 | }, 637 | "pathspec": { 638 | "hashes": [ 639 | "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", 640 | "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" 641 | ], 642 | "markers": "python_version >= '3.8'", 643 | "version": "==0.12.1" 644 | }, 645 | "platformdirs": { 646 | "hashes": [ 647 | "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", 648 | "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" 649 | ], 650 | "markers": "python_version >= '3.8'", 651 | "version": "==4.3.6" 652 | } 653 | } 654 | } 655 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 |

GHAS Reviewer App

4 | 5 | [![GitHub](https://img.shields.io/badge/github-%23121011.svg?style=for-the-badge&logo=github&logoColor=white)](https://github.com/advanced-security/ghas-reviewer-app) 6 | [![GitHub Actions](https://img.shields.io/github/actions/workflow/status/advanced-security/ghas-reviewer-app/build.yml?style=for-the-badge)](https://github.com/advanced-security/ghas-reviewer-app/actions/workflows/build.yml?query=branch%3Amain) 7 | [![GitHub Issues](https://img.shields.io/github/issues/advanced-security/ghas-reviewer-app?style=for-the-badge)](https://github.com/advanced-security/ghas-reviewer-app/issues) 8 | [![GitHub Stars](https://img.shields.io/github/stars/advanced-security/ghas-reviewer-app?style=for-the-badge)](https://github.com/advanced-security/ghas-reviewer-app) 9 | [![Licence](https://img.shields.io/github/license/Ileriayo/markdown-badges?style=for-the-badge)](./LICENSE) 10 | 11 |
12 | 13 | 14 | ## Overview 15 | 16 | GitHub Advanced Security (GHAS) Reviewer App allows security teams to enforces a reviewer to approve and dismiss alerts. 17 | This allows security experts to provide 4-eyes principle over all security alerts generated in GitHub. 18 | 19 | > [!TIP] 20 | > GitHub Advanced Security (GHAS) has now Alert Dismissal feature built directly into the product. 21 | > This app is not required for that functionality. 22 | 23 | ## ✨ Features 24 | 25 | - Re-open closed alerts if an unapproved users changes the alert 26 | - Notifies Security Team for vulneraiblities found in PR and assigns them as reviewers. **Requires security team to be repository collaborators.** 27 | - GitHub Advanced Security Features 28 | - [x] [Code Scanning][github-codescanning] alerts 29 | - [x] [Secret Scanning][github-secretscanning] alerts 30 | - [x] [Dependabot][github-supplychain] alerts 31 | 32 | ## ⚡️ Requirements 33 | 34 | - Python `+3.9` 35 | - GitHub Application Setup 36 | - [Permissions][permissions] 37 | - [optional] Docker / Docker Compose 38 | 39 | ## Usage 40 | 41 | GHAS Reviewer is a Python based web application which primarily uses Docker for easy deployment. 42 | 43 | ### GitHub Application Configuration 44 | 45 | [Checkout how to setup a GitHub App here](https://docs.github.com/en/developers/apps/building-github-apps/creating-a-github-app). 46 | 47 | Store the App key so the service can read it from the path provided along with the other environment variables or cli arguments. 48 | 49 | **Environment Variable:** 50 | 51 | Create a `.env` file in the root of the project with the following environment variables. 52 | 53 | ```env 54 | # Application ID 55 | GITHUB_APP_ID=123456 56 | # Path to the App private key 57 | GITHUB_APP_KEY_PATH=./config/key.pem 58 | # or use the private key directly 59 | GITHUB_APP_KEY=-----BEGIN PRIVATE KEY-----\n... 60 | # Webhook Secret 61 | GITHUB_APP_SECRET=123456789012345678901234567890 62 | GITHUB_APP_ENDPOINT=/ 63 | GITHUB_GHAS_TEAM="sec_team" 64 | # GHAS Severities 65 | GITHUB_GHAS_SEVERITIES="critical,high,error,errors" 66 | ``` 67 | 68 | You can also use the following CLI arguments to pass the configuration. 69 | 70 | If you choose to pass the private key via a file just store the key in a file and pass the path to the file. In our case, we store the key in `./config/key.pem`. You will later mount this file into the container. 71 | 72 | #### Permissions 73 | 74 | The GitHub App requires the following permissions: 75 | 76 | - Repository 77 | 78 | - [x] Code scanning alerts: Read & Write 79 | - [x] Dependabot alerts: Read & Write 80 | - [x] Secrets scanning alerts: Read & Write 81 | - [x] Issues: Read & Write 82 | - [x] Pull requests: Read & Write 83 | 84 | - Webhook events 85 | - [x] Code scanning alerts 86 | - [x] Dependabot alerts 87 | - [x] Secret scanning alerts 88 | 89 | ### Container / Docker 90 | 91 | The application is designed to be run in a container, this allows for easy deployment and scaling. 92 | 93 | **Pull / Download image:** 94 | 95 | ```bash 96 | # Pull latest or a release 97 | docker pull ghcr.io/advanced-security/ghas-reviewer-app:latest 98 | 99 | or 100 | 101 | docker pull ghcr.io/advanced-security/ghas-reviewer-app:v0.6.2 102 | ``` 103 | 104 | **Or Build From Source:** 105 | 106 | ```bash 107 | docker build -t advanced-security/ghas-reviewer-app . 108 | ``` 109 | 110 | or build locally 111 | 112 | ```bash 113 | docker build -t advanced-security/ghas-reviewer-app . 114 | ``` 115 | 116 | **Run Docker Image:** 117 | 118 | ```bash 119 | docker run \ 120 | --env-file=.env \ 121 | -v ./config:/ghasreview/config \ 122 | -p 9000:9000 \ 123 | ghcr.io/advanced-security/ghas-reviewer-app:latest # or use release tag, example v0.6.0 124 | ``` 125 | 126 | or run it locally 127 | 128 | ```bash 129 | docker run \ 130 | --env-file=.env \ 131 | -v ./config:/ghasreview/config \ 132 | -p 9000:9000 \ 133 | advanced-security/ghas-reviewer-app 134 | ``` 135 | 136 | \*\*Run 137 | 138 | ### Docker Compose 139 | 140 | If you are testing the GitHub App you can quickly use Docker Compose to spin-up the container. 141 | 142 | ```bash 143 | docker-compose build 144 | docker-compose up -d 145 | ``` 146 | 147 | ## Local Development 148 | 149 | If you want to run the application locally you can use the following the same steps as abouve meaning you need to create an GitHub App, store the private key and set the environment variables. 150 | 151 | After you have set the environment variables you can run the application using the following commands. 152 | 153 | ```bash 154 | # We are using Pipenv for dependency management 155 | pip install pipenv 156 | 157 | # Install dependencies 158 | pipenv install --dev 159 | 160 | # Run the application 161 | pipenv run develop 162 | ``` 163 | 164 | ## Limitations 165 | 166 | - Pull Request require team approval. The security team needs to be repository collaborator. 167 | 168 | ## Maintainers / Contributors 169 | 170 | - [@GeekMasher](https://github.com/GeekMasher) - Author / Core Maintainer 171 | - [@theztefan](https://github.com/theztefan) - Contributor 172 | 173 | ## Support 174 | 175 | Please create [GitHub Issues][github-issues] if there are bugs or feature requests. 176 | 177 | This project uses [Sematic Versioning (v2)](https://semver.org/) and with major releases, breaking changes will occur. 178 | 179 | ## License 180 | 181 | This project is licensed under the terms of the MIT open source license. 182 | Please refer to [MIT][license] for the full terms. 183 | 184 | 185 | 186 | [license]: ./LICENSE 187 | [github-issues]: https://github.com/advanced-security/ghas-reviewer-app/issues 188 | [github-codescanning]: https://docs.github.com/en/code-security/code-scanning/introduction-to-code-scanning/about-code-scanning 189 | [github-secretscanning]: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning 190 | [github-supplychain]: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-supply-chain-security 191 | [permissions]: https://github.com/advanced-security/ghas-reviewer-app#permissions 192 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | Thanks for helping make GitHub safe for everyone. 4 | 5 | GitHub takes the security of our software products and services seriously, including all of the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub). 6 | 7 | Even though [open source repositories are outside of the scope of our bug bounty program](https://bounty.github.com/index.html#scope) and therefore not eligible for bounty rewards, we will ensure that your finding gets passed along to the appropriate maintainers for remediation. 8 | 9 | ## Reporting Security Issues 10 | 11 | If you believe you have found a security vulnerability in any GitHub-owned repository, please report it to us through coordinated disclosure. 12 | 13 | **Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** 14 | 15 | Instead, please send an email to opensource-security[@]github.com. 16 | 17 | Please include as much of the information listed below as you can to help us better understand and resolve the issue: 18 | 19 | * The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting) 20 | * Full paths of source file(s) related to the manifestation of the issue 21 | * The location of the affected source code (tag/branch/commit or direct URL) 22 | * Any special configuration required to reproduce the issue 23 | * Step-by-step instructions to reproduce the issue 24 | * Proof-of-concept or exploit code (if possible) 25 | * Impact of the issue, including how an attacker might exploit the issue 26 | 27 | This information will help us triage your report more quickly. 28 | 29 | ## Policy 30 | 31 | See [GitHub's Safe Harbor Policy](https://docs.github.com/en/github/site-policy/github-bug-bounty-program-legal-safe-harbor#1-safe-harbor-terms) 32 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | 2 | # Support 3 | 4 | ## How to file issues and get help 5 | 6 | This project uses GitHub issues to track bugs and feature requests. 7 | Please search the existing issues before filing new issues to avoid duplicates. 8 | For new issues, file your bug or feature request as a new issue. 9 | 10 | For help or questions about using this project, please use the GitHub Discussions. 11 | 12 | This repository is under active development and maintained by GitHub staff and the community. 13 | We will do our best to respond to support, feature requests, and community questions in a timely manner. 14 | 15 | ## GitHub Support Policy 16 | 17 | Support for this project is limited to the resources listed above. 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ghasreview: 3 | build: . 4 | container_name: advanced-security/ghas-reviewer-app 5 | env_file: .env 6 | ports: 7 | - "9000:9000" 8 | volumes: 9 | - ./config:/ghasreview/config 10 | security_opt: 11 | - no-new-privileges:true -------------------------------------------------------------------------------- /docs/process-flow.md: -------------------------------------------------------------------------------- 1 | ## captures how the GHAS-Reviewer app will work when alerts are dismissed by Developers on the GitHub UI 2 | 3 | ## The process flow is as follows: 4 | 5 | 1. The Developer dismisses the alert on the GitHub UI 6 | 2. A webhook is triggered and sends a payload to the GHAS-Reviewer app 7 | 3. The GHAS-Reviewer app receives the payload and processes it 8 | 4. If the alert dismissed is a dependabot, code scanning or secret scanning alert, the GHAS-Reviewer app will check if the alert is dismissed by the Developer 9 | 5. If the alert is dismissed by the Developer, the GHAS-Reviewer app will re-open the alert on the GitHub UI, and assign it to a member of the Security team 10 | 6. The Security team member will review the alert and take necessary actions, such as dismissing the alert, or creating an issue to fix the vulnerability 11 | 12 | 13 | ## Sequence Diagram 14 | 15 | ```mermaid 16 | sequenceDiagram 17 | participant Developer 18 | participant GitHub-UI 19 | participant GHAS-Reviewer 20 | participant SecurityTeam 21 | 22 | 23 | Note over Developer: Encounters Dependabot, Code Scanning, Secret Scanning alerts within PR 24 | Developer->>GitHub: Dismisses alert on the GitHub UI 25 | GitHub->>GHAS-Reviewer: Calls registered Webhook with alert payload 26 | GHAS-Reviewer->>GHAS-Reviewer: Processes payload and understands alert type, dismmisser 27 | critical Determine who dismissed the alert 28 | GHAS-Reviewer-->>GitHub: Dismissed by Developer? 29 | option Yes 30 | Note over GHAS-Reviewer: Re-open alert and assign to Security Team 31 | GHAS-Reviewer->>GitHub: Re-open alert 32 | GHAS-Reviewer->>SecurityTeam: Assign alert 33 | SecurityTeam->>GHAS-Reviewer: Review alert 34 | SecurityTeam->>GitHub: Dismiss alert 35 | option No 36 | Note over GHAS-Reviewer: Alert dismissed by Security team member 37 | GHAS-Reviewer->>GitHub: Alert remains dismissed, no action required 38 | 39 | end 40 | ``` 41 | 42 | -------------------------------------------------------------------------------- /ghasreview/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.6.2" 2 | 3 | __url__ = "https://github.com/advanced-security/ghas-reviewer-app" 4 | -------------------------------------------------------------------------------- /ghasreview/__main__.py: -------------------------------------------------------------------------------- 1 | from ghasreview.app import create_app, config 2 | 3 | if __name__ == "__main__": 4 | app = create_app(config) 5 | app.run("0.0.0.0", port=9000, debug=config.get("GHAS_DEBUG", False)) 6 | -------------------------------------------------------------------------------- /ghasreview/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict 3 | 4 | from flask import Flask, redirect, current_app, jsonify 5 | from ghasreview.flask_githubapp import GitHubApp 6 | from ghasreview.setup import setup_app 7 | from ghasreview import __url__ 8 | from ghasreview.client import Client 9 | from ghasreview.models import ( 10 | DependabotAlert, 11 | CodeScanningAlert, 12 | SecretScanningAlert, 13 | ) 14 | 15 | logger = logging.getLogger("app") 16 | app = Flask("GHAS Review") 17 | githubapp = GitHubApp() 18 | 19 | 20 | def create_app(config: Dict): 21 | app.config.update(**config) 22 | githubapp.init_app(app) 23 | return app 24 | 25 | 26 | config = setup_app() 27 | app = create_app(config) 28 | 29 | 30 | # Secret Scanning 31 | @githubapp.on("secret_scanning_alert.resolved") 32 | def onSecretScanningAlertClose(): 33 | """Secret Scanning Alert Resolved Event""" 34 | logger.debug("Secret Scanning Alert Resolved by User") 35 | client = Client( 36 | githubapp.installation_client, githubapp.payload["installation"]["id"] 37 | ) 38 | alert = SecretScanningAlert() 39 | alert.payload = githubapp.payload 40 | 41 | # Check if the user is part of the security team 42 | if client.isUserPartOfTeam(alert.owner, config.get("GHAS_TEAM"), alert.getUser()): 43 | return {"message": "User is part of the security team"} 44 | 45 | # Open Alert back up 46 | open_alert = client.reOpenSecretScanningAlert( 47 | alert.owner, alert.repository, alert.id 48 | ) 49 | 50 | if open_alert.status_code != 200: 51 | logger.warning(f"Unable to re-open alert") 52 | return 53 | return {"message": "Secret Scanning Alert Reopened"} 54 | 55 | 56 | # Dependabot 57 | @githubapp.on("dependabot_alert.dismissed") 58 | def onDependabotAlertDismiss(): 59 | """Dependabot Alert Dismissed Event""" 60 | logger.debug("Dependabot Alert Dismissed by User") 61 | client = Client( 62 | githubapp.installation_client, githubapp.payload["installation"]["id"] 63 | ) 64 | alert = DependabotAlert() 65 | alert.payload = githubapp.payload 66 | 67 | # Check if the user is part of the security team 68 | if client.isUserPartOfTeam(alert.owner, config.get("GHAS_TEAM"), alert.getUser()): 69 | return {"message": "User is part of the security team"} 70 | 71 | # Open Alert back up 72 | open_alert = client.reOpenDependabotAlert(alert.owner, alert.repository, alert.id) 73 | if open_alert.status_code != 200: 74 | logger.warning(f"Unable to re-open alert") 75 | return 76 | return {"message": "Dependabot Alert Reopened"} 77 | 78 | 79 | # Code Scanning 80 | # https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#code_scanning_alert 81 | @githubapp.on("code_scanning_alert.created") 82 | def onCodeScanningAlertCreation(): 83 | """Code Scanning Alert event 84 | https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#code_scanning_alert 85 | """ 86 | alert = CodeScanningAlert() 87 | alert.payload = githubapp.payload 88 | alert.client = Client( 89 | githubapp.installation_client, 90 | githubapp.app_client, 91 | githubapp.payload["installation"]["id"], 92 | ) 93 | alert.ghas_team_name = config.get("GHAS_TEAM") 94 | 95 | # Check if in a PR 96 | if not alert.isPR(): 97 | logger.debug(f"Alert is not in a Pull Request, ignoring") 98 | return {"message": "Alert is not in a Pull Request. Not doing anything."} 99 | 100 | logger.debug(f"Alert Opened :: {alert.id} ({alert.ref})") 101 | 102 | # Check tool and severity 103 | tool = config.get("GHAS_TOOL") 104 | if tool and alert.tool != tool: 105 | logger.debug(f"Tool is not in the list of approved tools: {alert.tool}") 106 | return {"message": "Tool is not in the list of approved tools"} 107 | 108 | severities = config.get("GHAS_SEVERITIES") 109 | if severities and alert.severity not in severities: 110 | logger.debug( 111 | f"Severity is not high enough to get security involved: {alert.severity}" 112 | ) 113 | return {"message": "Severity is not high enough to get security involved"} 114 | 115 | # Currently, comment creating a very easy versus adding team to PR 116 | if not alert.hasCommentedInPR(): 117 | alert.createCommentOnPR() 118 | alert.addTeamToPullRequest() 119 | return {"message": "Code Scanning create alert in PR handled"} 120 | 121 | 122 | @githubapp.on("code_scanning_alert.closed_by_user") 123 | def onCodeScanningAlertClose(): 124 | """Code Scanning Alert Close Event""" 125 | alert = CodeScanningAlert() 126 | alert.payload = githubapp.payload 127 | alert.client = Client( 128 | githubapp.installation_client, githubapp.payload["installation"]["id"] 129 | ) 130 | 131 | logger.info( 132 | f"Processing Alert :: {alert.owner}/{alert.repository} => {alert.id} ({alert.ref})" 133 | ) 134 | 135 | # Check if comment in alert 136 | if current_app.config.get("GHAS_COMMENT_REQUIRED") and not alert.hasDismissedComment(): 137 | logger.debug(f"Comment required, reopeneing alert: {alert.id}") 138 | 139 | open_alert = alert.client.reOpenCodeScanningAlert( 140 | alert.owner, alert.repository, alert.id, 141 | ) 142 | if open_alert.status_code != 200: 143 | logger.error(f"Unable to re-open alert :: {alert.id}") 144 | logger.error("This might be a permissions issue, please check the documentation for more details") 145 | return {"message": "Unable to re-open alert"} 146 | return { 147 | "message": "Comment required, re-opening alert" 148 | } 149 | 150 | # Check tool and severity 151 | tool = current_app.config.get("GHAS_TOOL") 152 | if tool and alert.tool != tool: 153 | logger.debug(f"Tool is not in the list of approved tools: {alert.tool}") 154 | return {"message": "Tool is not in the list of approved tools"} 155 | 156 | # Severity check, if not high enough, do not involve security team 157 | severities = current_app.config.get("GHAS_SEVERITIES") 158 | if severities: 159 | if alert.severity not in severities: 160 | logger.debug( 161 | f"Severity is not high enough to get security involved: {alert.severity}" 162 | ) 163 | return {"message": "Severity is not high enough to get security involved, doing nothing."} 164 | if alert.payload.get("alert", {}).get("rule", {}).get("security_severity_level", "") not in severities: 165 | logger.debug( 166 | f"Security severity level is not high enough to get security involved: {alert.payload.get('alert', {}).get('rule', {}).get('security_severity_level', '')}" 167 | ) 168 | return {"message": "Security severity level is not high enough to get security involved, doing nothing."} 169 | else: 170 | logger.debug("No severities provided, reopening all findings") 171 | 172 | # Check team exists 173 | if not alert.client.checkIfTeamExists(alert.owner, config.get("GHAS_TEAM")): 174 | logger.info(f"GHAS Reviewer Team `{config.get('GHAS_TEAM')}` does not exist, creating team.") 175 | alert.client.createTeam(alert.owner, config.get("GHAS_TEAM")) 176 | 177 | # check if the user is part of the security team 178 | if alert.client.isUserPartOfTeam(alert.owner, config.get("GHAS_TEAM"), alert.getUser()): 179 | logger.debug(f"User is part of security team, no action taken.") 180 | return {"message": "User is part of the security team"} 181 | 182 | logger.info(f"User is not allowed to close alerts: {alert.getUser()} ({alert.owner}/{alert.repository} => {alert.id})") 183 | 184 | # Open Alert back up 185 | open_alert = alert.client.reOpenCodeScanningAlert( 186 | alert.owner, alert.repository, alert.id, 187 | ) 188 | 189 | if open_alert.status_code != 200: 190 | logger.error(f"Unable to re-open alert :: {alert.id}") 191 | logger.error("This might be a permissions issue, please check the documentation for more details") 192 | return {"message": "Unable to re-open alert"} 193 | 194 | if alert.isPR(): 195 | logger.debug(f"In PR request") 196 | 197 | return {"message": "Code Scanning Alert Reopened"} 198 | 199 | 200 | @app.errorhandler(500) 201 | def page_not_found(error): 202 | data = {"error": 500, "msg": "Internal Server Error"} 203 | if app.debug: 204 | data["msg"] = str(error) 205 | resp = jsonify(**data) 206 | return resp 207 | 208 | 209 | @app.route("/", methods=["GET"]) 210 | def index(): 211 | logger.info(f"Redirecting user to url...") 212 | return redirect(__url__) 213 | 214 | 215 | @app.route("/healthcheck", methods=["GET"]) 216 | def healthcheck(): 217 | logger.debug("Healthcheck status") 218 | return jsonify({"status": "healthy"}) 219 | -------------------------------------------------------------------------------- /ghasreview/client.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | import logging 3 | 4 | logger = logging.getLogger("GitHubClient") 5 | 6 | 7 | class Client: 8 | installation_client: object = None 9 | app_client: object = None 10 | installation_id: int = 0 11 | 12 | def __init__( 13 | self, installation_client, app_client: object = None, installation_id: int = 0 14 | ): 15 | self.installation_client = installation_client 16 | self.app_client = app_client 17 | self.installation_id = installation_id 18 | 19 | def getInstallationId(self) -> int: 20 | return self.installation_id 21 | 22 | def isUserPartOfTeam(self, owner_name: str, team_name: str, user: str) -> bool: 23 | 24 | team = self.callApi( 25 | "GET", 26 | f"{self.installation_client.session.base_url}/orgs/{owner_name}/teams/{team_name}", 27 | ) 28 | if team.status_code != 200: 29 | logger.error(f"Team does not exist :: {team_name} - {team.json()}") 30 | return False 31 | 32 | membership_res = self.callApi( 33 | "GET", 34 | f"{self.installation_client.session.base_url}/orgs/{owner_name}/teams/{team_name}/memberships/{user}", 35 | ) 36 | 37 | if membership_res.status_code == 200: 38 | logger.debug(f"User is part of the security team, no action taken.") 39 | return True 40 | return False 41 | 42 | def callApi(self, method: str, url: str, json: Dict = {}) -> Dict: 43 | response = self.installation_client.session.request(method, url, json=json) 44 | return response 45 | 46 | def reOpenAlert(self, owner: str, repo: str, type: str, alert_id: int) -> Dict: 47 | return self.callApi( 48 | "PATCH", 49 | f"{self.installation_client.session.base_url}/repos/{owner}/{repo}/{type}/alerts/{alert_id}", 50 | {"state": "open"}, 51 | ) 52 | 53 | def reOpenSecretScanningAlert(self, owner: str, repo: str, alert_id: int) -> Dict: 54 | return self.reOpenAlert(owner, repo, "secret-scanning", alert_id) 55 | 56 | def reOpenDependabotAlert(self, owner: str, repo: str, alert_id: int) -> Dict: 57 | return self.reOpenAlert(owner, repo, "dependabot", alert_id) 58 | 59 | def reOpenCodeScanningAlert(self, owner: str, repo: str, alert_id: int) -> Dict: 60 | return self.reOpenAlert(owner, repo, "code-scanning", alert_id) 61 | 62 | def checkIfTeamExists(self, owner: str, team_name: str) -> bool: 63 | team = self.installation_client.session.get( 64 | f"{self.installation_client.session.base_url}/orgs/{owner}/teams/{team_name}" 65 | ) 66 | if team.status_code != 200: 67 | logger.debug(f"Team does not exist :: {team_name} - {team.json()}") 68 | return False 69 | return True 70 | 71 | def createTeam(self, owner: str, team_name: str) -> bool: 72 | # https://docs.github.com/en/rest/reference/teams#create-a-team 73 | team_request = { 74 | "name": team_name, 75 | "description": "GitHub Advanced Security Reviewers", 76 | } 77 | team_creation = self.installation_client.session.post( 78 | f"{self.installation_client.session.base_url}/orgs/{owner}/teams", json=team_request 79 | ) 80 | if team_creation.status_code != 200: 81 | logger.warning(f"Failed to create team :: {team_name} in {owner}") 82 | logger.debug(f"{team_creation.json()}") 83 | return False 84 | logging.debug(f"Created team for org :: {owner}") 85 | return True 86 | 87 | def postComment( 88 | self, owner: str, repo: str, issue_number: int, comment: str 89 | ) -> Dict: 90 | return self.callApi( 91 | "POST", 92 | f"{self.installation_client.session.base_url}/repos/{owner}/{repo}/issues/{issue_number}/comments", 93 | {"body": comment}, 94 | ) 95 | 96 | def getPRComments(self, owner: str, repo: str, pull_number: int) -> Dict: 97 | return self.callApi( 98 | "GET", 99 | f"{self.getBaseUrl()}/repos/{owner}/{repo}/issues/{pull_number}/comments", 100 | ) 101 | 102 | def getBaseUrl(self) -> str: 103 | return self.installation_client.session.base_url 104 | 105 | def getBotUsername(self) -> str: 106 | 107 | app_req = self.app_client.session.get( 108 | f"{self.app_client.session.base_url}/app", 109 | ) 110 | if app_req.status_code != 200: 111 | logger.warning(f"Error getting app details: {app_req.status_code}") 112 | return None 113 | 114 | app_details = app_req.json() 115 | app_name = app_details.get("slug") 116 | if not app_name: 117 | logger.warning("Bot username could not be determined.") 118 | return None 119 | 120 | return app_name + "[bot]" 121 | 122 | def getPRReviewers(self, owner: str, repo: str, pull_number: int) -> Dict: 123 | # https://docs.github.com/en/rest/reference/pulls#list-requested-reviewers-for-a-pull-request 124 | return self.callApi( 125 | "GET", 126 | f"{self.getBaseUrl()}/repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers", 127 | ) 128 | 129 | def addTeamToPullRequestReviewer( 130 | self, team_name: str, owner: str, repo: str, pull_number: int 131 | ) -> bool: 132 | pr_reviewers = self.getPRReviewers(owner, repo, pull_number).json() 133 | team_names = [team["name"] for team in pr_reviewers.get("teams", [])] 134 | # If the team is already attached to the PR 135 | if team_name in team_names: 136 | logger.debug(f"Team is already a reviewer. Skipping") 137 | else: 138 | logger.debug(f"Team is not a reviewer. Adding") 139 | # https://docs.github.com/en/rest/reference/pulls#request-reviewers-for-a-pull-request 140 | pr_add_reviewer = self.callApi( 141 | "POST", 142 | f"{self.getBaseUrl()}/repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers", 143 | {"team_reviewers": [team_name]}, 144 | ) 145 | if pr_add_reviewer.status_code != 201: 146 | logger.warning(f"Failed to add to PR: {pull_number}") 147 | logger.debug( 148 | f"Response: {pr_add_reviewer.status_code} - {pr_add_reviewer.text}" 149 | ) 150 | return False 151 | return True 152 | -------------------------------------------------------------------------------- /ghasreview/flask_githubapp/README.md: -------------------------------------------------------------------------------- 1 | # Flask GitHub App 2 | 3 | This is a fork of the `flask-githubapp` project, which is a Flask extension for building GitHub Apps. 4 | 5 | The original project is available at: https://pypi.org/project/Flask-GitHubApp/ 6 | 7 | ## The Issue 8 | 9 | - Deprecation of the `_app_ctx_stack.top` 10 | - https://flask.palletsprojects.com/en/3.0.x/changes/#version-2-3-0 11 | -------------------------------------------------------------------------------- /ghasreview/flask_githubapp/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import GitHubApp 2 | 3 | __version__ = "1.0.0" 4 | 5 | __all__ = ["GitHubApp"] 6 | 7 | # Set default logging handler to avoid "No handler found" warnings. 8 | import logging 9 | from logging import NullHandler 10 | 11 | # Set initial level to WARN. Users must manually enable logging for 12 | # flask_githubapp to see our logging. 13 | rootlogger = logging.getLogger(__name__) 14 | rootlogger.addHandler(NullHandler()) 15 | 16 | if rootlogger.level == logging.NOTSET: 17 | rootlogger.setLevel(logging.WARN) 18 | -------------------------------------------------------------------------------- /ghasreview/flask_githubapp/core.py: -------------------------------------------------------------------------------- 1 | """Flask extension for rapid GitHub app development""" 2 | 3 | import hmac 4 | import logging 5 | 6 | from flask import abort, current_app, jsonify, make_response, request 7 | from github3 import GitHub, GitHubEnterprise 8 | from werkzeug.exceptions import BadRequest 9 | 10 | LOG = logging.getLogger(__name__) 11 | 12 | STATUS_FUNC_CALLED = "HIT" 13 | STATUS_NO_FUNC_CALLED = "MISS" 14 | 15 | 16 | class GitHubAppError(Exception): 17 | pass 18 | 19 | 20 | class GitHubAppValidationError(GitHubAppError): 21 | pass 22 | 23 | 24 | class GitHubApp(object): 25 | """The GitHubApp object provides the central interface for interacting GitHub hooks 26 | and creating GitHub app clients. 27 | 28 | GitHubApp object allows using the "on" decorator to make GitHub hooks to functions 29 | and provides authenticated github3.py clients for interacting with the GitHub API. 30 | 31 | Keyword Arguments: 32 | app {Flask object} -- App instance - created with Flask(__name__) (default: {None}) 33 | """ 34 | 35 | def __init__(self, app=None): 36 | self._hook_mappings = {} 37 | if app is not None: 38 | self.init_app(app) 39 | 40 | def init_app(self, app): 41 | """Initializes GitHubApp app by setting configuration variables. 42 | 43 | The GitHubApp instance is given the following configuration variables by calling on Flask's configuration: 44 | 45 | `GITHUBAPP_ID`: 46 | 47 | GitHub app ID as an int (required). 48 | Default: None 49 | 50 | `GITHUBAPP_KEY`: 51 | 52 | Private key used to sign access token requests as bytes or utf-8 encoded string (required). 53 | Default: None 54 | 55 | `GITHUBAPP_SECRET`: 56 | 57 | Secret used to secure webhooks as bytes or utf-8 encoded string (required). set to `False` to disable 58 | verification (not recommended for production). 59 | Default: None 60 | 61 | `GITHUBAPP_URL`: 62 | 63 | URL of GitHub API (used for GitHub Enterprise) as a string. 64 | Default: None 65 | 66 | `GITHUBAPP_ROUTE`: 67 | 68 | Path used for GitHub hook requests as a string. 69 | Default: '/' 70 | """ 71 | required_settings = ["GITHUBAPP_ID", "GITHUBAPP_KEY", "GITHUBAPP_SECRET"] 72 | for setting in required_settings: 73 | if not setting in app.config: 74 | raise RuntimeError( 75 | "Flask-GitHubApp requires the '%s' config var to be set" % setting 76 | ) 77 | 78 | app.add_url_rule( 79 | app.config.get("GITHUBAPP_ROUTE", "/"), 80 | view_func=self._flask_view_func, 81 | methods=["POST"], 82 | ) 83 | 84 | @property 85 | def id(self): 86 | return current_app.config["GITHUBAPP_ID"] 87 | 88 | @property 89 | def key(self): 90 | key = current_app.config["GITHUBAPP_KEY"] 91 | if hasattr(key, "encode"): 92 | key = key.encode("utf-8") 93 | return key 94 | 95 | @property 96 | def secret(self): 97 | secret = current_app.config["GITHUBAPP_SECRET"] 98 | if hasattr(secret, "encode"): 99 | secret = secret.encode("utf-8") 100 | return secret 101 | 102 | @property 103 | def _api_url(self): 104 | return current_app.config["GITHUBAPP_URL"] 105 | 106 | @property 107 | def client(self): 108 | """Unauthenticated GitHub client""" 109 | if current_app.config.get("GITHUBAPP_URL"): 110 | return GitHubEnterprise(current_app.config["GITHUBAPP_URL"]) 111 | return GitHub() 112 | 113 | @property 114 | def payload(self): 115 | """GitHub hook payload""" 116 | if request and request.json and "installation" in request.json: 117 | return request.json 118 | 119 | raise RuntimeError( 120 | "Payload is only available in the context of a GitHub hook request" 121 | ) 122 | 123 | @property 124 | def installation_client(self): 125 | """GitHub client authenticated as GitHub app installation""" 126 | # Get the current application context 127 | ctx = current_app.app_context() 128 | if ctx is not None: 129 | if not hasattr(ctx, "githubapp_installation"): 130 | client = self.client 131 | client.login_as_app_installation( 132 | self.key, self.id, self.payload["installation"]["id"] 133 | ) 134 | ctx.githubapp_installation = client 135 | return ctx.githubapp_installation 136 | 137 | @property 138 | def app_client(self): 139 | """GitHub client authenticated as GitHub app""" 140 | ctx = current_app.app_context() 141 | if ctx is not None: 142 | if not hasattr(ctx, "githubapp_app"): 143 | client = self.client 144 | client.login_as_app(self.key, self.id) 145 | ctx.githubapp_app = client 146 | return ctx.githubapp_app 147 | 148 | @property 149 | def installation_token(self): 150 | return self.installation_client.session.auth.token 151 | 152 | def on(self, event_action): 153 | """Decorator routes a GitHub hook to the wrapped function. 154 | 155 | Functions decorated as a hook recipient are registered as the function for the given GitHub event. 156 | 157 | @github_app.on('issues.opened') 158 | def cruel_closer(): 159 | owner = github_app.payload['repository']['owner']['login'] 160 | repo = github_app.payload['repository']['name'] 161 | num = github_app.payload['issue']['id'] 162 | issue = github_app.installation_client.issue(owner, repo, num) 163 | issue.create_comment('Could not replicate.') 164 | issue.close() 165 | 166 | Arguments: 167 | event_action {str} -- Name of the event and optional action (separated by a period), e.g. 'issues.opened' or 168 | 'pull_request' 169 | """ 170 | 171 | def decorator(f): 172 | if event_action not in self._hook_mappings: 173 | self._hook_mappings[event_action] = [f] 174 | else: 175 | self._hook_mappings[event_action].append(f) 176 | 177 | # make sure the function can still be called normally (e.g. if a user wants to pass in their 178 | # own Context for whatever reason). 179 | return f 180 | 181 | return decorator 182 | 183 | def _validate_request(self): 184 | if not request.is_json: 185 | raise GitHubAppValidationError( 186 | "Invalid HTTP Content-Type header for JSON body " 187 | "(must be application/json or application/*+json)." 188 | ) 189 | 190 | try: 191 | request.json 192 | except BadRequest: 193 | raise GitHubAppValidationError("Invalid HTTP body (must be JSON).") 194 | 195 | event = request.headers.get("X-GitHub-Event") 196 | 197 | if event is None: 198 | raise GitHubAppValidationError("Missing X-GitHub-Event HTTP header.") 199 | 200 | action = request.json.get("action") 201 | 202 | return event, action 203 | 204 | def _flask_view_func(self): 205 | functions_to_call = [] 206 | calls = {} 207 | 208 | try: 209 | event, action = self._validate_request() 210 | except GitHubAppValidationError as e: 211 | LOG.error(e) 212 | error_response = make_response( 213 | jsonify(status="ERROR", description=str(e)), 400 214 | ) 215 | return abort(error_response) 216 | 217 | if current_app.config["GITHUBAPP_SECRET"] is not False: 218 | self._verify_webhook() 219 | 220 | if event in self._hook_mappings: 221 | functions_to_call += self._hook_mappings[event] 222 | 223 | if action: 224 | event_action = ".".join([event, action]) 225 | if event_action in self._hook_mappings: 226 | functions_to_call += self._hook_mappings[event_action] 227 | 228 | if functions_to_call: 229 | for function in functions_to_call: 230 | calls[function.__name__] = function() 231 | status = STATUS_FUNC_CALLED 232 | else: 233 | status = STATUS_NO_FUNC_CALLED 234 | return jsonify({"status": status, "calls": calls}) 235 | 236 | def _verify_webhook(self): 237 | signature_header = "X-Hub-Signature-256" 238 | signature_header_legacy = "X-Hub-Signature" 239 | 240 | if request.headers.get(signature_header): 241 | signature = request.headers[signature_header].split("=")[1] 242 | digestmod = "sha256" 243 | elif request.headers.get(signature_header_legacy): 244 | signature = request.headers[signature_header_legacy].split("=")[1] 245 | digestmod = "sha1" 246 | else: 247 | LOG.warning( 248 | "Signature header missing. Configure your GitHub App with a secret or set GITHUBAPP_SECRET" 249 | "to False to disable verification." 250 | ) 251 | return abort(400) 252 | 253 | mac = hmac.new(self.secret, msg=request.data, digestmod=digestmod) 254 | 255 | if not hmac.compare_digest(mac.hexdigest(), signature): 256 | LOG.warning("GitHub hook signature verification failed.") 257 | return abort(400) 258 | -------------------------------------------------------------------------------- /ghasreview/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .dependabot import DependabotAlert 2 | from .codescanning import CodeScanningAlert 3 | from .secretscanning import SecretScanningAlert 4 | -------------------------------------------------------------------------------- /ghasreview/models/codescanning.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | import logging 3 | import json 4 | 5 | logger = logging.getLogger("CodeScanningAlert") 6 | 7 | 8 | class CodeScanningAlert: 9 | payload: Dict = {} 10 | 11 | comment: str = ( 12 | """Security Alerts discovered by "{tool}". Informing @{org_name}/{ghas_team} team members.""" 13 | ) 14 | 15 | _client: object = None 16 | 17 | ghas_team_name: str = "" 18 | 19 | @property 20 | def client(self): 21 | return self._client 22 | 23 | @client.setter 24 | def client(self, client): 25 | self._client = client 26 | 27 | @property 28 | def base_url(self): 29 | return self.client.getBaseUrl() 30 | 31 | @property 32 | def id(self) -> str: 33 | return self.payload.get("alert", {}).get("number", "") 34 | 35 | @property 36 | def ref(self) -> str: 37 | return self.payload.get("alert", {}).get("most_recent_instance", {}).get( 38 | "ref" 39 | ) or self.payload.get("ref", "") 40 | 41 | 42 | def hasDismissedComment(self) -> bool: 43 | return self.payload.get("dismissed_comment") is not None 44 | 45 | @property 46 | def tool(self) -> str: 47 | return self.payload.get("alert", {}).get("tool", {}).get("name", "") 48 | 49 | @property 50 | def severity(self) -> str: 51 | return self.payload.get("alert", {}).get("rule", {}).get("severity", "") 52 | 53 | @property 54 | def repository(self) -> str: 55 | return self.payload.get("repository", {}).get("name", "") 56 | 57 | @property 58 | def owner(self) -> str: 59 | return self.payload.get("repository", {}).get("owner", {}).get("login", "") 60 | 61 | @property 62 | def full_name(self) -> str: 63 | return self.payload.get("repository", {}).get("full_name", "") 64 | 65 | @property 66 | def date_updated(self) -> str: 67 | return self.payload.get("alert", {}).get("updated_at", "") 68 | 69 | def getUser(self): 70 | return self.payload.get("alert", {}).get("dismissed_by", {}).get("login") 71 | 72 | def storePayload(self): 73 | path = f"./samples/{self.repository.replace('/', '')}-{self.date_updated}.json" 74 | with open(path, "w") as handle: 75 | json.dump(self.payload, handle, indent=2) 76 | 77 | logger.info(f"Saving payload: {path}") 78 | 79 | def isPR(self) -> bool: 80 | return self.ref.startswith("refs/pull/") 81 | 82 | def pullRequest(self) -> int: 83 | # refs/pull/{id}/merge 84 | _, _, prid, *_ = self.ref.split("/") 85 | return int(prid) 86 | 87 | def createCommentOnPR(self) -> bool: 88 | comment = self.comment.format( 89 | tool=self.tool, org_name=self.owner, ghas_team=self.ghas_team_name 90 | ) 91 | 92 | pr_comment_req = self.client.postComment( 93 | self.owner, self.repository, self.pullRequest(), comment 94 | ) 95 | if pr_comment_req.status_code != 200 and pr_comment_req.status_code != 201: 96 | logger.warning( 97 | f"Failed to write comment to PR: {self.pullRequest()} :: {pr_comment_req.status_code}" 98 | ) 99 | logger.debug(pr_comment_req.json()) 100 | return False 101 | 102 | return True 103 | 104 | def createProjectBoard(self, board_name: str) -> bool: 105 | logger.warning(f"Creating Org level is currently not present") 106 | return False 107 | 108 | def getBotUsername(self) -> str: 109 | return self.client.getBotUsername() 110 | 111 | def hasCommentedInPR(self) -> bool: 112 | owner = self.owner 113 | repo = self.repository 114 | pull_number = self.pullRequest() 115 | 116 | bot_username = self.getBotUsername() 117 | if not bot_username: 118 | logger.warning("Bot username could not be determined.") 119 | return False 120 | 121 | pr_comments_req = self.client.getPRComments(owner, repo, pull_number) 122 | pr_comments = pr_comments_req.json() 123 | if pr_comments_req.status_code != 200: 124 | logger.warning(f"Error getting PR comments: {pr_comments_req.status_code}") 125 | return False 126 | 127 | for comment in pr_comments: 128 | if comment.get("user", {}).get("login") == bot_username: 129 | return True 130 | return False 131 | 132 | def addTeamToPullRequest(self) -> bool: 133 | org_name = self.owner 134 | repo_name = self.repository 135 | pull_number = self.pullRequest() 136 | team_name = self.ghas_team_name 137 | 138 | return self.client.addTeamToPullRequestReviewer( 139 | team_name, org_name, repo_name, pull_number 140 | ) 141 | -------------------------------------------------------------------------------- /ghasreview/models/dependabot.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | import logging 3 | 4 | logger = logging.getLogger("DependabotAlert") 5 | 6 | 7 | class DependabotAlert: 8 | payload: Dict = {} 9 | 10 | @property 11 | def id(self) -> int: 12 | return self.payload.get("alert", {}).get("number", 0) 13 | 14 | @property 15 | def state(self) -> str: 16 | return self.payload.get("alert", {}).get("state", "") 17 | 18 | @property 19 | def package_name(self) -> str: 20 | return ( 21 | self.payload.get("alert", {}) 22 | .get("dependency", {}) 23 | .get("package", {}) 24 | .get("name", "") 25 | ) 26 | 27 | @property 28 | def ecosystem(self) -> str: 29 | return ( 30 | self.payload.get("alert", {}) 31 | .get("dependency", {}) 32 | .get("package", {}) 33 | .get("ecosystem", "") 34 | ) 35 | 36 | @property 37 | def manifest_path(self) -> str: 38 | return ( 39 | self.payload.get("alert", {}).get("dependency", {}).get("manifest_path", "") 40 | ) 41 | 42 | @property 43 | def scope(self) -> str: 44 | return self.payload.get("alert", {}).get("dependency", {}).get("scope", "") 45 | 46 | @property 47 | def ghsa_id(self) -> str: 48 | return ( 49 | self.payload.get("alert", {}) 50 | .get("security_advisory", {}) 51 | .get("ghsa_id", "") 52 | ) 53 | 54 | @property 55 | def cve_id(self) -> str: 56 | return ( 57 | self.payload.get("alert", {}).get("security_advisory", {}).get("cve_id", "") 58 | ) 59 | 60 | @property 61 | def summary(self) -> str: 62 | return ( 63 | self.payload.get("alert", {}) 64 | .get("security_advisory", {}) 65 | .get("summary", "") 66 | ) 67 | 68 | @property 69 | def description(self) -> str: 70 | return ( 71 | self.payload.get("alert", {}) 72 | .get("security_advisory", {}) 73 | .get("description", "") 74 | ) 75 | 76 | @property 77 | def severity(self) -> str: 78 | return ( 79 | self.payload.get("alert", {}) 80 | .get("security_advisory", {}) 81 | .get("severity", "") 82 | ) 83 | 84 | @property 85 | def identifiers(self) -> List[Dict]: 86 | return ( 87 | self.payload.get("alert", {}) 88 | .get("security_advisory", {}) 89 | .get("identifiers", []) 90 | ) 91 | 92 | @property 93 | def references(self) -> List[Dict]: 94 | return ( 95 | self.payload.get("alert", {}) 96 | .get("security_advisory", {}) 97 | .get("references", []) 98 | ) 99 | 100 | @property 101 | def published_at(self) -> str: 102 | return ( 103 | self.payload.get("alert", {}) 104 | .get("security_advisory", {}) 105 | .get("published_at", "") 106 | ) 107 | 108 | @property 109 | def updated_at(self) -> str: 110 | return ( 111 | self.payload.get("alert", {}) 112 | .get("security_advisory", {}) 113 | .get("updated_at", "") 114 | ) 115 | 116 | @property 117 | def vulnerabilities(self) -> List[Dict]: 118 | return ( 119 | self.payload.get("alert", {}) 120 | .get("security_advisory", {}) 121 | .get("vulnerabilities", []) 122 | ) 123 | 124 | @property 125 | def cvss(self) -> Dict: 126 | return ( 127 | self.payload.get("alert", {}).get("security_advisory", {}).get("cvss", {}) 128 | ) 129 | 130 | @property 131 | def cwes(self) -> List[Dict]: 132 | return ( 133 | self.payload.get("alert", {}).get("security_advisory", {}).get("cwes", []) 134 | ) 135 | 136 | @property 137 | def url(self) -> str: 138 | return self.payload.get("alert", {}).get("url", "") 139 | 140 | @property 141 | def html_url(self) -> str: 142 | return self.payload.get("alert", {}).get("html_url", "") 143 | 144 | @property 145 | def created_at(self) -> str: 146 | return self.payload.get("alert", {}).get("created_at", "") 147 | 148 | @property 149 | def updated_at(self) -> str: 150 | return self.payload.get("alert", {}).get("updated_at", "") 151 | 152 | @property 153 | def dismissed_at(self) -> str: 154 | return self.payload.get("alert", {}).get("dismissed_at", "") 155 | 156 | @property 157 | def dismissed_by(self) -> str: 158 | return self.payload.get("alert", {}).get("dismissed_by", {}).get("login", "") 159 | 160 | @property 161 | def dismissed_reason(self) -> str: 162 | return self.payload.get("alert", {}).get("dismissed_reason", "") 163 | 164 | @property 165 | def dismissed_comment(self) -> str: 166 | return self.payload.get("alert", {}).get("dismissed_comment", "") 167 | 168 | @property 169 | def fixed_at(self) -> str: 170 | return self.payload.get("alert", {}).get("fixed_at", "") 171 | 172 | @property 173 | def auto_dismissed_at(self) -> str: 174 | return self.payload.get("alert", {}).get("auto_dismissed_at", "") 175 | 176 | @property 177 | def repository(self) -> str: 178 | return self.payload.get("repository", {}).get("name", "") 179 | 180 | @property 181 | def owner(self) -> str: 182 | return self.payload.get("repository", {}).get("owner", {}).get("login", "") 183 | 184 | @property 185 | def full_name(self) -> str: 186 | return self.payload.get("repository", {}).get("full_name", "") 187 | 188 | def getUser(self): 189 | return self.dismissed_by 190 | -------------------------------------------------------------------------------- /ghasreview/models/secretscanning.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | import logging 3 | 4 | logger = logging.getLogger("SecretScanningAlert") 5 | 6 | 7 | class SecretScanningAlert: 8 | payload: Dict = {} 9 | 10 | @property 11 | def id(self) -> int: 12 | return self.payload.get("alert", {}).get("number", 0) 13 | 14 | @property 15 | def secret_type(self) -> str: 16 | return self.payload.get("alert", {}).get("secret_type", "") 17 | 18 | @property 19 | def secret_type_display_name(self) -> str: 20 | return self.payload.get("alert", {}).get("secret_type_display_name", "") 21 | 22 | @property 23 | def url(self) -> str: 24 | return self.payload.get("alert", {}).get("url", "") 25 | 26 | @property 27 | def html_url(self) -> str: 28 | return self.payload.get("alert", {}).get("html_url", "") 29 | 30 | @property 31 | def locations_url(self) -> str: 32 | return self.payload.get("alert", {}).get("locations_url", "") 33 | 34 | @property 35 | def created_at(self) -> str: 36 | return self.payload.get("alert", {}).get("created_at", "") 37 | 38 | @property 39 | def updated_at(self) -> str: 40 | return self.payload.get("alert", {}).get("updated_at", "") 41 | 42 | @property 43 | def validity(self) -> str: 44 | return self.payload.get("alert", {}).get("validity", "") 45 | 46 | @property 47 | def resolution(self) -> str: 48 | return self.payload.get("alert", {}).get("resolution", "") 49 | 50 | @property 51 | def resolved_by(self) -> str: 52 | return self.payload.get("alert", {}).get("resolved_by", {}).get("login", "") 53 | 54 | @property 55 | def resolved_at(self) -> str: 56 | return self.payload.get("alert", {}).get("resolved_at", "") 57 | 58 | @property 59 | def repository(self) -> str: 60 | return self.payload.get("repository", {}).get("name", "") 61 | 62 | @property 63 | def owner(self) -> str: 64 | return self.payload.get("repository", {}).get("owner", {}).get("login", "") 65 | 66 | @property 67 | def full_name(self) -> str: 68 | return self.payload.get("repository", {}).get("full_name", "") 69 | 70 | def getUser(self): 71 | return self.resolved_by 72 | 73 | def isResolved(self) -> bool: 74 | return self.resolution != "" 75 | 76 | def isPushProtectionBypassed(self) -> bool: 77 | return self.payload.get("alert", {}).get("push_protection_bypassed", False) 78 | -------------------------------------------------------------------------------- /ghasreview/setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from argparse import ArgumentParser 4 | 5 | from dotenv import load_dotenv 6 | 7 | # Load environment variables from .env file 8 | load_dotenv() 9 | 10 | 11 | def parse_arguments(): 12 | parser = ArgumentParser("GHAS Review") 13 | parser.add_argument( 14 | "--debug", action="store_true", default=bool(os.environ.get("DEBUG")) 15 | ) 16 | parser.add_argument( 17 | "--test-mode", action="store_true", default=bool(os.environ.get("TEST_MODE", 0)) 18 | ) 19 | 20 | parser_github = parser.add_argument_group("GHAS Reviewer") 21 | parser_github.add_argument( 22 | "--ghas-team-name", 23 | default=os.environ.get("GITHUB_GHAS_TEAM") or "ghas-reviewers", 24 | ) 25 | parser_github.add_argument( 26 | "--ghas-tool-name", default=os.environ.get("GITHUB_TOOL_NAME") or "CodeQL" 27 | ) 28 | parser_github.add_argument( 29 | "--ghas-comment-required", default=bool(os.environ.get("GITHUB_GHAS_COMMENT_REQUIRED", 0)) 30 | ) 31 | parser_github.add_argument( 32 | "--ghas-severities", 33 | nargs="*", 34 | default=os.environ.get("GITHUB_GHAS_SEVERITIES", "").split(",") or ["critical", "high", "error", "errors"], 35 | ) 36 | 37 | parser_github = parser.add_argument_group("GitHub") 38 | parser_github.add_argument( 39 | "--github-app-endpoint", default=os.environ.get("GITHUB_APP_ENDPOINT") 40 | ) 41 | parser_github.add_argument( 42 | "--github-app-id", default=os.environ.get("GITHUB_APP_ID") 43 | ) 44 | parser_github.add_argument( 45 | "--github-app-key-path", default=os.environ.get("GITHUB_APP_KEY_PATH") 46 | ) 47 | parser_github.add_argument( 48 | "--github-app-key", default=os.environ.get("GITHUB_APP_KEY") 49 | ) 50 | parser_github.add_argument( 51 | "--github-app-secret", default=os.environ.get("GITHUB_APP_SECRET") 52 | ) 53 | args, _ = parser.parse_known_args() 54 | return args 55 | 56 | 57 | def setup_logging(arguments): 58 | logging.basicConfig( 59 | level=logging.DEBUG if arguments.debug else logging.INFO, 60 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 61 | ) 62 | logging.debug("Debug mode enabled") 63 | 64 | logging.debug(f"GHAS Endpoint :: {arguments.github_app_endpoint}") 65 | logging.info(f"GitHub App ID :: {arguments.github_app_id}") 66 | logging.debug(f"GitHub Key :: {arguments.github_app_key}") 67 | logging.info(f"GitHub Key Path :: {arguments.github_app_key_path}") 68 | logging.debug(f"GitHub App Secret :: {arguments.github_app_secret}") 69 | logging.debug(f"GHAS Tool Name :: {arguments.ghas_tool_name}") 70 | logging.debug(f"GHAS Comment Required :: {arguments.ghas_comment_required}") 71 | logging.debug(f"GHAS Severities :: {arguments.ghas_severities}") 72 | 73 | 74 | def validate_arguments(arguments): 75 | if arguments.test_mode: 76 | logging.info(f"Testing mode enabled, exiting...") 77 | exit(0) 78 | if not arguments.github_app_id: 79 | raise Exception(f"GitHub App ID not set") 80 | if not arguments.github_app_secret: 81 | raise Exception(f"GitHub App Secret not set") 82 | if not arguments.github_app_key: 83 | # Try and load from file 84 | if not arguments.github_app_key_path and not os.path.exists( 85 | arguments.github_app_key_path 86 | ): 87 | raise Exception(f"GitHub App Key not set") 88 | 89 | with open(arguments.github_app_key_path, "r") as handle: 90 | app_key = handle.read() 91 | else: 92 | logging.info(f"Loading in Key mode") 93 | app_key = arguments.github_app_key.replace("\\n", "\n") 94 | return app_key 95 | 96 | 97 | def setup_app(): 98 | arguments = parse_arguments() 99 | setup_logging(arguments) 100 | app_key = validate_arguments(arguments) 101 | config = { 102 | "GHAS_DEBUG": arguments.debug, 103 | # Set the route 104 | "GITHUBAPP_ROUTE": arguments.github_app_endpoint, 105 | # Team name 106 | "GHAS_TEAM": arguments.ghas_team_name, 107 | "GHAS_BOARD_NAME": "GHAS Reviewers Audit Board", 108 | "GHAS_COMMENT_REQUIRED": arguments.ghas_comment_required, 109 | # Tool and severities to check 110 | "GHAS_TOOL": arguments.ghas_tool_name, 111 | "GHAS_SEVERITIES": arguments.ghas_severities if arguments.ghas_severities else None, 112 | # GitHub App 113 | "GITHUBAPP_ID": arguments.github_app_id, 114 | "GITHUBAPP_KEY": app_key, 115 | "GITHUBAPP_SECRET": arguments.github_app_secret, 116 | } 117 | return config 118 | -------------------------------------------------------------------------------- /gunicorn_config.py: -------------------------------------------------------------------------------- 1 | # gunicorn_config.py 2 | 3 | loglevel = "debug" 4 | bind = "0.0.0.0:9000" 5 | workers = 4 6 | accesslog = "-" 7 | errorlog = "-" 8 | logconfig_dict = { 9 | "version": 1, 10 | "disable_existing_loggers": False, 11 | "formatters": { 12 | "default": { 13 | "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", 14 | }, 15 | }, 16 | "handlers": { 17 | "console": { 18 | "class": "logging.StreamHandler", 19 | "formatter": "default", 20 | }, 21 | }, 22 | "loggers": { 23 | "gunicorn.error": { 24 | "level": loglevel.upper(), 25 | "handlers": ["console"], 26 | "propagate": False, 27 | }, 28 | "gunicorn.access": { 29 | "level": loglevel.upper(), 30 | "handlers": ["console"], 31 | "propagate": False, 32 | }, 33 | "app": { 34 | "level": loglevel.upper(), 35 | "handlers": ["console"], 36 | "propagate": False, 37 | }, 38 | "GitHubClient": { 39 | "level": loglevel.upper(), 40 | "handlers": ["console"], 41 | "propagate": False, 42 | }, 43 | "CodeScanningAlert": { 44 | "level": loglevel.upper(), 45 | "handlers": ["console"], 46 | "propagate": False, 47 | }, 48 | "DependabotAlert": { 49 | "level": loglevel.upper(), 50 | "handlers": ["console"], 51 | "propagate": False, 52 | }, 53 | "SecretScanningAlert": { 54 | "level": loglevel.upper(), 55 | "handlers": ["console"], 56 | "propagate": False, 57 | }, 58 | "": { 59 | "level": loglevel.upper(), 60 | "handlers": ["console"], 61 | }, 62 | }, 63 | } 64 | --------------------------------------------------------------------------------