├── .github ├── dependabot.yml ├── linters │ └── .markdownlint.json └── workflows │ ├── codeql.yml │ ├── dependency-review.yml │ ├── super-linter.yml │ └── weekly-cleanup.yml ├── .gitignore ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── manage-sec-team.py ├── org-admin-demote.py ├── org-admin-promote.py ├── requirements.txt └── src ├── enterprises.py ├── organizations.py └── teams.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "pip" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/linters/.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "line-length": false, 3 | "MD033": { "allowed_elements": ["br"] } 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '18 12 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Use only 'java' to analyze code written in Java, Kotlin or both 38 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 39 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 40 | 41 | steps: 42 | - name: Checkout repository 43 | uses: actions/checkout@v4 44 | 45 | # Initializes the CodeQL tools for scanning. 46 | - name: Initialize CodeQL 47 | uses: github/codeql-action/init@v3 48 | with: 49 | languages: ${{ matrix.language }} 50 | # If you wish to specify custom queries, you can do so here or in a config file. 51 | # By default, queries listed here will override any specified in a config file. 52 | # Prefix the list here with "+" to use these queries and those in the config file. 53 | 54 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 55 | queries: security-extended,security-and-quality 56 | 57 | 58 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). 59 | # If this step fails, then you should remove it and run the build manually (see below) 60 | - name: Autobuild 61 | uses: github/codeql-action/autobuild@v3 62 | 63 | # ℹ️ Command-line programs to run using the OS shell. 64 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 65 | 66 | # If the Autobuild fails above, remove it and uncomment the following three lines. 67 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 68 | 69 | # - run: | 70 | # echo "Run, Build Application using script" 71 | # ./location_of_script_within_repo/buildscript.sh 72 | 73 | - name: Perform CodeQL Analysis 74 | uses: github/codeql-action/analyze@v3 75 | with: 76 | category: "/language:${{matrix.language}}" 77 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: "Dependency Review" 2 | on: [pull_request] 3 | 4 | permissions: 5 | contents: read 6 | 7 | jobs: 8 | dependency-review: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: "Checkout Repository" 12 | uses: actions/checkout@v4 13 | - name: "Dependency Review" 14 | uses: actions/dependency-review-action@v4 15 | -------------------------------------------------------------------------------- /.github/workflows/super-linter.yml: -------------------------------------------------------------------------------- 1 | name: Lint Code Base 2 | 3 | on: 4 | push: 5 | branches-ignore: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | name: Lint Code Base 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout Code 16 | uses: actions/checkout@v4 17 | with: 18 | # Full git history is needed to get a proper list of changed files within `super-linter` 19 | fetch-depth: 0 20 | 21 | - name: Lint Code Base 22 | uses: super-linter/super-linter/slim@v6 23 | env: 24 | VALIDATE_ALL_CODEBASE: false 25 | DEFAULT_BRANCH: main 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | VALIDATE_GITHUB_ACTIONS: true 28 | VALIDATE_MARKDOWN: true 29 | MARKDOWN_CONFIG_FILE: .markdownlint.json 30 | VALIDATE_PYTHON_BLACK: true 31 | -------------------------------------------------------------------------------- /.github/workflows/weekly-cleanup.yml: -------------------------------------------------------------------------------- 1 | name: Weekly repo cleanup 🔥 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "14 20 * * 2" # Weekly at 20:14 UTC on Tuesdays 7 | 8 | jobs: 9 | stale: 10 | name: Destalinate! 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Close stale issues and pull requests 14 | uses: actions/stale@v9 15 | with: 16 | stale-issue-message: "This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 1 week." 17 | close-issue-message: "This issue was closed because it has been stalled for 1 week with no activity." 18 | days-before-issue-stale: 30 19 | days-before-issue-close: 7 20 | stale-issue-label: "stale" 21 | exempt-issue-labels: "epic" 22 | stale-pr-message: "This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 1 week." 23 | close-pr-message: "This PR was closed because it has been stalled for 1 week with no activity." 24 | days-before-pr-stale: 30 25 | days-before-pr-close: 7 26 | stale-pr-label: "stale" 27 | exempt-pr-labels: "dependencies" 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Token file used in local testing 2 | token.txt 3 | unmanaged_orgs.txt 4 | 5 | # CSV files 6 | *.csv 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 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # For more information, see [docs](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-syntax) 2 | 3 | ## This repository is maintained by 4 | 5 | * @aegilops 6 | -------------------------------------------------------------------------------- /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 opensource@github.com. 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 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 4 | 5 | Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE). 6 | 7 | Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. 8 | 9 | ## Submitting a pull request 10 | 11 | 1. Fork and clone the repository 12 | 1. Configure and install the dependencies: `pip3 install -r requirements.txt` 13 | 1. Create a new branch: `git checkout -b my-branch-name` 14 | 1. Push to your fork and submit a pull request 15 | 1. Pat your self on the back and wait for your pull request to be reviewed! :tada: 16 | 17 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 18 | 19 | - Follow the [style guide](https://black.readthedocs.io/en/stable/) - it'll automatically run via the [super-linter](https://github.com/github/super-linter). 20 | - Write tests. 21 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 22 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 23 | 24 | ## Resources 25 | 26 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 27 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 28 | - [GitHub Help](https://help.github.com) 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Natalie Somersall 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Enterprise security management teams 2 | 3 | This set of scripts provides some basics of organization governance to GitHub Enterprise (cloud or server) administrators. The scripts will give you a list of all organizations in the enterprise as a CSV to work with programmatically, add you to all organizations as an owner, and can create/manage a team with the security manager role to see all GitHub Advanced Security alerts throughout the entire enterprise _without_ having admin rights to that code. 4 | 5 | :information_source: This uses the [security manager role](https://docs.github.com/en/organizations/managing-peoples-access-to-your-organization-with-roles/managing-security-managers-in-your-organization) and parts of the GraphQL API that is available in GitHub.com (free/pro/teams and enterprise), as well as GitHub Enterprise Server versions 3.5 and higher. 6 | 7 | ## Scripts 8 | 9 | 1. [`org-admin-promote.py`](/org-admin-promote.py) replaces some of the functionality of `ghe-org-admin-promote` ([link](https://docs.github.com/en/enterprise-server@latest/admin/configuration/configuring-your-enterprise/command-line-utilities#ghe-org-admin-promote)), a built-in shell command on GHES that promotes an enterprise admin to own all organizations in the enterprise. It also outputs a CSV file similar to the `all_organizations.csv` [report](https://docs.github.com/en/enterprise-server@latest/admin/configuration/configuring-your-enterprise/site-admin-dashboard#reports), to better inventory organizations. 10 | 1. [`manage-sec-team.py`](/manage-sec-team.py) creates a team in each organization, assigns it the security manager role, and then adds the people you want to that team (and removes the rest). 11 | 1. [`org-admin-demote.py`](/org-admin-demote.py) takes the text file of orgs that the user wasn't already an owner of and "un-does" that promotion to org owner. The goal is to keep the admin account's notifications uncluttered, but running this is totally optional. 12 | 13 | ## How to use it 14 | 15 | You need to be an enterprise administrator to use these scripts! 16 | 17 | 1. Read :point_up: and decide what you want to do. 18 | 1. Create a personal access token ([directions](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token)) with the `enterprise:admin` scope. 19 | 1. Clone this repository to a machine that has Python 3 installed. 20 | 1. Install the requirements. 21 | 22 | ```shell 23 | python3 -m pip install --upgrade pip 24 | pip install -r requirements.txt 25 | ``` 26 | 27 | 1. Edit the inputs at the start of the script as follows: 28 | - (for GHES) the API endpoint 29 | - Create a file called `token.txt` and save your token there to read it. 30 | - Add the enterprise slug, a string URL version of the enterprise identity. It's easily available in the enterprise admin url (for cloud and server), e.g. `https://github.com/enterprises/ENTERPRISE-SLUG-HERE`. 31 | - (for the security manager team), the list of orgs output by `org-admin-promote.py` and the name of the security manager team and the team members to add. 32 | 33 | 1. Run them in the following order, deciding where to stop. 34 | 1. `org-admin-promote.py` to add the enterprise admin to all organizations as an owner, creating a CSV of organizations. 35 | 1. `manage-sec-team.py` to create a security manager team on all organizations and manage the members. 36 | 1. `org-admin-demote.py` will remove the enterprise admin from all the organizations the previous script added them to. 37 | 38 | ## Assumptions 39 | 40 | - The security manager team isn't already an existing team that's using team sync [for enterprise](https://docs.github.com/en/enterprise-cloud@latest/admin/identity-and-access-management/using-saml-for-enterprise-iam/managing-team-synchronization-for-organizations-in-your-enterprise) or [for organizations](https://docs.github.com/en/enterprise-cloud@latest/organizations/organizing-members-into-teams/synchronizing-a-team-with-an-identity-provider-group). You may be able to edit the script a bit to make this work by adding an existing team to all orgs, but I wasn't going to dive deep into the weeds of identity management. 41 | 42 | ## Any extra info? 43 | 44 | This is what a successful run looks like. Here's the inputs: 45 | 46 | - The enterprise admin is named `ghe-admin`. 47 | - The security team is named `spy-stuff` and has two members `luigi` and `hubot`. 48 | - The organizations break down as such: 49 | - `acme` org was already configured correctly. 50 | - `testorg-00001` needed the team created, with `ghe-admin` removed and `luigi` and `hubot` added. 51 | - `testorg-00002` was already created 52 | 53 | ```console 54 | $ ./manage-sec-team.py 55 | Team spy-stuff updated as a security manager for acme! 56 | Creating team spy-stuff 57 | Team spy-stuff updated as a security manager for testorg-00001! 58 | Removing ghe-admin from spy-stuff 59 | Adding luigi to spy-stuff 60 | Adding hubot to spy-stuff 61 | Creating team spy-stuff 62 | Team spy-stuff updated as a security manager for testorg-00002! 63 | Removing ghe-admin from spy-stuff 64 | Team spy-stuff updated as a security manager for testorg-00003! 65 | ``` 66 | 67 | ## Architecture Footnotes 68 | 69 | - Scripts that do things are in the root directory. 70 | - Functions that do small parts are in `/src`, grouped roughly by what part of GitHub they work on. 71 | - All Python code is formatted with [black](https://black.readthedocs.io/en/stable/) because it's simple and beautiful and no one needs to think about style. 72 | - Python dependencies are minimal by default. There are two, both kept up-to-date with [Dependabot](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/about-dependabot-version-updates). You can check out the config file [here](.github/dependabot.yml) if you'd like. 73 | - [requests](https://pypi.org/project/requests/) is a simple and extremely popular HTTP library. 74 | - [defusedcsv](https://github.com/raphaelm/defusedcsv) is used over CSV to mitigate potential spreadsheet application exploitations based on how it processes user-generated data. OWASP has written much more about CSV injection attacks on their website [here](https://owasp.org/www-community/attacks/CSV_Injection). 75 | - The CSV files and TXT files are in the `.gitignore` file to not be accidentally committed into the repo. 76 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Only the latest major semver will receive security attention. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | Please open an issue with all information you can provide and add the "security" label. 10 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | ## How to file issues and get help 4 | 5 | This project uses GitHub issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new issue. 6 | 7 | For help or questions about using this project, please search the existing discussions and issues, then open a new discussion. Thanks! 8 | 9 | **Enterprise security teams** is actively developed and is maintained by GitHub staff **AND THE COMMUNITY** on a best-effort basis. We will do our best to respond to support and community questions in a timely manner. 10 | 11 | ## GitHub Support Policy 12 | 13 | Support for this project is limited to the resources listed above. 14 | -------------------------------------------------------------------------------- /manage-sec-team.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | This script manages a team in each organization that the user running this owns 5 | within the enterprise. It is meant to be run after `org-admin-promote.py` to 6 | create/update a team with the "security manager" role. 7 | 8 | Inputs: 9 | - GitHub API endpoint 10 | - PAT with `enterprise:admin` scope, read from a file called `token.txt` 11 | - `all_orgs.csv` file from `org-admin-promote.py` 12 | - Team name for the security manager team 13 | - List of security manager team members by handle 14 | 15 | Outputs: 16 | - Prints the org names the enterprise admin was removed from to `stdout` 17 | """ 18 | 19 | # Imports 20 | from defusedcsv import csv 21 | from src import teams, organizations 22 | 23 | # Inputs 24 | api_url = "https://api.github.com" # for GHEC 25 | # api_url = "https://GHES-HOSTNAME-HERE/api/v3" # for GHES/GHAE 26 | 27 | # github_pat = "GITHUB-PAT-HERE" # if you want to set that manually 28 | with open("token.txt", "r") as f: 29 | github_pat = f.read().strip() 30 | f.close() 31 | 32 | # List of organizations (filename) 33 | org_list = "all_orgs.csv" 34 | 35 | # Team name for the security manager team 36 | sec_team_name = "security-managers" 37 | 38 | # List of security manager team members by handle 39 | sec_team_members = ["teammate1", "teammate2", "teammate3"] 40 | 41 | # Read in the org list 42 | with open(org_list, "r") as f: 43 | orgs = list(csv.DictReader(f)) 44 | 45 | # Set up the headers 46 | headers = { 47 | "Authorization": "token {}".format(github_pat), 48 | "Accept": "application/vnd.github.v3+json", 49 | } 50 | 51 | 52 | if __name__ == "__main__": 53 | # For each organization, do 54 | for org in orgs: 55 | org_name = org["login"] 56 | 57 | # Get the list of teams 58 | teams_info = teams.list_teams(api_url, headers, org_name) 59 | teams_list = [team["name"] for team in teams_info] 60 | 61 | # Create the team if it doesn't exist 62 | if sec_team_name not in teams_list: 63 | print("Creating team {}".format(sec_team_name)) 64 | teams.create_team(api_url, headers, org_name, sec_team_name) 65 | 66 | # Update that team to have the "security manager" role 67 | teams.change_team_role(api_url, headers, org_name, sec_team_name) 68 | print( 69 | "Team {} updated as a security manager for {}!".format( 70 | sec_team_name, org_name 71 | ) 72 | ) 73 | 74 | # Get the list of org members, adding the missing ones to the org 75 | org_members = organizations.list_org_users(api_url, headers, org_name) 76 | org_members_list = [member["login"] for member in org_members] 77 | for username in sec_team_members: 78 | if username not in org_members_list: 79 | print("Adding {} to {}".format(username, org_name)) 80 | organizations.add_org_user(api_url, headers, org_name, username) 81 | 82 | # Get the list of team members, adding the missing ones to the team and removing the extra ones 83 | team_members = teams.list_team_members( 84 | api_url, headers, org_name, sec_team_name 85 | ) 86 | team_members_list = [member["login"] for member in team_members] 87 | for username in team_members_list: 88 | if username not in sec_team_members: 89 | print("Removing {} from {}".format(username, sec_team_name)) 90 | teams.remove_team_member( 91 | api_url, headers, org_name, sec_team_name, username 92 | ) 93 | for username in sec_team_members: 94 | if username not in team_members_list: 95 | print("Adding {} to {}".format(username, sec_team_name)) 96 | teams.add_team_member( 97 | api_url, headers, org_name, sec_team_name, username 98 | ) 99 | -------------------------------------------------------------------------------- /org-admin-demote.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | This script removes the enterprise admin running this script from all of the 5 | organizations listed in `unmanaged_orgs.csv`. It is meant to be run after 6 | `org-admin-promote.py` to remove the enterprise admin from all organizations 7 | that they were promoted to an owner of. 8 | 9 | Inputs: 10 | - GitHub API endpoint 11 | - PAT with `enterprise:admin` scope, read from a file called `token.txt` 12 | - Enterprise slug (the string that comes after `/enterprises/` in the URL) 13 | - `unmanaged_orgs.csv` file from `org-admin-promote.py` 14 | 15 | Outputs: 16 | - Prints the org names the enterprise admin was removed from to `stdout` 17 | """ 18 | 19 | # Imports 20 | from src import enterprises 21 | 22 | # Set API endpoint 23 | graphql_endpoint = "https://api.github.com/graphql" # for GHEC 24 | # graphql_endpoint = "https://GHES-HOSTNAME-HERE/api/graphql" # for GHES/GHAE 25 | 26 | # github_pat = "GITHUB-PAT-HERE" # if you want to set that manually 27 | with open("token.txt", "r") as f: 28 | github_pat = f.read().strip() 29 | f.close() 30 | 31 | enterprise_slug = "ENTERPRISE-SLUG-HERE" 32 | 33 | # Set up the headers 34 | headers = { 35 | "Authorization": "token {}".format(github_pat), 36 | "Accept": "application/vnd.github.v3+json", 37 | } 38 | 39 | # Do the things! 40 | if __name__ == "__main__": 41 | # Get the enterprise ID 42 | enterprise_id = enterprises.get_enterprise_id( 43 | graphql_endpoint, enterprise_slug, headers 44 | ) 45 | 46 | # Get the list of unmanaged orgs 47 | with open("unmanaged_orgs.txt", "r") as f: 48 | unmanaged_orgs = f.read().splitlines() 49 | 50 | # Print the total count of orgs to demote admin from 51 | print("Total count of orgs to demote admin from: {}".format(len(unmanaged_orgs))) 52 | 53 | # Remove the enterprise admin running this from all of the unmanaged orgs 54 | for i, org_id in enumerate(unmanaged_orgs): 55 | print( 56 | "Removing from organization: {} [{}/{}]".format( 57 | org_id, i + 1, len(unmanaged_orgs) 58 | ) 59 | ) 60 | enterprises.promote_admin( 61 | graphql_endpoint, headers, enterprise_id, org_id, "UNAFFILIATED" 62 | ) 63 | -------------------------------------------------------------------------------- /org-admin-promote.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | This script "replaces" `ghe-org-admin-promote` from GHES to be able to also run 5 | in GHEC/GHAE. It promotes the enterprise admin running this script to an 6 | organization owner of all organizations in the enterprise. 7 | 8 | Inputs: 9 | - GitHub API endpoint 10 | - PAT with `enterprise:admin` scope, read from a file called `token.txt` 11 | - Enterprise slug (the string that comes after `/enterprises/` in the URL) 12 | 13 | Outputs: 14 | - Total count of orgs to `stdout` 15 | - Total count of orgs that the enterprise owner now owns to `stdout` 16 | - A text file of all (previously) unmanaged orgs to `unmanaged_orgs.txt` 17 | - A CSV of all organizations in the enterprise to `orgs.csv` 18 | """ 19 | 20 | # Imports 21 | from src import enterprises, organizations 22 | 23 | # Set API endpoint 24 | graphql_endpoint = "https://api.github.com/graphql" # for GHEC 25 | # graphql_endpoint = "https://GHES-HOSTNAME-HERE/api/graphql" # for GHES/GHAE 26 | 27 | 28 | # github_pat = "GITHUB-PAT-HERE" # if you want to set that manually 29 | with open("token.txt", "r") as f: 30 | github_pat = f.read().strip() 31 | f.close() 32 | 33 | enterprise_slug = "ENTERPRISE-SLUG-HERE" 34 | 35 | # Set up the headers 36 | headers = { 37 | "Authorization": "token {}".format(github_pat), 38 | "Accept": "application/vnd.github.v3+json", 39 | } 40 | 41 | # Do the things! 42 | if __name__ == "__main__": 43 | # Get the total count of organizations 44 | total_org_count = organizations.get_total_count( 45 | graphql_endpoint, enterprise_slug, headers 46 | ) 47 | 48 | # Get the organization data, make sure it's the same length as the total count 49 | orgs = organizations.list_orgs(graphql_endpoint, enterprise_slug, headers) 50 | assert len(orgs) == total_org_count 51 | 52 | # Print a little data 53 | print( 54 | "Total count of organizations returned by the query is: {}".format( 55 | total_org_count 56 | ) 57 | ) 58 | 59 | # Get the enterprise ID 60 | enterprise_id = enterprises.get_enterprise_id( 61 | graphql_endpoint, enterprise_slug, headers 62 | ) 63 | 64 | # Promote enterprise admin running this to an organization owner of all orgs 65 | unmanaged_orgs = [ 66 | org["node"]["id"] for org in orgs if not org["node"]["viewerCanAdminister"] 67 | ] 68 | print( 69 | "Total count of unmanaged organizations to be promoted on: {}".format( 70 | len(unmanaged_orgs) 71 | ) 72 | ) 73 | for i, org_id in enumerate(unmanaged_orgs): 74 | print( 75 | "Promoting to owner on organization: {} [{}/{}]".format( 76 | org_id, i + 1, len(unmanaged_orgs) 77 | ) 78 | ) 79 | enterprises.promote_admin( 80 | graphql_endpoint, headers, enterprise_id, org_id, "OWNER" 81 | ) 82 | 83 | with open("unmanaged_orgs.txt", "w") as f: 84 | for i in unmanaged_orgs: 85 | f.write(i) 86 | f.write("\n") 87 | f.close() 88 | 89 | # Print a little data 90 | print( 91 | "Total count of newly managed organizations is: {}".format(len(unmanaged_orgs)) 92 | ) 93 | 94 | # Refresh that data 95 | orgs = organizations.list_orgs(graphql_endpoint, enterprise_slug, headers) 96 | 97 | # Write the orgs to a CSV 98 | organizations.write_orgs_to_csv(orgs, "all_orgs.csv") 99 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | defusedcsv==2.0.0 2 | requests==2.32.2 3 | -------------------------------------------------------------------------------- /src/enterprises.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | This file holds enterprise-related functions 5 | """ 6 | 7 | # Imports 8 | import requests 9 | 10 | 11 | # Get enterprise ID 12 | def get_enterprise_id(api_endpoint, enterprise_slug, headers): 13 | enterprise_query = """ 14 | query { 15 | enterprise(slug: "ENTERPRISE_SLUG") { 16 | id 17 | } 18 | } 19 | """.replace( 20 | "ENTERPRISE_SLUG", enterprise_slug 21 | ) 22 | response = requests.post( 23 | api_endpoint, json={"query": enterprise_query}, headers=headers 24 | ) 25 | response.raise_for_status() 26 | return response.json()["data"]["enterprise"]["id"] 27 | 28 | 29 | # Make promote mutation 30 | def make_promote_mutation(enterprise_id, org_id, role): 31 | return ( 32 | """ 33 | mutation { 34 | updateEnterpriseOwnerOrganizationRole(input: { enterpriseId: "ENTERPRISE_ID", organizationId: "ORG_ID", organizationRole: ORG_ROLE }) { 35 | clientMutationId 36 | } 37 | } 38 | """.replace( 39 | "ENTERPRISE_ID", enterprise_id 40 | ) 41 | .replace("ORG_ID", org_id) 42 | .replace("ORG_ROLE", role) 43 | ) 44 | 45 | 46 | # Promote an enterprise admin to an organization owner 47 | def promote_admin(api_endpoint, headers, enterprise_id, org_id, role): 48 | promote_query = make_promote_mutation(enterprise_id, org_id, role) 49 | response = requests.post( 50 | api_endpoint, json={"query": promote_query}, headers=headers 51 | ) 52 | response.raise_for_status() 53 | return response.json() 54 | -------------------------------------------------------------------------------- /src/organizations.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Organization queries 5 | """ 6 | 7 | # Imports 8 | from defusedcsv import csv 9 | import requests 10 | 11 | 12 | # Count all organizations in the enterprise 13 | def get_total_count(api_endpoint, enterprise_slug, headers): 14 | total_count_query = ( 15 | """ 16 | query countEnterpriseOrganizations { 17 | enterprise(slug: "%s") { 18 | organizations{ 19 | totalCount 20 | } 21 | } 22 | } 23 | """ 24 | % enterprise_slug 25 | ) 26 | response = requests.post( 27 | api_endpoint, json={"query": total_count_query}, headers=headers 28 | ) 29 | response.raise_for_status() 30 | return response.json()["data"]["enterprise"]["organizations"]["totalCount"] 31 | 32 | 33 | # Make query for all organization names in the enterprise 34 | def make_org_query(enterprise_slug, after_cursor=None): 35 | return """ 36 | query listEnterpriseOrganizations { 37 | enterprise(slug: SLUG) { 38 | organizations(first: 100, after:AFTER) { 39 | edges{ 40 | node{ 41 | id 42 | createdAt 43 | login 44 | email 45 | viewerCanAdminister 46 | viewerIsAMember 47 | repositories { 48 | totalCount 49 | totalDiskUsage 50 | } 51 | } 52 | cursor 53 | } 54 | pageInfo { 55 | endCursor 56 | hasNextPage 57 | } 58 | } 59 | } 60 | } 61 | """.replace( 62 | "SLUG", '"{}"'.format(enterprise_slug) 63 | ).replace( 64 | "AFTER", '"{}"'.format(after_cursor) if after_cursor else "null" 65 | ) 66 | 67 | 68 | # List all organizations in the enterprise by name (that the requestor can see) 69 | def list_orgs(api_endpoint, enterprise_slug, headers): 70 | orgs = [] 71 | after_cursor = None 72 | while True: 73 | response = requests.post( 74 | api_endpoint, 75 | json={"query": make_org_query(enterprise_slug, after_cursor)}, 76 | headers=headers, 77 | ) 78 | response.raise_for_status() 79 | data = response.json()["data"]["enterprise"]["organizations"] 80 | orgs.extend(data["edges"]) 81 | if data["pageInfo"]["hasNextPage"]: 82 | after_cursor = data["pageInfo"]["endCursor"] 83 | else: 84 | break 85 | return orgs 86 | 87 | 88 | # Write the orgs to a csv file 89 | def write_orgs_to_csv(orgs, filename): 90 | with open(filename, "w") as f: 91 | writer = csv.writer(f) 92 | writer.writerow( 93 | [ 94 | "id", 95 | "createdAt", 96 | "login", 97 | "email", 98 | "viewerCanAdminister", 99 | "viewerIsAMember", 100 | "repositories.totalCount", 101 | "repositories.totalDiskUsage", 102 | ] 103 | ) 104 | for org in orgs: 105 | writer.writerow( 106 | [ 107 | org["node"]["id"], 108 | org["node"]["createdAt"], 109 | org["node"]["login"], 110 | org["node"]["email"], 111 | org["node"]["viewerCanAdminister"], 112 | org["node"]["viewerIsAMember"], 113 | org["node"]["repositories"]["totalCount"], 114 | org["node"]["repositories"]["totalDiskUsage"], 115 | ] 116 | ) 117 | 118 | 119 | # List the users in an organization, using REST API with pagination 120 | def list_org_users(api_endpoint, headers, org): 121 | users = [] 122 | page = 1 123 | while True: 124 | response = requests.get( 125 | api_endpoint + "/orgs/{}/members?page={}".format(org, page), 126 | headers=headers, 127 | ) 128 | response.raise_for_status() 129 | users.extend(response.json()) 130 | if "next" not in response.links: 131 | break 132 | page += 1 133 | return users 134 | 135 | 136 | # Invite a user to an organization 137 | def add_org_user(api_endpoint, headers, org, username): 138 | response = requests.put( 139 | api_endpoint + "/orgs/{}/memberships/{}".format(org, username), 140 | json={"role": "member"}, 141 | headers=headers, 142 | ) 143 | response.raise_for_status() 144 | print(response.status_code) 145 | -------------------------------------------------------------------------------- /src/teams.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | This file holds team-related functions 5 | - check if team exists 6 | - create team if not 7 | - add users to team 8 | - assign team custom role on all org repos 9 | """ 10 | 11 | # Imports 12 | import requests 13 | 14 | 15 | # List teams using REST API with pagination 16 | def list_teams(api_endpoint, headers, org): 17 | teams = [] 18 | page = 1 19 | while True: 20 | response = requests.get( 21 | api_endpoint + "/orgs/{}/teams?page={}".format(org, page), 22 | headers=headers, 23 | ) 24 | response.raise_for_status() 25 | teams.extend(response.json()) 26 | if "next" not in response.links: 27 | break 28 | page += 1 29 | return teams 30 | 31 | 32 | # Create "closed" security manager team using REST API 33 | # Closed teams are visible to users, allowing an understanding of who's responsible 34 | def create_team(api_endpoint, headers, org, team_slug): 35 | response = requests.post( 36 | api_endpoint + "/orgs/{}/teams".format(org), 37 | json={ 38 | "name": team_slug, 39 | "description": "Enterprise security manager team", 40 | "privacy": "closed", 41 | }, 42 | headers=headers, 43 | ) 44 | response.raise_for_status() 45 | return response.json() 46 | 47 | 48 | # Change that security manager team's role to "security manager" 49 | def change_team_role(api_endpoint, headers, org, team_slug): 50 | response = requests.put( 51 | api_endpoint + "/orgs/{}/security-managers/teams/{}".format(org, team_slug), 52 | headers=headers, 53 | ) 54 | response.raise_for_status() 55 | 56 | 57 | # List team members using REST API with pagination 58 | def list_team_members(api_endpoint, headers, org, team_slug): 59 | members = [] 60 | page = 1 61 | while True: 62 | response = requests.get( 63 | api_endpoint 64 | + "/orgs/{}/teams/{}/members?page={}".format(org, team_slug, page), 65 | headers=headers, 66 | ) 67 | response.raise_for_status() 68 | members.extend(response.json()) 69 | if "next" not in response.links: 70 | break 71 | page += 1 72 | return members 73 | 74 | 75 | # Add a user to a team using REST API 76 | def add_team_member(api_endpoint, headers, org, team_slug, username): 77 | response = requests.put( 78 | api_endpoint 79 | + "/orgs/{}/teams/{}/memberships/{}".format(org, team_slug, username), 80 | headers=headers, 81 | ) 82 | response.raise_for_status() 83 | 84 | 85 | # Remove a user from a team using REST API 86 | def remove_team_member(api_endpoint, headers, org, team_slug, username): 87 | response = requests.delete( 88 | api_endpoint 89 | + "/orgs/{}/teams/{}/memberships/{}".format(org, team_slug, username), 90 | headers=headers, 91 | ) 92 | response.raise_for_status() 93 | --------------------------------------------------------------------------------