├── __init__.py ├── core ├── __init__.py ├── __pycache__ │ ├── utils.cpython-312.pyc │ ├── __init__.cpython-312.pyc │ ├── exceptions.cpython-312.pyc │ ├── interfaces.cpython-312.pyc │ └── domain_state.cpython-312.pyc ├── exceptions.py ├── interfaces.py ├── domain_state.py └── utils.py ├── models ├── __init__.py ├── __pycache__ │ ├── user.cpython-312.pyc │ ├── group.cpython-312.pyc │ ├── __init__.cpython-312.pyc │ └── computer.cpython-312.pyc ├── group.py ├── computer.py └── user.py ├── modules ├── __init__.py ├── pre2k │ ├── __init__.py │ ├── __pycache__ │ │ └── module.cpython-312.pyc │ ├── template.md │ └── module.py ├── asreproasting │ ├── __init__.py │ ├── __pycache__ │ │ └── module.cpython-312.pyc │ ├── template.md │ └── module.py ├── kerberoasting │ ├── __init__.py │ ├── __pycache__ │ │ └── module.cpython-312.pyc │ ├── module.py │ └── template.md ├── passwords_reuse │ ├── __init__.py │ ├── __pycache__ │ │ └── module.cpython-312.pyc │ ├── template.md │ └── module.py ├── weak_passwords │ ├── __init__.py │ ├── __pycache__ │ │ └── module.cpython-312.pyc │ ├── template.md │ └── module.py ├── reversible_encryption │ ├── __init__.py │ ├── __pycache__ │ │ └── module.cpython-312.pyc │ ├── template.md │ └── module.py ├── passwords_in_description │ ├── __init__.py │ ├── __pycache__ │ │ └── module.cpython-312.pyc │ ├── template.md │ └── module.py ├── unconstrained_delegation │ ├── __init__.py │ ├── __pycache__ │ │ └── module.cpython-312.pyc │ ├── template.md │ └── module.py └── __pycache__ │ └── __init__.cpython-312.pyc ├── parsers ├── __init__.py ├── __pycache__ │ ├── __init__.cpython-312.pyc │ ├── ldap_parser.cpython-312.pyc │ ├── hashcat_parser.cpython-312.pyc │ └── secrets_parser.cpython-312.pyc ├── hashcat_parser.py ├── secrets_parser.py └── ldap_parser.py ├── module_system ├── __init__.py ├── __pycache__ │ ├── __init__.cpython-312.pyc │ ├── module_loader.cpython-312.pyc │ ├── module_runner.cpython-312.pyc │ └── report_builder.cpython-312.pyc ├── module_runner.py ├── report_builder.py └── module_loader.py ├── requirements.txt ├── .vscode └── launch.json ├── .gitignore ├── README.md ├── main.py └── LICENSE /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /modules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /parsers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /module_system/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /modules/pre2k/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /modules/asreproasting/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /modules/kerberoasting/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /modules/passwords_reuse/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /modules/weak_passwords/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /modules/reversible_encryption/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /modules/passwords_in_description/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /modules/unconstrained_delegation/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | jinja2==3.1.2 2 | pycryptodome==3.20.0 -------------------------------------------------------------------------------- /core/__pycache__/utils.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/core/__pycache__/utils.cpython-312.pyc -------------------------------------------------------------------------------- /models/__pycache__/user.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/models/__pycache__/user.cpython-312.pyc -------------------------------------------------------------------------------- /core/__pycache__/__init__.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/core/__pycache__/__init__.cpython-312.pyc -------------------------------------------------------------------------------- /models/__pycache__/group.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/models/__pycache__/group.cpython-312.pyc -------------------------------------------------------------------------------- /core/__pycache__/exceptions.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/core/__pycache__/exceptions.cpython-312.pyc -------------------------------------------------------------------------------- /core/__pycache__/interfaces.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/core/__pycache__/interfaces.cpython-312.pyc -------------------------------------------------------------------------------- /models/__pycache__/__init__.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/models/__pycache__/__init__.cpython-312.pyc -------------------------------------------------------------------------------- /models/__pycache__/computer.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/models/__pycache__/computer.cpython-312.pyc -------------------------------------------------------------------------------- /modules/__pycache__/__init__.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/modules/__pycache__/__init__.cpython-312.pyc -------------------------------------------------------------------------------- /parsers/__pycache__/__init__.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/parsers/__pycache__/__init__.cpython-312.pyc -------------------------------------------------------------------------------- /core/__pycache__/domain_state.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/core/__pycache__/domain_state.cpython-312.pyc -------------------------------------------------------------------------------- /modules/pre2k/__pycache__/module.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/modules/pre2k/__pycache__/module.cpython-312.pyc -------------------------------------------------------------------------------- /parsers/__pycache__/ldap_parser.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/parsers/__pycache__/ldap_parser.cpython-312.pyc -------------------------------------------------------------------------------- /module_system/__pycache__/__init__.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/module_system/__pycache__/__init__.cpython-312.pyc -------------------------------------------------------------------------------- /parsers/__pycache__/hashcat_parser.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/parsers/__pycache__/hashcat_parser.cpython-312.pyc -------------------------------------------------------------------------------- /parsers/__pycache__/secrets_parser.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/parsers/__pycache__/secrets_parser.cpython-312.pyc -------------------------------------------------------------------------------- /module_system/__pycache__/module_loader.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/module_system/__pycache__/module_loader.cpython-312.pyc -------------------------------------------------------------------------------- /module_system/__pycache__/module_runner.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/module_system/__pycache__/module_runner.cpython-312.pyc -------------------------------------------------------------------------------- /module_system/__pycache__/report_builder.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/module_system/__pycache__/report_builder.cpython-312.pyc -------------------------------------------------------------------------------- /modules/asreproasting/__pycache__/module.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/modules/asreproasting/__pycache__/module.cpython-312.pyc -------------------------------------------------------------------------------- /modules/kerberoasting/__pycache__/module.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/modules/kerberoasting/__pycache__/module.cpython-312.pyc -------------------------------------------------------------------------------- /modules/passwords_reuse/__pycache__/module.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/modules/passwords_reuse/__pycache__/module.cpython-312.pyc -------------------------------------------------------------------------------- /modules/weak_passwords/__pycache__/module.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/modules/weak_passwords/__pycache__/module.cpython-312.pyc -------------------------------------------------------------------------------- /modules/reversible_encryption/__pycache__/module.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/modules/reversible_encryption/__pycache__/module.cpython-312.pyc -------------------------------------------------------------------------------- /modules/passwords_in_description/__pycache__/module.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/modules/passwords_in_description/__pycache__/module.cpython-312.pyc -------------------------------------------------------------------------------- /modules/unconstrained_delegation/__pycache__/module.cpython-312.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PShlyundin/Coverage/HEAD/modules/unconstrained_delegation/__pycache__/module.cpython-312.pyc -------------------------------------------------------------------------------- /models/group.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List 3 | 4 | @dataclass 5 | class Group: 6 | """Group model""" 7 | name: str 8 | sid: str 9 | memberof: List[str] = field(default_factory=list) # List of user SIDs 10 | members: List[str] = field(default_factory=list) 11 | 12 | def __str__(self) -> str: 13 | return f""" 14 | Name: {self.name} 15 | SID: {self.sid} 16 | memberof: {self.memberof} 17 | members: {self.members} 18 | """ -------------------------------------------------------------------------------- /core/exceptions.py: -------------------------------------------------------------------------------- 1 | class ParserError(Exception): 2 | """Base exception for parser errors""" 3 | pass 4 | 5 | class ValidationError(ParserError): 6 | """Exception for validation errors""" 7 | pass 8 | 9 | class ModuleError(Exception): 10 | """Base exception for module errors""" 11 | pass 12 | 13 | class ModuleLoadError(ModuleError): 14 | """Exception for module loading errors""" 15 | pass 16 | 17 | class ModuleExecutionError(ModuleError): 18 | """Exception for module execution errors""" 19 | pass 20 | 21 | class ReportError(Exception): 22 | """Base exception for report errors""" 23 | pass -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Python Debugger: Current File with Arguments", 6 | "type": "debugpy", 7 | "request": "launch", 8 | "program": "${file}", 9 | "console": "integratedTerminal", 10 | "cwd": "${workspaceFolder}", 11 | "env": { 12 | "PYTHONPATH": "${workspaceFolder}" 13 | }, 14 | "args": "--ldd ~/work/ctsg/minzdrav/ldapdomaindump --ntds ~/work/ctsg/minzdrav/DUMP --hashcat ~/work/ctsg/minzdrav/DUMP/brute.out -m weak_passwords" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /module_system/module_runner.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Any 2 | from core.interfaces import IModule 3 | from core.domain_state import DomainState 4 | 5 | class ModuleRunner: 6 | """Module runner""" 7 | def __init__(self, domain_state: DomainState): 8 | self.domain_state = domain_state 9 | 10 | def run_modules(self, modules: List[IModule]) -> List[Dict[str, Any]]: 11 | """Run modules and collect results""" 12 | results = [] 13 | for module in modules: 14 | try: 15 | result = modules[module].run(self.domain_state) 16 | if result: 17 | result['module_name'] = module 18 | results.append(result) 19 | except Exception as e: 20 | import traceback 21 | traceback.print_exc() 22 | print(f"Error running module: {str(e)}") 23 | return results -------------------------------------------------------------------------------- /core/interfaces.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Dict, List 3 | from core.domain_state import DomainState 4 | 5 | class IParser(ABC): 6 | @abstractmethod 7 | def validate_format(self) -> bool: 8 | """Validate the format of the input file""" 9 | pass 10 | 11 | @abstractmethod 12 | def parse(self, domain_state: DomainState) -> None: 13 | """Parse the input file and update domain state""" 14 | pass 15 | 16 | class IModule(ABC): 17 | @abstractmethod 18 | def run(self, domain_state: DomainState) -> None: 19 | """Run the module and update domain state""" 20 | pass 21 | 22 | @property 23 | @abstractmethod 24 | def template_path(self) -> str: 25 | """Path to report template""" 26 | pass 27 | 28 | @classmethod 29 | def module_name(cls) -> str: 30 | """Name of the module""" 31 | return cls.__file__ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Virtual Environment 24 | venv/ 25 | env/ 26 | ENV/ 27 | .env 28 | .venv 29 | 30 | # IDE 31 | .idea/ 32 | .vscode/ 33 | *.swp 34 | *.swo 35 | .DS_Store 36 | 37 | # Project specific 38 | report.md 39 | *.log 40 | *.tmp 41 | *.temp 42 | *.bak 43 | 44 | # Coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Distribution / packaging 57 | .Python 58 | env/ 59 | build/ 60 | develop-eggs/ 61 | dist/ 62 | downloads/ 63 | eggs/ 64 | .eggs/ 65 | lib/ 66 | lib64/ 67 | parts/ 68 | sdist/ 69 | var/ 70 | wheels/ 71 | *.egg-info/ 72 | .installed.cfg 73 | *.egg -------------------------------------------------------------------------------- /module_system/report_builder.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List, Dict, Any 3 | from jinja2 import Environment, FileSystemLoader 4 | 5 | class ReportBuilder: 6 | """Report builder""" 7 | def __init__(self, output_path: str): 8 | self.output_path = output_path 9 | # Get absolute path to modules directory 10 | modules_dir = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'modules')) 11 | self.env = Environment(loader=FileSystemLoader(modules_dir)) 12 | 13 | def build_report(self, results: List[Dict[str, Any]]) -> None: 14 | """Build final report from module results""" 15 | with open(self.output_path, 'w') as f: 16 | for result in results: 17 | if 'template' in result and 'module_name' in result: 18 | # Construct path to module-specific template 19 | template_path = os.path.join(result['module_name'], result['template']) 20 | template = self.env.get_template(template_path) 21 | f.write(template.render(**result)) 22 | f.write('\n\n') -------------------------------------------------------------------------------- /modules/kerberoasting/module.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | from core.interfaces import IModule 3 | from core.domain_state import DomainState 4 | from core.utils import mask_password 5 | 6 | class KerberoastingModule(IModule): 7 | def __init__(self): 8 | self._template_path = "template.md" 9 | 10 | @property 11 | def template_path(self) -> str: 12 | return self._template_path 13 | 14 | def run(self, domain_state: DomainState) -> Dict[str, Any]: 15 | """Run weak passwords check""" 16 | # Categorize users 17 | all_users = [] 18 | 19 | for user in domain_state.users.values(): 20 | if user.has_spn and user.sam_account_name != 'krbtgt': 21 | user_data = { 22 | "username": user.sam_account_name, 23 | "spn": user.spn_list, 24 | "password": mask_password(user.cracked_password), 25 | "is_domain_admin": domain_state.is_domain_admin(user.sam_account_name) 26 | } 27 | all_users.append(user_data) 28 | total_admins_enabled = sum(1 for user in all_users if user["is_domain_admin"]) 29 | 30 | if not all_users: 31 | return {} 32 | return { 33 | "template": self.template_path, 34 | "all_users": all_users, 35 | "total_found": len(all_users), 36 | "total_admins_enabled": total_admins_enabled 37 | } -------------------------------------------------------------------------------- /modules/passwords_reuse/template.md: -------------------------------------------------------------------------------- 1 | # Password Reuse Vulnerability Report 2 | 3 | ## Summary 4 | Found {{ total_found }} instances of password reuse with administrative accounts. 5 | 6 | Password reuse across administrative accounts significantly increases the risk of lateral movement and privilege escalation in the event of a single account compromise. If an attacker gains access to one system or user, reused credentials can enable access to other critical systems without further exploitation. This undermines segmentation and containment strategies, and may lead to full domain compromise, data breaches, and disruption of business operations. 7 | 8 | ## Password Reuse Details 9 | | Admin Account | Reuse Accounts | Password | 10 | |---------------|----------------|----------| 11 | {% for item in password_reuse %}| {{ item.admin_account }} | {% for acc in item.reuse_accounts %}{{ acc }}
{% endfor %} | {{ item.password }} | 12 | {% endfor %} 13 | 14 | ## Recommended Actions 15 | 16 | - Use a tool for password analysis in Active Directory to identify reused passwords among domain accounts (e.g., AD Sonar — [adsonar.ru](https://adsonar.ru/)). 17 | 18 | - Define and enforce a policy that requires the use of unique, strong passwords for all administrative accounts to prevent reuse across systems and services. 19 | 20 | - Implement automatic generation and scheduled rotation of unique, strong local administrator passwords for each computer using Microsoft LAPS ([Local Administrator Password Solution](https://technet.microsoft.com/en-us/mt227395.aspx)). -------------------------------------------------------------------------------- /modules/passwords_in_description/template.md: -------------------------------------------------------------------------------- 1 | # Passwords in Description Vulnerability Report 2 | 3 | ## Summary 4 | Found {{ total_found }} accounts with passwords in their descriptions{% if total_admins_enabled > 0 %}, including {{ total_admins_enabled }} privileged and enabled accounts.{% else %}.{% endif %} 5 | 6 | Storing passwords in the description field of Active Directory accounts exposes sensitive credentials in clear text to any user or process with read access to directory attributes. This significantly increases the risk of credential theft, especially if the affected accounts have administrative privileges or are active. An attacker with basic read access to the domain could easily harvest these passwords, potentially leading to unauthorized access, privilege escalation, and full domain compromise. 7 | 8 | ## Vulnerable Accounts Details 9 | | Account | Status | Admin Rights | Password | Description | 10 | |---------|---------|--------------|----------|----------| 11 | {% for item in passwords_in_description %}| {{ item.account }} | {% if item.is_enabled %}Enabled{% else %}Disabled{% endif %} | {% if item.is_admin %}Yes{% else %}No{% endif %} | {{ item.password }} | {{ item.description }} 12 | {% endfor %} 13 | 14 | ## Recommended Actions 15 | 16 | - Remove any plaintext passwords or other sensitive data from the description fields of all accounts. 17 | 18 | - Review account descriptions across the domain to ensure they do not contain confidential or security-relevant information. 19 | 20 | - Educate administrators and support staff on the risks of storing passwords or sensitive data in non-secure fields such as account descriptions. 21 | 22 | - Implement role-based access controls (RBAC) to restrict read access to account attributes where possible. -------------------------------------------------------------------------------- /modules/asreproasting/template.md: -------------------------------------------------------------------------------- 1 | # AS-REP Roasting Vulnerability Report 2 | 3 | ## Summary 4 | Found {{ total_found }} accounts vulnerable to AS-REP Roasting{% if total_admins_enabled > 0 %}, including {{ total_admins_enabled }} privileged and enabled accounts.{% else %}.{% endif %} 5 | AS-REP Roasting targets user accounts that do not require Kerberos pre-authentication. An attacker can request authentication data (AS-REP) for such accounts without knowing their password and then perform offline brute-force or dictionary attacks to recover the clear-text password. If successful, this may lead to unauthorized access, privilege escalation, and further lateral movement within the network. The risk is especially critical if affected accounts have elevated privileges or are used for service operations. 6 | 7 | ## Vulnerable Users 8 | | Username | Password | Status | 9 | |----------|----------|--------| 10 | {% for user in all_users %}| {{ user.username }} | {{ user.password }} | {{ "Enable" if user.enable else "Disable" }} | 11 | {% endfor %} 12 | 13 | ## Recommended Actions 14 | 15 | - Use a tool for password and account configuration analysis in Active Directory to identify accounts vulnerable to AS-REP Roasting — i.e., those with the "Do not require Kerberos preauthentication" flag enabled (e.g., AD Sonar — [adsonar.ru](https://adsonar.ru/)). 16 | 17 | - Disable the "Do not require Kerberos preauthentication" option (`DONT_REQUIRE_PREAUTH` flag) for all domain accounts, unless explicitly required for operational purposes. Special attention should be paid to accounts with elevated privileges. 18 | 19 | - Where the use of this flag is operationally justified, apply compensating controls such as strong, non-dictionary passwords and close monitoring for abnormal Kerberos authentication requests. -------------------------------------------------------------------------------- /modules/pre2k/template.md: -------------------------------------------------------------------------------- 1 | # Pre-Windows 2000 Compatibility Vulnerability Report 2 | 3 | ## Summary 4 | Found {{ total_found }} users with Pre-2000 compatibility enabled. 5 | 6 | When a computer account is pre-created in Active Directory with the "Pre-Windows 2000" compatibility option enabled, it is assigned a predictable default password based on the computer name (typically the lowercase name without the trailing '$'). An attacker who knows or can guess the computer name may authenticate using this weak password. This can lead to unauthorized domain access, potential privilege escalation, and lateral movement within the network. Such accounts are often overlooked during password audits, increasing the long-term risk of compromise. 7 | 8 | ## Vulnerable Computers 9 | | Computer | Password | Status | 10 | |----------|----------|--------| 11 | {% for computer in all_users %}| {{ computer.username }} | {{ computer.password }} | {{ "Enable" if computer.status else "Disable" }} | 12 | {% endfor %} 13 | 14 | ## Recommended Actions 15 | 16 | - Regularly audit computer accounts in Active Directory and remove those that are unused or were created for legacy systems. 17 | 18 | - Use tools such as [Pre2k](https://github.com/garrettfoster13/pre2k) or [NetExec (nxc)](https://github.com/NetExec-net/nxc) to identify computer accounts with the UserAccountControl value of 4128 (PASSWD_NOTREQD | WORKSTATION_TRUST_ACCOUNT), which indicates pre-created accounts with predictable default passwords. 19 | 20 | - For identified accounts, set unique and strong passwords that are resistant to brute-force attacks. 21 | 22 | - When creating new computer accounts, avoid using the "Assign this computer account as a pre-Windows 2000 computer" option to prevent assigning predictable passwords based on the computer name. 23 | -------------------------------------------------------------------------------- /models/computer.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List, Optional 3 | 4 | @dataclass 5 | class Computer: 6 | """Computer model""" 7 | sam_account_name: Optional[str] = None 8 | distinguished_name: Optional[str] = None 9 | spn_list: List[str] = field(default_factory=list) # List of Service Principal Names 10 | object_sid: Optional[str] = None 11 | nt_hash: Optional[str] = None # Hash from secretsdump 12 | lm_hash: Optional[str] = None # Hash from secretsdump 13 | clear_text_password: Optional[str] = None # Clear text password from secretsdump 14 | cracked_password: Optional[str] = None # Cracked password from hashcat 15 | enabled: bool = True 16 | memberof: List[str] = field(default_factory=list) # List of group SIDs 17 | user_account_control: int = 0 18 | user_with_domain: Optional[str] = None 19 | description: Optional[str] = None 20 | members: List[str] = field(default_factory=list) 21 | @property 22 | def has_spn(self) -> bool: 23 | return len(self.spn_list) > 0 24 | 25 | @property 26 | def password_cracked(self) -> bool: 27 | return self.cracked_password is not None 28 | 29 | def __str__(self) -> str: 30 | return f""" 31 | SamAccountName: {self.sam_account_name} 32 | DistinguishedName: {self.distinguished_name} 33 | SPNs: {self.spn_list} 34 | ObjectSID: {self.object_sid} 35 | Enabled: {self.enabled} 36 | MemberOf: {self.memberof} 37 | UserAccountControl: {self.user_account_control} 38 | Description: {self.description} 39 | Members: {self.members} 40 | NT Hash: {self.nt_hash} 41 | LM Hash: {self.lm_hash} 42 | Clear Text Password: {self.clear_text_password} 43 | Cracked Password: {self.cracked_password} 44 | """ -------------------------------------------------------------------------------- /modules/unconstrained_delegation/template.md: -------------------------------------------------------------------------------- 1 | # Unconstrained Delegation Vulnerability Report 2 | 3 | ## Summary 4 | Found {{ total_found }} accounts with unconstrained delegation enabled. 5 | 6 | Unconstrained delegation allows a system or service to impersonate users and access other services on their behalf without restriction. When enabled, the credentials (including Kerberos Ticket Granting Tickets, or TGTs) of any user who authenticates to the delegated system can be cached and reused. If an attacker compromises such a system, they can extract TGTs from memory and impersonate privileged users — including domain admins — across the domain. This exposes the environment to high-risk attacks such as Golden Ticket forging and full domain compromise. 7 | 8 | ## Vulnerable Accounts Details 9 | | Account | Type | Status | 10 | |---------|------|---------| 11 | {% for item in unconstrained_delegation %}| {{ item.account }} | {{ item.type }} | {% if item.is_enabled %}Enabled{% else %}Disabled{% endif %} | 12 | {% endfor %} 13 | 14 | ## Recommended Actions 15 | 16 | - Identify and review all accounts and computers with unconstrained delegation enabled, especially those with elevated privileges or exposed to user authentication (e.g., domain-joined servers). 17 | 18 | - Disable unconstrained delegation on all accounts and systems unless strictly required for legacy application compatibility. 19 | 20 | - Where delegation is needed, use **constrained delegation** (`"Trust this user for delegation to specified services only"`) or **resource-based constrained delegation (RBCD)** as more secure alternatives. 21 | 22 | - Isolate systems that require delegation into separate, hardened network segments and monitor them closely for unusual authentication behavior. 23 | 24 | - Regularly audit delegation settings via scripts or tools to prevent reintroduction of insecure configurations. 25 | -------------------------------------------------------------------------------- /modules/asreproasting/module.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | from core.interfaces import IModule 3 | from core.domain_state import DomainState 4 | from core.utils import mask_password 5 | 6 | class ASREPRoastingModule(IModule): 7 | def __init__(self): 8 | self._template_path = "template.md" 9 | 10 | @property 11 | def template_path(self) -> str: 12 | return self._template_path 13 | 14 | def run(self, domain_state: DomainState) -> Dict[str, Any]: 15 | """Run AS-REP Roasting check""" 16 | # Список пользователей, уязвимых к AS-REP Roasting 17 | all_users = [] 18 | enabled = [] 19 | disabled = [] 20 | for user in domain_state.users.values(): 21 | # Проверяем флаг DONT_REQUIRE_PREAUTH (0x400000) 22 | if user.user_account_control & 0x400000 and user.enabled: 23 | enabled.append({ 24 | "username": user.sam_account_name, 25 | "password": mask_password(user.cracked_password) if user.cracked_password else "Not cracked", 26 | "enable": user.enabled 27 | } ) 28 | elif user.user_account_control & 0x400000 and not user.enabled: 29 | disabled.append({ 30 | "username": user.sam_account_name, 31 | "password": mask_password(user.cracked_password) if user.cracked_password else "Not cracked", 32 | "enable": user.enabled 33 | }) 34 | 35 | all_users = enabled + disabled 36 | total_admins_enabled = sum(1 for user in all_users if user["enable"]) 37 | 38 | if not all_users: 39 | return {} 40 | return { 41 | "template": self.template_path, 42 | "all_users": all_users, 43 | "total_found": len(all_users), 44 | "total_admins_enabled": total_admins_enabled 45 | } -------------------------------------------------------------------------------- /modules/reversible_encryption/template.md: -------------------------------------------------------------------------------- 1 | # Reversible Encryption Vulnerability Report 2 | 3 | ## Summary 4 | Found {{ total_found }} accounts with reversible encryption enabled{% if total_admins_enabled > 0 %}, including {{ total_admins_enabled }} privileged and enabled accounts.{% else %}.{% endif %} 5 | 6 | When reversible encryption is enabled for an account in Active Directory, the password is stored in a format that can be easily decrypted to plain text by any process or user with the appropriate permissions. This significantly increases the risk of credential exposure through misconfigured access rights, backups, or compromised systems. If such an account has administrative privileges or is used in service integrations, an attacker gaining access to the decrypted password may escalate privileges or move laterally within the domain. Reversible encryption should only be used in rare, justified scenarios, as it weakens the overall security posture of the environment. 7 | 8 | ## Vulnerable Accounts Details 9 | | Account | Type | Status | Admin Rights | Password | 10 | |---------|------|---------|--------------|----------| 11 | {% for item in reversible_encryption %}| {{ item.account }} | {{ item.type }} | {% if item.is_enabled %}Enabled{% else %}Disabled{% endif %} | {% if item.is_admin %}Yes{% else %}No{% endif %} | {{ item.password }} | 12 | {% endfor %} 13 | 14 | ## Recommended Actions 15 | 16 | - Disable reversible password encryption for all user accounts, unless explicitly required for a specific application or authentication mechanism. 17 | 18 | - Review domain and local password policies to ensure that reversible encryption is not enabled by default. 19 | 20 | - Educate administrators on the risks of reversible encryption and establish guidelines for secure password storage practices. 21 | 22 | - Monitor Group Policy Objects (GPO) and account creation processes to prevent unintended re-enablement of reversible encryption settings. -------------------------------------------------------------------------------- /models/user.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import List, Optional 3 | 4 | @dataclass 5 | class User: 6 | """User model""" 7 | sam_account_name: Optional[str] = None 8 | distinguished_name: Optional[str] = None 9 | spn_list: List[str] = field(default_factory=list) # List of Service Principal Names 10 | object_sid: Optional[str] = None 11 | nt_hash: Optional[str] = None # Hash from secretsdump 12 | lm_hash: Optional[str] = None # Hash from secretsdump 13 | clear_text_password: Optional[str] = None # Clear text password from secretsdump 14 | cracked_password: Optional[str] = None # Cracked password from hashcat 15 | enabled: bool = True 16 | memberof: List[str] = field(default_factory=list) # List of group SIDs 17 | user_with_domain: Optional[str] = None 18 | user_account_control: int = 0 19 | description: Optional[str] = None 20 | members: List[str] = field(default_factory=list) 21 | 22 | @property 23 | def has_spn(self) -> bool: 24 | return len(self.spn_list) > 0 25 | 26 | @property 27 | def password_cracked(self) -> bool: 28 | return self.cracked_password is not None 29 | 30 | @property 31 | def is_enabled(self) -> bool: 32 | return self.enabled 33 | 34 | def __str__(self) -> str: 35 | return f""" 36 | SamAccountName: {self.sam_account_name} 37 | DistinguishedName: {self.distinguished_name} 38 | SPNs: {self.spn_list} 39 | ObjectSID: {self.object_sid} 40 | Enabled: {self.enabled} 41 | MemberOf: {self.memberof} 42 | UserAccountControl: {self.user_account_control} 43 | ClearTextPassword: {self.clear_text_password} 44 | LMHash: {self.lm_hash} 45 | NTHash: {self.nt_hash} 46 | CrackedPassword: {self.cracked_password} 47 | Description: {self.description} 48 | Members: {self.members} 49 | """ -------------------------------------------------------------------------------- /modules/unconstrained_delegation/module.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Any 2 | from core.domain_state import DomainState 3 | from core.interfaces import IModule 4 | 5 | class UnconstrainedDelegationModule(IModule): 6 | def __init__(self): 7 | self._template_path = "template.md" 8 | 9 | @property 10 | def template_path(self) -> str: 11 | return self._template_path 12 | 13 | def run(self, domain_state: DomainState) -> Dict[str, Any]: 14 | """ 15 | Check for accounts with unconstrained delegation enabled 16 | """ 17 | findings = [] 18 | 19 | # Process users 20 | for user in domain_state.users.values(): 21 | if user.user_account_control & 0x80000: # TRUSTED_FOR_DELEGATION flag 22 | findings.append({ 23 | "account": user.sam_account_name, 24 | "type": "USER", 25 | "is_enabled": user.enabled 26 | }) 27 | 28 | # Process computers 29 | for computer in domain_state.computers.values(): 30 | if computer.user_account_control & 0x80000: # TRUSTED_FOR_DELEGATION flag 31 | findings.append({ 32 | "account": computer.sam_account_name, 33 | "type": "COMPUTER", 34 | "is_enabled": computer.enabled 35 | }) 36 | 37 | # Sort findings: admins first, then enabled accounts, then disabled 38 | findings.sort(key=lambda x: ( 39 | not x["is_enabled"], # True sorts after False, so we negate 40 | x["account"] # Secondary sort by account name 41 | )) 42 | 43 | if not findings: 44 | return {} 45 | 46 | return { 47 | "template": self.template_path, 48 | "unconstrained_delegation": findings, 49 | "total_found": len(findings) 50 | } -------------------------------------------------------------------------------- /modules/pre2k/module.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | from core.interfaces import IModule 3 | from core.domain_state import DomainState 4 | from core.utils import mask_password 5 | from Crypto.Hash import MD4 6 | import binascii 7 | 8 | class Pre2kModule(IModule): 9 | def __init__(self): 10 | self._template_path = "template.md" 11 | 12 | @property 13 | def template_path(self) -> str: 14 | return self._template_path 15 | 16 | def get_hash(self, password: str) -> str: 17 | """Calculate MD4 hash of password""" 18 | md4 = MD4.new() 19 | md4.update(password.encode("utf-16le")) 20 | return md4.hexdigest() 21 | 22 | def run(self, domain_state: DomainState) -> Dict[str, Any]: 23 | """Run Pre-2000 compatibility check""" 24 | # List of users with Pre-2000 compatibility 25 | all_users = [] 26 | enabled = [] 27 | disabled = [] 28 | for comp_name, computer in domain_state.computers.items(): 29 | if self.get_hash(computer.sam_account_name.replace("$", "").lower()) == computer.nt_hash: 30 | if computer.enabled: 31 | enabled.append({ 32 | "username": computer.sam_account_name, 33 | "password": computer.sam_account_name.replace("$", ""), 34 | "status": computer.enabled 35 | }) 36 | else: 37 | disabled.append({ 38 | "username": computer.sam_account_name, 39 | "password": computer.sam_account_name.replace("$", ""), 40 | "status": computer.enabled 41 | }) 42 | all_users = enabled + disabled 43 | 44 | if not all_users: 45 | return {} 46 | return { 47 | "template": self.template_path, 48 | "all_users": all_users, 49 | "total_found": len(all_users) 50 | } -------------------------------------------------------------------------------- /modules/weak_passwords/template.md: -------------------------------------------------------------------------------- 1 | # Weak Passwords Vulnerability Report 2 | 3 | ## Summary 4 | Found {{ total_found }} users with weak passwords{% if total_admins_enabled > 0 %}, including {{ total_admins_enabled }} privileged and enabled accounts.{% else %}.{% endif %} As a result, passwords were recovered for {{ '%0.2f' | format((total_found/total_domain_users)*100) }}% of users. 5 | 6 | Weak passwords significantly increase the risk of unauthorized access to critical systems and sensitive data. If an attacker successfully compromises an account — especially one with elevated privileges — this can lead to full domain compromise, lateral movement across the network, data breaches, and disruption of business operations. The presence of active domain accounts with weak or compromised passwords presents a critical vulnerability in the organization's security posture. 7 | 8 | ## Users with Weak Passwords 9 | | Username | Password | Domain Admin | Status | 10 | |----------|----------|--------------|--------| 11 | {% for user in weak_password_users %}| {{ user.username }} | {{ user.password }} | {{ "Yes" if user.is_domain_admin else "No" }} | {{ "Active" if user.enabled else "Disabled" }} | 12 | {% endfor %} 13 | 14 | ## Recommended Actions 15 | 16 | - Use a tool for password analysis in Active Directory to identify weak, common, or compromised passwords among domain accounts (e.g., AD Sonar — [adsonar.ru](https://adsonar.ru/)). 17 | 18 | - Define and enforce a password policy that requires a minimum password length of 12 characters, including numbers, uppercase and lowercase letters, and special characters. Domain accounts should be locked indefinitely after 5 failed login attempts, with manual unlocking required. This policy should be implemented through Group Policy Objects (GPO) or equivalent mechanisms. 19 | 20 | - Avoid using common (dictionary-based) or easily guessable passwords. When developing a password policy, include examples of known weak password types. 21 | 22 | - Use monitoring tools to detect online password brute-force attacks. -------------------------------------------------------------------------------- /module_system/module_loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import importlib.util 3 | from typing import List 4 | from core.interfaces import IModule 5 | 6 | class ModuleLoader: 7 | """Module loader""" 8 | def __init__(self, modules_dir: str): 9 | self.modules_dir = modules_dir 10 | 11 | def load_modules(self) -> List[IModule]: 12 | """Load all modules from directory""" 13 | modules = {} 14 | for module_name in os.listdir(self.modules_dir): 15 | module_path = os.path.join(self.modules_dir, module_name) 16 | if os.path.isdir(module_path): 17 | module = self._load_module(module_path) 18 | if module: 19 | modules[module_path.split("/")[-1]] = module 20 | return modules 21 | 22 | def load_specific_modules(self, module_names: List[str]) -> List[IModule]: 23 | """Load specific modules by name""" 24 | modules = {} 25 | for module_name in module_names: 26 | module_path = os.path.join(self.modules_dir, module_name) 27 | if os.path.isdir(module_path): 28 | module = self._load_module(module_path) 29 | if module: 30 | modules[module_path.split("/")[-1]] = module 31 | return modules 32 | 33 | def _load_module(self, module_path: str) -> IModule: 34 | """Load single module from path""" 35 | try: 36 | module_file = os.path.join(module_path, "module.py") 37 | if not os.path.exists(module_file): 38 | return None 39 | 40 | spec = importlib.util.spec_from_file_location("module", module_file) 41 | if not spec or not spec.loader: 42 | return None 43 | 44 | module = importlib.util.module_from_spec(spec) 45 | spec.loader.exec_module(module) 46 | 47 | # Find module class that implements IModule 48 | for attr_name in dir(module): 49 | attr = getattr(module, attr_name) 50 | if isinstance(attr, type) and issubclass(attr, IModule) and attr != IModule: 51 | return attr() 52 | 53 | return None 54 | except Exception as e: 55 | print(f"Error loading module {module_path}: {str(e)}") 56 | return None -------------------------------------------------------------------------------- /modules/kerberoasting/template.md: -------------------------------------------------------------------------------- 1 | # Kerberoasting Vulnerability Report 2 | 3 | ## Summary 4 | Found {{ total_found }} service accounts with cracked SPN passwords{% if total_admins_enabled > 0 %}, including {{ total_admins_enabled }} privileged and enabled accounts.{% else %}.{% endif %} 5 | Kerberoasting attacks target service accounts with registered SPNs by requesting their Kerberos service tickets and attempting to crack them offline. If successful, the attacker obtains the clear-text password of the associated service account. These accounts often have elevated privileges or broad access within the domain. As a result, Kerberoasting can lead to privilege escalation, unauthorized access to critical systems, and facilitate further lateral movement across the network, potentially compromising the entire domain. 6 | 7 | ## Vulnerable Service Accounts 8 | | Username | SPN | Password | Domain Admin | 9 | |----------|-----|----------|--------------| 10 | {% for user in all_users %}| {{ user.username }} | {% for spn in user.spn %}{{ spn }} {% endfor %} | {{ user.password }} | {{ "Yes" if user.is_domain_admin else "No" }} | 11 | {% endfor %} 12 | 13 | ## Recommended Actions 14 | 15 | - Use a tool for password analysis in Active Directory to identify weak or easily crackable passwords among accounts with registered SPNs (e.g., AD Sonar — [adsonar.ru](https://adsonar.ru/)). 16 | 17 | - Use only non-privileged accounts to run services whenever possible. Service accounts should have the minimum necessary permissions required to function. 18 | 19 | - Replace traditional service accounts with Group Managed Service Accounts (gMSA), which provide automatic password management and eliminate the need for manually set, potentially weak or reused passwords ([Learn more about gMSA](https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/group-managed-service-accounts/group-managed-service-accounts/group-managed-service-accounts-overview)). 20 | 21 | - Regularly audit Active Directory for accounts with SPNs and identify those with elevated privileges. Eliminate unnecessary privileges or unused SPNs. 22 | 23 | - Implement strict password policies for all accounts with SPNs, ensuring strong, complex, and regularly rotated passwords. 24 | 25 | - Monitor for abnormal Kerberos ticket requests and service ticket activity to detect signs of Kerberoasting attempts. -------------------------------------------------------------------------------- /modules/reversible_encryption/module.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Any 2 | from core.domain_state import DomainState 3 | from core.utils import mask_password 4 | from core.interfaces import IModule 5 | 6 | class ReversibleEncryptionModule(IModule): 7 | def __init__(self): 8 | self._template_path = "template.md" 9 | 10 | @property 11 | def template_path(self) -> str: 12 | return self._template_path 13 | 14 | def run(self, domain_state: DomainState) -> Dict[str, Any]: 15 | """ 16 | Check for accounts with reversible encryption enabled 17 | """ 18 | findings = [] 19 | 20 | # Process users 21 | for user in domain_state.users.values(): 22 | if user.clear_text_password: 23 | findings.append({ 24 | "account": user.sam_account_name, 25 | "password": mask_password(user.clear_text_password), 26 | "is_admin": domain_state.is_domain_admin(user.object_sid), 27 | "is_enabled": user.enabled, 28 | "type": "USER" 29 | }) 30 | 31 | # Process computers 32 | for computer in domain_state.computers.values(): 33 | if computer.clear_text_password: 34 | findings.append({ 35 | "account": computer.sam_account_name, 36 | "password": mask_password(computer.clear_text_password), 37 | "is_admin": domain_state.is_domain_admin(computer.object_sid), 38 | "is_enabled": computer.enabled, 39 | "type": "COMPUTER" 40 | }) 41 | 42 | # Sort findings: admins first, then enabled accounts, then disabled 43 | findings.sort(key=lambda x: ( 44 | not x["is_admin"], # True sorts after False, so we negate 45 | not x["is_enabled"], # True sorts after False, so we negate 46 | x["account"] # Secondary sort by account name 47 | )) 48 | total_admins_enabled = sum(1 for user in findings if user["is_admin"]) 49 | 50 | if not findings: 51 | return {} 52 | 53 | return { 54 | "template": self.template_path, 55 | "reversible_encryption": findings, 56 | "total_found": len(findings), 57 | "total_admins_enabled": total_admins_enabled 58 | } -------------------------------------------------------------------------------- /parsers/hashcat_parser.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, List, Tuple, Optional 2 | from dataclasses import dataclass 3 | from core.interfaces import IParser 4 | from core.domain_state import DomainState 5 | from core.exceptions import ValidationError 6 | 7 | @dataclass 8 | class HashcatEntry: 9 | """Data class for Hashcat entries""" 10 | nt_hash: str 11 | password: str 12 | 13 | class HashcatParser(IParser): 14 | """Parser for Hashcat output files""" 15 | def __init__(self, output_path: str): 16 | self.output_path = output_path 17 | self.data: List[HashcatEntry] = [] 18 | 19 | def load_data(self) -> None: 20 | """Load Hashcat data from file""" 21 | try: 22 | with open(self.output_path, 'r') as f: 23 | for line in f: 24 | entry = self._parse_line(line) 25 | if entry: 26 | self.data.append(entry) 27 | except Exception as e: 28 | raise ValidationError(f"Failed to load Hashcat data: {str(e)}") 29 | 30 | def _parse_line(self, line: str) -> Optional[HashcatEntry]: 31 | """Parse single line from Hashcat output 32 | 33 | Expected format: hash:password 34 | Example: 31d6cfe0d16ae931b73c59d7e0c089c0:password123 35 | Example with colon in password: 31d6cfe0d16ae931b73c59d7e0c089c0:P@ssw0rd12:345 36 | """ 37 | try: 38 | line = line.strip() 39 | if not line: 40 | return None 41 | 42 | # Split by first colon only to handle passwords with colons 43 | parts = line.split(':', 1) 44 | if len(parts) != 2: 45 | return None 46 | 47 | nt_hash, password = parts 48 | if len(nt_hash) != 32: 49 | return None 50 | 51 | return HashcatEntry(nt_hash=nt_hash, password=password) 52 | except Exception: 53 | return None 54 | 55 | def validate_format(self) -> bool: 56 | """Validate Hashcat output format""" 57 | try: 58 | self.load_data() 59 | if not self.data: 60 | print("Warning: No valid entries found in Hashcat output") 61 | return False 62 | 63 | return True 64 | except Exception as e: 65 | print(f"Invalid Hashcat output format: {str(e)}") 66 | return False 67 | 68 | def parse(self, domain_state: DomainState) -> None: 69 | """Parse Hashcat output and update domain state""" 70 | for entry in self.data: 71 | try: 72 | domain_state.update_user_password(entry.nt_hash, entry.password) 73 | except Exception as e: 74 | print(f"Error updating password for hash {entry.nt_hash}: {str(e)}") -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Domain Coverage Analysis Tool 2 | 3 | Tool for analyzing domain security based on various data sources: 4 | - LDAP domain dump 5 | - NTDS.dit dump 6 | - Hashcat output 7 | 8 | ## Installation 9 | 10 | ### Using UV (Recommended) 11 | ```bash 12 | uv venv 13 | uv pip install -r requirements.txt 14 | uv run main.py -h 15 | ``` 16 | 17 | ### Old method 18 | ```bash 19 | pip install -r requirements.txt 20 | ``` 21 | 22 | ## Preparation 23 | 24 | To run the script, you need to have the output of ldapdomaindump, secretsdump and the result of a brute-force attack on the obtained *.ntds file 25 | 26 | ```bash 27 | mkdir ldapdomaindump && cd ldapdomaindump 28 | ldapdomaindump -u vulnad.local\\Administrator -p "1qaz@WSX" 10.10.10.10 29 | 30 | cd .. && mkdir DUMP 31 | secretsdump.py vulnad.local/Administrator:1qaz@WSX@10.10.10.10 -outputfile DUMP/DUMP 32 | 33 | hashcat -m 1000 DUMP/DUMP.ntds -o DUMP/DUMP.ntds.out /usr/share/wordlists/rockyou.txt 34 | ``` 35 | 36 | ## Usage 37 | 38 | ```bash 39 | usage: main.py [-h] [-l] [--ldd LDD] [--ntds NTDS] [--hashcat HASHCAT] [-o OUTPUT] [-m MODULES] [-v] 40 | 41 | Domain Coverage Analysis Tool 42 | 43 | options: 44 | -h, --help show this help message and exit 45 | -l, --list-modules List available modules 46 | --ldd LDD Path to LDAP domain dump (JSON file, directory with JSON files, or ZIP archive) 47 | --ntds NTDS Path to NTDS.dit dump 48 | --hashcat HASHCAT Path to Hashcat output 49 | -o OUTPUT, --output OUTPUT 50 | Path to output report file 51 | -m MODULES, --modules MODULES 52 | Comma-separated list of modules to run (default: all) 53 | -v, --verbose Enable verbose output 54 | ``` 55 | 56 | ```bash 57 | uv run main.py --ldd --ntds --hashcat --output 58 | ``` 59 | 60 | ### List modules 61 | ```bash 62 | uv run main.py -l 63 | Available modules: 64 | - reversible_encryption 65 | - passwords_reuse 66 | - weak_passwords 67 | - passwords_in_description 68 | - kerberoasting 69 | - pre2k 70 | - asreproasting 71 | - unconstrained_delegation 72 | ``` 73 | 74 | ### Analysis using 3 modules: 75 | ```bash 76 | uv run main.py --ldd ldapdomaindump --ntds DUMP --hashcat DUMP/DUMP.ntds.out -m passwords_reuse,weak_passwords,passwords_in_description 77 | Parsing LDAP data... 78 | Parsing NTDS data... 79 | Parsing Hashcat output... 80 | Loaded 3 modules 81 | Running modules... 82 | Building report to report.md... 83 | Done! 84 | ``` 85 | 86 | ## Module Development 87 | 88 | To create a new module: 89 | 90 | 1. Create a new directory in `modules/` 91 | 2. Create `module.py` implementing `IModule` interface 92 | 3. Create `template.md` with Jinja2 template for report 93 | 94 | Example module structure: 95 | ```bash 96 | modules/my_module/ 97 | ├── module.py 98 | └── template.md 99 | ``` -------------------------------------------------------------------------------- /modules/passwords_reuse/module.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, List, Set 2 | from core.interfaces import IModule 3 | from core.domain_state import DomainState 4 | from core.utils import mask_password 5 | 6 | class PasswordsReuseModule(IModule): 7 | def __init__(self): 8 | self._template_path = "template.md" 9 | 10 | @property 11 | def template_path(self) -> str: 12 | return self._template_path 13 | 14 | def run(self, domain_state: DomainState) -> Dict[str, Any]: 15 | """Run password reuse check with admin accounts""" 16 | # Dictionary to store hashes and corresponding accounts 17 | hash_to_accounts: Dict[str, List[str]] = {} 18 | 19 | # Collect all accounts with hashes 20 | for user in domain_state.users.values(): 21 | if user.nt_hash: 22 | if user.nt_hash not in hash_to_accounts: 23 | hash_to_accounts[user.nt_hash] = [] 24 | hash_to_accounts[user.nt_hash].append(user.sam_account_name) 25 | 26 | # List to store results 27 | password_reuse = [] 28 | 29 | # Check each hash 30 | for nt_hash, accounts in hash_to_accounts.items(): 31 | # If there is more than one account with this hash 32 | if len(accounts) > 1: 33 | # Get password for display 34 | first_user = domain_state.find_by_sam_account_name(accounts[0]) 35 | password = mask_password(first_user.cracked_password) if first_user and first_user.cracked_password else "Not cracked" 36 | 37 | # Check if there are administrative accounts among them 38 | admin_accounts = [acc for acc in accounts if domain_state.is_domain_admin(acc)] 39 | 40 | # If there is at least one admin account 41 | if admin_accounts: 42 | for admin_acc in admin_accounts: 43 | # Collect all accounts with the same hash, except current admin 44 | reuse_accounts = [acc for acc in accounts if acc != admin_acc] 45 | 46 | password_reuse.append({ 47 | "admin_account": admin_acc, 48 | "reuse_accounts": reuse_accounts, 49 | "password": password, 50 | "is_cracked": first_user and first_user.cracked_password is not None 51 | }) 52 | 53 | # Sort results: first cracked passwords, then others 54 | password_reuse.sort(key=lambda x: (not x["is_cracked"], x["admin_account"])) 55 | 56 | if not password_reuse: 57 | return {} 58 | return { 59 | "template": self.template_path, 60 | "password_reuse": password_reuse, 61 | "total_found": len(password_reuse) 62 | } -------------------------------------------------------------------------------- /modules/weak_passwords/module.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | from core.interfaces import IModule 3 | from core.domain_state import DomainState 4 | from core.utils import mask_password 5 | 6 | class WeakPasswordsModule(IModule): 7 | def __init__(self): 8 | self._template_path = "template.md" 9 | 10 | @property 11 | def template_path(self) -> str: 12 | return self._template_path 13 | 14 | def run(self, domain_state: DomainState) -> Dict[str, Any]: 15 | """Run weak passwords check""" 16 | weak_password_users = [] 17 | 18 | admin_users = [] 19 | for user in domain_state.users.values(): 20 | if user.password_cracked and domain_state.is_domain_admin(user.sam_account_name): 21 | admin_users.append({ 22 | "username": user.sam_account_name, 23 | "password": mask_password(user.cracked_password), 24 | "is_domain_admin": domain_state.is_domain_admin(user.sam_account_name), 25 | "enabled": user.enabled 26 | }) 27 | weak_password_users = admin_users 28 | enabled_users = [] 29 | for user in domain_state.users.values(): 30 | if user.password_cracked and user.enabled and not user.sam_account_name in [e['username'] for e in weak_password_users]: 31 | enabled_users.append({ 32 | "username": user.sam_account_name, 33 | "password": mask_password(user.cracked_password), 34 | "is_domain_admin": domain_state.is_domain_admin(user.sam_account_name), 35 | "enabled": user.enabled 36 | }) 37 | weak_password_users = weak_password_users + enabled_users 38 | other_users = [] 39 | for user in domain_state.users.values(): 40 | if user.password_cracked and not user.enabled and not user.sam_account_name in [e['username'] for e in weak_password_users]: 41 | other_users.append({ 42 | "username": user.sam_account_name, 43 | "password": mask_password(user.cracked_password), 44 | "is_domain_admin": domain_state.is_domain_admin(user.sam_account_name), 45 | "enabled": user.enabled 46 | }) 47 | weak_password_users = weak_password_users + other_users 48 | total_admins_enabled = len([e for e in weak_password_users if e['is_domain_admin'] and e['enabled']]) 49 | total_domain_users = len(domain_state.users) 50 | if not weak_password_users: 51 | return {} 52 | 53 | return { 54 | "template": self.template_path, 55 | "weak_password_users": weak_password_users, 56 | "total_found": len(weak_password_users), 57 | "total_admins_enabled": total_admins_enabled, 58 | "total_domain_users": total_domain_users 59 | } -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import os 4 | import sys 5 | from typing import List, Optional 6 | 7 | from core.domain_state import DomainState 8 | from parsers.ldap_parser import LdapParser 9 | from parsers.secrets_parser import SecretsParser 10 | from parsers.hashcat_parser import HashcatParser 11 | from module_system.module_loader import ModuleLoader 12 | from module_system.module_runner import ModuleRunner 13 | from module_system.report_builder import ReportBuilder 14 | from core.utils import list_modules, find_ldap_json, find_ntds_dit 15 | 16 | def parse_arguments(): 17 | """Parse command line arguments""" 18 | parser = argparse.ArgumentParser(description='Domain Coverage Analysis Tool') 19 | 20 | # Add list-modules flag 21 | parser.add_argument('-l', '--list-modules', action='store_true', help='List available modules') 22 | 23 | # Add required arguments 24 | parser.add_argument('--ldd', help='Path to LDAP domain dump (JSON file, directory with JSON files, or ZIP archive)') 25 | parser.add_argument('--ntds', help='Path to NTDS.dit dump') 26 | parser.add_argument('--hashcat', help='Path to Hashcat output') 27 | 28 | # Optional arguments 29 | parser.add_argument('-o', '--output', help='Path to output report file') 30 | parser.add_argument('-m', '--modules', help='Comma-separated list of modules to run (default: all)') 31 | parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output') 32 | 33 | args = parser.parse_args() 34 | 35 | # If not listing modules, ensure required arguments are provided 36 | if not args.list_modules: 37 | if not all([args.ldd]): 38 | parser.error("--ldd, --ntds, and --hashcat are required") 39 | 40 | return args 41 | 42 | def setup_logging(debug: bool): 43 | """Setup logging based on debug flag""" 44 | import logging 45 | level = logging.DEBUG if debug else logging.INFO 46 | logging.basicConfig( 47 | level=level, 48 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 49 | ) 50 | 51 | def main(): 52 | """Main entry point""" 53 | args = parse_arguments() 54 | if args.list_modules: 55 | list_modules() 56 | return 57 | 58 | setup_logging(args.verbose) 59 | 60 | # Initialize domain state 61 | domain_state = DomainState() 62 | 63 | # Parse LDAP data 64 | ldap_files = find_ldap_json(args.ldd) 65 | if not ldap_files: 66 | print(f"Error: Could not find any LDAP JSON files in {args.ldd}") 67 | sys.exit(1) 68 | 69 | ldap_parser = LdapParser(ldap_files) 70 | if not ldap_parser.validate_format(): 71 | print("Error: Invalid LDAP data format") 72 | sys.exit(1) 73 | 74 | print("Parsing LDAP data...") 75 | ldap_parser.parse(domain_state) 76 | 77 | # Parse NTDS data 78 | ntds_files = find_ntds_dit(args.ntds) 79 | secrets_parser = SecretsParser(ntds_files) 80 | if not secrets_parser.validate_format(): 81 | print("Error: Invalid NTDS data format") 82 | sys.exit(1) 83 | 84 | print("Parsing NTDS data...") 85 | secrets_parser.parse(domain_state) 86 | 87 | # Parse Hashcat output 88 | hashcat_parser = HashcatParser(args.hashcat) 89 | if not hashcat_parser.validate_format(): 90 | print("Error: Invalid Hashcat output format") 91 | sys.exit(1) 92 | 93 | print("Parsing Hashcat output...") 94 | hashcat_parser.parse(domain_state) 95 | 96 | # Load modules 97 | module_loader = ModuleLoader(os.path.join(os.path.dirname(__file__), 'modules')) 98 | 99 | if args.modules: 100 | module_names = [name.strip() for name in args.modules.split(',')] 101 | modules = module_loader.load_specific_modules(module_names) 102 | else: 103 | modules = module_loader.load_modules() 104 | 105 | if not modules: 106 | print("Error: No modules found") 107 | sys.exit(1) 108 | 109 | print(f"Loaded {len(modules)} modules") 110 | 111 | # Run modules 112 | module_runner = ModuleRunner(domain_state) 113 | print("Running modules...") 114 | results = module_runner.run_modules(modules) 115 | 116 | # Build report 117 | output_path = args.output or 'report.md' 118 | report_builder = ReportBuilder(output_path) 119 | print(f"Building report to {output_path}...") 120 | report_builder.build_report(results) 121 | 122 | print("Done!") 123 | 124 | if __name__ == "__main__": 125 | main() -------------------------------------------------------------------------------- /modules/passwords_in_description/module.py: -------------------------------------------------------------------------------- 1 | import re 2 | import hashlib 3 | from typing import Dict, List, Any, Tuple 4 | from core.domain_state import DomainState 5 | from core.interfaces import IModule 6 | from core.utils import mask_password 7 | 8 | class PasswordsInDescriptionModule(IModule): 9 | def __init__(self): 10 | self._template_path = "template.md" 11 | 12 | @property 13 | def template_path(self) -> str: 14 | return self._template_path 15 | 16 | def _extract_potential_passwords(self, description: str) -> List[str]: 17 | """ 18 | Extract potential passwords from description using various patterns 19 | """ 20 | passwords = [] 21 | 22 | # Common patterns for passwords in descriptions 23 | patterns = [ 24 | r'password\s*[=:]\s*([^\s]+)', # password = value or password:value 25 | r'pass\s*[=:]\s*([^\s]+)', # pass = value or pass:value 26 | r'pwd\s*[=:]\s*([^\s]+)', # pwd = value or pwd:value 27 | r'\(([^)]+)\)', # (password) 28 | r'\[([^\]]+)\]', # [password] 29 | r'\{([^}]+)\}', # {password} 30 | r'"([^"]+)"', # "password" 31 | r"'([^']+)'", # 'password' 32 | r'`([^`]+)`', # `password` 33 | r'\\"([^\\"]+)\\"', # \"password\" 34 | r"\\'([^\\']+)\\'", # \'password\' 35 | r'\\`([^\\`]+)\\`', # \`password\` 36 | r'\\\(([^\\()]+)\\\)', # \(password\) 37 | r'\\\[([^\\[\]]+)\\\]', # \[password\] 38 | r'\\\{([^\\{}]+)\\\}', # \{password\} 39 | ] 40 | 41 | for pattern in patterns: 42 | matches = re.finditer(pattern, description, re.IGNORECASE) 43 | for match in matches: 44 | potential_password = match.group(1) 45 | # Basic validation to avoid false positives 46 | if len(potential_password) >= 8 and any(c.isupper() for c in potential_password) and any(c.islower() for c in potential_password) and any(c.isdigit() for c in potential_password): 47 | passwords.append(potential_password) 48 | 49 | return passwords 50 | 51 | def _calculate_ntlm_hash(self, password: str) -> str: 52 | """ 53 | Calculate NTLM hash for a given password 54 | """ 55 | # Convert password to bytes 56 | password_bytes = password.encode('utf-16le') 57 | 58 | # Calculate MD4 hash 59 | md4_hash = hashlib.new('md4', password_bytes).hexdigest() 60 | 61 | return md4_hash 62 | 63 | def run(self, domain_state: DomainState) -> Dict[str, Any]: 64 | """ 65 | Check for passwords in user descriptions 66 | """ 67 | findings = [] 68 | 69 | # Process users 70 | for user in domain_state.users.values(): 71 | if not user.description: 72 | continue 73 | 74 | # Extract potential passwords from description 75 | potential_passwords = self._extract_potential_passwords(user.description) 76 | 77 | # Check each potential password against user's NTLM hash 78 | for password in potential_passwords: 79 | ntlm_hash = self._calculate_ntlm_hash(password) 80 | 81 | if ntlm_hash == user.nt_hash: 82 | findings.append({ 83 | "account": user.sam_account_name, 84 | "password": mask_password(password), 85 | "is_admin": domain_state.is_domain_admin(user.object_sid), 86 | "is_enabled": user.enabled, 87 | "description": user.description 88 | }) 89 | break # Found matching password, no need to check others 90 | 91 | # Sort findings: admins first, then enabled accounts, then disabled 92 | findings.sort(key=lambda x: ( 93 | not x["is_admin"], # True sorts after False, so we negate 94 | not x["is_enabled"], # True sorts after False, so we negate 95 | x["account"] # Secondary sort by account name 96 | )) 97 | total_admins_enabled = sum(1 for user in findings if user["is_admin"]) 98 | if not findings: 99 | return {} 100 | 101 | return { 102 | "template": self.template_path, 103 | "passwords_in_description": findings, 104 | "total_found": len(findings), 105 | "total_admins_enabled": total_admins_enabled 106 | } -------------------------------------------------------------------------------- /core/domain_state.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional, List, Set 2 | from models.user import User 3 | from models.group import Group 4 | from models.computer import Computer 5 | from core.utils import is_user_in_domain_admin_groups 6 | 7 | class DomainState: 8 | """Class for storing domain state""" 9 | def __init__(self): 10 | self.users: Dict[str, User] = {} # key - SAM Account Name 11 | self.groups: Dict[str, Group] = {} # key - SID 12 | self.computers: Dict[str, Computer] = {} # key - SID 13 | self.sid_to_sam: Dict[str, str] = {} # mapping SID -> SAM Account Name 14 | self.sam_to_sid: Dict[str, str] = {} # mapping SAM Account Name -> SID 15 | self.hash_to_sid: Dict[str, str] = {} # mapping NT Hash -> SID 16 | self._domain_sid: Optional[str] = None # Cached domain SID 17 | 18 | @property 19 | def domain_sid(self) -> str: 20 | """Extract domain SID from any user or computer SID. Lazy Initialization""" 21 | if self._domain_sid is None or self._domain_sid == "S-1-5-21": 22 | # Try to find a SID from users 23 | for user in self.users.values(): 24 | if user.object_sid: 25 | self._domain_sid = '-'.join(user.object_sid.split('-')[:-1]) 26 | break 27 | 28 | # If not found in users, try computers 29 | if self._domain_sid is None: 30 | for computer in self.computers.values(): 31 | if computer.object_sid: 32 | self._domain_sid = '-'.join(computer.object_sid.split('-')[:-1]) 33 | break 34 | 35 | # If still not found, use a default 36 | if self._domain_sid is None: 37 | self._domain_sid = "S-1-5-21" # Default domain SID 38 | 39 | return self._domain_sid 40 | 41 | def add_user(self, user: User) -> None: 42 | """Add user to state""" 43 | self.users[user.sam_account_name] = user 44 | self.sid_to_sam[user.object_sid] = {'object_type': 'user', 'name': user.sam_account_name} 45 | self.sam_to_sid[user.sam_account_name] = user.object_sid 46 | if user.nt_hash: 47 | self.hash_to_sid[user.nt_hash] = user.object_sid 48 | 49 | def add_group(self, group: Group) -> None: 50 | """Add group to state""" 51 | self.groups[group.name] = group 52 | self.sam_to_sid[group.name] = group.sid 53 | self.sid_to_sam[group.sid] = {'object_type': 'group', 'name': group.name} 54 | 55 | def add_computer(self, computer: Computer) -> None: 56 | """Add computer to state""" 57 | self.computers[computer.sam_account_name] = computer 58 | self.sid_to_sam[computer.object_sid] = {'object_type': 'computer', 'name': computer.sam_account_name} 59 | if computer.nt_hash: 60 | self.hash_to_sid[computer.nt_hash] = computer.object_sid 61 | 62 | def update_user_password(self, nt_hash: str, password: str) -> None: 63 | """Update user password by hash 64 | 65 | Args: 66 | nt_hash: NT hash to find users/computers with 67 | password: Cracked password to set 68 | """ 69 | # Update users with matching hash 70 | for user in self.users.values(): 71 | if user.nt_hash == nt_hash: 72 | user.cracked_password = password 73 | 74 | # Update computers with matching hash 75 | for computer in self.computers.values(): 76 | if computer.nt_hash == nt_hash: 77 | computer.cracked_password = password 78 | 79 | def is_domain_admin(self, sam_account_name: str) -> bool: 80 | """Check if user is domain admin 81 | 82 | This method recursively checks if the user is a member of: 83 | - Domain Admins group 84 | - Administrators group 85 | - Enterprise Admins group 86 | 87 | Args: 88 | sam_account_name: User SAM Account Name to check 89 | 90 | Returns: 91 | True if user is a domain admin 92 | """ 93 | if sam_account_name not in self.users: 94 | return False 95 | return is_user_in_domain_admin_groups(sam_account_name, self) 96 | 97 | def find_by_sam_account_name(self, sam_account_name: str) -> Optional[User]: 98 | """Find user by SAM Account Name""" 99 | user = self.users.get(sam_account_name) 100 | if user: 101 | return user 102 | computer = self.computers.get(sam_account_name) 103 | if computer: 104 | return computer 105 | group = self.groups.get(sam_account_name) 106 | if group: 107 | return group 108 | return None 109 | 110 | def find_by_sid(self, sid: str) -> Optional[User]: 111 | """Find user by SID""" 112 | sam_account_name = self.sid_to_sam.get(sid) 113 | if sam_account_name: 114 | obj = self.find_by_sam_account_name(sam_account_name['name']) 115 | if obj: 116 | return obj 117 | return None 118 | 119 | def print_users(self) -> None: 120 | """Print all users""" 121 | print(f"users: {self.users['c221']}") 122 | 123 | -------------------------------------------------------------------------------- /parsers/secrets_parser.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, Tuple, Optional, List 2 | from dataclasses import dataclass 3 | from core.interfaces import IParser 4 | from core.domain_state import DomainState 5 | from core.exceptions import ValidationError 6 | from models.user import User 7 | from models.computer import Computer 8 | 9 | @dataclass 10 | class NTDSEntry: 11 | """Data class for NTDS entries""" 12 | sam_account_name: str # Can include domain prefix (e.g. domain.local\username) 13 | rid: str 14 | clear_text_password: Optional[str] = None 15 | lm_hash: Optional[str] = None 16 | nt_hash: Optional[str] = None 17 | 18 | @property 19 | def username(self) -> str: 20 | """Get username without domain prefix""" 21 | return self.sam_account_name.split('\\')[-1] 22 | 23 | class SecretsParser(IParser): 24 | """Parser for NTDS.dit dump files""" 25 | def __init__(self, ntds_paths: List[str]): 26 | self.ntds_paths = ntds_paths 27 | self.data: List[NTDSEntry] = [] 28 | 29 | def load_data(self) -> None: 30 | """Load NTDS data from files""" 31 | for ntds_path in self.ntds_paths: 32 | try: 33 | if ntds_path.endswith('.kerberos'): 34 | continue # Skip kerberos files for now 35 | 36 | file_type = 'cleartext' if ntds_path.endswith('.cleartext') else 'ntds' 37 | with open(ntds_path, 'r') as f: 38 | for line in f: 39 | entry = self._parse_line(line, file_type) 40 | if entry: 41 | self.data.append(entry) 42 | except Exception as e: 43 | raise ValidationError(f"Failed to load NTDS data from {ntds_path}: {str(e)}") 44 | 45 | def _parse_line(self, line: str, file_type: str) -> Optional[NTDSEntry]: 46 | """Parse single line from NTDS file 47 | 48 | For .ntds files: 49 | Expected format: domain.local\SamAccountName:rid:LM_hash:NT_hash 50 | Example: domain.local\Administrator:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0 51 | 52 | For .ntds.cleartext files: 53 | Expected format: domain.local\SamAccountName:CLEARTEXT:password 54 | Example: domain.local\admin:CLEARTEXT:P@ssw0rd 55 | """ 56 | try: 57 | line = line.strip() 58 | if not line: 59 | return None 60 | 61 | # Split by : and handle domain prefix 62 | parts = line.split(':') 63 | if len(parts) < 3: 64 | return None 65 | 66 | # Extract sam_account_name (can include domain prefix) 67 | sam_account_name = parts[0] 68 | rid = parts[1] 69 | 70 | if file_type == 'cleartext': 71 | if parts[1] != 'CLEARTEXT': 72 | return None 73 | return NTDSEntry( 74 | sam_account_name=sam_account_name, 75 | rid=rid, 76 | clear_text_password=parts[2] 77 | ) 78 | else: # ntds 79 | if len(parts) < 4: 80 | return None 81 | return NTDSEntry( 82 | sam_account_name=sam_account_name, 83 | rid=rid, 84 | lm_hash=parts[2], 85 | nt_hash=parts[3] 86 | ) 87 | except Exception: 88 | return None 89 | 90 | def validate_format(self) -> bool: 91 | """Validate NTDS format""" 92 | try: 93 | self.load_data() 94 | if not self.data: 95 | print("Warning: No valid entries found in NTDS files") 96 | return False 97 | 98 | # Check first entry format 99 | entry = self.data[0] 100 | if entry.clear_text_password is not None: 101 | if not isinstance(entry.clear_text_password, str) or len(entry.clear_text_password) < 1: 102 | print("Warning: Invalid password format in NTDS file") 103 | return False 104 | elif entry.nt_hash is not None: 105 | if not isinstance(entry.nt_hash, str) or len(entry.nt_hash) != 32: 106 | print("Warning: Invalid hash format in NTDS file") 107 | return False 108 | 109 | return True 110 | except Exception as e: 111 | print(f"Invalid NTDS format: {str(e)}") 112 | return False 113 | 114 | def parse(self, domain_state: DomainState) -> None: 115 | """Parse NTDS data and update domain state""" 116 | for entry in self.data: 117 | try: 118 | # Find object by SAM account name 119 | obj = domain_state.find_by_sam_account_name(entry.username) 120 | if obj: 121 | if entry.clear_text_password is not None: 122 | obj.clear_text_password = entry.clear_text_password 123 | if entry.nt_hash is not None and entry.lm_hash is not None: 124 | obj.nt_hash = entry.nt_hash 125 | obj.lm_hash = entry.lm_hash 126 | else: 127 | if entry.username.endswith('$'): 128 | computer = Computer( 129 | sam_account_name=entry.username, 130 | nt_hash=entry.nt_hash, 131 | lm_hash=entry.lm_hash, 132 | user_with_domain=entry.sam_account_name 133 | ) 134 | domain_state.add_computer(computer) 135 | else: 136 | user = User( 137 | sam_account_name=entry.username, 138 | nt_hash=entry.nt_hash, 139 | lm_hash=entry.lm_hash, 140 | user_with_domain=entry.sam_account_name 141 | ) 142 | domain_state.add_user(user) 143 | except Exception as e: 144 | print(f"Error updating data for {entry.username}: {str(e)}") -------------------------------------------------------------------------------- /core/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import zipfile 3 | import tempfile 4 | import shutil 5 | from typing import Optional, List, Set, Dict 6 | from models.group import Group 7 | from models.user import User 8 | from models.computer import Computer 9 | 10 | def extract_zip(zip_path: str, extract_dir: Optional[str] = None) -> str: 11 | """Extract ZIP file to temporary directory""" 12 | if extract_dir is None: 13 | extract_dir = tempfile.mkdtemp() 14 | 15 | with zipfile.ZipFile(zip_path, 'r') as zip_ref: 16 | zip_ref.extractall(extract_dir) 17 | 18 | return extract_dir 19 | 20 | def find_file_in_directory(directory: str, filename: str) -> Optional[str]: 21 | """Find file in directory recursively""" 22 | for root, _, files in os.walk(directory): 23 | if filename in files: 24 | return os.path.join(root, filename) 25 | return None 26 | 27 | def list_modules() -> None: 28 | """List available modules""" 29 | print("Available modules:") 30 | # Look for modules in the project root directory 31 | modules_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'modules') 32 | 33 | if not os.path.exists(modules_path): 34 | print(f"Warning: Modules directory not found at {modules_path}") 35 | return 36 | 37 | for module in os.listdir(modules_path): 38 | if os.path.isdir(os.path.join(modules_path, module)) and not module.startswith('__'): 39 | print(f"- {module}") 40 | 41 | def find_ldap_json(path: str) -> Optional[str]: 42 | """Find LDAP JSON file in directory, ZIP file or return path if it's a file""" 43 | ldd = [] 44 | if os.path.isfile(path): 45 | if path.endswith('.zip'): 46 | # Extract ZIP to temporary directory 47 | temp_dir = extract_zip(path) 48 | try: 49 | # Look for JSON files in extracted directory 50 | for root, _, files in os.walk(temp_dir): 51 | for file in files: 52 | if file.endswith('.json'): 53 | ldd.append(os.path.join(root, file)) 54 | except Exception as e: 55 | print(f'Error: {e}') 56 | return ldd 57 | elif os.path.isdir(path): 58 | # Look for JSON files in directory 59 | for root, _, files in os.walk(path): 60 | for file in files: 61 | if file.endswith('.json'): 62 | ldd.append(os.path.join(root, file)) 63 | return ldd 64 | 65 | return None 66 | 67 | def find_ntds_dit(path: str) -> Optional[str]: 68 | """Find NTDS.dit file in directory, ZIP file or return path if it's a file""" 69 | ntds = [] 70 | if os.path.isfile(path): 71 | if path.endswith('.zip'): 72 | # Extract ZIP to temporary directory 73 | temp_dir = extract_zip(path) 74 | try: 75 | # Look for NTDS.dit file in extracted directory 76 | for root, _, files in os.walk(temp_dir): 77 | for file in files: 78 | if '.ntds' in file.lower(): 79 | ntds.append(os.path.join(root, file)) 80 | except Exception as e: 81 | print(f'Error: {e}') 82 | return ntds 83 | elif os.path.isdir(path): 84 | # Look for NTDS.dit file in directory 85 | for root, _, files in os.walk(path): 86 | for file in files: 87 | if '.ntds' in file.lower(): 88 | ntds.append(os.path.join(root, file)) 89 | return ntds 90 | 91 | return None 92 | 93 | def mask_password(password: str) -> str: 94 | """Mask password for display in reports 95 | 96 | Args: 97 | password: Password to mask 98 | 99 | Returns: 100 | Masked password string 101 | """ 102 | if not password: 103 | return "***" 104 | 105 | length = len(password) 106 | 107 | if length > 4: 108 | return f"{password[:2]}***{password[-2:]}" 109 | elif length > 2: 110 | return f"{password[0]}***{password[-1]}" 111 | else: 112 | return "***" 113 | 114 | def get_domain_admin_recursive_groups(domain, da_groups: List[str]) -> List[str]: 115 | """Get SIDs of domain admin groups 116 | 117 | Args: 118 | domain_sid: Domain SID 119 | 120 | Returns: 121 | List of SIDs for Domain Admins, Administrators and Enterprise Admins groups 122 | """ 123 | # Well-known RIDs for admin groups 124 | da_groups = [group for group in [domain.find_by_sid(e) for e in da_groups if type(e) == str] if group is not None] 125 | #recursively get all members of the groups 126 | for group_name, group in domain.groups.items(): 127 | for member in group.memberof: 128 | if member in [group.name for group in da_groups] and not group in da_groups: 129 | da_groups.append(group) 130 | 131 | return da_groups 132 | 133 | def cn2sam(domain, dn: str) -> str: 134 | """Convert CN to SAM Account Name""" 135 | for user in domain.users.values(): 136 | if user.distinguished_name == dn: 137 | return user.sam_account_name 138 | return None 139 | 140 | def get_all_group_members(domain, target_group_sids: List[str]|str) -> List[str]: 141 | """Recursively get all members of target groups 142 | 143 | Args: 144 | groups: Dictionary of all groups 145 | target_group_sids: List of target group SIDs to check membership for 146 | visited_groups: Set of already visited group SIDs to avoid cycles 147 | 148 | Returns: 149 | Set of member SIDs 150 | """ 151 | members = [] 152 | if isinstance(target_group_sids, str): 153 | target_group_sids = [target_group_sids] 154 | 155 | for group in target_group_sids: 156 | users = group.members 157 | for user in users: 158 | user = cn2sam(domain, user) 159 | if user not in members: 160 | members.append(user) 161 | 162 | return members 163 | 164 | def is_user_in_domain_admin_groups(sam_account_name: str, domain) -> bool: 165 | """Check if SID belongs to domain admin groups 166 | 167 | Args: 168 | sid: SID to check 169 | domain_sid: Domain SID 170 | groups: Dictionary of all groups 171 | 172 | Returns: 173 | True if SID belongs to domain admin groups 174 | """ 175 | da_groups=[ 176 | f"{domain.domain_sid}-512", # Domain Admins 177 | f"S-1-5-32-544", # Administrators 178 | f"{domain.domain_sid}-519" # Enterprise Admins 179 | ] 180 | count_groups = 0 181 | while count_groups < len(da_groups): 182 | count_groups = len(da_groups) 183 | da_groups = get_domain_admin_recursive_groups(domain, da_groups) 184 | 185 | admin_members = get_all_group_members(domain, da_groups) 186 | return sam_account_name in admin_members -------------------------------------------------------------------------------- /parsers/ldap_parser.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, Any, List, Optional 3 | from core.interfaces import IParser 4 | from core.domain_state import DomainState 5 | from models.user import User 6 | from models.group import Group 7 | from models.computer import Computer 8 | from core.exceptions import ValidationError 9 | 10 | class BaseLdapParser: 11 | """Base class for LDAP data parsers""" 12 | def __init__(self, json_path: str): 13 | self.json_path = json_path 14 | self.data: List[Dict[str, Any]] = [] 15 | 16 | def load_data(self) -> None: 17 | """Load JSON data from file""" 18 | try: 19 | with open(self.json_path, 'r') as f: 20 | self.data = json.load(f) 21 | except Exception as e: 22 | raise ValidationError(f"Failed to load JSON data: {str(e)}") 23 | 24 | def get_attribute(self, obj: Dict[str, Any], key: str, default=None, is_list: bool = False) -> Any: 25 | """Get attribute value from LDAP object""" 26 | value = obj.get('attributes', {}).get(key, default) 27 | if is_list: 28 | return value if isinstance(value, list) else [] 29 | return value[0] if isinstance(value, list) and value else value 30 | 31 | def validate_format(self) -> bool: 32 | """Template method for format validation""" 33 | try: 34 | self.load_data() 35 | return True 36 | except Exception as e: 37 | print(f"Invalid JSON format: {str(e)}") 38 | return False 39 | 40 | def __str__(self) -> str: 41 | return f"BaseLdapParser: {self.json_path}" 42 | 43 | def CN2name(self, cn: str) -> str: 44 | """Convert CN to name""" 45 | return cn.split(',')[0].split('=')[1] 46 | 47 | class UserParser(BaseLdapParser): 48 | """Parser for domain_users.json""" 49 | def parse(self, domain_state: DomainState) -> None: 50 | """Parse user data and update domain state""" 51 | for user_data in self.data: 52 | try: 53 | user_account_control=self.get_attribute(user_data, 'userAccountControl', 0) 54 | user = User( 55 | sam_account_name=self.get_attribute(user_data, 'sAMAccountName', ''), 56 | distinguished_name=self.get_attribute(user_data, 'distinguishedName', ''), 57 | spn_list=self.get_attribute(user_data, 'servicePrincipalName', [], is_list=True), 58 | object_sid=self.get_attribute(user_data, 'objectSid', ''), 59 | memberof=[self.CN2name(e) for e in self.get_attribute(user_data, 'memberOf', [], is_list=True)], 60 | user_account_control=self.get_attribute(user_data, 'userAccountControl', 0), 61 | enabled=not bool(user_account_control & 0x2), 62 | description=self.get_attribute(user_data, 'description', ''), 63 | members=self.get_attribute(user_data, 'member', [], is_list=True) 64 | ) 65 | domain_state.add_user(user) 66 | except Exception as e: 67 | print(f"Error parsing user {self.get_attribute(user_data, 'sAMAccountName', 'unknown')}: {str(e)}") 68 | 69 | class GroupParser(BaseLdapParser): 70 | """Parser for domain_groups.json""" 71 | def parse(self, domain_state: DomainState) -> None: 72 | """Parse group data and update domain state""" 73 | for group_data in self.data: 74 | try: 75 | group = Group( 76 | name=self.get_attribute(group_data, 'name', ''), 77 | sid=self.get_attribute(group_data, 'objectSid', ''), 78 | memberof=[self.CN2name(e) for e in self.get_attribute(group_data, 'memberOf', [], is_list=True)], 79 | members=self.get_attribute(group_data, 'member', [], is_list=True) 80 | ) 81 | if group: 82 | domain_state.add_group(group) 83 | except Exception as e: 84 | print(f"Error parsing group {self.get_attribute(group_data, 'name', 'unknown')}: {str(e)}") 85 | 86 | class ComputerParser(BaseLdapParser): 87 | """Parser for domain_computers.json""" 88 | def parse(self, domain_state: DomainState) -> None: 89 | """Parse computer data and update domain state""" 90 | for computer_data in self.data: 91 | try: 92 | computer = Computer( 93 | sam_account_name=self.get_attribute(computer_data, 'sAMAccountName', ''), 94 | distinguished_name=self.get_attribute(computer_data, 'distinguishedName', ''), 95 | spn_list=self.get_attribute(computer_data, 'servicePrincipalName', [], is_list=True), 96 | object_sid=self.get_attribute(computer_data, 'objectSid', ''), 97 | memberof=[self.CN2name(e) for e in self.get_attribute(computer_data, 'memberOf', [], is_list=True)], 98 | user_account_control=self.get_attribute(computer_data, 'userAccountControl', 0), 99 | description=self.get_attribute(computer_data, 'description', ''), 100 | members=self.get_attribute(computer_data, 'member', [], is_list=True) 101 | ) 102 | domain_state.add_computer(computer) 103 | except Exception as e: 104 | print(f"Error parsing computer {self.get_attribute(computer_data, 'sAMAccountName', 'unknown')}: {str(e)}") 105 | 106 | class LdapParser(IParser): 107 | """Main LDAP parser that coordinates parsing of all LDAP data files""" 108 | def __init__(self, json_files: List[str]): 109 | self.json_files = json_files 110 | self.parsers: Dict[str, BaseLdapParser] = {} 111 | 112 | def _get_parser_for_file(self, file_path: str) -> Optional[BaseLdapParser]: 113 | """Get appropriate parser for file based on its name""" 114 | filename = file_path.lower() 115 | if 'users' in filename: 116 | return UserParser(file_path) 117 | elif 'groups' in filename: 118 | return GroupParser(file_path) 119 | elif 'computers' in filename: 120 | return ComputerParser(file_path) 121 | return None 122 | 123 | def validate_format(self) -> bool: 124 | """Validate format of all LDAP JSON files""" 125 | try: 126 | for file_path in self.json_files: 127 | parser = self._get_parser_for_file(file_path) 128 | if parser is None: 129 | continue 130 | 131 | if not parser.validate_format(): 132 | print(f"Warning: Invalid format in {file_path}") 133 | return False 134 | 135 | self.parsers[file_path] = parser 136 | 137 | return True 138 | except Exception as e: 139 | print(f"Error validating LDAP data: {str(e)}") 140 | return False 141 | 142 | def parse(self, domain_state: DomainState) -> None: 143 | """Parse all LDAP data files and update domain state""" 144 | for file_path, parser in self.parsers.items(): 145 | try: 146 | parser.parse(domain_state) 147 | except Exception as e: 148 | print(f"Error parsing {file_path}: {str(e)}") -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------