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