├── aroadtools ├── __init__.py ├── roadlib │ ├── __init__.py │ ├── database │ │ ├── __init__.py │ │ ├── metadef │ │ │ ├── __init__.py │ │ │ ├── basetypes.py │ │ │ ├── complextypes.py │ │ │ ├── entitytypes.py │ │ │ └── database.py │ │ └── dbgen.py │ ├── utils.py │ ├── constants.py │ └── deviceauth.py ├── roadrecon │ ├── __init__.py │ └── plugins │ │ ├── __init__.py │ │ ├── server │ │ ├── __init__.py │ │ └── schema.py │ │ ├── xlsexport.py │ │ └── policies.py └── _version.py ├── Manifest.in ├── README.md ├── pyproject.toml ├── Makefile ├── setup.py ├── LICENSE └── .gitignore /aroadtools/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aroadtools/roadlib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aroadtools/roadrecon/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aroadtools/roadlib/database/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aroadtools/roadrecon/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aroadtools/roadlib/database/metadef/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aroadtools/roadrecon/plugins/server/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Manifest.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | -------------------------------------------------------------------------------- /aroadtools/roadlib/utils.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | async def printhook(msg): 5 | print(msg) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aroadtools 2 | fully async implementation of Dirkjan's ROADTools 3 | Partial implementation. 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0"] 3 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /aroadtools/_version.py: -------------------------------------------------------------------------------- 1 | 2 | __version__ = "0.0.1" 3 | __banner__ = \ 4 | """ 5 | # aroadtools %s 6 | # Author: Tamas Jos @skelsec (info@skelsecprojects.com) 7 | """ % __version__ -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | clean: 2 | rm -f -r build/ 3 | rm -f -r dist/ 4 | rm -f -r *.egg-info 5 | find . -name '*.pyc' -exec rm -f {} + 6 | find . -name '*.pyo' -exec rm -f {} + 7 | find . -name '*~' -exec rm -f {} + 8 | 9 | publish: clean 10 | python3 setup.py sdist bdist_wheel 11 | python3 -m twine upload dist/* 12 | 13 | rebuild: clean 14 | python3 setup.py install 15 | 16 | build: 17 | python3 setup.py install 18 | 19 | install: 20 | python3 setup.py install -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import re 3 | 4 | VERSIONFILE="aroadtools/_version.py" 5 | verstrline = open(VERSIONFILE, "rt").read() 6 | VSRE = r"^__version__ = ['\"]([^'\"]*)['\"]" 7 | mo = re.search(VSRE, verstrline, re.M) 8 | if mo: 9 | verstr = mo.group(1) 10 | else: 11 | raise RuntimeError("Unable to find version string in %s." % (VERSIONFILE,)) 12 | 13 | 14 | setup( 15 | # Application name: 16 | name="aroadtools", 17 | 18 | # Version number (initial): 19 | version=verstr, 20 | 21 | # Application author details: 22 | author="Tamas Jos", 23 | author_email="info@skelsecprojects.com", 24 | 25 | # Packages 26 | packages=find_packages(), 27 | 28 | # Include additional files into the package 29 | include_package_data=True, 30 | 31 | 32 | # Details 33 | url="https://github.com/skelsec/aroadtools", 34 | 35 | zip_safe = True, 36 | # 37 | # license="LICENSE.txt", 38 | description="", 39 | long_description="", 40 | 41 | # long_description=open("README.txt").read(), 42 | python_requires='>=3.6', 43 | classifiers=[ 44 | "Programming Language :: Python :: 3.6", 45 | "License :: OSI Approved :: MIT License", 46 | "Operating System :: OS Independent", 47 | ], 48 | install_requires=[ 49 | 'asn1crypto', 50 | 'cryptography', 51 | 'sqlalchemy', 52 | 'jwt', 53 | 'httpx', 54 | ], 55 | entry_points={ 56 | 'console_scripts': [ 57 | 'aroadtools-auth = aroadtools.roadlib.auth:main', 58 | 'aroadtools-gather = aroadtools.roadrecon.gather:main', 59 | ], 60 | } 61 | ) 62 | -------------------------------------------------------------------------------- /aroadtools/roadlib/database/metadef/basetypes.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy.types # import Integer, String, JSON, Binary 2 | from sqlalchemy import Table, Column, MetaData, ForeignKey 3 | 4 | class Edm(object): 5 | class String(object): 6 | DBTYPE = sqlalchemy.types.Text 7 | 8 | class Boolean(object): 9 | DBTYPE = sqlalchemy.types.Boolean 10 | 11 | class Stream(object): 12 | DBTYPE = sqlalchemy.types.LargeBinary 13 | 14 | class Int32(object): 15 | DBTYPE = sqlalchemy.types.Integer 16 | 17 | class Int16(object): 18 | DBTYPE = sqlalchemy.types.Integer 19 | 20 | class Single(object): 21 | DBTYPE = sqlalchemy.types.Integer 22 | 23 | class Double(object): 24 | DBTYPE = sqlalchemy.types.Float 25 | 26 | class Decimal(object): 27 | DBTYPE = sqlalchemy.types.Float 28 | 29 | class Int64(object): 30 | DBTYPE = sqlalchemy.types.Integer 31 | 32 | class Guid(object): 33 | DBTYPE = sqlalchemy.types.Text 34 | 35 | class Duration(object): 36 | DBTYPE = sqlalchemy.types.Text 37 | 38 | class DateTime(object): 39 | DBTYPE = sqlalchemy.types.DateTime 40 | 41 | class Date(object): 42 | DBTYPE = sqlalchemy.types.Date 43 | 44 | class TimeOfDay(object): 45 | DBTYPE = sqlalchemy.types.Time 46 | 47 | class DateTimeOffset(object): 48 | DBTYPE = sqlalchemy.types.DateTime 49 | 50 | class Binary(object): 51 | DBTYPE = sqlalchemy.types.LargeBinary 52 | 53 | class Byte(object): 54 | DBTYPE = sqlalchemy.types.LargeBinary 55 | 56 | class Collection(object): 57 | DBTYPE = sqlalchemy.types.JSON 58 | 59 | class ComplexType(object): 60 | DBTYPE = sqlalchemy.types.JSON 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 skelsec 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | 24 | ============================================================================== 25 | This library is a slight modification of Dirkjan's ROADTools package. 26 | Original code is also MIT licensed. 27 | 28 | MIT License 29 | 30 | Copyright (c) 2023 Dirk-jan Mollema 31 | 32 | Permission is hereby granted, free of charge, to any person obtaining a copy 33 | of this software and associated documentation files (the "Software"), to deal 34 | in the Software without restriction, including without limitation the rights 35 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 36 | copies of the Software, and to permit persons to whom the Software is 37 | furnished to do so, subject to the following conditions: 38 | 39 | The above copyright notice and this permission notice shall be included in all 40 | copies or substantial portions of the Software. 41 | 42 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 43 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 44 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 45 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 46 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 47 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 48 | SOFTWARE. -------------------------------------------------------------------------------- /.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 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /aroadtools/roadlib/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Constants for roadlib containing well-known resources, login templates (xml), etc 3 | """ 4 | 5 | WELLKNOWN_RESOURCES = { 6 | "msgraph": "https://graph.microsoft.com/", 7 | "aadgraph": "https://graph.windows.net/", 8 | "devicereg": "urn:ms-drs:enterpriseregistration.windows.net", 9 | "drs": "urn:ms-drs:enterpriseregistration.windows.net", 10 | "azrm": "https://management.core.windows.net/", 11 | "azurerm": "https://management.core.windows.net/", 12 | } 13 | 14 | WELLKNOWN_CLIENTS = { 15 | "aadps": "1b730954-1685-4b74-9bfd-dac224a7b894", 16 | "azcli": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", 17 | "teams": "1fec8e78-bce4-4aaf-ab1b-5451cc387264", 18 | "msteams": "1fec8e78-bce4-4aaf-ab1b-5451cc387264", 19 | "azps": "1950a258-227b-4e31-a9cf-717495945fc2", 20 | "msedge": "ecd6b820-32c2-49b6-98a6-444530e5a77a", 21 | "edge": "ecd6b820-32c2-49b6-98a6-444530e5a77a", 22 | "msbroker": "29d9ed98-a469-4536-ade2-f981bc1d605e", 23 | "broker": "29d9ed98-a469-4536-ade2-f981bc1d605e" 24 | } 25 | 26 | WELLKNOWN_USER_AGENTS = { 27 | "edge": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0", 28 | "edge_windows": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0", 29 | "edge_android": "Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.66 Mobile Safari/537.36 EdgA/118.0.2088.66", 30 | "chrome": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", 31 | "chrome_windows": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", 32 | "chrome_android": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.3", 33 | "chrome_macos": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", 34 | "chrome_linux": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.3", 35 | "chrome_ios": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_8 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.109 Mobile/15E148 Safari/604.", 36 | "firefox": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0", 37 | "firefox_windows": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0", 38 | "firefox_macos": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/119.0", 39 | "firefox_ubuntu": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0", 40 | "safari": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.1", 41 | "safari_macos": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.1", 42 | "safari_ios": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.", 43 | "empty": "No user agent header" 44 | } 45 | 46 | DSSO_BODY_KERBEROS = ''' 47 | 56 | 57 | http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue 58 | https://autologon.microsoftazuread-sso.com/{tenant}/winauth/trust/2005/windowstransport?client-request-id=4190b9ab-205d-4024-8caa-aedb3f79988b 59 | urn:uuid:36a2e970-3107-4e3f-99dd-569a9588f02c 60 | 61 | 62 | 63 | http://schemas.xmlsoap.org/ws/2005/02/trust/Issue 64 | 65 | 66 | urn:federation:MicrosoftOnline 67 | 68 | 69 | http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey 70 | 71 | 72 | 73 | ''' 74 | 75 | DSSO_BODY_USERPASS = ''' 76 | 77 | 78 | http://schemas.xmlsoap.org/ws/2005/02/trust/RST/Issue 79 | urn:uuid:a5c80716-16fe-473c-bb5d-be0602f9e7fd 80 | 81 | http://www.w3.org/2005/08/addressing/anonymous 82 | 83 | https://autologon.microsoftazuread-sso.com/{tenant}/winauth/trust/2005/usernamemixed?client-request-id=19ac39db-81d2-4713-8046-b0b7240592be 84 | 85 | 86 | 2020-11-23T14:50:44.068Z 87 | 2020-11-23T15:00:44.068Z 88 | 89 | 90 | {username} 91 | {password} 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | urn:federation:MicrosoftOnline 100 | 101 | 102 | http://schemas.xmlsoap.org/ws/2005/05/identity/NoProofKey 103 | http://schemas.xmlsoap.org/ws/2005/02/trust/Issue 104 | 105 | 106 | 107 | ''' 108 | -------------------------------------------------------------------------------- /aroadtools/roadrecon/plugins/server/schema.py: -------------------------------------------------------------------------------- 1 | from marshmallow import Schema, fields 2 | from marshmallow_sqlalchemy import ModelConverter, SQLAlchemyAutoSchema 3 | 4 | from aroadtools.roadlib.database.metadef.database import User, JSON, Group, DirectoryRole, ServicePrincipal, AppRoleAssignment, TenantDetail, Application, Device, OAuth2PermissionGrant, AuthorizationPolicy, DirectorySetting, AdministrativeUnit, RoleDefinition 5 | 6 | 7 | # Model definitions that include a custom JSON type, which doesn't get converted 8 | class RTModelConverter(ModelConverter): 9 | SQLA_TYPE_MAPPING = dict( 10 | list(ModelConverter.SQLA_TYPE_MAPPING.items()) + 11 | [(JSON, fields.Raw)] 12 | ) 13 | 14 | # Our custom model schema which uses the model converter from above 15 | class RTModelSchema(SQLAlchemyAutoSchema): 16 | class Meta: 17 | model_converter = RTModelConverter 18 | 19 | # Schemas for objects 20 | # For each object type there is an Schema and Schema 21 | # the plural version is for lists of objects (doesn't include all fields) 22 | # the regular version includes all possible fields based on the SQLAlchemy meta definition 23 | class UsersSchema(Schema): 24 | class Meta: 25 | model = User 26 | fields = ('objectId', 'objectType', 'userPrincipalName', 'displayName', 'mail', 'lastDirSyncTime', 'accountEnabled', 'department', 'lastPasswordChangeDateTime', 'jobTitle', 'mobile', 'dirSyncEnabled', 'strongAuthenticationDetail', 'userType') 27 | 28 | class DevicesSchema(Schema): 29 | class Meta: 30 | model = User 31 | fields = ('objectId', 'objectType', 'accountEnabled', 'displayName', 'deviceManufacturer', 'deviceModel', 'deviceOSType', 'deviceOSVersion', 'deviceTrustType', 'isCompliant', 'deviceId', 'isManaged', 'isRooted', 'dirSyncEnabled') 32 | 33 | class DirectoryRoleSchema(Schema): 34 | class Meta: 35 | model = DirectoryRole 36 | fields = ('displayName', 'description') 37 | 38 | class OAuth2PermissionGrantsSchema(SQLAlchemyAutoSchema): 39 | class Meta: 40 | model = OAuth2PermissionGrant 41 | 42 | class AppRoleAssignmentsSchema(SQLAlchemyAutoSchema): 43 | class Meta: 44 | model = AppRoleAssignment 45 | 46 | class GroupsSchema(Schema): 47 | class Meta: 48 | model = Group 49 | fields = ('displayName', 'description', 'createdDateTime', 'dirSyncEnabled', 'objectId', 'objectType', 'groupTypes', 'mail', 'isPublic', 'isAssignableToRole', 'membershipRule') 50 | 51 | class AdministrativeUnitsSchema(Schema): 52 | class Meta: 53 | model = AdministrativeUnit 54 | fields = ('displayName', 'description', 'createdDateTime', 'objectId', 'objectType', 'membershipType', 'membershipRule') 55 | 56 | class SimpleServicePrincipalsSchema(Schema): 57 | """ 58 | Simple ServicePrincipalSchema to prevent looping relationships with serviceprincipals 59 | owning other serviceprincipals 60 | """ 61 | class Meta: 62 | model = ServicePrincipal 63 | fields = ('objectId', 'objectType', 'displayName', 'servicePrincipalType') 64 | 65 | class ServicePrincipalsSchema(Schema): 66 | class Meta: 67 | model = ServicePrincipal 68 | fields = ('objectId', 'objectType', 'displayName', 'appDisplayName', 'appRoleAssignmentRequired', 'appId', 'appOwnerTenantId', 'publisherName', 'replyUrls', 'appRoles', 'microsoftFirstParty', 'isDirSyncEnabled', 'oauth2Permissions', 'passwordCredentials', 'keyCredentials', 'ownerUsers', 'ownerServicePrincipals', 'accountEnabled', 'servicePrincipalType') 69 | ownerUsers = fields.Nested(UsersSchema, many=True) 70 | ownerServicePrincipals = fields.Nested(SimpleServicePrincipalsSchema, many=True) 71 | 72 | class ApplicationsSchema(Schema): 73 | class Meta: 74 | model = Application 75 | fields = ('objectId', 'objectType', 'displayName', 'appId', 'appDisplayName', 'oauth2AllowIdTokenImplicitFlow', 'availableToOtherTenants', 'publisherDomain', 'replyUrls', 'appRoles', 'publicClient', 'oauth2AllowImplicitFlow', 'oauth2Permissions', 'homepage', 'passwordCredentials', 'keyCredentials', 'ownerUsers', 'ownerServicePrincipals') 76 | ownerUsers = fields.Nested(UsersSchema, many=True) 77 | ownerServicePrincipals = fields.Nested(SimpleServicePrincipalsSchema, many=True) 78 | 79 | class DirectoryRolesSchema(RTModelSchema): 80 | class Meta(RTModelSchema.Meta): 81 | model = DirectoryRole 82 | memberUsers = fields.Nested(UsersSchema, many=True) 83 | memberServicePrincipals = fields.Nested(ServicePrincipalsSchema, many=True) 84 | memberGroups = fields.Nested(GroupsSchema, many=True) 85 | 86 | class UserSchema(RTModelSchema): 87 | class Meta(RTModelSchema.Meta): 88 | model = User 89 | memberOf = fields.Nested(GroupsSchema, many=True) 90 | memberOfRole = fields.Nested(DirectoryRoleSchema, many=True) 91 | ownedDevices = fields.Nested(DevicesSchema, many=True) 92 | ownedServicePrincipals = fields.Nested(ServicePrincipalsSchema, many=True) 93 | ownedApplications = fields.Nested(ApplicationsSchema, many=True) 94 | ownedGroups = fields.Nested(GroupsSchema, many=True) 95 | 96 | class DeviceSchema(RTModelSchema): 97 | class Meta(RTModelSchema.Meta): 98 | model = Device 99 | memberOf = fields.Nested(GroupsSchema, many=True) 100 | owner = fields.Nested(UsersSchema, many=True) 101 | 102 | class GroupSchema(RTModelSchema): 103 | class Meta(RTModelSchema.Meta): 104 | model = Group 105 | memberOf = fields.Nested(GroupsSchema, many=True) 106 | memberGroups = fields.Nested(GroupsSchema, many=True) 107 | memberUsers = fields.Nested(UsersSchema, many=True) 108 | memberOfRole = fields.Nested(DirectoryRoleSchema, many=True) 109 | memberDevices = fields.Nested(DevicesSchema, many=True) 110 | memberServicePrincipals = fields.Nested(SimpleServicePrincipalsSchema, many=True) 111 | ownerUsers = fields.Nested(UsersSchema, many=True) 112 | ownerServicePrincipals = fields.Nested(SimpleServicePrincipalsSchema, many=True) 113 | 114 | class AdministrativeUnitSchema(RTModelSchema): 115 | class Meta(RTModelSchema.Meta): 116 | model = AdministrativeUnit 117 | memberGroups = fields.Nested(GroupsSchema, many=True) 118 | memberUsers = fields.Nested(UsersSchema, many=True) 119 | memberDevices = fields.Nested(DevicesSchema, many=True) 120 | 121 | class ServicePrincipalSchema(RTModelSchema): 122 | class Meta(RTModelSchema.Meta): 123 | model = ServicePrincipal 124 | ownerUsers = fields.Nested(UsersSchema, many=True) 125 | ownerServicePrincipals = fields.Nested(ServicePrincipalsSchema, many=True) 126 | memberOfRole = fields.Nested(DirectoryRoleSchema, many=True) 127 | memberOf = fields.Nested(GroupSchema, many=True) 128 | oauth2PermissionGrants = fields.Nested(OAuth2PermissionGrantsSchema, many=True) 129 | appRolesAssigned = fields.Nested(AppRoleAssignmentsSchema, many=True) 130 | appRolesAssignedTo = fields.Nested(AppRoleAssignmentsSchema, many=True) 131 | 132 | class ApplicationSchema(RTModelSchema): 133 | class Meta(RTModelSchema.Meta): 134 | model = Application 135 | ownerUsers = fields.Nested(UsersSchema, many=True) 136 | ownerServicePrincipals = fields.Nested(ServicePrincipalsSchema, many=True) 137 | 138 | class TenantDetailSchema(RTModelSchema): 139 | class Meta(RTModelSchema.Meta): 140 | model = TenantDetail 141 | 142 | class AuthorizationPolicySchema(RTModelSchema): 143 | class Meta(RTModelSchema.Meta): 144 | model = AuthorizationPolicy 145 | 146 | # Instantiate all schemas 147 | user_schema = UserSchema() 148 | device_schema = DeviceSchema() 149 | group_schema = GroupSchema() 150 | application_schema = ApplicationSchema() 151 | td_schema = TenantDetailSchema() 152 | serviceprincipal_schema = ServicePrincipalSchema() 153 | administrativeunit_schema = AdministrativeUnitSchema() 154 | authorizationpolicy_schema = AuthorizationPolicySchema(many=True) 155 | users_schema = UsersSchema(many=True) 156 | devices_schema = DevicesSchema(many=True) 157 | groups_schema = GroupsSchema(many=True) 158 | applications_schema = ApplicationsSchema(many=True) 159 | serviceprincipals_schema = ServicePrincipalsSchema(many=True) 160 | directoryroles_schema = DirectoryRolesSchema(many=True) 161 | administrativeunits_schema = AdministrativeUnitsSchema(many=True) 162 | -------------------------------------------------------------------------------- /aroadtools/roadlib/database/dbgen.py: -------------------------------------------------------------------------------- 1 | from aroadtools.roadlib.database.metadef.entitytypes import * 2 | 3 | header = '''import os 4 | import json 5 | import datetime 6 | import sqlalchemy.types 7 | from sqlalchemy import Column, Text, Boolean, BigInteger as Integer, create_engine, Table, ForeignKey 8 | from sqlalchemy.orm import relationship, sessionmaker, foreign, declarative_base 9 | from sqlalchemy.types import TypeDecorator, TEXT 10 | Base = declarative_base() 11 | 12 | 13 | class JSON(TypeDecorator): 14 | impl = TEXT 15 | cache_ok = True 16 | def process_bind_param(self, value, dialect): 17 | if value is not None: 18 | value = json.dumps(value) 19 | 20 | return value 21 | 22 | def process_result_value(self, value, dialect): 23 | if value is not None: 24 | value = json.loads(value) 25 | return value 26 | 27 | class DateTime(TypeDecorator): 28 | impl = sqlalchemy.types.DateTime 29 | def process_bind_param(self, value, dialect): 30 | if value is not None and isinstance(value, str): 31 | # Sometimes it ends on a Z, sometimes it doesn't 32 | if value[-1] == 'Z': 33 | if '.' in value: 34 | try: 35 | value = datetime.datetime.strptime(value[:-2], '%Y-%m-%dT%H:%M:%S.%f') 36 | except ValueError: 37 | value = datetime.datetime.strptime(value[:-2], '%Y-%m-%dT%H:%M:%S.') 38 | else: 39 | value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%SZ') 40 | elif '.' in value: 41 | if '+' in value: 42 | value = datetime.datetime.strptime(value[:-7], '%Y-%m-%dT%H:%M:%S.%f') 43 | else: 44 | try: 45 | value = datetime.datetime.strptime(value[:-1], '%Y-%m-%dT%H:%M:%S.%f') 46 | except ValueError: 47 | value = datetime.datetime.strptime(value[:-1], '%Y-%m-%dT%H:%M:%S.') 48 | else: 49 | value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S') 50 | 51 | return value 52 | 53 | class SerializeMixin(): 54 | def as_dict(self, delete_empty=False): 55 | """ 56 | Converts the object to a dict 57 | """ 58 | result = {} 59 | for c in self.__table__.columns: 60 | attr = getattr(self, c.name) 61 | if delete_empty: 62 | if attr: 63 | result[c.name] = attr 64 | else: 65 | result[c.name] = attr 66 | return result 67 | 68 | 69 | def __repr__(self): 70 | return str(self.as_dict(True)) 71 | ''' 72 | 73 | dbdef = ''' 74 | class %s(Base, SerializeMixin): 75 | __tablename__ = "%ss" 76 | %s 77 | %s 78 | ''' 79 | 80 | footer = ''' 81 | def parse_db_argument(dbarg): 82 | \'\'\' 83 | Parse DB string given as argument into full path required 84 | for SQLAlchemy 85 | \'\'\' 86 | if not ':/' in dbarg: 87 | if dbarg[0] != '/': 88 | return 'sqlite:///' + os.path.join(os.getcwd(), dbarg) 89 | else: 90 | return 'sqlite:///' + dbarg 91 | else: 92 | return dbarg 93 | 94 | def init(create=False, dburl='sqlite:///roadrecon.db'): 95 | if 'postgresql' in dburl: 96 | engine = create_engine(dburl, 97 | executemany_mode='values', 98 | executemany_values_page_size=1001) 99 | else: 100 | engine = create_engine(dburl) 101 | 102 | if create: 103 | Base.metadata.drop_all(engine) 104 | Base.metadata.create_all(engine) 105 | return engine 106 | 107 | def get_session(engine): 108 | Session = sessionmaker(bind=engine) 109 | return Session() 110 | ''' 111 | 112 | # Custom joins for service principals since these are kinda weird 113 | custom_splinks = ''' 114 | oauth2PermissionGrants = relationship("OAuth2PermissionGrant", 115 | primaryjoin=objectId == foreign(OAuth2PermissionGrant.clientId)) 116 | 117 | appRolesAssigned = relationship("AppRoleAssignment", 118 | primaryjoin=objectId == foreign(AppRoleAssignment.resourceId)) 119 | 120 | appRolesAssignedTo = relationship("AppRoleAssignment", 121 | primaryjoin=objectId == foreign(AppRoleAssignment.principalId)) 122 | ''' 123 | 124 | coldef = ' %s = Column(%s)' 125 | pcoldef = ' %s = Column(%s, primary_key=True)' 126 | fcoldef = ' %s = Column(%s, ForeignKey("%s"))' 127 | 128 | def gen_db_class(classdef, rels, rev_rels): 129 | classname = classdef.__name__ 130 | props = {} 131 | for base in classdef.__bases__: 132 | try: 133 | props.update(base.props) 134 | except AttributeError: 135 | # No base, so no props 136 | pass 137 | props.update(classdef.props) 138 | cols = [] 139 | for pname, pclass in props.items(): 140 | try: 141 | dbtype = pclass.DBTYPE.__name__ 142 | except AttributeError: 143 | # Complex type 144 | dbtype = 'JSON' 145 | if dbtype == 'Binary': 146 | dbtype = 'Text' 147 | if dbtype == 'LargeBinary': 148 | dbtype = 'Text' 149 | if pname == 'objectId' or (classname == 'Domain' and pname == 'name') or (classname in ['RoleAssignment', 'EligibleRoleAssignment', 'AuthorizationPolicy', 'DirectorySetting'] and pname == 'id') or (classname == 'ApplicationRef' and pname == 'appId'): 150 | cols.append(pcoldef % (pname, dbtype)) 151 | elif pname == 'roleDefinitionId': 152 | cols.append(fcoldef % (pname, dbtype, 'RoleDefinitions.objectId')) 153 | else: 154 | cols.append(coldef % (pname, dbtype)) 155 | outrels = [] 156 | for rel in rels: 157 | reldata = relations[rel] 158 | if reldata[0] == reldata[1]: 159 | outrels.append(gen_link_fkey(rel, reldata[1], reldata[2], reldata[3], reldata[0], 'child'+reldata[0])) 160 | elif reldata[0] == 'RoleDefinition' or reldata[1] == 'RoleDefinition': 161 | outrels.append(gen_link_nolinktbl(reldata[1], reldata[2], reldata[3])) 162 | else: 163 | outrels.append(gen_link(rel, reldata[1], reldata[2], reldata[3])) 164 | 165 | for rel in rev_rels: 166 | reldata = relations[rel] 167 | if reldata[0] == reldata[1]: 168 | outrels.append(gen_link_fkey(rel, reldata[0], reldata[3], reldata[2], 'child'+reldata[0], reldata[0])) 169 | elif reldata[0] == 'RoleDefinition' or reldata[1] == 'RoleDefinition': 170 | outrels.append(gen_link_nolinktbl(reldata[0], reldata[3], reldata[2])) 171 | else: 172 | outrels.append(gen_link(rel, reldata[0], reldata[3], reldata[2])) 173 | 174 | if classname == 'ServicePrincipal': 175 | outrels.append(custom_splinks) 176 | return dbdef % (classname, classname, '\n'.join(cols), '\n'.join(outrels)) 177 | 178 | # Relationships defined here 179 | relations = { 180 | # Relationship name: (LeftGroup, RightGroup, relation name, reverse relation name) 181 | 'group_member_user': ('Group', 'User', 'memberUsers', 'memberOf'), 182 | 'group_member_group': ('Group', 'Group', 'memberGroups', 'memberOf'), 183 | 'group_member_contact': ('Group', 'Contact', 'memberContacts', 'memberOf'), 184 | 'group_member_device': ('Group', 'Device', 'memberDevices', 'memberOf'), 185 | 'group_member_serviceprincipal': ('Group', 'ServicePrincipal', 'memberServicePrincipals', 'memberOf'), 186 | 'device_owner': ('Device', 'User', 'owner', 'ownedDevices'), 187 | 'application_owner_user': ('Application', 'User', 'ownerUsers', 'ownedApplications'), 188 | 'application_owner_serviceprincipal': ('Application', 'ServicePrincipal', 'ownerServicePrincipals', 'ownedApplications'), 189 | 'serviceprincipal_owner_user': ('ServicePrincipal', 'User', 'ownerUsers', 'ownedServicePrincipals'), 190 | 'serviceprincipal_owner_serviceprincipal': ('ServicePrincipal', 'ServicePrincipal', 'ownerServicePrincipals', 'ownedServicePrincipals'), 191 | 'role_member_user': ('DirectoryRole', 'User', 'memberUsers', 'memberOfRole'), 192 | 'role_member_serviceprincipal': ('DirectoryRole', 'ServicePrincipal', 'memberServicePrincipals', 'memberOfRole'), 193 | 'role_member_group': ('DirectoryRole', 'Group', 'memberGroups', 'memberOfRole'), 194 | 'group_owner_user': ('Group', 'User', 'ownerUsers', 'ownedGroups'), 195 | 'group_owner_serviceprincipal': ('Group', 'ServicePrincipal', 'ownerServicePrincipals', 'ownedGroups'), 196 | 'au_member_user': ('AdministrativeUnit', 'User', 'memberUsers', 'memberOfAu'), 197 | 'au_member_group': ('AdministrativeUnit', 'Group', 'memberGroups', 'memberOfAu'), 198 | 'au_member_device': ('AdministrativeUnit', 'Device', 'memberDevices', 'memberOfAu'), 199 | 'role_assignment_active': ('RoleDefinition', 'RoleAssignment', 'assignments', 'roleDefinition'), 200 | 'role_assignment_eligible': ('RoleDefinition', 'EligibleRoleAssignment', 'eligibleAssignments', 'roleDefinition'), 201 | } 202 | 203 | link_tbl_tpl = ''' 204 | lnk_%s = Table('lnk_%s', Base.metadata, 205 | Column('%s', Text, ForeignKey('%ss.objectId')), 206 | Column('%s', Text, ForeignKey('%ss.objectId')) 207 | ) 208 | ''' 209 | 210 | def gen_link_table(linkname, left_tbl, right_tbl): 211 | # For links between same properties, use different names 212 | if left_tbl == right_tbl: 213 | right_tbl_name = 'child' + right_tbl 214 | else: 215 | right_tbl_name = right_tbl 216 | return link_tbl_tpl % (linkname, linkname, left_tbl, left_tbl, right_tbl_name, right_tbl) 217 | 218 | # Simple link template for many to many relationships with link table 219 | link_tpl = ''' %s = relationship("%s", 220 | secondary=lnk_%s, 221 | back_populates="%s") 222 | ''' 223 | 224 | def gen_link(link_name, ref_table, rel_name, rev_rel_name): 225 | return link_tpl % (rel_name, ref_table, link_name, rev_rel_name) 226 | 227 | # Simple link template for one to many relationships 228 | link_tpl_nolinktbl = ''' %s = relationship("%s", 229 | back_populates="%s") 230 | ''' 231 | 232 | def gen_link_nolinktbl(ref_table, rel_name, rev_rel_name): 233 | return link_tpl_nolinktbl % (rel_name, ref_table, rev_rel_name) 234 | 235 | # Link template with explicit foreign key 236 | # this voodoo is inspired by https://docs.sqlalchemy.org/en/13/orm/join_conditions.html#self-referential-many-to-many-relationship 237 | link_tpl_fkey = ''' {0} = relationship("{1}", 238 | secondary=lnk_{2}, 239 | primaryjoin=objectId==lnk_{2}.c.{3}, 240 | secondaryjoin=objectId==lnk_{2}.c.{4}, 241 | back_populates="{5}") 242 | ''' 243 | 244 | def gen_link_fkey(link_name, ref_table, rel_name, rev_rel_name, ref_column, sec_ref_column): 245 | return link_tpl_fkey.format(rel_name, ref_table, link_name, ref_column, sec_ref_column, rev_rel_name) 246 | 247 | # Tables to generate and relationships with other tables are defined here 248 | tables = [ 249 | # Table, relation, back_relation 250 | # These come first since they are referenced from service principals 251 | (AppRoleAssignment, [], []), 252 | (OAuth2PermissionGrant, [], []), 253 | (User, [], ['group_member_user', 'application_owner_user', 'serviceprincipal_owner_user', 'role_member_user', 'device_owner', 'group_owner_user', 'au_member_user']), 254 | (ServicePrincipal, ['serviceprincipal_owner_user', 'serviceprincipal_owner_serviceprincipal'], ['role_member_serviceprincipal', 'serviceprincipal_owner_serviceprincipal', 'application_owner_serviceprincipal', 'group_member_serviceprincipal', 'group_owner_serviceprincipal']), 255 | (Group, ['group_member_group', 'group_member_user', 'group_member_contact', 'group_member_device', 'group_member_serviceprincipal', 'group_owner_user', 'group_owner_serviceprincipal'], ['group_member_group', 'role_member_group', 'au_member_group']), 256 | (Application, ['application_owner_user', 'application_owner_serviceprincipal'], []), 257 | (Device, ['device_owner'], ['group_member_device', 'au_member_device']), 258 | # (Domain, [], []), 259 | (DirectoryRole, ['role_member_user', 'role_member_serviceprincipal', 'role_member_group'], []), 260 | (TenantDetail, [], []), 261 | (ApplicationRef, [], []), 262 | (ExtensionProperty, [], []), 263 | (Contact, [], ['group_member_contact']), 264 | (Policy, [], []), 265 | (RoleDefinition, ['role_assignment_eligible', 'role_assignment_active'], []), 266 | (RoleAssignment, [], ['role_assignment_active']), 267 | (EligibleRoleAssignment, [], ['role_assignment_eligible']), 268 | (AuthorizationPolicy, [], []), 269 | (DirectorySetting, [], []), 270 | (AdministrativeUnit, ['au_member_group', 'au_member_user', 'au_member_device'], []) 271 | ] 272 | with open('metadef/database.py', 'w') as outf: 273 | outf.write(header) 274 | for relname, reldata in relations.items(): 275 | if relname == 'role_assignment_active' or relname == 'role_assignment_eligible': 276 | continue 277 | outf.write(gen_link_table(relname, reldata[0], reldata[1])) 278 | for table, links, revlinks in tables: 279 | outf.write(gen_db_class(table, links, revlinks)) 280 | outf.write(footer) 281 | -------------------------------------------------------------------------------- /aroadtools/roadlib/database/metadef/complextypes.py: -------------------------------------------------------------------------------- 1 | from aroadtools.roadlib.database.metadef.basetypes import Edm, Collection 2 | 3 | class AccessPass(object): 4 | props = { 5 | 'creationTime': Edm.DateTime, 6 | 'startTime': Edm.DateTime, 7 | 'endTime': Edm.DateTime, 8 | 'passId': Edm.Guid, 9 | 'accessPassUsage': Edm.String, 10 | 'accessPassCode': Edm.String, 11 | } 12 | 13 | 14 | class AlternativeSecurityId(object): 15 | props = { 16 | 'type': Edm.Int32, 17 | 'identityProvider': Edm.String, 18 | 'key': Edm.Binary, 19 | } 20 | 21 | 22 | class AppAddress(object): 23 | props = { 24 | 'address': Edm.String, 25 | 'addressType': Edm.String, 26 | } 27 | 28 | 29 | class AppBranding(object): 30 | props = { 31 | 'fontColor': Edm.String, 32 | 'logoBackgroundColor': Edm.String, 33 | } 34 | 35 | 36 | class AppMetadataEntry(object): 37 | props = { 38 | 'key': Edm.String, 39 | 'value': Edm.Binary, 40 | } 41 | 42 | 43 | class AppRole(object): 44 | props = { 45 | 'allowedMemberTypes': Collection, 46 | 'description': Edm.String, 47 | 'displayName': Edm.String, 48 | 'id': Edm.Guid, 49 | 'isEnabled': Edm.Boolean, 50 | 'lang': Edm.String, 51 | 'origin': Edm.String, 52 | 'value': Edm.String, 53 | } 54 | 55 | 56 | class AssignedLabel(object): 57 | props = { 58 | 'labelId': Edm.String, 59 | 'displayName': Edm.String, 60 | } 61 | 62 | 63 | class AssignedLicense(object): 64 | props = { 65 | 'disabledPlans': Collection, 66 | 'skuId': Edm.Guid, 67 | } 68 | 69 | 70 | class AssignedPlan(object): 71 | props = { 72 | 'assignedTimestamp': Edm.DateTime, 73 | 'capabilityStatus': Edm.String, 74 | 'service': Edm.String, 75 | 'servicePlanId': Edm.Guid, 76 | } 77 | 78 | 79 | class BitLockerKey(object): 80 | props = { 81 | 'creationTime': Edm.DateTime, 82 | 'customKeyInformation': Edm.Binary, 83 | 'keyIdentifier': Edm.Guid, 84 | 'keyMaterial': Edm.Binary, 85 | } 86 | 87 | 88 | class CustomSecurityAttributeValue(object): 89 | props = { 90 | 'fake_3DA706FF54E945C09DF178B67EE299C2': Edm.String, 91 | } 92 | 93 | 94 | class Certification(object): 95 | props = { 96 | 'isPublisherAttested': Edm.Boolean, 97 | 'isCertifiedByMicrosoft': Edm.Boolean, 98 | 'lastCertificationDateTime': Edm.DateTime, 99 | 'certificationExpirationDateTime': Edm.DateTime, 100 | 'certificationDetailsUrl': Edm.String, 101 | } 102 | 103 | 104 | class CertificateAuthorityInformation(object): 105 | props = { 106 | 'authorityType': Edm.String, 107 | 'crlDistributionPoint': Edm.String, 108 | 'deltaCrlDistributionPoint': Edm.String, 109 | 'trustedCertificate': Edm.Binary, 110 | 'trustedIssuer': Edm.String, 111 | 'trustedIssuerSki': Edm.String, 112 | } 113 | 114 | 115 | class CloudMSRtcServiceAttributes(object): 116 | props = { 117 | 'applicationOptions': Edm.Int32, 118 | 'deploymentLocator': Edm.String, 119 | 'hideFromAddressLists': Edm.Boolean, 120 | 'optionFlags': Edm.Int32, 121 | } 122 | 123 | 124 | class CompliantApplication(object): 125 | props = { 126 | 'mamEnrollmentId': Edm.Guid, 127 | 'expirationTime': Edm.DateTime, 128 | 'applicationId': Edm.Guid, 129 | 'userId': Edm.Guid, 130 | } 131 | 132 | 133 | class CredentialList(object): 134 | props = { 135 | 'passwords': Collection, 136 | 'userName': Edm.String, 137 | } 138 | 139 | 140 | class DefaultUserRolePermissions(object): 141 | props = { 142 | 'allowedToCreateApps': Edm.Boolean, 143 | 'allowedToCreateSecurityGroups': Edm.Boolean, 144 | 'allowedToReadOtherUsers': Edm.Boolean, 145 | } 146 | 147 | 148 | class DeviceKey(object): 149 | props = { 150 | 'creationTime': Edm.DateTime, 151 | 'customKeyInformation': Edm.Binary, 152 | 'keyIdentifier': Edm.Guid, 153 | 'keyMaterial': Edm.Binary, 154 | 'usage': Edm.String, 155 | } 156 | 157 | 158 | class EmployeeOrgData(object): 159 | props = { 160 | 'costCenter': Edm.String, 161 | 'division': Edm.String, 162 | } 163 | 164 | 165 | class SecuredEncryptionKey(object): 166 | props = { 167 | 'partnerId': Edm.String, 168 | 'shardId': Edm.Int32, 169 | 'version': Edm.Int32, 170 | 'publicKey': Edm.Binary, 171 | } 172 | 173 | 174 | class GuestTenantDetail(object): 175 | props = { 176 | 'tenantId': Edm.String, 177 | 'country': Edm.String, 178 | 'countryCode': Edm.String, 179 | 'defaultDomain': Edm.String, 180 | 'displayName': Edm.String, 181 | 'domains': Collection, 182 | 'isHomeTenant': Edm.Boolean, 183 | 'tenantType': Edm.String, 184 | 'tenantBrandingLogoUrl': Edm.String, 185 | } 186 | 187 | 188 | class IdentityInfo(object): 189 | props = { 190 | 'objectId': Edm.Guid, 191 | 'displayName': Edm.String, 192 | 'userPrincipalName': Edm.String, 193 | } 194 | 195 | 196 | class KeyCredential(object): 197 | props = { 198 | 'customKeyIdentifier': Edm.Binary, 199 | 'endDate': Edm.DateTime, 200 | 'keyId': Edm.Guid, 201 | 'startDate': Edm.DateTime, 202 | 'type': Edm.String, 203 | 'usage': Edm.String, 204 | 'value': Edm.Binary, 205 | } 206 | 207 | 208 | class LicenseAssignment(object): 209 | props = { 210 | 'accountId': Edm.Guid, 211 | 'skuId': Edm.Guid, 212 | } 213 | 214 | 215 | class KeyValue(object): 216 | props = { 217 | 'key': Edm.String, 218 | 'value': Edm.String, 219 | } 220 | 221 | 222 | class InvitationTicket(object): 223 | props = { 224 | 'type': Edm.String, 225 | 'ticket': Edm.String, 226 | } 227 | 228 | 229 | class LicenseUnitsDetail(object): 230 | props = { 231 | 'enabled': Edm.Int32, 232 | 'suspended': Edm.Int32, 233 | 'warning': Edm.Int32, 234 | 'lockedOut': Edm.Int32, 235 | } 236 | 237 | 238 | class PrivacyProfile(object): 239 | props = { 240 | 'contactEmail': Edm.String, 241 | 'statementUrl': Edm.String, 242 | } 243 | 244 | 245 | class DirectorySyncStatus(object): 246 | props = { 247 | 'attributeSetName': Edm.String, 248 | 'state': Edm.String, 249 | 'version': Edm.Int32, 250 | } 251 | 252 | 253 | class SettingValue(object): 254 | props = { 255 | 'name': Edm.String, 256 | 'value': Edm.String, 257 | } 258 | 259 | 260 | class SettingTemplateValue(object): 261 | props = { 262 | 'name': Edm.String, 263 | 'type': Edm.String, 264 | 'defaultValue': Edm.String, 265 | 'description': Edm.String, 266 | } 267 | 268 | 269 | class SignInNamesInfo(object): 270 | props = { 271 | 'type': Edm.String, 272 | 'value': Edm.String, 273 | } 274 | 275 | 276 | class OAuth2Permission(object): 277 | props = { 278 | 'adminConsentDescription': Edm.String, 279 | 'adminConsentDisplayName': Edm.String, 280 | 'id': Edm.Guid, 281 | 'isEnabled': Edm.Boolean, 282 | 'lang': Edm.String, 283 | 'origin': Edm.String, 284 | 'type': Edm.String, 285 | 'userConsentDescription': Edm.String, 286 | 'userConsentDisplayName': Edm.String, 287 | 'value': Edm.String, 288 | } 289 | 290 | 291 | class PasswordCredential(object): 292 | props = { 293 | 'customKeyIdentifier': Edm.Binary, 294 | 'endDate': Edm.DateTime, 295 | 'keyId': Edm.Guid, 296 | 'startDate': Edm.DateTime, 297 | 'value': Edm.String, 298 | } 299 | 300 | 301 | class PasswordProfile(object): 302 | props = { 303 | 'password': Edm.String, 304 | 'forceChangePasswordNextLogin': Edm.Boolean, 305 | 'enforceChangePasswordPolicy': Edm.Boolean, 306 | } 307 | 308 | 309 | class ProvisionedPlan(object): 310 | props = { 311 | 'capabilityStatus': Edm.String, 312 | 'provisioningStatus': Edm.String, 313 | 'service': Edm.String, 314 | } 315 | 316 | 317 | class ProvisioningError(object): 318 | props = { 319 | 'errorDetail': Edm.String, 320 | 'resolved': Edm.Boolean, 321 | 'service': Edm.String, 322 | 'timestamp': Edm.DateTime, 323 | } 324 | 325 | 326 | class ResourceAccess(object): 327 | props = { 328 | 'id': Edm.Guid, 329 | 'type': Edm.String, 330 | } 331 | 332 | 333 | class ResourceAction(object): 334 | props = { 335 | 'allowedResourceActions': Collection, 336 | } 337 | 338 | 339 | class DynamicResourceAccess(object): 340 | props = { 341 | 'appIdentifier': Edm.String, 342 | 'scopes': Collection, 343 | 'appRoles': Collection, 344 | } 345 | 346 | 347 | class SearchableDeviceKey(object): 348 | props = { 349 | 'usage': Edm.String, 350 | 'keyIdentifier': Edm.String, 351 | 'keyMaterial': Edm.Binary, 352 | 'creationTime': Edm.DateTime, 353 | 'deviceId': Edm.Guid, 354 | 'customKeyInformation': Edm.Binary, 355 | 'fidoAaGuid': Edm.String, 356 | 'fidoAuthenticatorVersion': Edm.String, 357 | 'fidoAttestationCertificates': Collection, 358 | } 359 | 360 | 361 | class ServicePlanInfo(object): 362 | props = { 363 | 'servicePlanId': Edm.Guid, 364 | 'servicePlanName': Edm.String, 365 | 'provisioningStatus': Edm.String, 366 | 'appliesTo': Edm.String, 367 | } 368 | 369 | 370 | class ServiceOriginatedResource(object): 371 | props = { 372 | 'capability': Edm.String, 373 | 'isLicenseReconciliationNeeded': Edm.Boolean, 374 | 'serviceInstance': Edm.String, 375 | 'servicePlanId': Edm.Guid, 376 | } 377 | 378 | 379 | class SelfServePasswordResetData(object): 380 | props = { 381 | 'alternateAuthenticationPhoneRegisteredTime': Edm.DateTime, 382 | 'alternateEmailRegisteredTime': Edm.DateTime, 383 | 'authenticationEmailRegisteredTime': Edm.DateTime, 384 | 'authenticationPhoneRegisteredTime': Edm.DateTime, 385 | 'deferralCount': Edm.Int32, 386 | 'deferredTime': Edm.DateTime, 387 | 'lastRegisteredTime': Edm.DateTime, 388 | 'mobilePhoneRegisteredTime': Edm.DateTime, 389 | 'reinforceAfterTime': Edm.DateTime, 390 | 'securityAnswersRegisteredTime': Edm.DateTime, 391 | } 392 | 393 | 394 | class SelfServePasswordResetPolicy(object): 395 | props = { 396 | 'enforcedRegistrationEnablement': Edm.String, 397 | 'enforcedRegistrationIntervalInDays': Edm.Int32, 398 | } 399 | 400 | 401 | class ServicePrincipalAuthenticationPolicy(object): 402 | props = { 403 | 'defaultPolicy': Edm.String, 404 | 'allowedPolicies': Collection, 405 | } 406 | 407 | 408 | class SigningCertificateUpdateStatus(object): 409 | props = { 410 | 'result': Edm.Int32, 411 | 'lastRunAt': Edm.DateTime, 412 | } 413 | 414 | 415 | class SamlSingleSignOnSettings(object): 416 | props = { 417 | 'relayState': Edm.String, 418 | } 419 | 420 | 421 | class EncryptedSecretHash(object): 422 | props = { 423 | 'encryptedHashValue': Edm.Binary, 424 | 'version': Edm.Int32, 425 | 'hashAlgorithm': Edm.String, 426 | 'hashSalt': Edm.Binary, 427 | 'iterationCount': Edm.Int32, 428 | 'creationTime': Edm.DateTime, 429 | } 430 | 431 | 432 | class StrongAuthenticationMethod(object): 433 | props = { 434 | 'methodType': Edm.String, 435 | 'isDefault': Edm.Boolean, 436 | } 437 | 438 | 439 | class StrongAuthenticationRequirement(object): 440 | props = { 441 | 'relyingParty': Edm.String, 442 | 'state': Edm.String, 443 | 'rememberDevicesNotIssuedBefore': Edm.DateTime, 444 | } 445 | 446 | 447 | class StrongAuthenticationPhoneAppDetail(object): 448 | props = { 449 | 'authenticationType': Edm.String, 450 | 'authenticatorFlavor': Edm.String, 451 | 'deviceId': Edm.Guid, 452 | 'deviceToken': Edm.String, 453 | 'deviceName': Edm.String, 454 | 'deviceTag': Edm.String, 455 | 'hashFunction': Edm.String, 456 | 'id': Edm.Guid, 457 | 'lastAuthenticatedTimestamp': Edm.DateTime, 458 | 'oathSecretKey': Edm.String, 459 | 'oathTokenTimeDrift': Edm.Int32, 460 | 'tenantDeviceId': Edm.String, 461 | 'timeInterval': Edm.Int32, 462 | 'phoneAppVersion': Edm.String, 463 | 'notificationType': Edm.String, 464 | } 465 | 466 | 467 | class StrongAuthenticationUserDetail(object): 468 | props = { 469 | 'alternativePhoneNumber': Edm.String, 470 | 'email': Edm.String, 471 | 'voiceOnlyPhoneNumber': Edm.String, 472 | 'phoneNumber': Edm.String, 473 | } 474 | 475 | 476 | class CompanyStrongAuthenticationDetails(object): 477 | props = { 478 | 'availableMFAMethods': Collection, 479 | 'phoneFactorId': Edm.String, 480 | 'blockApplicationPassword': Edm.Boolean, 481 | 'rememberDevicesEnabled': Edm.Boolean, 482 | 'rememberDevicesDurationDays': Edm.Int32, 483 | 'rememberDevicesUpdateTimestamp': Edm.DateTime, 484 | 'pinEnabled': Edm.Boolean, 485 | 'pinMinimumLength': Edm.Int32, 486 | } 487 | 488 | 489 | class RelyingPartyStrongAuthenticationPolicy(object): 490 | props = { 491 | 'relyingParties': Collection, 492 | 'enabled': Edm.Boolean, 493 | } 494 | 495 | 496 | class VerifiedDomain(object): 497 | props = { 498 | 'capabilities': Edm.String, 499 | 'default': Edm.Boolean, 500 | 'id': Edm.String, 501 | 'initial': Edm.Boolean, 502 | 'name': Edm.String, 503 | 'type': Edm.String, 504 | } 505 | 506 | 507 | class TrustedCertificateSubject(object): 508 | props = { 509 | 'authorityId': Edm.Guid, 510 | 'subjectName': Edm.String, 511 | 'revokedCertificateIdentifiers': Collection, 512 | } 513 | 514 | 515 | class InformationalUrl(object): 516 | props = { 517 | 'termsOfService': Edm.String, 518 | 'support': Edm.String, 519 | 'privacy': Edm.String, 520 | 'marketing': Edm.String, 521 | } 522 | 523 | 524 | class OptionalClaim(object): 525 | props = { 526 | 'name': Edm.String, 527 | 'source': Edm.String, 528 | 'essential': Edm.Boolean, 529 | 'additionalProperties': Collection, 530 | } 531 | 532 | 533 | class ParentalControlSettings(object): 534 | props = { 535 | 'countriesBlockedForMinors': Collection, 536 | 'legalAgeGroupRule': Edm.String, 537 | } 538 | 539 | 540 | class AuthorizationAction(object): 541 | props = { 542 | 'id': Edm.String, 543 | } 544 | 545 | 546 | class AuthorizationDecision(object): 547 | props = { 548 | 'actionId': Edm.String, 549 | 'accessDecision': Edm.String, 550 | } 551 | 552 | 553 | class AuthorizationResource(object): 554 | props = { 555 | 'scope': Edm.String, 556 | } 557 | 558 | 559 | class AuthorizationSubject(object): 560 | props = { 561 | 'appId': Edm.String, 562 | 'authorizationFlow': Edm.String, 563 | 'userId': Edm.String, 564 | } 565 | 566 | 567 | class RoleAssignmentDetail(object): 568 | props = { 569 | 'isDirect': Edm.Boolean, 570 | 'principalType': Edm.String, 571 | 'principalId': Edm.String, 572 | 'principalDisplayName': Edm.String, 573 | 'roleAssignmentId': Edm.String, 574 | } 575 | 576 | 577 | class VerifiedPublisher(object): 578 | props = { 579 | 'displayName': Edm.String, 580 | 'verifiedPublisherId': Edm.String, 581 | 'addedDateTime': Edm.DateTime, 582 | } 583 | 584 | 585 | class CrossTenantSynchronizationInfo(object): 586 | props = { 587 | 'creationType': Edm.String, 588 | } 589 | 590 | 591 | class AddIn(object): 592 | props = { 593 | 'id': Edm.Guid, 594 | 'type': Edm.String, 595 | 'properties': Collection, 596 | } 597 | 598 | 599 | class AppMetadata(object): 600 | props = { 601 | 'version': Edm.Int32, 602 | 'data': Collection, 603 | } 604 | 605 | 606 | class RolePermission(object): 607 | props = { 608 | 'resourceActions': ResourceAction, 609 | 'condition': Edm.String, 610 | } 611 | 612 | 613 | class DomainFederationSettings(object): 614 | props = { 615 | 'activeLogOnUri': Edm.String, 616 | 'defaultInteractiveAuthenticationMethod': Edm.String, 617 | 'federationBrandName': Edm.String, 618 | 'issuerUri': Edm.String, 619 | 'isExternal': Edm.Boolean, 620 | 'logOffUri': Edm.String, 621 | 'metadataExchangeUri': Edm.String, 622 | 'nextSigningCertificate': Edm.String, 623 | 'openIdConnectDiscoveryEndpoint': Edm.String, 624 | 'passiveLogOnUri': Edm.String, 625 | 'passwordChangeUri': Edm.String, 626 | 'passwordResetUri': Edm.String, 627 | 'preferredAuthenticationProtocol': Edm.String, 628 | 'promptLoginBehavior': Edm.String, 629 | 'signingCertificate': Edm.String, 630 | 'signingCertificateUpdateStatus': SigningCertificateUpdateStatus, 631 | 'supportsMfa': Edm.Boolean, 632 | } 633 | 634 | 635 | class RequiredResourceAccess(object): 636 | props = { 637 | 'resourceAppId': Edm.String, 638 | 'resourceAccess': Collection, 639 | } 640 | 641 | 642 | class OathTokenMetadata(object): 643 | props = { 644 | 'id': Edm.Guid, 645 | 'enabled': Edm.Boolean, 646 | 'tokenType': Edm.String, 647 | 'manufacturer': Edm.String, 648 | 'manufacturerProperties': Collection, 649 | 'serialNumber': Edm.String, 650 | } 651 | 652 | 653 | class StrongAuthenticationDetail(object): 654 | props = { 655 | 'encryptedPinHash': EncryptedSecretHash, 656 | 'encryptedPinHashHistory': Edm.Binary, 657 | 'methods': Collection, 658 | 'oathTokenMetadata': Collection, 659 | 'requirements': Collection, 660 | 'phoneAppDetails': Collection, 661 | 'proofupTime': Edm.Int64, 662 | 'verificationDetail': StrongAuthenticationUserDetail, 663 | } 664 | 665 | 666 | class StrongAuthenticationPolicy(object): 667 | props = { 668 | 'relyingPartyStrongAuthenticationPolicy': Collection, 669 | } 670 | 671 | 672 | class OptionalClaims(object): 673 | props = { 674 | 'idToken': Collection, 675 | 'accessToken': Collection, 676 | 'saml2Token': Collection, 677 | } 678 | 679 | 680 | class ResourceAuthorizationDecision(object): 681 | props = { 682 | 'resourceScope': Edm.String, 683 | 'authorizationDecisions': Collection, 684 | } 685 | 686 | 687 | class CrossTenantSynchronizationResource(object): 688 | props = { 689 | 'originTenantId': Edm.String, 690 | 'originId': Edm.String, 691 | 'synchronizationInfo': CrossTenantSynchronizationInfo, 692 | } 693 | 694 | -------------------------------------------------------------------------------- /aroadtools/roadrecon/plugins/xlsexport.py: -------------------------------------------------------------------------------- 1 | """ 2 | Export to Excel file plugin 3 | Contributed by Bastien Cacace (XMCO) 4 | 5 | Copyright 2020 - MIT License 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | """ 25 | 26 | import argparse 27 | import json 28 | from html import escape 29 | import os 30 | import pprint 31 | import types 32 | import asyncio 33 | 34 | from marshmallow import Schema, fields 35 | from marshmallow_sqlalchemy import ModelConverter, SQLAlchemyAutoSchema 36 | from openpyxl import Workbook 37 | from openpyxl.styles import Font 38 | from openpyxl.styles import PatternFill 39 | 40 | from aroadtools.roadlib.database.metadef.database import ( 41 | User, JSON, Group, DirectoryRole, ServicePrincipal, AppRoleAssignment, 42 | RoleAssignment, TenantDetail, Application, Device, OAuth2PermissionGrant 43 | ) 44 | from aroadtools.roadrecon.plugins.server.schema import ( 45 | user_schema, device_schema, group_schema, application_schema, 46 | td_schema, serviceprincipal_schema, users_schema, devices_schema, 47 | groups_schema, applications_schema, serviceprincipals_schema 48 | ) 49 | import aroadtools.roadlib.database.metadef.database as database 50 | from aroadtools.roadlib.utils import printhook 51 | 52 | # Required property - plugin description 53 | DESCRIPTION = "Export data to an Excel file" 54 | 55 | # Plugin properties 56 | SUPPORTED_EXTENSIONS = ['.xls', '.xlsx'] 57 | 58 | 59 | class ExportToFilePlugin(): 60 | """ 61 | Export data to a file (Excel format). 62 | """ 63 | 64 | def __init__(self, session, file, verbose=False, printhook=printhook): 65 | self.session = session 66 | self.file = file 67 | self.verbose = verbose 68 | self.print = printhook 69 | 70 | def _print_msg(self, msg): 71 | if self.verbose: 72 | # this is a disgusting solution but I'm lazy 73 | asyncio.create_task(self.print(msg)) 74 | 75 | def _create_excel_headers(self, sheet, list_headers): 76 | i = 1 77 | for header in list_headers: 78 | sheet.cell(row=1, column=i).value = header 79 | i += 1 80 | 81 | def _apply_style_sheet(self, sheet, width): 82 | # Style 83 | i = ord('A') 84 | for header in sheet[1]: 85 | header.font = Font(bold=True, color="FFFEFB") 86 | header.fill = PatternFill("solid", fgColor="47402D") 87 | sheet.column_dimensions[chr(i)].width = width 88 | i += 1 89 | 90 | def _fill_sheet(self, sheet, assets, fields): 91 | i = 2 92 | for asset in assets: 93 | j = 1 94 | for field in fields: 95 | if isinstance(asset, dict): 96 | result = asset.get(field) 97 | else: 98 | result = getattr(asset, field) 99 | 100 | if isinstance(result, types.GeneratorType): 101 | result = sorted(set(result)) 102 | result = "\n".join(result) 103 | if isinstance(result, (list,)) and len(result) == 0: 104 | result = "" 105 | if isinstance(result, (list,)): 106 | if isinstance(result[0], (Group,User,ServicePrincipal)): 107 | object_name = [] 108 | for obj in result: 109 | object_name.append(obj.displayName) 110 | result = "\n".join(object_name) 111 | elif isinstance(result[0], (dict,)): 112 | result = len(result) 113 | else: 114 | result = sorted(set(result)) 115 | result = " ".join(result) 116 | if isinstance(result, (dict,)): 117 | result = json.dumps(result) 118 | 119 | sheet.cell(row=i, column=j).value = result 120 | j += 1 121 | i += 1 122 | 123 | def _create_sheet(self, book, name): 124 | book.create_sheet(name) 125 | sheet = book[name] 126 | return sheet 127 | 128 | def get_users(self, book, column_width=40): 129 | sheet_name = "Users" 130 | self._print_msg('Export %s info' % sheet_name) 131 | 132 | sheet = self._create_sheet(book, sheet_name) 133 | self._create_excel_headers(sheet, users_schema.Meta().fields) 134 | self._apply_style_sheet(sheet, column_width) 135 | all_users = self.session.query(User).all() 136 | self._fill_sheet(sheet, all_users, users_schema.Meta().fields) 137 | 138 | def get_devices(self, book, column_width=40): 139 | sheet_name = "Devices" 140 | self._print_msg('Export %s info' % sheet_name) 141 | 142 | sheet = self._create_sheet(book, sheet_name) 143 | self._create_excel_headers(sheet, devices_schema.Meta().fields) 144 | self._apply_style_sheet(sheet, column_width) 145 | all_devices = self.session.query(Device).all() 146 | self._fill_sheet(sheet, all_devices, devices_schema.Meta().fields) 147 | 148 | def get_groups(self, book, column_width=40): 149 | sheet_name = "Groups" 150 | self._print_msg('Export %s info' % sheet_name) 151 | 152 | sheet = self._create_sheet(book, sheet_name) 153 | self._create_excel_headers(sheet, groups_schema.Meta().fields) 154 | self._apply_style_sheet(sheet, column_width) 155 | all_groups = self.session.query(Group).all() 156 | self._fill_sheet(sheet, all_groups, groups_schema.Meta().fields) 157 | 158 | def get_member_of(self, book, column_width=40): 159 | sheet_name = "MemberOf" 160 | self._print_msg('Export %s info' % sheet_name) 161 | 162 | members_of = dict() 163 | hearders = ['objectId', 'displayName', 'memberOf'] 164 | sheet = self._create_sheet(book, "MemberOf") 165 | self._create_excel_headers(sheet, hearders) 166 | self._apply_style_sheet(sheet, column_width) 167 | all_users = self.session.query(User).all() 168 | self._fill_sheet(sheet, all_users, hearders) 169 | 170 | def get_directory_roles(self, book, column_width=40): 171 | sheet_name = "Directory roles" 172 | self._print_msg('Export %s info' % sheet_name) 173 | 174 | fields = ( 175 | 'objectId', 'objectType', 'displayName', 'cloudSecurityIdentifier', 176 | 'description', 'isSystem', 'roleDisabled', 'member', 'memberType' 177 | ) 178 | sheet = self._create_sheet(book, sheet_name) 179 | self._create_excel_headers(sheet, fields) 180 | self._apply_style_sheet(sheet, column_width) 181 | all_directory_roles = self.session.query(DirectoryRole).all() 182 | directory_roles_by_member = [] 183 | for directory_role in all_directory_roles: 184 | members = directory_role.memberUsers if directory_role.memberUsers else directory_role.memberServicePrincipals 185 | for member in members: 186 | directory_roles_by_member.append({ 187 | 'objectId': directory_role.objectId, 188 | 'objectType': directory_role.objectType, 189 | 'displayName': directory_role.displayName, 190 | 'cloudSecurityIdentifier': directory_role.cloudSecurityIdentifier, 191 | 'displayName': directory_role.displayName, 192 | 'description': directory_role.description, 193 | 'member': member.displayName, 194 | 'memberType': member.objectType, 195 | 'isSystem': directory_role.isSystem, 196 | 'roleDisabled': directory_role.roleDisabled 197 | }) 198 | 199 | self._fill_sheet(sheet, directory_roles_by_member, fields) 200 | 201 | def get_applications(self, book, column_width=40): 202 | sheet_name = "Applications" 203 | self._print_msg('Export %s info' % sheet_name) 204 | 205 | fields = ( 206 | 'objectId', 'objectType', 'displayName', 'appId', 207 | 'oauth2AllowIdTokenImplicitFlow', 'availableToOtherTenants', 208 | 'publisherDomain', 'replyUrls', 'appRoles', 'publicClient', 209 | 'oauth2AllowImplicitFlow', 'oauth2Permissions', 'homepage', 210 | 'passwordCredentials', 'keyCredentials', 'ownerUsers', 211 | 'ownerServicePrincipals' 212 | ) 213 | sheet = self._create_sheet(book, sheet_name) 214 | self._create_excel_headers(sheet, fields) 215 | self._apply_style_sheet(sheet, column_width) 216 | all_applications = self.session.query(Application).all() 217 | self._fill_sheet(sheet, all_applications, fields) 218 | 219 | def get_service_principals(self, book, column_width=40): 220 | sheet_name = "Service principals" 221 | self._print_msg('Export %s info' % sheet_name) 222 | 223 | fields = ( 224 | 'objectId', 'objectType', 'displayName', 'appDisplayName', 225 | 'appId', 'publisherName', 'replyUrls', 'appRoles', 226 | 'microsoftFirstParty', 'oauth2Permissions', 'passwordCredentials', 227 | 'keyCredentials', 'ownerUsers', 'ownerServicePrincipals', 228 | 'accountEnabled', 'servicePrincipalType' 229 | ) 230 | sheet = self._create_sheet(book, sheet_name) 231 | self._create_excel_headers(sheet, fields) 232 | self._apply_style_sheet(sheet, column_width) 233 | all_service_principal = self.session.query(ServicePrincipal).all() 234 | self._fill_sheet(sheet, all_service_principal, fields) 235 | 236 | def get_app_roles(self, book, column_width=40): 237 | sheet_name = "Applications roles" 238 | self._print_msg('Export %s info' % sheet_name) 239 | 240 | sheet = self._create_sheet(book, sheet_name) 241 | fields = ('objid', 'ptype', 'pname', 'app', 'value', 'desc', 'spid') 242 | self._create_excel_headers(sheet, fields) 243 | self._apply_style_sheet(sheet, column_width) 244 | approles = [] 245 | for ar in self.session.query(AppRoleAssignment).all(): 246 | rsp = self.session.get(ServicePrincipal, ar.resourceId) 247 | if ar.principalType == 'ServicePrincipal': 248 | sp = self.session.get(ServicePrincipal, ar.principalId) 249 | if ar.principalType == 'User': 250 | sp = self.session.get(User, ar.principalId) 251 | if ar.principalType == 'Group': 252 | sp = self.session.get(Group, ar.principalId) 253 | if not sp: 254 | self._print_msg('Could not resolve service principal for approle {0}'.format(str(ar))) 255 | continue 256 | if ar.id == '00000000-0000-0000-0000-000000000000': 257 | approles.append({ 258 | 'objid': sp.objectId, 259 | 'ptype': ar.principalType, 260 | 'pname': sp.displayName, 261 | 'app': ar.resourceDisplayName, 262 | 'value': 'Default', 263 | 'desc': 'Default Role', 264 | 'spid': ar.resourceId, 265 | }) 266 | else: 267 | for approle in rsp.appRoles: 268 | if approle['id'] == ar.id: 269 | approles.append({ 270 | 'objid': sp.objectId, 271 | 'ptype': ar.principalType, 272 | 'pname': sp.displayName, 273 | 'app': ar.resourceDisplayName, 274 | 'value': approle['value'], 275 | 'desc': approle['displayName'], 276 | 'spid': ar.resourceId, 277 | }) 278 | self._fill_sheet(sheet, approles, fields) 279 | 280 | def get_oauth2_permissions(self, book, column_width=40): 281 | sheet_name = "Oauth2 permissions" 282 | self._print_msg('Export %s info' % sheet_name) 283 | 284 | sheet = self._create_sheet(book, sheet_name) 285 | oauth2permissions = [] 286 | fields = ( 287 | 'type', 'userid', 'userdisplayname', 'targetapplication', 'targetspobjectid', 288 | 'sourceapplication', 'sourcespobjectid', 'expiry', 'scope' 289 | ) 290 | self._create_excel_headers(sheet, fields) 291 | self._apply_style_sheet(sheet, column_width) 292 | for permgrant in self.session.query(OAuth2PermissionGrant).all(): 293 | grant = {} 294 | rsp = self.session.get(ServicePrincipal, permgrant.clientId) 295 | if permgrant.consentType == 'Principal': 296 | grant['type'] = 'user' 297 | user = self.session.get(User, permgrant.principalId) 298 | grant['userid'] = user.objectId 299 | grant['userdisplayname'] = user.displayName 300 | else: 301 | grant['type'] = 'all' 302 | grant['userid'] = None 303 | grant['userdisplayname'] = None 304 | 305 | targetapp = self.session.get(ServicePrincipal, permgrant.resourceId) 306 | grant['targetapplication'] = targetapp.displayName 307 | grant['targetspobjectid'] = targetapp.objectId 308 | grant['sourceapplication'] = rsp.displayName 309 | grant['sourcespobjectid'] = rsp.objectId 310 | grant['expiry'] = permgrant.expiryTime 311 | grant['scope'] = permgrant.scope 312 | oauth2permissions.append(grant) 313 | 314 | self._fill_sheet(sheet, oauth2permissions, fields) 315 | 316 | def get_mfa(self, book, column_width=40): 317 | sheet_name = "MFA" 318 | self._print_msg('Export %s info' % sheet_name) 319 | 320 | sheet = self._create_sheet(book, sheet_name) 321 | fields = ( 322 | 'objectId', 'displayName', 'mfamethods', 'accountEnabled', 'has_app', 323 | 'has_phonenr', 'has_fido', 'encryptedPinHash', 'encryptedPinHashHistory', 324 | 'methods', 'oathTokenMetadata', 'requirements', 'phoneAppDetails', 325 | 'proofupTime', 'verificationDetail', 'requirements' 326 | ) 327 | self._create_excel_headers(sheet, fields) 328 | self._apply_style_sheet(sheet, column_width) 329 | all_mfa = self.session.query(User).all() 330 | mfa = [] 331 | for user in all_mfa: 332 | mfa_methods = len(user.strongAuthenticationDetail['methods']) 333 | methods = [method['methodType'] for method in user.strongAuthenticationDetail['methods']] 334 | has_app = 'PhoneAppOTP' in methods or 'PhoneAppNotification' in methods 335 | has_phonenr = 'OneWaySms' in methods or 'TwoWayVoiceMobile' in methods 336 | has_fido = 'FIDO' in [key['usage'] for key in user.searchableDeviceKey] 337 | mfa.append({ 338 | 'objectId': user.objectId, 339 | 'displayName': user.displayName, 340 | 'mfamethods': mfa_methods, 341 | 'accountEnabled': user.accountEnabled, 342 | 'has_app': has_app, 343 | 'has_phonenr': has_phonenr, 344 | 'has_fido': has_fido, 345 | 'encryptedPinHash': user.strongAuthenticationDetail['encryptedPinHash'], 346 | 'encryptedPinHashHistory': user.strongAuthenticationDetail['encryptedPinHashHistory'], 347 | 'methods': methods, 348 | 'oathTokenMetadata': user.strongAuthenticationDetail['oathTokenMetadata'], 349 | 'requirements': user.strongAuthenticationDetail['requirements'], 350 | 'phoneAppDetails': user.strongAuthenticationDetail['phoneAppDetails'], 351 | 'proofupTime': user.strongAuthenticationDetail['proofupTime'], 352 | 'verificationDetail': user.strongAuthenticationDetail['verificationDetail'], 353 | 'requirements': user.strongAuthenticationDetail['requirements'] 354 | }) 355 | 356 | self._fill_sheet(sheet, mfa, fields) 357 | 358 | def main(self): 359 | wb = Workbook() 360 | wb.remove(wb.active) 361 | 362 | self.get_users(wb) 363 | self.get_devices(wb) 364 | self.get_groups(wb) 365 | self.get_member_of(wb) 366 | self.get_directory_roles(wb) 367 | self.get_applications(wb) 368 | self.get_service_principals(wb) 369 | self.get_app_roles(wb) 370 | self.get_oauth2_permissions(wb) 371 | self.get_mfa(wb) 372 | wb.save(self.file) 373 | 374 | 375 | def create_args_parser(): 376 | parser = argparse.ArgumentParser( 377 | add_help=True, 378 | description=DESCRIPTION, 379 | formatter_class=argparse.RawDescriptionHelpFormatter 380 | ) 381 | parser.add_argument( 382 | '-d', '--database', 383 | action='store', 384 | help='Database file. Can be the local database name for SQLite, or an SQLAlchemy ' 385 | 'compatible URL such as postgresql+psycopg2://dirkjan@/roadtools', 386 | default='roadrecon.db' 387 | ) 388 | add_args(parser) 389 | return parser 390 | 391 | 392 | def add_args(parser): 393 | parser.add_argument( 394 | '-f', '--file', 395 | action='store', 396 | help='Output excel file (default: data.xlsx)', 397 | default='data.xls' 398 | ) 399 | parser.add_argument( 400 | '-v', '--verbose', 401 | action='store_true', 402 | help='Also print details to the console', 403 | required=False 404 | ) 405 | 406 | 407 | def main(args=None): 408 | if args is None: 409 | parser = create_args_parser() 410 | args = parser.parse_args() 411 | 412 | db_url = database.parse_db_argument(args.database) 413 | session = database.get_session(database.init(dburl=db_url)) 414 | filename, file_extension = os.path.splitext(args.file) 415 | if not file_extension: 416 | file_extension = '.xlsx' 417 | if file_extension not in SUPPORTED_EXTENSIONS: 418 | print("%s is not a supported extention. Only %s are supported" % (file_extension, ', '.join(SUPPORTED_EXTENSIONS))) 419 | return 420 | 421 | plugin = ExportToFilePlugin(session, filename + file_extension, verbose=args.verbose) 422 | plugin.main() 423 | print("Data have been exported to the %s%s file" % (filename, file_extension)) 424 | 425 | if __name__ == '__main__': 426 | main() 427 | -------------------------------------------------------------------------------- /aroadtools/roadrecon/plugins/policies.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Conditional Access Policies parsing plugin 3 | Contributed by Dirk-jan Mollema and Adrien Raulot (Fox-IT) 4 | Uses code from ldapdomaindump under MIT license 5 | 6 | The code here isn't very tidy, don't use it as a perfect example 7 | 8 | Copyright 2020 - MIT License 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. 27 | ''' 28 | import json 29 | import os 30 | import codecs 31 | import argparse 32 | import pprint 33 | import base64 34 | import zlib 35 | from html import escape 36 | from aroadtools.roadlib.database.metadef.database import ServicePrincipal, User, Policy, Application, Group, DirectoryRole 37 | import aroadtools.roadlib.database.metadef.database as database 38 | from aroadtools.roadlib.utils import printhook 39 | 40 | # Required property - plugin description 41 | DESCRIPTION = ''' 42 | Parse Conditional Access policies and export those to a file called caps.html 43 | ''' 44 | 45 | STYLE_CSS = ''' 46 | tbody th { 47 | border: 1px solid #000; 48 | } 49 | tbody td { 50 | border: 1px solid #ababab; 51 | border-spacing: 0px; 52 | padding: 4px; 53 | border-collapse: collapse; 54 | } 55 | body { 56 | font-family: verdana; 57 | } 58 | table { 59 | font-size: 13px; 60 | border-collapse: collapse; 61 | width: 100%; 62 | } 63 | tbody tr:nth-child(odd) td { 64 | background-color: #eee; 65 | } 66 | tbody tr:hover td { 67 | background-color: lightblue; 68 | } 69 | thead td { 70 | font-size: 19px; 71 | font-weight: bold; 72 | padding: 10px 0px; 73 | } 74 | ''' 75 | 76 | class AccessPoliciesPlugin(): 77 | """ 78 | Conditional Access Policies parsing plugin 79 | """ 80 | def __init__(self, session, file, printhook=printhook): 81 | self.session = session 82 | self.file = file 83 | self.print = printhook 84 | 85 | def write_html(self, rel_outfile, body, genfunc=None, genargs=None, closeTable=True): 86 | outfile = os.path.join('.', rel_outfile) 87 | with codecs.open(outfile, 'w', 'utf8') as of: 88 | of.write('\n\n') 89 | #Include the style: 90 | of.write('') 93 | of.write('') 94 | #If the generator is not specified, we should write the HTML blob directly 95 | if genfunc is None: 96 | of.write(body) 97 | else: 98 | for tpart in genfunc(*genargs): 99 | of.write(tpart) 100 | #Does the body contain an open table? 101 | if closeTable: 102 | of.write('') 103 | of.write('') 104 | 105 | def _get_group(self, gid): 106 | if isinstance(gid, list): 107 | return self.session.query(Group).filter(Group.objectId.in_(gid)).all() 108 | return self.session.query(Group).filter(Group.objectId == gid).first() 109 | 110 | def _get_application(self, aid): 111 | if isinstance(aid, list): 112 | res = self.session.query(Application).filter(Application.appId.in_(aid)).all() 113 | # if no result, query the ServicePrincipals 114 | if len(res) != len(aid): 115 | return self.session.query(ServicePrincipal).filter(ServicePrincipal.appId.in_(aid)).all() 116 | else: 117 | return res 118 | else: 119 | res = self.session.query(Application).filter(Application.appId == aid).first() 120 | # if no result, query the ServicePrincipals 121 | if res is None or len(res) == 0: 122 | return self.session.query(ServicePrincipal).filter(ServicePrincipal.appId == aid).first() 123 | 124 | def _get_user(self, uid): 125 | if isinstance(uid, list): 126 | return self.session.query(User).filter(User.objectId.in_(uid)).all() 127 | return self.session.query(User).filter(User.objectId == uid).first() 128 | 129 | def _get_serviceprincipal(self, uid): 130 | if isinstance(uid, list): 131 | return self.session.query(ServicePrincipal).filter(ServicePrincipal.objectId.in_(uid)).all() 132 | return self.session.query(ServicePrincipal).filter(ServicePrincipal.objectId == uid).first() 133 | 134 | def _get_role(self, rid): 135 | if isinstance(rid, list): 136 | return self.session.query(DirectoryRole).filter(DirectoryRole.roleTemplateId.in_(rid)).all() 137 | return self.session.query(DirectoryRole).filter(DirectoryRole.roleTemplateId == rid).first() 138 | 139 | async def _print_object(self, obj): 140 | if obj is None: 141 | return 142 | if isinstance(obj, list): 143 | for objitem in obj: 144 | await self._print_object(objitem) 145 | else: 146 | await self.print('\t ') 147 | await self.print(obj.objectId 148 | + ': ' 149 | + obj.displayName) 150 | try: 151 | await self.print(' (' + obj.appId + ')') 152 | await self.print(' (' + obj.objectType+ ')') 153 | except: 154 | pass 155 | await self.print() 156 | 157 | def _translate_guestsexternal(self, value): 158 | return [value['GuestOrExternalUserTypes'], ] 159 | 160 | def _translate_authstrength(self, authstrengthguid): 161 | built_in = { 162 | '00000000-0000-0000-0000-000000000002': 'Multi-factor authentication', 163 | '00000000-0000-0000-0000-000000000003': 'Passwordless MFA', 164 | '00000000-0000-0000-0000-000000000004': 'Phishing-resistant MFA' 165 | } 166 | try: 167 | return built_in[authstrengthguid] 168 | except KeyError: 169 | return f"Unknown authentication strengh policy: {authstrengthguid} (probably custom)" 170 | 171 | def _parse_ucrit(self, crit): 172 | funct = { 173 | 'Applications' : self._get_application, 174 | 'Users' : self._get_user, 175 | 'Groups' : self._get_group, 176 | 'Roles': self._get_role, 177 | 'ServicePrincipals': self._get_serviceprincipal, 178 | 'GuestsOrExternalUsers': self._translate_guestsexternal 179 | } 180 | ot = '' 181 | for ctype, clist in crit.items(): 182 | if 'All' in clist: 183 | ot += 'All users' 184 | break 185 | if 'None' in clist: 186 | ot += 'Nobody' 187 | break 188 | if 'Guests' in clist: 189 | ot += 'Guest users' 190 | try: 191 | objects = funct[ctype](clist) 192 | except KeyError: 193 | raise Exception('Unsupported criterium type: {0}'.format(ctype)) 194 | if len(objects) > 0: 195 | if ctype == 'Users': 196 | ot += 'Users: ' 197 | ot += ', '.join([escape(uobj.displayName) for uobj in objects]) 198 | elif ctype == 'ServicePrincipals': 199 | ot += 'Service Principals: ' 200 | ot += ', '.join([escape(uobj.displayName) for uobj in objects]) 201 | elif ctype == 'Groups': 202 | ot += 'Users in groups: ' 203 | ot += ', '.join([escape(uobj.displayName) for uobj in objects]) 204 | elif ctype == 'Roles': 205 | ot += 'Users in roles: ' 206 | ot += ', '.join([escape(uobj.displayName) for uobj in objects]) 207 | elif ctype == 'GuestsOrExternalUsers': 208 | ot += 'Guests or external user types: ' 209 | ot += ', '.join([escape(uobj) for uobj in objects]) 210 | else: 211 | raise Exception('Unsupported criterium type: {0}'.format(ctype)) 212 | else: 213 | if not 'Guests' in clist: 214 | ot += 'Unknown object(s) {0}'.format(', '.join(clist)) 215 | print('Warning: Not all object IDs could be resolved for this policy') 216 | return ot 217 | 218 | def _parse_appcrit(self, crit): 219 | ot = '' 220 | for ctype, clist in crit.items(): 221 | if ctype == 'Acrs': 222 | ot += 'Action: ' 223 | ot += ', '.join([escape(action) for action in clist]) 224 | else: 225 | if 'All' in clist: 226 | ot += 'All applications' 227 | break 228 | if 'None' in clist: 229 | ot += 'None' 230 | break 231 | if 'Office365' in clist: 232 | ot += 'All Office 365 applications' 233 | objects = self._get_application(clist) 234 | if objects is not None: 235 | if len(objects) > 0: 236 | if ctype == 'Applications': 237 | ot += 'Applications: ' 238 | ot += ', '.join([escape(uobj.displayName) for uobj in objects]) 239 | return ot 240 | 241 | def _parse_platform(self, cond): 242 | try: 243 | pcond = cond['DevicePlatforms'] 244 | except KeyError: 245 | return '' 246 | ot = 'Including: ' 247 | 248 | for icrit in pcond['Include']: 249 | if 'All' in icrit['DevicePlatforms']: 250 | ot += 'All platforms' 251 | else: 252 | ot += ', '.join(icrit['DevicePlatforms']) 253 | 254 | if 'Exclude' in pcond: 255 | ot += '\n
Excluding: ' 256 | 257 | for icrit in pcond['Exclude']: 258 | ot += ', '.join(icrit['DevicePlatforms']) 259 | return ot 260 | 261 | def _parse_devices(self, cond): 262 | try: 263 | pcond = cond['Devices'] 264 | except KeyError: 265 | return '' 266 | ot = 'Including: ' 267 | 268 | for icrit in pcond['Include']: 269 | if 'DeviceStates' in icrit.keys(): 270 | ot += 'Device states: ' 271 | if 'All' in icrit['DeviceStates']: 272 | ot += 'All' 273 | else: 274 | ot += ' '.join(icrit['DeviceStates']) 275 | if 'DeviceRule' in icrit.keys(): 276 | ot += 'Device rule: ' 277 | if 'All' in icrit['DeviceRule']: 278 | ot += 'All devices' 279 | else: 280 | ot += icrit['DeviceRule'] 281 | 282 | if 'Exclude' in pcond: 283 | ot += '\n
Excluding: ' 284 | 285 | for icrit in pcond['Exclude']: 286 | if 'DeviceStates' in icrit.keys(): 287 | ot += 'Device states: ' 288 | if 'All' in icrit['DeviceStates']: 289 | ot += 'All' 290 | else: 291 | ot += ' '.join(icrit['DeviceStates']) 292 | if 'DeviceRule' in icrit.keys(): 293 | ot += 'Device rule: ' 294 | if 'All' in icrit['DeviceRule']: 295 | ot += 'All devices' 296 | else: 297 | ot += icrit['DeviceRule'] 298 | return ot 299 | 300 | def _parse_locations(self, cond): 301 | try: 302 | lcond = cond['Locations'] 303 | except KeyError: 304 | return '' 305 | ot = 'Including: ' 306 | 307 | for icrit in lcond['Include']: 308 | ot += self._parse_locationcrit(icrit) 309 | 310 | if 'Exclude' in lcond: 311 | ot += '\n
Excluding: ' 312 | 313 | for icrit in lcond['Exclude']: 314 | ot += self._parse_locationcrit(icrit) 315 | return ot 316 | 317 | def _parse_signinrisks(self, cond): 318 | try: 319 | srcond = cond['SignInRisks'] 320 | except KeyError: 321 | return '' 322 | 323 | ot = 'Including: ' 324 | for icrit in srcond['Include']: 325 | ot += ', '.join([escape(crit) for crit in icrit['SignInRisks']]) 326 | 327 | if 'Exclude' in srcond: 328 | ot += '\n
Excluding: ' 329 | for icrit in srcond['Exclude']: 330 | ot += ', '.join([escape(crit) for crit in icrit['SignInRisks']]) 331 | 332 | return ot 333 | 334 | def _parse_locationcrit(self, crit): 335 | ot = '' 336 | for ctype, clist in crit.items(): 337 | if 'AllTrusted' in clist: 338 | ot += 'All trusted locations' 339 | break 340 | if 'All' in clist: 341 | ot += 'All locations' 342 | break 343 | objects = self._translate_locations(clist) 344 | ot += 'Locations: ' 345 | ot += ', '.join([escape(uobj) for uobj in objects]) 346 | return ot 347 | 348 | def _translate_locations(self, locs): 349 | policies = self.session.query(Policy).filter(Policy.policyType == 6).all() 350 | out = [] 351 | # Not sure if there can be multiple 352 | for policy in policies: 353 | for pdetail in policy.policyDetail: 354 | detaildata = json.loads(pdetail) 355 | if 'KnownNetworkPolicies' in detaildata and detaildata['KnownNetworkPolicies']['NetworkId'] in locs: 356 | out.append(detaildata['KnownNetworkPolicies']['NetworkName']) 357 | # New format 358 | for loc in locs: 359 | policies = self.session.query(Policy).filter(Policy.policyType == 6, Policy.policyIdentifier == loc).all() 360 | for policy in policies: 361 | out.append(policy.displayName) 362 | return out 363 | 364 | def _parse_who(self, cond): 365 | ucond = cond['Users'] 366 | ot = 'Including: ' 367 | 368 | if len(ucond['Include']) == 1 and 'Nobody' in self._parse_ucrit(ucond['Include'][0]) and 'ServicePrincipals' in cond: 369 | # Service Principal policy 370 | spcond = cond['ServicePrincipals'] 371 | for icrit in spcond['Include']: 372 | ot += self._parse_ucrit(icrit) 373 | 374 | if 'Exclude' in spcond: 375 | ot += '\n
Excluding: ' 376 | otl = [] 377 | for icrit in spcond['Exclude']: 378 | otl.append(self._parse_ucrit(icrit)) 379 | ot += '
               '.join(otl) 380 | 381 | else: 382 | for icrit in ucond['Include']: 383 | ot += self._parse_ucrit(icrit) 384 | 385 | if 'Exclude' in ucond: 386 | ot += '\n
Excluding: ' 387 | otl = [] 388 | for icrit in ucond['Exclude']: 389 | otl.append(self._parse_ucrit(icrit)) 390 | ot += '
               '.join(otl) 391 | return ot 392 | 393 | def _parse_application(self, cond): 394 | ucond = cond['Applications'] 395 | ot = 'Including: ' 396 | 397 | for icrit in ucond['Include']: 398 | ot += self._parse_appcrit(icrit) 399 | 400 | if 'Exclude' in ucond: 401 | ot += '\n
Excluding: ' 402 | 403 | for icrit in ucond['Exclude']: 404 | ot += self._parse_appcrit(icrit) 405 | return ot 406 | 407 | 408 | def _parse_associated_polcies(self,location_object,is_trusted_location,condition_policy_list): 409 | found_pols = [] 410 | 411 | for pol in condition_policy_list: 412 | if not pol.policyDetail: 413 | continue 414 | parsed = json.loads(pol.policyDetail[0]) 415 | if not parsed.get('Conditions') or not parsed.get('Conditions').get('Locations'): 416 | continue 417 | 418 | cloc = parsed.get('Conditions').get('Locations') 419 | incl = cloc.get('Include') or [] 420 | excl = cloc.get('Exclude') or [] 421 | for i in incl: 422 | if location_object in i.get('Locations') or (is_trusted_location and "AllTrusted" in i.get('Locations')): 423 | found_pols.append(escape(pol.displayName)) 424 | 425 | for i in excl: 426 | if location_object in i.get('Locations') or (is_trusted_location and "AllTrusted" in i.get('Locations')): 427 | found_pols.append(escape(pol.displayName)) 428 | 429 | 430 | 431 | return found_pols 432 | 433 | 434 | def _parse_controls(self, controls): 435 | acontrols = [] 436 | for c in controls: 437 | if 'Control' in c: 438 | acontrols.append(', '.join(c['Control'])) 439 | if 'AuthStrengthIds' in c: 440 | acontrols.append(', '.join([self._translate_authstrength(authstrengthguid) for authstrengthguid in c['AuthStrengthIds']])) 441 | if 'Block' in acontrols: 442 | ot = 'Deny logon' 443 | return ot 444 | if len(controls) > 1: 445 | ot = 'Requirements (all): ' 446 | else: 447 | ot = 'Requirements (any): ' 448 | ot += ', '.join(acontrols) 449 | return ot 450 | 451 | def _parse_clients(self, cond): 452 | if not 'ClientTypes' in cond: 453 | return '' 454 | ucond = cond['ClientTypes'] 455 | ot = 'Including: ' 456 | 457 | for icrit in ucond['Include']: 458 | ot += ', '.join([escape(crit) for crit in icrit['ClientTypes']]) 459 | 460 | if 'Exclude' in ucond: 461 | ot += '\n
Excluding: ' 462 | 463 | for icrit in ucond['Exclude']: 464 | ot += ', '.join([escape(crit) for crit in icrit['ClientTypes']]) 465 | return ot 466 | 467 | def _parse_sessioncontrols(self, cond): 468 | if not 'SessionControls' in cond: 469 | return '' 470 | ucond = cond['SessionControls'] 471 | return ', '.join(ucond) 472 | 473 | 474 | def _parse_compressed_cidr(self,detail): 475 | if not 'CompressedCidrIpRanges' in detail: 476 | return '' 477 | compressed = detail['CompressedCidrIpRanges'] 478 | b = base64.b64decode(compressed) 479 | cstr = zlib.decompress(b, -zlib.MAX_WBITS) 480 | decoded_cidrs = escape(cstr.decode()).split(",") 481 | return decoded_cidrs 482 | 483 | 484 | async def main(self, should_print=False): 485 | pp = pprint.PrettyPrinter(indent=4) 486 | ol = [] 487 | oloc = [] 488 | html = '

Policies

' 489 | condition_policy_list = self.session.query(Policy).filter(Policy.policyType == 18) 490 | for policy in self.session.query(Policy).filter(Policy.policyType == 18).order_by(Policy.displayName): 491 | out = {} 492 | out['name'] = escape(policy.displayName) 493 | if should_print: 494 | await self.print() 495 | await self.print('####################') 496 | await self.print(policy.displayName) 497 | await self.print(policy.objectId) 498 | detail = json.loads(policy.policyDetail[0]) 499 | if detail['State'] == 'Reporting': 500 | out['name'] += ' (Report only)' 501 | elif detail['State'] != 'Enabled': 502 | out['name'] += ' (Disabled)' 503 | if should_print: 504 | await self.print(pp.pformat(detail)) 505 | try: 506 | conditions = detail['Conditions'] 507 | except KeyError: 508 | conditions = None 509 | if conditions is None: 510 | if should_print: 511 | await self.print('Invalid policy - no conditions') 512 | continue 513 | out['who'] = self._parse_who(conditions) 514 | out['applications'] = self._parse_application(conditions) 515 | out['platforms'] = self._parse_platform(conditions) 516 | out['locations'] = self._parse_locations(conditions) 517 | out['clients'] = self._parse_clients(conditions) 518 | out['signinrisks'] = self._parse_signinrisks(conditions) 519 | out['sessioncontrols'] = self._parse_sessioncontrols(detail) 520 | out['devices'] = self._parse_devices(conditions) 521 | 522 | try: 523 | out['controls'] = self._parse_controls(detail['Controls']) 524 | except KeyError: 525 | out['controls'] = '' 526 | ol.append(out) 527 | if should_print: 528 | await self.print('####################') 529 | 530 | 531 | 532 | for policy in self.session.query(Policy).filter(Policy.policyType == 6).order_by(Policy.displayName): 533 | loc = {} 534 | loc['name'] = escape(policy.displayName) 535 | if should_print: 536 | await self.print() 537 | await self.print('####################') 538 | await self.print(policy.displayName) 539 | await self.print(policy.objectId) 540 | detail = None 541 | oldpolicy = False 542 | 543 | for pdetail in policy.policyDetail: 544 | detaildata = json.loads(pdetail) 545 | if 'KnownNetworkPolicies' in detaildata: 546 | detail = detaildata['KnownNetworkPolicies'] 547 | oldpolicy = True 548 | 549 | if not oldpolicy: 550 | # New format 551 | detail = json.loads(policy.policyDetail[0]) 552 | 553 | if should_print: 554 | await self.print(pp.pformat(detail)) 555 | if not detail: 556 | continue 557 | 558 | loc['trusted'] = ("trusted" in detail.get("Categories","") if detail.get("Categories") else False) 559 | loc['appliestounknowncountry'] = escape(str(detail.get("ApplyToUnknownCountry"))) if detail.get("ApplyToUnknownCountry") is not None else False 560 | loc['ipranges'] = "\n
".join(self._parse_compressed_cidr(detail)) 561 | loc['categories'] = escape(", ".join(detail.get("Categories"))) if detail.get("Categories") is not None else "" 562 | loc['associated_policies'] = "\n
".join(self._parse_associated_polcies(policy.policyIdentifier,loc['trusted'],condition_policy_list)) 563 | loc['country_codes'] = escape(", ".join(detail.get("CountryIsoCodes"))) if detail.get("CountryIsoCodes") else None 564 | if should_print: 565 | await self.print(self._parse_compressed_cidr(detail)) 566 | else: 567 | # Old format 568 | if should_print: 569 | await self.print(pp.pformat(detail)) 570 | if not detail: 571 | continue 572 | 573 | loc['name'] = escape(detail.get("NetworkName")) 574 | loc['trusted'] = ("trusted" in detail.get("Categories","") if detail.get("Categories") else False) 575 | loc['appliestounknowncountry'] = escape(str(detail.get("ApplyToUnknownCountry"))) if detail.get("ApplyToUnknownCountry") is not None else False 576 | loc['ipranges'] = "\n
".join(detail.get('CidrIpRanges')) 577 | loc['categories'] = escape(", ".join(detail.get("Categories"))) if detail.get("Categories") is not None else "" 578 | loc['associated_policies'] = "\n
".join(self._parse_associated_polcies(detail.get('NetworkId'),loc['trusted'],condition_policy_list)) 579 | loc['country_codes'] = escape(", ".join(detail.get("CountryIsoCodes"))) if detail.get("CountryIsoCodes") else None 580 | 581 | 582 | oloc.append(loc) 583 | 584 | 585 | 586 | 587 | for out in ol: 588 | table = ''.format(out['name']) 589 | table += ''.format(out['who']) 590 | table += ''.format(out['applications']) 591 | if out['platforms'] != '': 592 | table += ''.format(out['platforms']) 593 | if out['devices'] != '': 594 | table += ''.format(out['devices']) 595 | if out['clients'] != '': 596 | table += ''.format(out['clients']) 597 | if out['locations'] != '': 598 | table += ''.format(out['locations']) 599 | if out['signinrisks'] != '': 600 | table += ''.format(out['signinrisks']) 601 | if out['controls'] != '': 602 | table += ''.format(out['controls']) 603 | if out['sessioncontrols'] != '': 604 | table += ''.format(out['sessioncontrols']) 605 | table += '' 606 | html += table 607 | html += '
{0}
Applies to{0}
Applications{0}
On platforms{0}
Device filter{0}
Using clients{0}
At locations{0}
Sign-in risks{0}
Controls{0}
Session controls{0}
' 608 | if len(oloc) > 0: 609 | html += "

Named Locations

" 610 | for loc in oloc: 611 | table = ''.format(loc['name']) 612 | table += ''.format(str(loc['trusted'])) 613 | table += ''.format(loc['appliestounknowncountry']) 614 | table += ''.format(loc['ipranges']) 615 | if(loc['categories']): 616 | table += ''.format(loc['categories']) 617 | 618 | if(loc['associated_policies']): 619 | table += ''.format(loc['associated_policies']) 620 | 621 | if(loc['country_codes']): 622 | table += ''.format(loc['country_codes']) 623 | 624 | table += "" 625 | 626 | html += table 627 | 628 | html += "
{0}
Trusted{0}
Apply to unknown country{0}
IP ranges{0}
Categories{0}
Associated Policies{0}
Country ISO Codes{0}
" 629 | self.write_html(self.file, html) 630 | await self.print('Results written to {0}'.format(self.file)) 631 | 632 | def add_args(parser): 633 | parser.add_argument('-f', 634 | '--file', 635 | action='store', 636 | help='Output file (default: caps.html)', 637 | default='caps.html') 638 | parser.add_argument('-p', 639 | '--print', 640 | action='store_true', 641 | help='Also print details to the console') 642 | async def amain(args=None): 643 | if args is None: 644 | parser = argparse.ArgumentParser(add_help=True, description='ROADrecon policies to HTML plugin', formatter_class=argparse.RawDescriptionHelpFormatter) 645 | parser.add_argument('-d', 646 | '--database', 647 | action='store', 648 | help='Database file. Can be the local database name for SQLite, or an SQLAlchemy compatible URL such as postgresql+psycopg2://dirkjan@/roadtools', 649 | default='roadrecon.db') 650 | add_args(parser) 651 | args = parser.parse_args() 652 | db_url = database.parse_db_argument(args.database) 653 | session = database.get_session(database.init(dburl=db_url)) 654 | plugin = AccessPoliciesPlugin(session, args.file) 655 | await plugin.main(args.print) 656 | 657 | def main(): 658 | import asyncio 659 | asyncio.run(amain()) 660 | 661 | if __name__ == '__main__': 662 | main() 663 | -------------------------------------------------------------------------------- /aroadtools/roadlib/deviceauth.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import binascii 3 | import struct 4 | import json 5 | import sys 6 | import pprint 7 | import random 8 | import string 9 | import codecs 10 | import jwt 11 | import ssl 12 | import os 13 | import time 14 | import warnings 15 | import datetime 16 | import uuid 17 | from cryptography.hazmat.primitives import serialization, padding, hashes 18 | from cryptography.hazmat.primitives.asymmetric import rsa 19 | from cryptography.hazmat.primitives.asymmetric import padding as apadding 20 | from cryptography.hazmat.primitives.keywrap import aes_key_unwrap 21 | from cryptography.hazmat.primitives.serialization import pkcs12 22 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 23 | from cryptography import x509 24 | from cryptography.x509.oid import NameOID 25 | from cryptography.utils import CryptographyDeprecationWarning 26 | from aroadtools.roadlib.auth import Authentication, get_data, AuthenticationException 27 | import httpx 28 | 29 | warnings.filterwarnings("ignore", category=CryptographyDeprecationWarning) 30 | 31 | 32 | class DeviceAuthentication(): 33 | """ 34 | Device authentication for ROADtools. Handles device registration, 35 | PRT request/renew and token request using WAM emulation. 36 | """ 37 | def __init__(self, httptransport=None, httpauth = None): 38 | self.httptransport = httptransport 39 | self.httpauth = httpauth 40 | # Cryptography certificate object 41 | self.certificate = None 42 | # Cryptography private key object 43 | self.privkey = None 44 | # PEM key data 45 | self.keydata = None 46 | # Separate transport key if applicable 47 | self.transportprivkey = None 48 | self.transportkeydata = None 49 | 50 | # PRT data 51 | self.prt = None 52 | self.session_key = None 53 | 54 | # Windows Hello key - Cryptography private key object 55 | self.hellokey = None 56 | # Hello key as PEM 57 | self.hellokeydata = None 58 | 59 | # Proxies 60 | self.proxies = {} 61 | # Verify TLS certs 62 | self.verify = True 63 | 64 | def loadcert(self, pemfile=None, privkeyfile=None, pfxfile=None, pfxpass=None, pfxbase64=None): 65 | """ 66 | Load a device certificate from disk 67 | """ 68 | if pemfile and privkeyfile: 69 | with open(pemfile, "rb") as certf: 70 | self.certificate = x509.load_pem_x509_certificate(certf.read()) 71 | with open(privkeyfile, "rb") as keyf: 72 | self.transportkeydata = self.keydata = keyf.read() 73 | self.transportprivkey = self.privkey = serialization.load_pem_private_key(self.keydata, password=None) 74 | return True 75 | if pfxfile or pfxbase64: 76 | if pfxfile: 77 | with open(pfxfile, 'rb') as pfxf: 78 | pfxdata = pfxf.read() 79 | if pfxbase64: 80 | pfxdata = base64.b64decode(pfxbase64) 81 | if isinstance(pfxpass, str): 82 | pfxpass = pfxpass.encode() 83 | self.privkey, self.certificate, _ = pkcs12.load_key_and_certificates(pfxdata, pfxpass) 84 | self.transportprivkey = self.privkey 85 | # PyJWT needs the key as PEM data anyway, so encode it 86 | self.transportkeydata = self.keydata = self.privkey.private_bytes( 87 | encoding=serialization.Encoding.PEM, 88 | format=serialization.PrivateFormat.TraditionalOpenSSL, 89 | encryption_algorithm=serialization.NoEncryption(), 90 | ) 91 | return True 92 | print('You must specify either a PEM certificate file and private key file or a pfx file with the device keypair.') 93 | return False 94 | 95 | def loadkey(self, privkeyfile=None, pfxfile=None, pfxpass=None, pfxbase64=None, transport_only=False): 96 | """ 97 | Load private key only (to use as transport key) 98 | Optionally load it as transport key only and not as device key to support separate transport keys 99 | """ 100 | if privkeyfile: 101 | with open(privkeyfile, "rb") as keyf: 102 | if transport_only: 103 | # Only load as transport key 104 | self.transportkeydata = keyf.read() 105 | self.transportprivkey = serialization.load_pem_private_key(self.keydata, password=None) 106 | else: 107 | self.transportkeydata = self.keydata = keyf.read() 108 | self.transportprivkey = self.privkey = serialization.load_pem_private_key(self.keydata, password=None) 109 | return True 110 | if pfxfile or pfxbase64: 111 | if pfxfile: 112 | with open(pfxfile, 'rb') as pfxf: 113 | pfxdata = pfxf.read() 114 | if pfxbase64: 115 | pfxdata = base64.b64decode(pfxbase64) 116 | if isinstance(pfxpass, str): 117 | pfxpass = pfxpass.encode() 118 | # Load cert anyway since it's in the same file 119 | if not transport_only: 120 | self.privkey, self.certificate, _ = pkcs12.load_key_and_certificates(pfxdata, pfxpass) 121 | self.transportprivkey = self.privkey 122 | # PyJWT needs the key as PEM data anyway, so encode it 123 | self.transportkeydata = self.keydata = self.privkey.private_bytes( 124 | encoding=serialization.Encoding.PEM, 125 | format=serialization.PrivateFormat.TraditionalOpenSSL, 126 | encryption_algorithm=serialization.NoEncryption(), 127 | ) 128 | else: 129 | self.transportprivkey, _, _ = pkcs12.load_key_and_certificates(pfxdata, pfxpass) 130 | self.transportkeydata = self.privkey.private_bytes( 131 | encoding=serialization.Encoding.PEM, 132 | format=serialization.PrivateFormat.TraditionalOpenSSL, 133 | encryption_algorithm=serialization.NoEncryption(), 134 | ) 135 | return True 136 | print('You must specify either a private key file or a pfx file with the device keypair.') 137 | return False 138 | 139 | def loadprt(self, prtfile): 140 | """ 141 | Load PRT from disk 142 | """ 143 | if not prtfile: 144 | return False 145 | try: 146 | with codecs.open(prtfile, 'r', 'utf-8') as prtf: 147 | prtdata = json.load(prtf) 148 | self.prt = Authentication.ensure_plain_prt(prtdata['refresh_token']) 149 | self.session_key = Authentication.ensure_binary_sessionkey(prtdata['session_key']) 150 | except FileNotFoundError: 151 | return False 152 | return True 153 | 154 | def setprt(self, prt, sessionkey): 155 | """ 156 | Set PRT parameters in the correct internal format 157 | """ 158 | self.prt = Authentication.ensure_plain_prt(prt) 159 | self.session_key = Authentication.ensure_binary_sessionkey(sessionkey) 160 | 161 | def saveprt(self, prtdata, prtfile): 162 | """ 163 | Save PRT data to file 164 | """ 165 | with codecs.open(prtfile, 'w', 'utf-8') as prtf: 166 | json.dump(prtdata, prtf, sort_keys=True, indent=4) 167 | print(f"Saved PRT to {prtfile}") 168 | 169 | def loadhellokey(self, privkeyfile): 170 | """ 171 | Load Windows Hello key from file 172 | """ 173 | if not privkeyfile: 174 | return False 175 | try: 176 | with open(privkeyfile, "rb") as keyf: 177 | self.hellokeydata = keyf.read() 178 | self.hellokey = serialization.load_pem_private_key(self.hellokeydata, password=None) 179 | except FileNotFoundError: 180 | return False 181 | return True 182 | 183 | def get_privkey_kid(self, key=None): 184 | """ 185 | Get the kid (key ID) for the given key from a file 186 | """ 187 | if not key: 188 | key = self.hellokey 189 | 190 | pubkeycngblob = self.create_pubkey_blob_from_key(key) 191 | digest = hashes.Hash(hashes.SHA256()) 192 | digest.update(pubkeycngblob) 193 | kid = base64.b64encode(digest.finalize()).decode('utf-8') 194 | return kid 195 | 196 | def create_hello_key(self, privout=None): 197 | """ 198 | Create a key for Windows Hello, saving it to a file 199 | """ 200 | if not privout: 201 | privout = 'winhello.key' 202 | 203 | # Generate our key 204 | key = rsa.generate_private_key( 205 | public_exponent=65537, 206 | key_size=2048, 207 | ) 208 | # Write device key to disk 209 | print(f'Saving private key to {privout}') 210 | with open(privout, "wb") as keyf: 211 | keyf.write(key.private_bytes( 212 | encoding=serialization.Encoding.PEM, 213 | format=serialization.PrivateFormat.TraditionalOpenSSL, 214 | encryption_algorithm=serialization.NoEncryption(), 215 | )) 216 | 217 | pubkeycngblob = base64.b64encode(self.create_pubkey_blob_from_key(key)) 218 | return key, pubkeycngblob 219 | 220 | def create_hello_prt_assertion(self, username): 221 | now = int(time.time()) 222 | payload = { 223 | "iss": username, 224 | # Should be tenant ID, but this is not verified 225 | "aud": "common", 226 | "iat": now-3600, 227 | "exp": now+3600, 228 | "scope": "openid aza ugs" 229 | } 230 | headers = { 231 | "kid": self.get_privkey_kid(), 232 | "use": "ngc" 233 | } 234 | reqjwt = jwt.encode(payload, algorithm='RS256', key=self.hellokeydata, headers=headers) 235 | return reqjwt 236 | 237 | async def get_prt_with_hello_key(self, username, assertion=None): 238 | authlib = Authentication() 239 | challenge = await authlib.get_srv_challenge()['Nonce'] 240 | if not assertion: 241 | assertion = self.create_hello_prt_assertion(username) 242 | # Construct 243 | payload = { 244 | "client_id": "38aa3b87-a06d-4817-b275-7a316988d93b", 245 | "request_nonce": challenge, 246 | "scope": "openid aza ugs", 247 | # Not sure if these matter 248 | "group_sids": [], 249 | "win_ver": "10.0.19041.868", 250 | "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", 251 | # Windows includes this, but it is not required or used 252 | # user is instead taken from JWT assertion 253 | "username": username, 254 | "assertion": assertion 255 | } 256 | return await self.request_token_with_devicecert_signed_payload(payload) 257 | 258 | async def register_winhello_key(self, pubkeycngblob, access_token): 259 | headers = { 260 | 'Authorization': f'Bearer {access_token}', 261 | 'Content-Type': 'application/json', 262 | 'User-Agent': 'Dsreg/10.0 (Windows 10.0.19044.1826)', 263 | 'Accept': 'application/json', 264 | } 265 | data = { 266 | "kngc": pubkeycngblob.decode('utf-8') 267 | } 268 | 269 | async with httpx.AsyncClient(transport=self.httptransport, auth = self.httpauth) as session: 270 | res = await session.post('https://enterpriseregistration.windows.net/EnrollmentServer/key/?api-version=1.0', headers=headers, json=data) 271 | resdata = res.json() 272 | 273 | return resdata 274 | 275 | def create_pubkey_blob_from_key(self, key): 276 | """ 277 | Convert a key (or certificate) to RSA key blob 278 | https://docs.microsoft.com/en-us/windows/win32/api/bcrypt/ns-bcrypt-bcrypt_rsakey_blob 279 | """ 280 | pubkey = key.public_key() 281 | pubnumbers = pubkey.public_numbers() 282 | 283 | # From python docs https://docs.python.org/3/library/stdtypes.html#int.to_bytes 284 | exponent_as_bytes = pubnumbers.e.to_bytes((pubnumbers.e.bit_length() + 7) // 8, byteorder='big') 285 | modulus_as_bytes = pubnumbers.n.to_bytes((pubnumbers.n.bit_length() + 7) // 8, byteorder='big') 286 | 287 | header = [ 288 | b'RSA1', 289 | struct.pack('